Return learner-visible practices with access metadata.

Expose practices to learner roles based on practice shell publish state and include per-practice access fields derived from linked question set readiness so clients can manage completion/access UX explicitly.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Yared Yemane 2026-05-28 00:32:44 -07:00
parent ffbb885d06
commit c77a97b40d
4 changed files with 68 additions and 39 deletions

View File

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

View File

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

View File

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

View File

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