diff --git a/db/migrations/000079_duolingo_lifetime_subscriptions.down.sql b/db/migrations/000079_duolingo_lifetime_subscriptions.down.sql new file mode 100644 index 0000000..fc3a81f --- /dev/null +++ b/db/migrations/000079_duolingo_lifetime_subscriptions.down.sql @@ -0,0 +1 @@ +-- No-op: prior per-subscription expires_at values cannot be restored. diff --git a/db/migrations/000079_duolingo_lifetime_subscriptions.up.sql b/db/migrations/000079_duolingo_lifetime_subscriptions.up.sql new file mode 100644 index 0000000..034f989 --- /dev/null +++ b/db/migrations/000079_duolingo_lifetime_subscriptions.up.sql @@ -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'; diff --git a/db/migrations/000080_lms_programs_learn_english_only.down.sql b/db/migrations/000080_lms_programs_learn_english_only.down.sql new file mode 100644 index 0000000..6bc8f34 --- /dev/null +++ b/db/migrations/000080_lms_programs_learn_english_only.down.sql @@ -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')); diff --git a/db/migrations/000080_lms_programs_learn_english_only.up.sql b/db/migrations/000080_lms_programs_learn_english_only.up.sql new file mode 100644 index 0000000..0d87542 --- /dev/null +++ b/db/migrations/000080_lms_programs_learn_english_only.up.sql @@ -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'); diff --git a/db/query/programs.sql b/db/query/programs.sql index fed6fff..41f31e2 100644 --- a/db/query/programs.sql +++ b/db/query/programs.sql @@ -43,6 +43,10 @@ WHERE ( sqlc.arg('published_only')::boolean = FALSE 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 LIMIT $1 OFFSET $2; diff --git a/db/query/subscriptions.sql b/db/query/subscriptions.sql index aad736e..4944f45 100644 --- a/db/query/subscriptions.sql +++ b/db/query/subscriptions.sql @@ -164,6 +164,7 @@ SELECT us.*, sp.name AS plan_name FROM user_subscriptions us JOIN subscription_plans sp ON sp.id = us.plan_id WHERE us.status = 'ACTIVE' + AND sp.category <> 'DUOLINGO' AND us.expires_at <= CURRENT_TIMESTAMP; -- name: ListLearnEnglishSubscriptionsExpiringInSevenDays :many @@ -191,6 +192,7 @@ FROM user_subscriptions us JOIN subscription_plans sp ON sp.id = us.plan_id JOIN users u ON u.id = us.user_id WHERE us.status = 'ACTIVE' + AND sp.category <> 'DUOLINGO' AND us.expires_at > CURRENT_TIMESTAMP AND us.expires_at <= CURRENT_TIMESTAMP + INTERVAL '7 days'; diff --git a/gen/db/programs.sql.go b/gen/db/programs.sql.go index 7057681..098659c 100644 --- a/gen/db/programs.sql.go +++ b/gen/db/programs.sql.go @@ -139,14 +139,19 @@ WHERE ( $3::boolean = FALSE OR p.publish_status = 'PUBLISHED'::TEXT ) +AND ( + $4::text = '' + OR p.category = $4::text +) ORDER BY p.sort_order ASC, p.id ASC LIMIT $1 OFFSET $2 ` type ListProgramsParams struct { - Limit int32 `json:"limit"` - Offset int32 `json:"offset"` - PublishedOnly bool `json:"published_only"` + Limit int32 `json:"limit"` + Offset int32 `json:"offset"` + PublishedOnly bool `json:"published_only"` + Category string `json:"category"` } type ListProgramsRow struct { @@ -163,7 +168,12 @@ type ListProgramsRow struct { } 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 { return nil, err } diff --git a/gen/db/subscriptions.sql.go b/gen/db/subscriptions.sql.go index f071733..b8dc4b9 100644 --- a/gen/db/subscriptions.sql.go +++ b/gen/db/subscriptions.sql.go @@ -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 JOIN subscription_plans sp ON sp.id = us.plan_id WHERE us.status = 'ACTIVE' + AND sp.category <> 'DUOLINGO' 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 users u ON u.id = us.user_id WHERE us.status = 'ACTIVE' + AND sp.category <> 'DUOLINGO' AND us.expires_at > CURRENT_TIMESTAMP AND us.expires_at <= CURRENT_TIMESTAMP + INTERVAL '7 days' ` diff --git a/internal/domain/program.go b/internal/domain/program.go index 17f20a2..a7cc7a9 100644 --- a/internal/domain/program.go +++ b/internal/domain/program.go @@ -24,7 +24,7 @@ func (p Program) VisibleToLearners() bool { type CreateProgramInput struct { Name string `json:"name" validate:"required"` 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"` // 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"` @@ -35,7 +35,7 @@ type CreateProgramInput struct { type UpdateProgramInput struct { Name *string `json:"name,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"` SortOrder *int `json:"sort_order,omitempty"` PublishStatus *string `json:"publish_status,omitempty" validate:"omitempty,oneof=DRAFT draft PUBLISHED published"` diff --git a/internal/domain/subscriptions.go b/internal/domain/subscriptions.go index 6c64db6..192136c 100644 --- a/internal/domain/subscriptions.go +++ b/internal/domain/subscriptions.go @@ -1,6 +1,7 @@ package domain import ( + "strings" "time" ) @@ -20,7 +21,49 @@ type SubscriptionExpiryReminder struct { } // 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 @@ -108,6 +151,22 @@ type CreateUserSubscriptionInput struct { 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 func CalculateExpiryDate(startTime time.Time, durationValue int32, durationUnit string) time.Time { switch durationUnit { diff --git a/internal/domain/subscriptions_test.go b/internal/domain/subscriptions_test.go new file mode 100644 index 0000000..52b841a --- /dev/null +++ b/internal/domain/subscriptions_test.go @@ -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) + } +} diff --git a/internal/ports/program.go b/internal/ports/program.go index 9567bde..ce5ed09 100644 --- a/internal/ports/program.go +++ b/internal/ports/program.go @@ -8,7 +8,7 @@ import ( type ProgramStore interface { CreateProgram(ctx context.Context, input domain.CreateProgramInput) (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) ReorderPrograms(ctx context.Context, orderedIDs []int64) error UpdateProgram(ctx context.Context, id int64, input domain.UpdateProgramInput) (domain.Program, error) diff --git a/internal/repository/programs.go b/internal/repository/programs.go index b1049ce..6097876 100644 --- a/internal/repository/programs.go +++ b/internal/repository/programs.go @@ -88,11 +88,12 @@ func (s *Store) GetProgramByID(ctx context.Context, id int64) (domain.Program, e 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{ Limit: limit, Offset: offset, PublishedOnly: publishedOnly, + Category: category, }) if err != nil { return nil, 0, err diff --git a/internal/services/arifpay/service.go b/internal/services/arifpay/service.go index d58a9a9..1bd6090 100644 --- a/internal/services/arifpay/service.go +++ b/internal/services/arifpay/service.go @@ -269,7 +269,7 @@ func (s *ArifpayService) ProcessPaymentWebhook(ctx context.Context, req domain.W } 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) autoRenew := false paymentRef := nonce @@ -1031,7 +1031,7 @@ func (s *ArifpayService) VerifyDirectPaymentOTP(ctx context.Context, userID int6 plan, err := s.subscriptionStore.GetSubscriptionPlanByID(ctx, *payment.PlanID) if err == nil { 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) autoRenew := false paymentRef := payment.Nonce diff --git a/internal/services/chapa/service.go b/internal/services/chapa/service.go index 30bdd48..a89cd19 100644 --- a/internal/services/chapa/service.go +++ b/internal/services/chapa/service.go @@ -371,7 +371,7 @@ func (s *Service) activateSubscription(ctx context.Context, payment *domain.Paym } 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) autoRenew := false paymentRef := payment.Nonce diff --git a/internal/services/programs/service.go b/internal/services/programs/service.go index dfb7ac4..9eabb99 100644 --- a/internal/services/programs/service.go +++ b/internal/services/programs/service.go @@ -34,7 +34,7 @@ func (s *Service) GetByID(ctx context.Context, id int64) (domain.Program, error) 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 { limit = 20 } @@ -44,7 +44,7 @@ func (s *Service) List(ctx context.Context, publishedOnly bool, limit, offset in if 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) { diff --git a/internal/services/subscriptions/service.go b/internal/services/subscriptions/service.go index 3e1ef48..22e4daf 100644 --- a/internal/services/subscriptions/service.go +++ b/internal/services/subscriptions/service.go @@ -75,7 +75,7 @@ func (s *Service) Subscribe(ctx context.Context, userID, planID int64, paymentRe // Calculate expiry date startsAt := time.Now() - expiresAt := domain.CalculateExpiryDate(startsAt, plan.DurationValue, plan.DurationUnit) + expiresAt := domain.CalculateSubscriptionExpiresAt(startsAt, plan.Category, plan.DurationValue, plan.DurationUnit) input := domain.CreateUserSubscriptionInput{ UserID: userID, @@ -176,7 +176,7 @@ func (s *Service) RenewSubscription(ctx context.Context, subscriptionID int64) ( if baseTime.Before(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) if err != nil { diff --git a/internal/web_server/handlers/course_handler.go b/internal/web_server/handlers/course_handler.go index 0c450c6..da8dbda 100644 --- a/internal/web_server/handlers/course_handler.go +++ b/internal/web_server/handlers/course_handler.go @@ -96,12 +96,27 @@ func (h *Handler) ListCoursesByProgram(c *fiber.Ctx) error { if publishedOnly { // Draft programs hide their courses from non-managers. 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{ Message: "Program not found", 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)) if err != nil { @@ -174,6 +189,22 @@ func (h *Handler) GetCourse(c *fiber.Ctx) 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) role := c.Locals("role").(domain.Role) if err := h.lmsProgressSvc.ApplyAccessCourse(c.Context(), role, uid, &course); err != nil { diff --git a/internal/web_server/handlers/exam_prep_catalog_course_handler.go b/internal/web_server/handlers/exam_prep_catalog_course_handler.go index d970d45..c8f8e10 100644 --- a/internal/web_server/handlers/exam_prep_catalog_course_handler.go +++ b/internal/web_server/handlers/exam_prep_catalog_course_handler.go @@ -62,22 +62,15 @@ func (h *Handler) ListExamPrepCatalogCourses(c *fiber.Ctx) error { publishedOnly := !h.canManageExamPrepCatalogCourses(c) role, _ := c.Locals("role").(domain.Role) - if role.IsCustomerLearnerRole() && !domain.CategorySubscriptionGateDisabled { - userID, ok := c.Locals("user_id").(int64) - 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 role.IsCustomerLearnerRole() { + hasIELTS, err := h.learnerHasSubscriptionCategory(c, domain.SubscriptionCategoryIELTS) if err != nil { return c.Status(fiber.StatusInternalServerError).JSON(domain.ErrorResponse{ Message: "Failed to verify IELTS subscription", Error: err.Error(), }) } - hasDuolingo, err := h.subscriptionsSvc.HasActiveSubscriptionByCategory(c.Context(), userID, domain.SubscriptionCategoryDuolingo) + hasDuolingo, err := h.learnerHasSubscriptionCategory(c, domain.SubscriptionCategoryDuolingo) if err != nil { return c.Status(fiber.StatusInternalServerError).JSON(domain.ErrorResponse{ 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)) - 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) - } - } - } + filtered := filterExamPrepCatalogCoursesForLearner(allItems, hasIELTS, hasDuolingo) total := len(filtered) start := offset @@ -239,6 +220,9 @@ func (h *Handler) GetExamPrepCatalogCourseByID(c *fiber.Ctx) 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 { return c.Status(fiber.StatusInternalServerError).JSON(domain.ErrorResponse{ Message: "Failed to build catalog course", diff --git a/internal/web_server/handlers/lms_progress_handler.go b/internal/web_server/handlers/lms_progress_handler.go index ff77abf..5b9b049 100644 --- a/internal/web_server/handlers/lms_progress_handler.go +++ b/internal/web_server/handlers/lms_progress_handler.go @@ -257,7 +257,7 @@ func (h *Handler) listAllPrograms(ctx context.Context, publishedOnly bool) ([]do offset int32 ) 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 { return nil, err } diff --git a/internal/web_server/handlers/program_handler.go b/internal/web_server/handlers/program_handler.go index f78a6a4..d68e7e9 100644 --- a/internal/web_server/handlers/program_handler.go +++ b/internal/web_server/handlers/program_handler.go @@ -70,7 +70,12 @@ func (h *Handler) ListPrograms(c *fiber.Ctx) error { limit, _ := strconv.Atoi(c.Query("limit", "20")) offset, _ := strconv.Atoi(c.Query("offset", "0")) 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 { return c.Status(fiber.StatusInternalServerError).JSON(domain.ErrorResponse{ Message: "Failed to list programs", @@ -78,7 +83,6 @@ func (h *Handler) ListPrograms(c *fiber.Ctx) error { }) } uid := c.Locals("user_id").(int64) - role := c.Locals("role").(domain.Role) for i := range items { if err := h.lmsProgressSvc.ApplyAccessProgram(c.Context(), role, uid, &items[i]); err != nil { return c.Status(fiber.StatusInternalServerError).JSON(domain.ErrorResponse{ @@ -135,6 +139,9 @@ func (h *Handler) GetProgram(c *fiber.Ctx) error { Error: programs.ErrProgramNotFound.Error(), }) } + if err := h.blockLearnerIfNotLMSProgram(c, p); err != nil { + return err + } uid := c.Locals("user_id").(int64) role := c.Locals("role").(domain.Role) if err := h.lmsProgressSvc.ApplyAccessProgram(c.Context(), role, uid, &p); err != nil { diff --git a/internal/web_server/handlers/subscription_content_gate.go b/internal/web_server/handlers/subscription_content_gate.go new file mode 100644 index 0000000..be33420 --- /dev/null +++ b/internal/web_server/handlers/subscription_content_gate.go @@ -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 +}