diff --git a/internal/web_server/content_access_middleware.go b/internal/web_server/content_access_middleware.go index 030765c..342ce92 100644 --- a/internal/web_server/content_access_middleware.go +++ b/internal/web_server/content_access_middleware.go @@ -19,6 +19,11 @@ import ( func (a *App) RequireLMSSubscriptionUnlessFree() fiber.Handler { return func(c *fiber.Ctx) error { + // Reads return catalog metadata (including effective_access_tier) for client-side lock UI. + // Subscription is enforced on consumption routes (complete, questions, video heartbeat). + if isReadOnlyHTTPMethod(c.Method()) { + return c.Next() + } role, userID, err := subscriptionScopedUser(c) if err != nil { return err diff --git a/internal/web_server/handlers/content_access_gate.go b/internal/web_server/handlers/content_access_gate.go index f03849a..7c314c8 100644 --- a/internal/web_server/handlers/content_access_gate.go +++ b/internal/web_server/handlers/content_access_gate.go @@ -1,34 +1,33 @@ package handlers import ( + "context" + "Yimaru-Backend/internal/domain" - "fmt" + "Yimaru-Backend/internal/services/courses" + "Yimaru-Backend/internal/services/examprep" + "Yimaru-Backend/internal/services/modules" + "Yimaru-Backend/internal/services/programs" "github.com/gofiber/fiber/v2" ) -func (h *Handler) learnerHasLearnEnglishSubscription(c *fiber.Ctx) (bool, error) { - return h.learnerHasSubscriptionCategory(c, domain.SubscriptionCategoryLearnEnglish) +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) ensureLearnerPremiumContentAccess(c *fiber.Ctx, effectiveTier domain.ContentAccessTier, category domain.SubscriptionCategory) error { +func (h *Handler) blockLearnerIfNotLMSProgram(c *fiber.Ctx, program domain.Program) error { role, _ := c.Locals("role").(domain.Role) - if !role.IsCustomerLearnerRole() || domain.CategorySubscriptionGateDisabled { + if !role.IsCustomerLearnerRole() { return nil } - if !effectiveTier.RequiresSubscription() { - return nil - } - active, err := h.learnerHasSubscriptionCategory(c, category) - if err != nil { - return c.Status(fiber.StatusInternalServerError).JSON(domain.ErrorResponse{ - Message: fmt.Sprintf("Failed to verify %s subscription", category), - Error: err.Error(), - }) - } - if !active { - return c.Status(fiber.StatusForbidden).JSON(domain.ErrorResponse{ - Message: fmt.Sprintf("An active %s subscription is required", humanizeSubscriptionCategory(category)), + if !domain.IsLMSContentCategory(program.Category) { + return c.Status(fiber.StatusNotFound).JSON(domain.ErrorResponse{ + Message: "Program not found", }) } return nil @@ -66,58 +65,191 @@ func applyExamPrepLessonEffectiveAccessTier(lesson *domain.ExamPrepLesson, catal lesson.EffectiveAccessTier = domain.EffectiveContentAccessTier(catalogCourse.AccessTier, unit.AccessTier, module.AccessTier, lesson.AccessTier) } -func learnerCanViewEffectiveTier(hasSubscription bool, effectiveTier domain.ContentAccessTier) bool { - return !effectiveTier.RequiresSubscription() || hasSubscription -} - -func filterProgramsForLearner(items []domain.Program, hasLearnEnglish bool) []domain.Program { - filtered := make([]domain.Program, 0, len(items)) - for _, item := range items { - applyProgramEffectiveAccessTier(&item) - if learnerCanViewEffectiveTier(hasLearnEnglish, item.EffectiveAccessTier) { - filtered = append(filtered, item) - } +func annotateProgramsEffectiveAccessTier(items []domain.Program) { + for i := range items { + applyProgramEffectiveAccessTier(&items[i]) } - return filtered } -func filterCoursesForLearner(items []domain.Course, program domain.Program, hasLearnEnglish bool) []domain.Course { - filtered := make([]domain.Course, 0, len(items)) +func annotateCoursesEffectiveAccessTier(items []domain.Course, program domain.Program) { for i := range items { applyCourseEffectiveAccessTier(&items[i], program) - if learnerCanViewEffectiveTier(hasLearnEnglish, items[i].EffectiveAccessTier) { - filtered = append(filtered, items[i]) - } } - return filtered } -func filterExamPrepCatalogCoursesForLearner(items []domain.ExamPrepCatalogCourse, hasIELTS, hasDuolingo bool) []domain.ExamPrepCatalogCourse { - if domain.ExamPrepSubscriptionGateDisabled { - out := make([]domain.ExamPrepCatalogCourse, 0, len(items)) - for _, item := range items { - applyExamPrepCatalogCourseEffectiveAccessTier(&item) - out = append(out, item) - } - return out +func annotateExamPrepCatalogCoursesEffectiveAccessTier(items []domain.ExamPrepCatalogCourse) { + for i := range items { + applyExamPrepCatalogCourseEffectiveAccessTier(&items[i]) + } +} + +func (h *Handler) annotateLMSModulesEffectiveAccessTier(ctx context.Context, courseID int64, items []domain.Module) error { + course, err := h.courseSvc.GetByID(ctx, courseID) + if err != nil { + return err + } + program, err := h.programSvc.GetByID(ctx, course.ProgramID) + if err != nil { + return err + } + for i := range items { + applyModuleEffectiveAccessTier(&items[i], program, course) + } + return nil +} + +func (h *Handler) annotateLMSModuleEffectiveAccessTier(ctx context.Context, module *domain.Module) error { + course, err := h.courseSvc.GetByID(ctx, module.CourseID) + if err != nil { + return err + } + program, err := h.programSvc.GetByID(ctx, course.ProgramID) + if err != nil { + return err + } + applyModuleEffectiveAccessTier(module, program, course) + return nil +} + +func (h *Handler) annotateLMSLessonsEffectiveAccessTier(ctx context.Context, moduleID int64, items []domain.Lesson) error { + module, err := h.moduleSvc.GetByID(ctx, moduleID) + if err != nil { + return err + } + course, err := h.courseSvc.GetByID(ctx, module.CourseID) + if err != nil { + return err + } + program, err := h.programSvc.GetByID(ctx, course.ProgramID) + if err != nil { + return err + } + for i := range items { + applyLessonEffectiveAccessTier(&items[i], program, course, module) + } + return nil +} + +func (h *Handler) annotateLMSLessonEffectiveAccessTier(ctx context.Context, lesson *domain.Lesson) error { + module, err := h.moduleSvc.GetByID(ctx, lesson.ModuleID) + if err != nil { + return err + } + course, err := h.courseSvc.GetByID(ctx, module.CourseID) + if err != nil { + return err + } + program, err := h.programSvc.GetByID(ctx, course.ProgramID) + if err != nil { + return err + } + applyLessonEffectiveAccessTier(lesson, program, course, module) + return nil +} + +func (h *Handler) annotateExamPrepUnitsEffectiveAccessTier(ctx context.Context, catalogCourseID int64, items []domain.ExamPrepUnit) error { + cc, err := h.examPrepSvc.GetCatalogCourseByID(ctx, catalogCourseID) + if err != nil { + return err + } + for i := range items { + applyExamPrepUnitEffectiveAccessTier(&items[i], cc) + } + return nil +} + +func (h *Handler) annotateExamPrepUnitEffectiveAccessTier(ctx context.Context, unit *domain.ExamPrepUnit) error { + cc, err := h.examPrepSvc.GetCatalogCourseByID(ctx, unit.CatalogCourseID) + if err != nil { + return err + } + applyExamPrepUnitEffectiveAccessTier(unit, cc) + return nil +} + +func (h *Handler) annotateExamPrepModulesEffectiveAccessTier(ctx context.Context, unitID int64, items []domain.ExamPrepModule) error { + unit, err := h.examPrepSvc.GetUnitByID(ctx, unitID) + if err != nil { + return err + } + cc, err := h.examPrepSvc.GetCatalogCourseByID(ctx, unit.CatalogCourseID) + if err != nil { + return err + } + for i := range items { + applyExamPrepModuleEffectiveAccessTier(&items[i], cc, unit) + } + return nil +} + +func (h *Handler) annotateExamPrepModuleEffectiveAccessTier(ctx context.Context, module *domain.ExamPrepModule) error { + unit, err := h.examPrepSvc.GetUnitByID(ctx, module.UnitID) + if err != nil { + return err + } + cc, err := h.examPrepSvc.GetCatalogCourseByID(ctx, unit.CatalogCourseID) + if err != nil { + return err + } + applyExamPrepModuleEffectiveAccessTier(module, cc, unit) + return nil +} + +func (h *Handler) annotateExamPrepLessonsEffectiveAccessTier(ctx context.Context, moduleID int64, items []domain.ExamPrepLesson) error { + module, err := h.examPrepSvc.GetModuleByID(ctx, moduleID) + if err != nil { + return err + } + unit, err := h.examPrepSvc.GetUnitByID(ctx, module.UnitID) + if err != nil { + return err + } + cc, err := h.examPrepSvc.GetCatalogCourseByID(ctx, unit.CatalogCourseID) + if err != nil { + return err + } + for i := range items { + applyExamPrepLessonEffectiveAccessTier(&items[i], cc, unit, module) + } + return nil +} + +func (h *Handler) annotateExamPrepLessonEffectiveAccessTier(ctx context.Context, lesson *domain.ExamPrepLesson) error { + module, err := h.examPrepSvc.GetModuleByID(ctx, lesson.UnitModuleID) + if err != nil { + return err + } + unit, err := h.examPrepSvc.GetUnitByID(ctx, module.UnitID) + if err != nil { + return err + } + cc, err := h.examPrepSvc.GetCatalogCourseByID(ctx, unit.CatalogCourseID) + if err != nil { + return err + } + applyExamPrepLessonEffectiveAccessTier(lesson, cc, unit, module) + return nil +} + +func mapLMSEffectiveAccessTierError(err error) (int, string) { + switch { + case err == nil: + return 0, "" + case err == programs.ErrProgramNotFound, err == courses.ErrCourseNotFound, err == modules.ErrModuleNotFound: + return 404, "Not found" + default: + return 500, "Failed to build content access metadata" + } +} + +func mapExamPrepEffectiveAccessTierError(err error) (int, string) { + switch { + case err == nil: + return 0, "" + case err == examprep.ErrCatalogCourseNotFound, err == examprep.ErrUnitNotFound, + err == examprep.ErrModuleNotFound, err == examprep.ErrLessonNotFound: + return 404, "Not found" + default: + return 500, "Failed to build content access metadata" } - filtered := make([]domain.ExamPrepCatalogCourse, 0, len(items)) - for _, item := range items { - applyExamPrepCatalogCourseEffectiveAccessTier(&item) - if !item.EffectiveAccessTier.RequiresSubscription() { - filtered = append(filtered, item) - continue - } - 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 } diff --git a/internal/web_server/handlers/course_handler.go b/internal/web_server/handlers/course_handler.go index 16ea9f9..198d0f1 100644 --- a/internal/web_server/handlers/course_handler.go +++ b/internal/web_server/handlers/course_handler.go @@ -134,16 +134,24 @@ func (h *Handler) ListCoursesByProgram(c *fiber.Ctx) error { Error: err.Error(), }) } - if role.IsCustomerLearnerRole() && publishedOnly { - hasLearnEnglish, err := h.learnerHasLearnEnglishSubscription(c) - if err != nil { - return c.Status(fiber.StatusInternalServerError).JSON(domain.ErrorResponse{ - Message: "Failed to verify Learn English subscription", - Error: err.Error(), - }) + if publishedOnly { + if program.ID == 0 { + p, err := h.programSvc.GetByID(c.Context(), programID) + 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(), + }) + } + program = p } - items = filterCoursesForLearner(items, program, hasLearnEnglish) - total = int64(len(items)) + annotateCoursesEffectiveAccessTier(items, program) } uid := c.Locals("user_id").(int64) for i := range items { @@ -219,9 +227,6 @@ func (h *Handler) GetCourse(c *fiber.Ctx) error { return err } applyCourseEffectiveAccessTier(&course, p) - if err := h.ensureLearnerPremiumContentAccess(c, course.EffectiveAccessTier, domain.SubscriptionCategoryLearnEnglish); 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 { @@ -230,9 +235,6 @@ func (h *Handler) GetCourse(c *fiber.Ctx) error { Error: err.Error(), }) } - if err := lmsBlockIfInaccessible(c, course.Access); err != nil { - return err - } return c.JSON(domain.Response{ Message: "Course retrieved successfully", Data: course, 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 ad2973d..7daa50b 100644 --- a/internal/web_server/handlers/exam_prep_catalog_course_handler.go +++ b/internal/web_server/handlers/exam_prep_catalog_course_handler.go @@ -61,64 +61,6 @@ func (h *Handler) ListExamPrepCatalogCourses(c *fiber.Ctx) error { offset, _ := strconv.Atoi(c.Query("offset", "0")) publishedOnly := !h.canManageExamPrepCatalogCourses(c) - role, _ := c.Locals("role").(domain.Role) - 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.learnerHasSubscriptionCategory(c, domain.SubscriptionCategoryDuolingo) - if err != nil { - return c.Status(fiber.StatusInternalServerError).JSON(domain.ErrorResponse{ - Message: "Failed to verify Duolingo subscription", - Error: err.Error(), - }) - } - - allItems, _, err := h.examPrepSvc.ListCatalogCourses(c.Context(), publishedOnly, 200, 0) - if err != nil { - return c.Status(fiber.StatusInternalServerError).JSON(domain.ErrorResponse{ - Message: "Failed to list catalog courses", - Error: err.Error(), - }) - } - - filtered := filterExamPrepCatalogCoursesForLearner(allItems, hasIELTS, hasDuolingo) - - total := len(filtered) - start := offset - if start > total { - start = total - } - end := start + limit - if end > total { - end = total - } - - page := filtered[start:end] - if err := h.applyExamPrepAccessCatalogCourses(c.Context(), c, page); err != nil { - return c.Status(fiber.StatusInternalServerError).JSON(domain.ErrorResponse{ - Message: "Failed to build catalog course list", - Error: err.Error(), - }) - } - - return c.JSON(domain.Response{ - Message: "Catalog courses retrieved successfully", - Data: fiber.Map{ - "catalog_courses": page, - "total_count": total, - "limit": limit, - "offset": offset, - }, - Success: true, - StatusCode: fiber.StatusOK, - }) - } - items, total, err := h.examPrepSvc.ListCatalogCourses(c.Context(), publishedOnly, int32(limit), int32(offset)) if err != nil { return c.Status(fiber.StatusInternalServerError).JSON(domain.ErrorResponse{ @@ -126,6 +68,7 @@ func (h *Handler) ListExamPrepCatalogCourses(c *fiber.Ctx) error { Error: err.Error(), }) } + annotateExamPrepCatalogCoursesEffectiveAccessTier(items) if err := h.applyExamPrepAccessCatalogCourses(c.Context(), c, items); err != nil { return c.Status(fiber.StatusInternalServerError).JSON(domain.ErrorResponse{ Message: "Failed to build catalog course list", @@ -221,9 +164,6 @@ func (h *Handler) GetExamPrepCatalogCourseByID(c *fiber.Ctx) error { }) } applyExamPrepCatalogCourseEffectiveAccessTier(&out) - if err := h.ensureLearnerExamPrepContentAccess(c, out.Category, out.EffectiveAccessTier); 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/exam_prep_lesson_handler.go b/internal/web_server/handlers/exam_prep_lesson_handler.go index 141dfe4..391b8a8 100644 --- a/internal/web_server/handlers/exam_prep_lesson_handler.go +++ b/internal/web_server/handlers/exam_prep_lesson_handler.go @@ -98,6 +98,11 @@ func (h *Handler) ListExamPrepLessonsByUnitModule(c *fiber.Ctx) error { Error: err.Error(), }) } + if err := h.annotateExamPrepLessonsEffectiveAccessTier(c.Context(), moduleID, items); err != nil { + if status, msg := mapExamPrepEffectiveAccessTierError(err); status != 0 { + return c.Status(status).JSON(domain.ErrorResponse{Message: msg, Error: err.Error()}) + } + } if err := h.applyExamPrepAccessLessons(c.Context(), c, items); err != nil { return c.Status(fiber.StatusInternalServerError).JSON(domain.ErrorResponse{ Message: "Failed to build lesson list", @@ -198,6 +203,11 @@ func (h *Handler) GetExamPrepLessonByID(c *fiber.Ctx) error { Error: examprep.ErrLessonNotFound.Error(), }) } + if err := h.annotateExamPrepLessonEffectiveAccessTier(c.Context(), &les); err != nil { + if status, msg := mapExamPrepEffectiveAccessTierError(err); status != 0 { + return c.Status(status).JSON(domain.ErrorResponse{Message: msg, Error: err.Error()}) + } + } if err := h.applyExamPrepAccessLesson(c.Context(), c, &les); err != nil { return c.Status(fiber.StatusInternalServerError).JSON(domain.ErrorResponse{ Message: "Failed to build lesson", diff --git a/internal/web_server/handlers/exam_prep_module_handler.go b/internal/web_server/handlers/exam_prep_module_handler.go index f82583c..181070f 100644 --- a/internal/web_server/handlers/exam_prep_module_handler.go +++ b/internal/web_server/handlers/exam_prep_module_handler.go @@ -96,6 +96,11 @@ func (h *Handler) ListExamPrepModulesByUnit(c *fiber.Ctx) error { Error: err.Error(), }) } + if err := h.annotateExamPrepModulesEffectiveAccessTier(c.Context(), unitID, items); err != nil { + if status, msg := mapExamPrepEffectiveAccessTierError(err); status != 0 { + return c.Status(status).JSON(domain.ErrorResponse{Message: msg, Error: err.Error()}) + } + } if err := h.applyExamPrepAccessModules(c.Context(), c, items); err != nil { return c.Status(fiber.StatusInternalServerError).JSON(domain.ErrorResponse{ Message: "Failed to build module list", @@ -198,6 +203,11 @@ func (h *Handler) GetExamPrepModuleByID(c *fiber.Ctx) error { Error: examprep.ErrModuleNotFound.Error(), }) } + if err := h.annotateExamPrepModuleEffectiveAccessTier(c.Context(), &out); err != nil { + if status, msg := mapExamPrepEffectiveAccessTierError(err); status != 0 { + return c.Status(status).JSON(domain.ErrorResponse{Message: msg, Error: err.Error()}) + } + } if err := h.applyExamPrepAccessModule(c.Context(), c, &out); err != nil { return c.Status(fiber.StatusInternalServerError).JSON(domain.ErrorResponse{ Message: "Failed to build module", diff --git a/internal/web_server/handlers/exam_prep_unit_handler.go b/internal/web_server/handlers/exam_prep_unit_handler.go index a6590ff..9b1579c 100644 --- a/internal/web_server/handlers/exam_prep_unit_handler.go +++ b/internal/web_server/handlers/exam_prep_unit_handler.go @@ -103,6 +103,11 @@ func (h *Handler) ListExamPrepUnitsByCatalogCourse(c *fiber.Ctx) error { Error: err.Error(), }) } + if err := h.annotateExamPrepUnitsEffectiveAccessTier(c.Context(), catalogCourseID, items); err != nil { + if status, msg := mapExamPrepEffectiveAccessTierError(err); status != 0 { + return c.Status(status).JSON(domain.ErrorResponse{Message: msg, Error: err.Error()}) + } + } if err := h.applyExamPrepAccessUnits(c.Context(), c, items); err != nil { return c.Status(fiber.StatusInternalServerError).JSON(domain.ErrorResponse{ Message: "Failed to build unit list", @@ -206,6 +211,11 @@ func (h *Handler) GetExamPrepUnitByID(c *fiber.Ctx) error { Error: examprep.ErrUnitNotFound.Error(), }) } + if err := h.annotateExamPrepUnitEffectiveAccessTier(c.Context(), &out); err != nil { + if status, msg := mapExamPrepEffectiveAccessTierError(err); status != 0 { + return c.Status(status).JSON(domain.ErrorResponse{Message: msg, Error: err.Error()}) + } + } if err := h.applyExamPrepAccessUnit(c.Context(), c, &out); err != nil { return c.Status(fiber.StatusInternalServerError).JSON(domain.ErrorResponse{ Message: "Failed to build unit", diff --git a/internal/web_server/handlers/lesson_handler.go b/internal/web_server/handlers/lesson_handler.go index 60e2f46..189c9f5 100644 --- a/internal/web_server/handlers/lesson_handler.go +++ b/internal/web_server/handlers/lesson_handler.go @@ -98,6 +98,11 @@ func (h *Handler) ListLessonsByModule(c *fiber.Ctx) error { Error: err.Error(), }) } + if err := h.annotateLMSLessonsEffectiveAccessTier(c.Context(), moduleID, items); err != nil { + if status, msg := mapLMSEffectiveAccessTierError(err); status != 0 { + return c.Status(status).JSON(domain.ErrorResponse{Message: msg, Error: err.Error()}) + } + } uid := c.Locals("user_id").(int64) role := c.Locals("role").(domain.Role) for i := range items { @@ -152,6 +157,11 @@ func (h *Handler) GetLesson(c *fiber.Ctx) error { Error: lessons.ErrLessonNotFound.Error(), }) } + if err := h.annotateLMSLessonEffectiveAccessTier(c.Context(), &les); err != nil { + if status, msg := mapLMSEffectiveAccessTierError(err); status != 0 { + return c.Status(status).JSON(domain.ErrorResponse{Message: msg, Error: err.Error()}) + } + } uid := c.Locals("user_id").(int64) role := c.Locals("role").(domain.Role) if err := h.lmsProgressSvc.ApplyAccessLesson(c.Context(), role, uid, &les); err != nil { @@ -160,9 +170,6 @@ func (h *Handler) GetLesson(c *fiber.Ctx) error { Error: err.Error(), }) } - if err := lmsBlockIfInaccessible(c, les.Access); err != nil { - return err - } return c.JSON(domain.Response{ Message: "Lesson retrieved successfully", Data: les, diff --git a/internal/web_server/handlers/lms_gating.go b/internal/web_server/handlers/lms_gating.go deleted file mode 100644 index 52ac4b8..0000000 --- a/internal/web_server/handlers/lms_gating.go +++ /dev/null @@ -1,18 +0,0 @@ -package handlers - -import ( - "Yimaru-Backend/internal/domain" - - "github.com/gofiber/fiber/v2" -) - -// lmsBlockIfInaccessible returns a 403 response when a learner is blocked; otherwise nil. -func lmsBlockIfInaccessible(c *fiber.Ctx, a *domain.LMSEntityAccess) error { - if a == nil || a.IsAccessible { - return nil - } - return c.Status(fiber.StatusForbidden).JSON(domain.ErrorResponse{ - Message: a.Reason, - Error: "LMS_PREREQUISITE_NOT_MET", - }) -} diff --git a/internal/web_server/handlers/module_handler.go b/internal/web_server/handlers/module_handler.go index b84569e..39c2036 100644 --- a/internal/web_server/handlers/module_handler.go +++ b/internal/web_server/handlers/module_handler.go @@ -114,6 +114,11 @@ func (h *Handler) ListModulesByCourse(c *fiber.Ctx) error { Error: err.Error(), }) } + if err := h.annotateLMSModulesEffectiveAccessTier(c.Context(), courseID, items); err != nil { + if status, msg := mapLMSEffectiveAccessTierError(err); status != 0 { + return c.Status(status).JSON(domain.ErrorResponse{Message: msg, Error: err.Error()}) + } + } uid := c.Locals("user_id").(int64) role := c.Locals("role").(domain.Role) for i := range items { @@ -171,15 +176,17 @@ func (h *Handler) GetModule(c *fiber.Ctx) error { } uid := c.Locals("user_id").(int64) role := c.Locals("role").(domain.Role) + if err := h.annotateLMSModuleEffectiveAccessTier(c.Context(), &mod); err != nil { + if status, msg := mapLMSEffectiveAccessTierError(err); status != 0 { + return c.Status(status).JSON(domain.ErrorResponse{Message: msg, Error: err.Error()}) + } + } if err := h.lmsProgressSvc.ApplyAccessModule(c.Context(), role, uid, &mod); err != nil { return c.Status(fiber.StatusInternalServerError).JSON(domain.ErrorResponse{ Message: "Failed to evaluate module access", Error: err.Error(), }) } - if err := lmsBlockIfInaccessible(c, mod.Access); err != nil { - return err - } return c.JSON(domain.Response{ Message: "Module retrieved successfully", Data: mod, diff --git a/internal/web_server/handlers/program_handler.go b/internal/web_server/handlers/program_handler.go index 13a1228..6d7e849 100644 --- a/internal/web_server/handlers/program_handler.go +++ b/internal/web_server/handlers/program_handler.go @@ -82,17 +82,7 @@ func (h *Handler) ListPrograms(c *fiber.Ctx) error { Error: err.Error(), }) } - if role.IsCustomerLearnerRole() { - hasLearnEnglish, err := h.learnerHasLearnEnglishSubscription(c) - if err != nil { - return c.Status(fiber.StatusInternalServerError).JSON(domain.ErrorResponse{ - Message: "Failed to verify Learn English subscription", - Error: err.Error(), - }) - } - items = filterProgramsForLearner(items, hasLearnEnglish) - total = int64(len(items)) - } + annotateProgramsEffectiveAccessTier(items) uid := c.Locals("user_id").(int64) for i := range items { if err := h.lmsProgressSvc.ApplyAccessProgram(c.Context(), role, uid, &items[i]); err != nil { @@ -154,9 +144,6 @@ func (h *Handler) GetProgram(c *fiber.Ctx) error { return err } applyProgramEffectiveAccessTier(&p) - if err := h.ensureLearnerPremiumContentAccess(c, p.EffectiveAccessTier, domain.SubscriptionCategoryLearnEnglish); 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 { @@ -165,9 +152,6 @@ func (h *Handler) GetProgram(c *fiber.Ctx) error { Error: err.Error(), }) } - if err := lmsBlockIfInaccessible(c, p.Access); err != nil { - return err - } return c.JSON(domain.Response{ Message: "Program retrieved successfully", Data: p, diff --git a/internal/web_server/handlers/subscription_content_gate.go b/internal/web_server/handlers/subscription_content_gate.go deleted file mode 100644 index 19add86..0000000 --- a/internal/web_server/handlers/subscription_content_gate.go +++ /dev/null @@ -1,55 +0,0 @@ -package handlers - -import ( - "Yimaru-Backend/internal/domain" - - "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, effectiveTier domain.ContentAccessTier) error { - role, _ := c.Locals("role").(domain.Role) - if !role.IsCustomerLearnerRole() || domain.ExamPrepSubscriptionGateDisabled { - return nil - } - if !domain.IsExamPrepContentCategory(contentCategory) { - return c.Status(fiber.StatusNotFound).JSON(domain.ErrorResponse{ - Message: "Catalog course not found", - }) - } - return h.ensureLearnerPremiumContentAccess(c, effectiveTier, domain.SubscriptionCategory(contentCategory)) -} - -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) - } -} - diff --git a/internal/web_server/middleware.go b/internal/web_server/middleware.go index dc46401..ecd2ec7 100644 --- a/internal/web_server/middleware.go +++ b/internal/web_server/middleware.go @@ -247,6 +247,9 @@ func (a *App) RequireSubscriptionCategory(category domain.SubscriptionCategory) func (a *App) RequireExamPrepSubscription() fiber.Handler { return func(c *fiber.Ctx) error { + if isReadOnlyHTTPMethod(c.Method()) { + return c.Next() + } role, userID, err := subscriptionScopedUser(c) if err != nil { return err @@ -332,6 +335,15 @@ func subscriptionScopedUser(c *fiber.Ctx) (domain.Role, int64, error) { return role, userID, nil } +func isReadOnlyHTTPMethod(method string) bool { + switch method { + case fiber.MethodGet, fiber.MethodHead, fiber.MethodOptions: + return true + default: + return false + } +} + func bypassSubscriptionForRole(role domain.Role) bool { switch role { case domain.RoleSuperAdmin, domain.RoleAdmin, domain.RoleInstructor, domain.RoleSupport: