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

View File

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

View File

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

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

View File

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

View File

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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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
}