feat: scope subscriptions by content type and make Duolingo plans lifetime
LEARN_ENGLISH plans unlock LMS only; IELTS and DUOLINGO unlock matching exam-prep catalog courses. Enable category subscription gating, restrict programs to Learn English, and treat Duolingo subscriptions as non-expiring one-time purchases. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
parent
58790f0998
commit
26cf7d2908
|
|
@ -0,0 +1 @@
|
||||||
|
-- No-op: prior per-subscription expires_at values cannot be restored.
|
||||||
|
|
@ -0,0 +1,9 @@
|
||||||
|
-- DUOLINGO subscriptions are one-time purchases with no real expiry.
|
||||||
|
UPDATE user_subscriptions us
|
||||||
|
SET
|
||||||
|
expires_at = TIMESTAMPTZ '9999-12-31 23:59:59+00',
|
||||||
|
updated_at = CURRENT_TIMESTAMP
|
||||||
|
FROM subscription_plans sp
|
||||||
|
WHERE us.plan_id = sp.id
|
||||||
|
AND sp.category = 'DUOLINGO'
|
||||||
|
AND us.expires_at < TIMESTAMPTZ '9999-01-01 00:00:00+00';
|
||||||
|
|
@ -0,0 +1,6 @@
|
||||||
|
ALTER TABLE programs
|
||||||
|
DROP CONSTRAINT IF EXISTS chk_programs_category;
|
||||||
|
|
||||||
|
ALTER TABLE programs
|
||||||
|
ADD CONSTRAINT chk_programs_category
|
||||||
|
CHECK (category IN ('LEARN_ENGLISH', 'IELTS', 'DUOLINGO'));
|
||||||
13
db/migrations/000080_lms_programs_learn_english_only.up.sql
Normal file
13
db/migrations/000080_lms_programs_learn_english_only.up.sql
Normal file
|
|
@ -0,0 +1,13 @@
|
||||||
|
-- LMS programs are Learn English content only; exam prep uses exam_prep.catalog_courses.
|
||||||
|
UPDATE programs
|
||||||
|
SET
|
||||||
|
category = 'LEARN_ENGLISH',
|
||||||
|
updated_at = CURRENT_TIMESTAMP
|
||||||
|
WHERE category <> 'LEARN_ENGLISH';
|
||||||
|
|
||||||
|
ALTER TABLE programs
|
||||||
|
DROP CONSTRAINT IF EXISTS chk_programs_category;
|
||||||
|
|
||||||
|
ALTER TABLE programs
|
||||||
|
ADD CONSTRAINT chk_programs_category
|
||||||
|
CHECK (category = 'LEARN_ENGLISH');
|
||||||
|
|
@ -43,6 +43,10 @@ WHERE (
|
||||||
sqlc.arg('published_only')::boolean = FALSE
|
sqlc.arg('published_only')::boolean = FALSE
|
||||||
OR p.publish_status = 'PUBLISHED'::TEXT
|
OR p.publish_status = 'PUBLISHED'::TEXT
|
||||||
)
|
)
|
||||||
|
AND (
|
||||||
|
sqlc.arg('category')::text = ''
|
||||||
|
OR p.category = sqlc.arg('category')::text
|
||||||
|
)
|
||||||
ORDER BY p.sort_order ASC, p.id ASC
|
ORDER BY p.sort_order ASC, p.id ASC
|
||||||
LIMIT $1 OFFSET $2;
|
LIMIT $1 OFFSET $2;
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -164,6 +164,7 @@ SELECT us.*, sp.name AS plan_name
|
||||||
FROM user_subscriptions us
|
FROM user_subscriptions us
|
||||||
JOIN subscription_plans sp ON sp.id = us.plan_id
|
JOIN subscription_plans sp ON sp.id = us.plan_id
|
||||||
WHERE us.status = 'ACTIVE'
|
WHERE us.status = 'ACTIVE'
|
||||||
|
AND sp.category <> 'DUOLINGO'
|
||||||
AND us.expires_at <= CURRENT_TIMESTAMP;
|
AND us.expires_at <= CURRENT_TIMESTAMP;
|
||||||
|
|
||||||
-- name: ListLearnEnglishSubscriptionsExpiringInSevenDays :many
|
-- name: ListLearnEnglishSubscriptionsExpiringInSevenDays :many
|
||||||
|
|
@ -191,6 +192,7 @@ FROM user_subscriptions us
|
||||||
JOIN subscription_plans sp ON sp.id = us.plan_id
|
JOIN subscription_plans sp ON sp.id = us.plan_id
|
||||||
JOIN users u ON u.id = us.user_id
|
JOIN users u ON u.id = us.user_id
|
||||||
WHERE us.status = 'ACTIVE'
|
WHERE us.status = 'ACTIVE'
|
||||||
|
AND sp.category <> 'DUOLINGO'
|
||||||
AND us.expires_at > CURRENT_TIMESTAMP
|
AND us.expires_at > CURRENT_TIMESTAMP
|
||||||
AND us.expires_at <= CURRENT_TIMESTAMP + INTERVAL '7 days';
|
AND us.expires_at <= CURRENT_TIMESTAMP + INTERVAL '7 days';
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -139,14 +139,19 @@ WHERE (
|
||||||
$3::boolean = FALSE
|
$3::boolean = FALSE
|
||||||
OR p.publish_status = 'PUBLISHED'::TEXT
|
OR p.publish_status = 'PUBLISHED'::TEXT
|
||||||
)
|
)
|
||||||
|
AND (
|
||||||
|
$4::text = ''
|
||||||
|
OR p.category = $4::text
|
||||||
|
)
|
||||||
ORDER BY p.sort_order ASC, p.id ASC
|
ORDER BY p.sort_order ASC, p.id ASC
|
||||||
LIMIT $1 OFFSET $2
|
LIMIT $1 OFFSET $2
|
||||||
`
|
`
|
||||||
|
|
||||||
type ListProgramsParams struct {
|
type ListProgramsParams struct {
|
||||||
Limit int32 `json:"limit"`
|
Limit int32 `json:"limit"`
|
||||||
Offset int32 `json:"offset"`
|
Offset int32 `json:"offset"`
|
||||||
PublishedOnly bool `json:"published_only"`
|
PublishedOnly bool `json:"published_only"`
|
||||||
|
Category string `json:"category"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type ListProgramsRow struct {
|
type ListProgramsRow struct {
|
||||||
|
|
@ -163,7 +168,12 @@ type ListProgramsRow struct {
|
||||||
}
|
}
|
||||||
|
|
||||||
func (q *Queries) ListPrograms(ctx context.Context, arg ListProgramsParams) ([]ListProgramsRow, error) {
|
func (q *Queries) ListPrograms(ctx context.Context, arg ListProgramsParams) ([]ListProgramsRow, error) {
|
||||||
rows, err := q.db.Query(ctx, ListPrograms, arg.Limit, arg.Offset, arg.PublishedOnly)
|
rows, err := q.db.Query(ctx, ListPrograms,
|
||||||
|
arg.Limit,
|
||||||
|
arg.Offset,
|
||||||
|
arg.PublishedOnly,
|
||||||
|
arg.Category,
|
||||||
|
)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -247,6 +247,7 @@ SELECT us.id, us.user_id, us.plan_id, us.starts_at, us.expires_at, us.status, us
|
||||||
FROM user_subscriptions us
|
FROM user_subscriptions us
|
||||||
JOIN subscription_plans sp ON sp.id = us.plan_id
|
JOIN subscription_plans sp ON sp.id = us.plan_id
|
||||||
WHERE us.status = 'ACTIVE'
|
WHERE us.status = 'ACTIVE'
|
||||||
|
AND sp.category <> 'DUOLINGO'
|
||||||
AND us.expires_at <= CURRENT_TIMESTAMP
|
AND us.expires_at <= CURRENT_TIMESTAMP
|
||||||
`
|
`
|
||||||
|
|
||||||
|
|
@ -310,6 +311,7 @@ FROM user_subscriptions us
|
||||||
JOIN subscription_plans sp ON sp.id = us.plan_id
|
JOIN subscription_plans sp ON sp.id = us.plan_id
|
||||||
JOIN users u ON u.id = us.user_id
|
JOIN users u ON u.id = us.user_id
|
||||||
WHERE us.status = 'ACTIVE'
|
WHERE us.status = 'ACTIVE'
|
||||||
|
AND sp.category <> 'DUOLINGO'
|
||||||
AND us.expires_at > CURRENT_TIMESTAMP
|
AND us.expires_at > CURRENT_TIMESTAMP
|
||||||
AND us.expires_at <= CURRENT_TIMESTAMP + INTERVAL '7 days'
|
AND us.expires_at <= CURRENT_TIMESTAMP + INTERVAL '7 days'
|
||||||
`
|
`
|
||||||
|
|
|
||||||
|
|
@ -24,7 +24,7 @@ func (p Program) VisibleToLearners() bool {
|
||||||
type CreateProgramInput struct {
|
type CreateProgramInput struct {
|
||||||
Name string `json:"name" validate:"required"`
|
Name string `json:"name" validate:"required"`
|
||||||
Description *string `json:"description,omitempty"`
|
Description *string `json:"description,omitempty"`
|
||||||
Category string `json:"category" validate:"required,oneof=LEARN_ENGLISH IELTS DUOLINGO"`
|
Category string `json:"category" validate:"required,oneof=LEARN_ENGLISH"`
|
||||||
Thumbnail *string `json:"thumbnail,omitempty"`
|
Thumbnail *string `json:"thumbnail,omitempty"`
|
||||||
// SortOrder inserts at this global program order when set; omit to append after current max (sort_order uniqueness is enforced).
|
// SortOrder inserts at this global program order when set; omit to append after current max (sort_order uniqueness is enforced).
|
||||||
SortOrder *int `json:"sort_order,omitempty" validate:"omitempty,min=0"`
|
SortOrder *int `json:"sort_order,omitempty" validate:"omitempty,min=0"`
|
||||||
|
|
@ -35,7 +35,7 @@ type CreateProgramInput struct {
|
||||||
type UpdateProgramInput struct {
|
type UpdateProgramInput struct {
|
||||||
Name *string `json:"name,omitempty"`
|
Name *string `json:"name,omitempty"`
|
||||||
Description *string `json:"description,omitempty"`
|
Description *string `json:"description,omitempty"`
|
||||||
Category *string `json:"category,omitempty" validate:"omitempty,oneof=LEARN_ENGLISH IELTS DUOLINGO"`
|
Category *string `json:"category,omitempty" validate:"omitempty,oneof=LEARN_ENGLISH"`
|
||||||
Thumbnail *string `json:"thumbnail,omitempty"`
|
Thumbnail *string `json:"thumbnail,omitempty"`
|
||||||
SortOrder *int `json:"sort_order,omitempty"`
|
SortOrder *int `json:"sort_order,omitempty"`
|
||||||
PublishStatus *string `json:"publish_status,omitempty" validate:"omitempty,oneof=DRAFT draft PUBLISHED published"`
|
PublishStatus *string `json:"publish_status,omitempty" validate:"omitempty,oneof=DRAFT draft PUBLISHED published"`
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
package domain
|
package domain
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
@ -20,7 +21,49 @@ type SubscriptionExpiryReminder struct {
|
||||||
}
|
}
|
||||||
|
|
||||||
// CategorySubscriptionGateDisabled skips subscription enforcement on learner-facing routes (temporary).
|
// CategorySubscriptionGateDisabled skips subscription enforcement on learner-facing routes (temporary).
|
||||||
var CategorySubscriptionGateDisabled = true
|
var CategorySubscriptionGateDisabled = false
|
||||||
|
|
||||||
|
// IsLMSSubscriptionCategory reports plan categories that unlock Learn English (LMS) content.
|
||||||
|
func IsLMSSubscriptionCategory(category string) bool {
|
||||||
|
return normalizeSubscriptionCategory(category) == string(SubscriptionCategoryLearnEnglish)
|
||||||
|
}
|
||||||
|
|
||||||
|
// IsExamPrepSubscriptionCategory reports plan categories that unlock exam-prep content.
|
||||||
|
func IsExamPrepSubscriptionCategory(category string) bool {
|
||||||
|
switch normalizeSubscriptionCategory(category) {
|
||||||
|
case string(SubscriptionCategoryIELTS), string(SubscriptionCategoryDuolingo):
|
||||||
|
return true
|
||||||
|
default:
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// IsLMSContentCategory reports content categories stored on LMS programs.
|
||||||
|
func IsLMSContentCategory(category string) bool {
|
||||||
|
return IsLMSSubscriptionCategory(category)
|
||||||
|
}
|
||||||
|
|
||||||
|
// IsExamPrepContentCategory reports content categories stored on exam-prep catalog courses.
|
||||||
|
func IsExamPrepContentCategory(category string) bool {
|
||||||
|
return IsExamPrepSubscriptionCategory(category)
|
||||||
|
}
|
||||||
|
|
||||||
|
// SubscriptionGrantsContentAccess reports whether an active plan category unlocks the given content category.
|
||||||
|
func SubscriptionGrantsContentAccess(subscriptionCategory, contentCategory string) bool {
|
||||||
|
sub := normalizeSubscriptionCategory(subscriptionCategory)
|
||||||
|
content := normalizeSubscriptionCategory(contentCategory)
|
||||||
|
if IsLMSContentCategory(content) {
|
||||||
|
return sub == string(SubscriptionCategoryLearnEnglish)
|
||||||
|
}
|
||||||
|
if IsExamPrepContentCategory(content) {
|
||||||
|
return sub == content
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
func normalizeSubscriptionCategory(category string) string {
|
||||||
|
return strings.ToUpper(strings.TrimSpace(category))
|
||||||
|
}
|
||||||
|
|
||||||
type DurationUnit string
|
type DurationUnit string
|
||||||
|
|
||||||
|
|
@ -108,6 +151,22 @@ type CreateUserSubscriptionInput struct {
|
||||||
AutoRenew *bool
|
AutoRenew *bool
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// LifetimeSubscriptionExpiresAt is stored for one-time (non-expiring) DUOLINGO subscriptions.
|
||||||
|
var LifetimeSubscriptionExpiresAt = time.Date(9999, 12, 31, 23, 59, 59, 0, time.UTC)
|
||||||
|
|
||||||
|
// IsLifetimeSubscriptionCategory reports categories that grant permanent access after purchase.
|
||||||
|
func IsLifetimeSubscriptionCategory(category string) bool {
|
||||||
|
return strings.ToUpper(strings.TrimSpace(category)) == string(SubscriptionCategoryDuolingo)
|
||||||
|
}
|
||||||
|
|
||||||
|
// CalculateSubscriptionExpiresAt returns when a subscription ends. DUOLINGO plans never expire.
|
||||||
|
func CalculateSubscriptionExpiresAt(startTime time.Time, category string, durationValue int32, durationUnit string) time.Time {
|
||||||
|
if IsLifetimeSubscriptionCategory(category) {
|
||||||
|
return LifetimeSubscriptionExpiresAt
|
||||||
|
}
|
||||||
|
return CalculateExpiryDate(startTime, durationValue, durationUnit)
|
||||||
|
}
|
||||||
|
|
||||||
// CalculateExpiryDate calculates the expiry date based on plan duration
|
// CalculateExpiryDate calculates the expiry date based on plan duration
|
||||||
func CalculateExpiryDate(startTime time.Time, durationValue int32, durationUnit string) time.Time {
|
func CalculateExpiryDate(startTime time.Time, durationValue int32, durationUnit string) time.Time {
|
||||||
switch durationUnit {
|
switch durationUnit {
|
||||||
|
|
|
||||||
41
internal/domain/subscriptions_test.go
Normal file
41
internal/domain/subscriptions_test.go
Normal file
|
|
@ -0,0 +1,41 @@
|
||||||
|
package domain
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestCalculateSubscriptionExpiresAt_duolingoIsLifetime(t *testing.T) {
|
||||||
|
start := time.Date(2026, 6, 10, 12, 0, 0, 0, time.UTC)
|
||||||
|
got := CalculateSubscriptionExpiresAt(start, "DUOLINGO", 1, "MONTH")
|
||||||
|
if !got.Equal(LifetimeSubscriptionExpiresAt) {
|
||||||
|
t.Fatalf("expected lifetime expiry, got %v", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSubscriptionGrantsContentAccess(t *testing.T) {
|
||||||
|
if !SubscriptionGrantsContentAccess("LEARN_ENGLISH", "LEARN_ENGLISH") {
|
||||||
|
t.Fatal("LEARN_ENGLISH plan should unlock LMS content")
|
||||||
|
}
|
||||||
|
if SubscriptionGrantsContentAccess("LEARN_ENGLISH", "IELTS") {
|
||||||
|
t.Fatal("LEARN_ENGLISH plan should not unlock exam prep content")
|
||||||
|
}
|
||||||
|
if !SubscriptionGrantsContentAccess("IELTS", "IELTS") {
|
||||||
|
t.Fatal("IELTS plan should unlock IELTS content")
|
||||||
|
}
|
||||||
|
if SubscriptionGrantsContentAccess("IELTS", "DUOLINGO") {
|
||||||
|
t.Fatal("IELTS plan should not unlock Duolingo content")
|
||||||
|
}
|
||||||
|
if !SubscriptionGrantsContentAccess("DUOLINGO", "DUOLINGO") {
|
||||||
|
t.Fatal("DUOLINGO plan should unlock Duolingo content")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCalculateSubscriptionExpiresAt_learnEnglishUsesDuration(t *testing.T) {
|
||||||
|
start := time.Date(2026, 6, 10, 12, 0, 0, 0, time.UTC)
|
||||||
|
got := CalculateSubscriptionExpiresAt(start, "LEARN_ENGLISH", 1, "MONTH")
|
||||||
|
want := start.AddDate(0, 1, 0)
|
||||||
|
if !got.Equal(want) {
|
||||||
|
t.Fatalf("expected %v, got %v", want, got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -8,7 +8,7 @@ import (
|
||||||
type ProgramStore interface {
|
type ProgramStore interface {
|
||||||
CreateProgram(ctx context.Context, input domain.CreateProgramInput) (domain.Program, error)
|
CreateProgram(ctx context.Context, input domain.CreateProgramInput) (domain.Program, error)
|
||||||
GetProgramByID(ctx context.Context, id int64) (domain.Program, error)
|
GetProgramByID(ctx context.Context, id int64) (domain.Program, error)
|
||||||
ListPrograms(ctx context.Context, publishedOnly bool, limit, offset int32) ([]domain.Program, int64, error)
|
ListPrograms(ctx context.Context, publishedOnly bool, category string, limit, offset int32) ([]domain.Program, int64, error)
|
||||||
ListAllProgramIDs(ctx context.Context) ([]int64, error)
|
ListAllProgramIDs(ctx context.Context) ([]int64, error)
|
||||||
ReorderPrograms(ctx context.Context, orderedIDs []int64) error
|
ReorderPrograms(ctx context.Context, orderedIDs []int64) error
|
||||||
UpdateProgram(ctx context.Context, id int64, input domain.UpdateProgramInput) (domain.Program, error)
|
UpdateProgram(ctx context.Context, id int64, input domain.UpdateProgramInput) (domain.Program, error)
|
||||||
|
|
|
||||||
|
|
@ -88,11 +88,12 @@ func (s *Store) GetProgramByID(ctx context.Context, id int64) (domain.Program, e
|
||||||
return programToDomain(p), nil
|
return programToDomain(p), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Store) ListPrograms(ctx context.Context, publishedOnly bool, limit, offset int32) ([]domain.Program, int64, error) {
|
func (s *Store) ListPrograms(ctx context.Context, publishedOnly bool, category string, limit, offset int32) ([]domain.Program, int64, error) {
|
||||||
rows, err := s.queries.ListPrograms(ctx, dbgen.ListProgramsParams{
|
rows, err := s.queries.ListPrograms(ctx, dbgen.ListProgramsParams{
|
||||||
Limit: limit,
|
Limit: limit,
|
||||||
Offset: offset,
|
Offset: offset,
|
||||||
PublishedOnly: publishedOnly,
|
PublishedOnly: publishedOnly,
|
||||||
|
Category: category,
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, 0, err
|
return nil, 0, err
|
||||||
|
|
|
||||||
|
|
@ -269,7 +269,7 @@ func (s *ArifpayService) ProcessPaymentWebhook(ctx context.Context, req domain.W
|
||||||
}
|
}
|
||||||
|
|
||||||
startsAt := time.Now()
|
startsAt := time.Now()
|
||||||
expiresAt := domain.CalculateExpiryDate(startsAt, plan.DurationValue, plan.DurationUnit)
|
expiresAt := domain.CalculateSubscriptionExpiresAt(startsAt, plan.Category, plan.DurationValue, plan.DurationUnit)
|
||||||
activeStatus := string(domain.SubscriptionStatusActive)
|
activeStatus := string(domain.SubscriptionStatusActive)
|
||||||
autoRenew := false
|
autoRenew := false
|
||||||
paymentRef := nonce
|
paymentRef := nonce
|
||||||
|
|
@ -1031,7 +1031,7 @@ func (s *ArifpayService) VerifyDirectPaymentOTP(ctx context.Context, userID int6
|
||||||
plan, err := s.subscriptionStore.GetSubscriptionPlanByID(ctx, *payment.PlanID)
|
plan, err := s.subscriptionStore.GetSubscriptionPlanByID(ctx, *payment.PlanID)
|
||||||
if err == nil {
|
if err == nil {
|
||||||
startsAt := time.Now()
|
startsAt := time.Now()
|
||||||
expiresAt := domain.CalculateExpiryDate(startsAt, plan.DurationValue, plan.DurationUnit)
|
expiresAt := domain.CalculateSubscriptionExpiresAt(startsAt, plan.Category, plan.DurationValue, plan.DurationUnit)
|
||||||
activeStatus := string(domain.SubscriptionStatusActive)
|
activeStatus := string(domain.SubscriptionStatusActive)
|
||||||
autoRenew := false
|
autoRenew := false
|
||||||
paymentRef := payment.Nonce
|
paymentRef := payment.Nonce
|
||||||
|
|
|
||||||
|
|
@ -371,7 +371,7 @@ func (s *Service) activateSubscription(ctx context.Context, payment *domain.Paym
|
||||||
}
|
}
|
||||||
|
|
||||||
startsAt := time.Now()
|
startsAt := time.Now()
|
||||||
expiresAt := domain.CalculateExpiryDate(startsAt, plan.DurationValue, plan.DurationUnit)
|
expiresAt := domain.CalculateSubscriptionExpiresAt(startsAt, plan.Category, plan.DurationValue, plan.DurationUnit)
|
||||||
activeStatus := string(domain.SubscriptionStatusActive)
|
activeStatus := string(domain.SubscriptionStatusActive)
|
||||||
autoRenew := false
|
autoRenew := false
|
||||||
paymentRef := payment.Nonce
|
paymentRef := payment.Nonce
|
||||||
|
|
|
||||||
|
|
@ -34,7 +34,7 @@ func (s *Service) GetByID(ctx context.Context, id int64) (domain.Program, error)
|
||||||
return p, nil
|
return p, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Service) List(ctx context.Context, publishedOnly bool, limit, offset int32) ([]domain.Program, int64, error) {
|
func (s *Service) List(ctx context.Context, publishedOnly bool, category string, limit, offset int32) ([]domain.Program, int64, error) {
|
||||||
if limit <= 0 {
|
if limit <= 0 {
|
||||||
limit = 20
|
limit = 20
|
||||||
}
|
}
|
||||||
|
|
@ -44,7 +44,7 @@ func (s *Service) List(ctx context.Context, publishedOnly bool, limit, offset in
|
||||||
if offset < 0 {
|
if offset < 0 {
|
||||||
offset = 0
|
offset = 0
|
||||||
}
|
}
|
||||||
return s.store.ListPrograms(ctx, publishedOnly, limit, offset)
|
return s.store.ListPrograms(ctx, publishedOnly, category, limit, offset)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Service) Update(ctx context.Context, id int64, input domain.UpdateProgramInput) (domain.Program, error) {
|
func (s *Service) Update(ctx context.Context, id int64, input domain.UpdateProgramInput) (domain.Program, error) {
|
||||||
|
|
|
||||||
|
|
@ -75,7 +75,7 @@ func (s *Service) Subscribe(ctx context.Context, userID, planID int64, paymentRe
|
||||||
|
|
||||||
// Calculate expiry date
|
// Calculate expiry date
|
||||||
startsAt := time.Now()
|
startsAt := time.Now()
|
||||||
expiresAt := domain.CalculateExpiryDate(startsAt, plan.DurationValue, plan.DurationUnit)
|
expiresAt := domain.CalculateSubscriptionExpiresAt(startsAt, plan.Category, plan.DurationValue, plan.DurationUnit)
|
||||||
|
|
||||||
input := domain.CreateUserSubscriptionInput{
|
input := domain.CreateUserSubscriptionInput{
|
||||||
UserID: userID,
|
UserID: userID,
|
||||||
|
|
@ -176,7 +176,7 @@ func (s *Service) RenewSubscription(ctx context.Context, subscriptionID int64) (
|
||||||
if baseTime.Before(time.Now()) {
|
if baseTime.Before(time.Now()) {
|
||||||
baseTime = time.Now()
|
baseTime = time.Now()
|
||||||
}
|
}
|
||||||
newExpiry := domain.CalculateExpiryDate(baseTime, plan.DurationValue, plan.DurationUnit)
|
newExpiry := domain.CalculateSubscriptionExpiresAt(baseTime, plan.Category, plan.DurationValue, plan.DurationUnit)
|
||||||
|
|
||||||
err = s.store.ExtendSubscription(ctx, subscriptionID, newExpiry)
|
err = s.store.ExtendSubscription(ctx, subscriptionID, newExpiry)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|
|
||||||
|
|
@ -96,12 +96,27 @@ func (h *Handler) ListCoursesByProgram(c *fiber.Ctx) error {
|
||||||
if publishedOnly {
|
if publishedOnly {
|
||||||
// Draft programs hide their courses from non-managers.
|
// Draft programs hide their courses from non-managers.
|
||||||
p, err := h.programSvc.GetByID(c.Context(), programID)
|
p, err := h.programSvc.GetByID(c.Context(), programID)
|
||||||
if err == nil && !p.VisibleToLearners() {
|
if err != nil {
|
||||||
|
if errors.Is(err, programs.ErrProgramNotFound) {
|
||||||
|
return c.Status(fiber.StatusNotFound).JSON(domain.ErrorResponse{
|
||||||
|
Message: "Program not found",
|
||||||
|
Error: err.Error(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return c.Status(fiber.StatusInternalServerError).JSON(domain.ErrorResponse{
|
||||||
|
Message: "Failed to load program",
|
||||||
|
Error: err.Error(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
if !p.VisibleToLearners() {
|
||||||
return c.Status(fiber.StatusNotFound).JSON(domain.ErrorResponse{
|
return c.Status(fiber.StatusNotFound).JSON(domain.ErrorResponse{
|
||||||
Message: "Program not found",
|
Message: "Program not found",
|
||||||
Error: programs.ErrProgramNotFound.Error(),
|
Error: programs.ErrProgramNotFound.Error(),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
if err := h.blockLearnerIfNotLMSProgram(c, p); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
}
|
}
|
||||||
items, total, err := h.courseSvc.ListByProgram(c.Context(), programID, publishedOnly, int32(limit), int32(offset))
|
items, total, err := h.courseSvc.ListByProgram(c.Context(), programID, publishedOnly, int32(limit), int32(offset))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|
@ -174,6 +189,22 @@ func (h *Handler) GetCourse(c *fiber.Ctx) error {
|
||||||
Error: courses.ErrCourseNotFound.Error(),
|
Error: courses.ErrCourseNotFound.Error(),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
p, err := h.programSvc.GetByID(c.Context(), course.ProgramID)
|
||||||
|
if err != nil {
|
||||||
|
if errors.Is(err, programs.ErrProgramNotFound) {
|
||||||
|
return c.Status(fiber.StatusNotFound).JSON(domain.ErrorResponse{
|
||||||
|
Message: "Course not found",
|
||||||
|
Error: courses.ErrCourseNotFound.Error(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return c.Status(fiber.StatusInternalServerError).JSON(domain.ErrorResponse{
|
||||||
|
Message: "Failed to load course",
|
||||||
|
Error: err.Error(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
if err := h.blockLearnerIfNotLMSProgram(c, p); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
uid := c.Locals("user_id").(int64)
|
uid := c.Locals("user_id").(int64)
|
||||||
role := c.Locals("role").(domain.Role)
|
role := c.Locals("role").(domain.Role)
|
||||||
if err := h.lmsProgressSvc.ApplyAccessCourse(c.Context(), role, uid, &course); err != nil {
|
if err := h.lmsProgressSvc.ApplyAccessCourse(c.Context(), role, uid, &course); err != nil {
|
||||||
|
|
|
||||||
|
|
@ -62,22 +62,15 @@ func (h *Handler) ListExamPrepCatalogCourses(c *fiber.Ctx) error {
|
||||||
publishedOnly := !h.canManageExamPrepCatalogCourses(c)
|
publishedOnly := !h.canManageExamPrepCatalogCourses(c)
|
||||||
|
|
||||||
role, _ := c.Locals("role").(domain.Role)
|
role, _ := c.Locals("role").(domain.Role)
|
||||||
if role.IsCustomerLearnerRole() && !domain.CategorySubscriptionGateDisabled {
|
if role.IsCustomerLearnerRole() {
|
||||||
userID, ok := c.Locals("user_id").(int64)
|
hasIELTS, err := h.learnerHasSubscriptionCategory(c, domain.SubscriptionCategoryIELTS)
|
||||||
if !ok || userID == 0 {
|
|
||||||
return c.Status(fiber.StatusUnauthorized).JSON(domain.ErrorResponse{
|
|
||||||
Message: "Unauthorized",
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
hasIELTS, err := h.subscriptionsSvc.HasActiveSubscriptionByCategory(c.Context(), userID, domain.SubscriptionCategoryIELTS)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return c.Status(fiber.StatusInternalServerError).JSON(domain.ErrorResponse{
|
return c.Status(fiber.StatusInternalServerError).JSON(domain.ErrorResponse{
|
||||||
Message: "Failed to verify IELTS subscription",
|
Message: "Failed to verify IELTS subscription",
|
||||||
Error: err.Error(),
|
Error: err.Error(),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
hasDuolingo, err := h.subscriptionsSvc.HasActiveSubscriptionByCategory(c.Context(), userID, domain.SubscriptionCategoryDuolingo)
|
hasDuolingo, err := h.learnerHasSubscriptionCategory(c, domain.SubscriptionCategoryDuolingo)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return c.Status(fiber.StatusInternalServerError).JSON(domain.ErrorResponse{
|
return c.Status(fiber.StatusInternalServerError).JSON(domain.ErrorResponse{
|
||||||
Message: "Failed to verify Duolingo subscription",
|
Message: "Failed to verify Duolingo subscription",
|
||||||
|
|
@ -93,19 +86,7 @@ func (h *Handler) ListExamPrepCatalogCourses(c *fiber.Ctx) error {
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
filtered := make([]domain.ExamPrepCatalogCourse, 0, len(allItems))
|
filtered := filterExamPrepCatalogCoursesForLearner(allItems, hasIELTS, hasDuolingo)
|
||||||
for _, item := range allItems {
|
|
||||||
switch domain.SubscriptionCategory(item.Category) {
|
|
||||||
case domain.SubscriptionCategoryIELTS:
|
|
||||||
if hasIELTS {
|
|
||||||
filtered = append(filtered, item)
|
|
||||||
}
|
|
||||||
case domain.SubscriptionCategoryDuolingo:
|
|
||||||
if hasDuolingo {
|
|
||||||
filtered = append(filtered, item)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
total := len(filtered)
|
total := len(filtered)
|
||||||
start := offset
|
start := offset
|
||||||
|
|
@ -239,6 +220,9 @@ func (h *Handler) GetExamPrepCatalogCourseByID(c *fiber.Ctx) error {
|
||||||
Error: examprep.ErrCatalogCourseNotFound.Error(),
|
Error: examprep.ErrCatalogCourseNotFound.Error(),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
if err := h.ensureLearnerExamPrepContentAccess(c, out.Category); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
if err := h.applyExamPrepAccessCatalogCourse(c.Context(), c, &out); err != nil {
|
if err := h.applyExamPrepAccessCatalogCourse(c.Context(), c, &out); err != nil {
|
||||||
return c.Status(fiber.StatusInternalServerError).JSON(domain.ErrorResponse{
|
return c.Status(fiber.StatusInternalServerError).JSON(domain.ErrorResponse{
|
||||||
Message: "Failed to build catalog course",
|
Message: "Failed to build catalog course",
|
||||||
|
|
|
||||||
|
|
@ -257,7 +257,7 @@ func (h *Handler) listAllPrograms(ctx context.Context, publishedOnly bool) ([]do
|
||||||
offset int32
|
offset int32
|
||||||
)
|
)
|
||||||
for {
|
for {
|
||||||
items, total, err := h.programSvc.List(ctx, publishedOnly, lmsProgressSummaryPageSize, offset)
|
items, total, err := h.programSvc.List(ctx, publishedOnly, string(domain.SubscriptionCategoryLearnEnglish), lmsProgressSummaryPageSize, offset)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -70,7 +70,12 @@ func (h *Handler) ListPrograms(c *fiber.Ctx) error {
|
||||||
limit, _ := strconv.Atoi(c.Query("limit", "20"))
|
limit, _ := strconv.Atoi(c.Query("limit", "20"))
|
||||||
offset, _ := strconv.Atoi(c.Query("offset", "0"))
|
offset, _ := strconv.Atoi(c.Query("offset", "0"))
|
||||||
publishedOnly := !h.canManagePrograms(c)
|
publishedOnly := !h.canManagePrograms(c)
|
||||||
items, total, err := h.programSvc.List(c.Context(), publishedOnly, int32(limit), int32(offset))
|
role := c.Locals("role").(domain.Role)
|
||||||
|
category := ""
|
||||||
|
if role.IsCustomerLearnerRole() {
|
||||||
|
category = string(domain.SubscriptionCategoryLearnEnglish)
|
||||||
|
}
|
||||||
|
items, total, err := h.programSvc.List(c.Context(), publishedOnly, category, int32(limit), int32(offset))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return c.Status(fiber.StatusInternalServerError).JSON(domain.ErrorResponse{
|
return c.Status(fiber.StatusInternalServerError).JSON(domain.ErrorResponse{
|
||||||
Message: "Failed to list programs",
|
Message: "Failed to list programs",
|
||||||
|
|
@ -78,7 +83,6 @@ func (h *Handler) ListPrograms(c *fiber.Ctx) error {
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
uid := c.Locals("user_id").(int64)
|
uid := c.Locals("user_id").(int64)
|
||||||
role := c.Locals("role").(domain.Role)
|
|
||||||
for i := range items {
|
for i := range items {
|
||||||
if err := h.lmsProgressSvc.ApplyAccessProgram(c.Context(), role, uid, &items[i]); err != nil {
|
if err := h.lmsProgressSvc.ApplyAccessProgram(c.Context(), role, uid, &items[i]); err != nil {
|
||||||
return c.Status(fiber.StatusInternalServerError).JSON(domain.ErrorResponse{
|
return c.Status(fiber.StatusInternalServerError).JSON(domain.ErrorResponse{
|
||||||
|
|
@ -135,6 +139,9 @@ func (h *Handler) GetProgram(c *fiber.Ctx) error {
|
||||||
Error: programs.ErrProgramNotFound.Error(),
|
Error: programs.ErrProgramNotFound.Error(),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
if err := h.blockLearnerIfNotLMSProgram(c, p); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
uid := c.Locals("user_id").(int64)
|
uid := c.Locals("user_id").(int64)
|
||||||
role := c.Locals("role").(domain.Role)
|
role := c.Locals("role").(domain.Role)
|
||||||
if err := h.lmsProgressSvc.ApplyAccessProgram(c.Context(), role, uid, &p); err != nil {
|
if err := h.lmsProgressSvc.ApplyAccessProgram(c.Context(), role, uid, &p); err != nil {
|
||||||
|
|
|
||||||
84
internal/web_server/handlers/subscription_content_gate.go
Normal file
84
internal/web_server/handlers/subscription_content_gate.go
Normal file
|
|
@ -0,0 +1,84 @@
|
||||||
|
package handlers
|
||||||
|
|
||||||
|
import (
|
||||||
|
"Yimaru-Backend/internal/domain"
|
||||||
|
"fmt"
|
||||||
|
|
||||||
|
"github.com/gofiber/fiber/v2"
|
||||||
|
)
|
||||||
|
|
||||||
|
func (h *Handler) learnerHasSubscriptionCategory(c *fiber.Ctx, category domain.SubscriptionCategory) (bool, error) {
|
||||||
|
userID, ok := c.Locals("user_id").(int64)
|
||||||
|
if !ok || userID == 0 {
|
||||||
|
return false, fiber.NewError(fiber.StatusUnauthorized, "Unauthorized")
|
||||||
|
}
|
||||||
|
return h.subscriptionsSvc.HasActiveSubscriptionByCategory(c.Context(), userID, category)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *Handler) ensureLearnerExamPrepContentAccess(c *fiber.Ctx, contentCategory string) error {
|
||||||
|
role, _ := c.Locals("role").(domain.Role)
|
||||||
|
if !role.IsCustomerLearnerRole() {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if !domain.IsExamPrepContentCategory(contentCategory) {
|
||||||
|
return c.Status(fiber.StatusNotFound).JSON(domain.ErrorResponse{
|
||||||
|
Message: "Catalog course not found",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
active, err := h.learnerHasSubscriptionCategory(c, domain.SubscriptionCategory(contentCategory))
|
||||||
|
if err != nil {
|
||||||
|
return c.Status(fiber.StatusInternalServerError).JSON(domain.ErrorResponse{
|
||||||
|
Message: fmt.Sprintf("Failed to verify %s subscription", contentCategory),
|
||||||
|
Error: err.Error(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
if !active {
|
||||||
|
return c.Status(fiber.StatusForbidden).JSON(domain.ErrorResponse{
|
||||||
|
Message: fmt.Sprintf("An active %s subscription is required", humanizeSubscriptionCategory(domain.SubscriptionCategory(contentCategory))),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *Handler) blockLearnerIfNotLMSProgram(c *fiber.Ctx, program domain.Program) error {
|
||||||
|
role, _ := c.Locals("role").(domain.Role)
|
||||||
|
if !role.IsCustomerLearnerRole() {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if !domain.IsLMSContentCategory(program.Category) {
|
||||||
|
return c.Status(fiber.StatusNotFound).JSON(domain.ErrorResponse{
|
||||||
|
Message: "Program not found",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func humanizeSubscriptionCategory(category domain.SubscriptionCategory) string {
|
||||||
|
switch category {
|
||||||
|
case domain.SubscriptionCategoryLearnEnglish:
|
||||||
|
return "learn english"
|
||||||
|
case domain.SubscriptionCategoryIELTS:
|
||||||
|
return "IELTS"
|
||||||
|
case domain.SubscriptionCategoryDuolingo:
|
||||||
|
return "Duolingo"
|
||||||
|
default:
|
||||||
|
return string(category)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func filterExamPrepCatalogCoursesForLearner(items []domain.ExamPrepCatalogCourse, hasIELTS, hasDuolingo bool) []domain.ExamPrepCatalogCourse {
|
||||||
|
filtered := make([]domain.ExamPrepCatalogCourse, 0, len(items))
|
||||||
|
for _, item := range items {
|
||||||
|
switch domain.SubscriptionCategory(item.Category) {
|
||||||
|
case domain.SubscriptionCategoryIELTS:
|
||||||
|
if hasIELTS {
|
||||||
|
filtered = append(filtered, item)
|
||||||
|
}
|
||||||
|
case domain.SubscriptionCategoryDuolingo:
|
||||||
|
if hasDuolingo {
|
||||||
|
filtered = append(filtered, item)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return filtered
|
||||||
|
}
|
||||||
Loading…
Reference in New Issue
Block a user