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:
Yared Yemane 2026-06-10 03:59:26 -07:00
parent 58790f0998
commit 26cf7d2908
22 changed files with 297 additions and 43 deletions

View File

@ -0,0 +1 @@
-- No-op: prior per-subscription expires_at values cannot be restored.

View File

@ -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';

View File

@ -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'));

View 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');

View File

@ -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;

View File

@ -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';

View File

@ -139,6 +139,10 @@ 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
` `
@ -147,6 +151,7 @@ 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
} }

View File

@ -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'
` `

View File

@ -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"`

View File

@ -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 {

View 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)
}
}

View File

@ -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)

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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) {

View File

@ -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 {

View File

@ -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 {

View File

@ -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",

View File

@ -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
} }

View File

@ -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 {

View 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
}