diff --git a/db/query/lms_practices.sql b/db/query/lms_practices.sql index 46c3167..f3e48de 100644 --- a/db/query/lms_practices.sql +++ b/db/query/lms_practices.sql @@ -34,15 +34,10 @@ SELECT p.created_at, p.updated_at FROM lms_practices p -INNER JOIN question_sets qs ON qs.id = p.question_set_id WHERE p.course_id = $1 AND ( sqlc.arg('published_only')::boolean = FALSE - OR ( - p.publish_status = 'PUBLISHED'::TEXT - AND qs.set_type = 'PRACTICE' - AND qs.status = 'PUBLISHED' - ) + OR p.publish_status = 'PUBLISHED'::TEXT ) ORDER BY p.created_at DESC LIMIT $2 OFFSET $3; @@ -64,15 +59,10 @@ SELECT p.created_at, p.updated_at FROM lms_practices p -INNER JOIN question_sets qs ON qs.id = p.question_set_id WHERE p.module_id = $1 AND ( sqlc.arg('published_only')::boolean = FALSE - OR ( - p.publish_status = 'PUBLISHED'::TEXT - AND qs.set_type = 'PRACTICE' - AND qs.status = 'PUBLISHED' - ) + OR p.publish_status = 'PUBLISHED'::TEXT ) ORDER BY p.created_at DESC LIMIT $2 OFFSET $3; @@ -94,15 +84,10 @@ SELECT p.created_at, p.updated_at FROM lms_practices p -INNER JOIN question_sets qs ON qs.id = p.question_set_id WHERE p.lesson_id = $1 AND ( sqlc.arg('published_only')::boolean = FALSE - OR ( - p.publish_status = 'PUBLISHED'::TEXT - AND qs.set_type = 'PRACTICE' - AND qs.status = 'PUBLISHED' - ) + OR p.publish_status = 'PUBLISHED'::TEXT ) ORDER BY p.created_at DESC LIMIT $2 OFFSET $3; diff --git a/gen/db/lms_practices.sql.go b/gen/db/lms_practices.sql.go index 0e868df..8039241 100644 --- a/gen/db/lms_practices.sql.go +++ b/gen/db/lms_practices.sql.go @@ -147,15 +147,10 @@ SELECT p.created_at, p.updated_at FROM lms_practices p -INNER JOIN question_sets qs ON qs.id = p.question_set_id WHERE p.course_id = $1 AND ( $4::boolean = FALSE - OR ( - p.publish_status = 'PUBLISHED'::TEXT - AND qs.set_type = 'PRACTICE' - AND qs.status = 'PUBLISHED' - ) + OR p.publish_status = 'PUBLISHED'::TEXT ) ORDER BY p.created_at DESC LIMIT $2 OFFSET $3 @@ -242,15 +237,10 @@ SELECT p.created_at, p.updated_at FROM lms_practices p -INNER JOIN question_sets qs ON qs.id = p.question_set_id WHERE p.lesson_id = $1 AND ( $4::boolean = FALSE - OR ( - p.publish_status = 'PUBLISHED'::TEXT - AND qs.set_type = 'PRACTICE' - AND qs.status = 'PUBLISHED' - ) + OR p.publish_status = 'PUBLISHED'::TEXT ) ORDER BY p.created_at DESC LIMIT $2 OFFSET $3 @@ -337,15 +327,10 @@ SELECT p.created_at, p.updated_at FROM lms_practices p -INNER JOIN question_sets qs ON qs.id = p.question_set_id WHERE p.module_id = $1 AND ( $4::boolean = FALSE - OR ( - p.publish_status = 'PUBLISHED'::TEXT - AND qs.set_type = 'PRACTICE' - AND qs.status = 'PUBLISHED' - ) + OR p.publish_status = 'PUBLISHED'::TEXT ) ORDER BY p.created_at DESC LIMIT $2 OFFSET $3 diff --git a/internal/domain/practice.go b/internal/domain/practice.go index e620f42..508f10f 100644 --- a/internal/domain/practice.go +++ b/internal/domain/practice.go @@ -51,10 +51,16 @@ type Practice struct { QuestionSetID int64 `json:"question_set_id"` PublishStatus PracticePublishStatus `json:"publish_status"` QuickTips *string `json:"quick_tips,omitempty"` + Access *PracticeAccess `json:"access,omitempty"` CreatedAt time.Time `json:"created_at"` UpdatedAt *time.Time `json:"updated_at,omitempty"` } +type PracticeAccess struct { + IsAccessible bool `json:"is_accessible"` + Reason string `json:"reason,omitempty"` +} + // VisibleToLearners is true when the practice shell should appear in subscribed learner catalogs and progression. func (p Practice) VisibleToLearners() bool { return p.PublishStatus == PracticePublishPublished diff --git a/internal/web_server/handlers/practice_handler.go b/internal/web_server/handlers/practice_handler.go index f0c10a9..65386e1 100644 --- a/internal/web_server/handlers/practice_handler.go +++ b/internal/web_server/handlers/practice_handler.go @@ -9,6 +9,7 @@ import ( "context" "errors" "strconv" + "strings" "github.com/gofiber/fiber/v2" ) @@ -76,7 +77,8 @@ func (h *Handler) ListPracticesByCourse(c *fiber.Ctx) error { } limit, _ := strconv.Atoi(c.Query("limit", "20")) offset, _ := strconv.Atoi(c.Query("offset", "0")) - publishedOnly := !h.canManageLMSPractices(c) + role, _ := c.Locals("role").(domain.Role) + publishedOnly := role.IsCustomerLearnerRole() || !h.canManageLMSPractices(c) items, total, err := h.practiceSvc.ListByCourse(c.Context(), courseID, publishedOnly, int32(limit), int32(offset)) if err != nil { if errors.Is(err, courses.ErrCourseNotFound) { @@ -84,6 +86,9 @@ func (h *Handler) ListPracticesByCourse(c *fiber.Ctx) error { } return c.Status(fiber.StatusInternalServerError).JSON(domain.ErrorResponse{Message: "Failed to list practices", Error: err.Error()}) } + if err := h.applyPracticeAccess(c.Context(), c, items); err != nil { + return c.Status(fiber.StatusInternalServerError).JSON(domain.ErrorResponse{Message: "Failed to build practices", Error: err.Error()}) + } return c.JSON(domain.Response{ Message: "Practices retrieved successfully", Data: fiber.Map{ @@ -108,7 +113,8 @@ func (h *Handler) ListPracticesByModule(c *fiber.Ctx) error { } limit, _ := strconv.Atoi(c.Query("limit", "20")) offset, _ := strconv.Atoi(c.Query("offset", "0")) - publishedOnly := !h.canManageLMSPractices(c) + role, _ := c.Locals("role").(domain.Role) + publishedOnly := role.IsCustomerLearnerRole() || !h.canManageLMSPractices(c) items, total, err := h.practiceSvc.ListByModule(c.Context(), moduleID, publishedOnly, int32(limit), int32(offset)) if err != nil { if errors.Is(err, modules.ErrModuleNotFound) { @@ -116,6 +122,9 @@ func (h *Handler) ListPracticesByModule(c *fiber.Ctx) error { } return c.Status(fiber.StatusInternalServerError).JSON(domain.ErrorResponse{Message: "Failed to list practices", Error: err.Error()}) } + if err := h.applyPracticeAccess(c.Context(), c, items); err != nil { + return c.Status(fiber.StatusInternalServerError).JSON(domain.ErrorResponse{Message: "Failed to build practices", Error: err.Error()}) + } return c.JSON(domain.Response{ Message: "Practices retrieved successfully", Data: fiber.Map{ @@ -140,7 +149,8 @@ func (h *Handler) ListPracticesByLesson(c *fiber.Ctx) error { } limit, _ := strconv.Atoi(c.Query("limit", "20")) offset, _ := strconv.Atoi(c.Query("offset", "0")) - publishedOnly := !h.canManageLMSPractices(c) + role, _ := c.Locals("role").(domain.Role) + publishedOnly := role.IsCustomerLearnerRole() || !h.canManageLMSPractices(c) items, total, err := h.practiceSvc.ListByLesson(c.Context(), lessonID, publishedOnly, int32(limit), int32(offset)) if err != nil { if errors.Is(err, lessons.ErrLessonNotFound) { @@ -148,6 +158,9 @@ func (h *Handler) ListPracticesByLesson(c *fiber.Ctx) error { } return c.Status(fiber.StatusInternalServerError).JSON(domain.ErrorResponse{Message: "Failed to list practices", Error: err.Error()}) } + if err := h.applyPracticeAccess(c.Context(), c, items); err != nil { + return c.Status(fiber.StatusInternalServerError).JSON(domain.ErrorResponse{Message: "Failed to build practices", Error: err.Error()}) + } return c.JSON(domain.Response{ Message: "Practices retrieved successfully", Data: fiber.Map{ @@ -180,9 +193,49 @@ func (h *Handler) GetPractice(c *fiber.Ctx) error { if !p.VisibleToLearners() && !h.canManageLMSPractices(c) { return c.Status(fiber.StatusNotFound).JSON(domain.ErrorResponse{Message: "Practice not found"}) } + if err := h.applyPracticeAccess(c.Context(), c, []domain.Practice{p}); err != nil { + return c.Status(fiber.StatusInternalServerError).JSON(domain.ErrorResponse{Message: "Failed to build practice", Error: err.Error()}) + } + role, _ := c.Locals("role").(domain.Role) + if role.IsCustomerLearnerRole() { + if set, err := h.questionsSvc.GetQuestionSetByID(c.Context(), p.QuestionSetID); err == nil { + p.Access = practiceAccessForQuestionSet(set) + } else { + p.Access = &domain.PracticeAccess{IsAccessible: false, Reason: "Question set not found"} + } + } return c.JSON(domain.Response{Message: "Practice retrieved successfully", Data: p, Success: true, StatusCode: fiber.StatusOK}) } +func (h *Handler) applyPracticeAccess(ctx context.Context, c *fiber.Ctx, items []domain.Practice) error { + role, _ := c.Locals("role").(domain.Role) + if !role.IsCustomerLearnerRole() { + for i := range items { + items[i].Access = nil + } + return nil + } + for i := range items { + set, err := h.questionsSvc.GetQuestionSetByID(ctx, items[i].QuestionSetID) + if err != nil { + items[i].Access = &domain.PracticeAccess{IsAccessible: false, Reason: "Question set not found"} + continue + } + items[i].Access = practiceAccessForQuestionSet(set) + } + return nil +} + +func practiceAccessForQuestionSet(set domain.QuestionSet) *domain.PracticeAccess { + if !strings.EqualFold(set.SetType, string(domain.QuestionSetTypePractice)) { + return &domain.PracticeAccess{IsAccessible: false, Reason: "Question set is not a practice"} + } + if !strings.EqualFold(set.Status, "PUBLISHED") { + return &domain.PracticeAccess{IsAccessible: false, Reason: "Practice is not published yet"} + } + return &domain.PracticeAccess{IsAccessible: true} +} + // UpdatePractice godoc // @Tags practices // @Param id path int true "Practice ID"