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
|
||||
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;
|
||||
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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'
|
||||
`
|
||||
|
|
|
|||
|
|
@ -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"`
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
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 {
|
||||
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)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
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