feat: add LMS lesson reorder endpoint within a module

Add PUT /api/v1/modules/:moduleId/lessons/reorder with lessons.reorder permission, matching the existing modules and exam-prep lesson reorder patterns.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Yared Yemane 2026-06-10 03:28:13 -07:00
parent e56bea3abf
commit dfdb2a52dc
9 changed files with 162 additions and 1 deletions

View File

@ -30,6 +30,16 @@ FROM lessons
l
WHERE l.id = $1;
-- name: ListLessonIDsByModule :many
SELECT
l.id
FROM
lessons AS l
WHERE
l.module_id = $1
ORDER BY
l.id;
-- name: ListLessonsByModuleID :many
SELECT
COUNT(*) OVER () AS total_count,

View File

@ -124,6 +124,37 @@ func (q *Queries) GetLessonByID(ctx context.Context, id int64) (GetLessonByIDRow
return i, err
}
const ListLessonIDsByModule = `-- name: ListLessonIDsByModule :many
SELECT
l.id
FROM
lessons AS l
WHERE
l.module_id = $1
ORDER BY
l.id
`
func (q *Queries) ListLessonIDsByModule(ctx context.Context, moduleID int64) ([]int64, error) {
rows, err := q.db.Query(ctx, ListLessonIDsByModule, moduleID)
if err != nil {
return nil, err
}
defer rows.Close()
var items []int64
for rows.Next() {
var id int64
if err := rows.Scan(&id); err != nil {
return nil, err
}
items = append(items, id)
}
if err := rows.Err(); err != nil {
return nil, err
}
return items, nil
}
const ListLessonsByModuleID = `-- name: ListLessonsByModuleID :many
SELECT
COUNT(*) OVER () AS total_count,

View File

@ -9,6 +9,8 @@ type LessonStore interface {
CreateLesson(ctx context.Context, moduleID int64, input domain.CreateLessonInput) (domain.Lesson, error)
GetLessonByID(ctx context.Context, id int64) (domain.Lesson, error)
ListLessonsByModuleID(ctx context.Context, moduleID int64, publishedOnly bool, limit, offset int32) ([]domain.Lesson, int64, error)
ListLessonIDsByModule(ctx context.Context, moduleID int64) ([]int64, error)
UpdateLesson(ctx context.Context, id int64, input domain.UpdateLessonInput) (domain.Lesson, error)
DeleteLesson(ctx context.Context, id int64) error
ReorderLessonsInModule(ctx context.Context, moduleID int64, orderedIDs []int64) error
}

View File

@ -103,6 +103,10 @@ func (s *Store) GetLessonByID(ctx context.Context, id int64) (domain.Lesson, err
return out, nil
}
func (s *Store) ListLessonIDsByModule(ctx context.Context, moduleID int64) ([]int64, error) {
return s.queries.ListLessonIDsByModule(ctx, moduleID)
}
func (s *Store) ListLessonsByModuleID(ctx context.Context, moduleID int64, publishedOnly bool, limit, offset int32) ([]domain.Lesson, int64, error) {
rows, err := s.queries.ListLessonsByModuleID(ctx, dbgen.ListLessonsByModuleIDParams{
ModuleID: moduleID,

View File

@ -71,3 +71,37 @@ WHERE id = $2
}
return tx.Commit(ctx)
}
// ReorderLessonsInModule sets sort_order to 1..n under moduleID (transactional).
// Uses an intermediate bump so UNIQUE (module_id, sort_order) is never violated mid-reorder.
func (s *Store) ReorderLessonsInModule(ctx context.Context, moduleID int64, orderedIDs []int64) error {
tx, err := s.conn.Begin(ctx)
if err != nil {
return err
}
defer func() { _ = tx.Rollback(ctx) }()
if _, err := tx.Exec(ctx, `
UPDATE lessons
SET sort_order = sort_order + $1,
updated_at = CURRENT_TIMESTAMP
WHERE module_id = $2`, lessonReorderSortBump, moduleID); err != nil {
return err
}
for i, id := range orderedIDs {
tag, err := tx.Exec(ctx, `
UPDATE lessons
SET sort_order = $1,
updated_at = CURRENT_TIMESTAMP
WHERE id = $2
AND module_id = $3`, int32(i+1), id, moduleID)
if err != nil {
return err
}
if tag.RowsAffected() == 0 {
return fmt.Errorf("lesson id %d not in module %d", id, moduleID)
}
}
return tx.Commit(ctx)
}

View File

@ -86,3 +86,22 @@ func (s *Service) Delete(ctx context.Context, id int64) error {
}
return s.lessons.DeleteLesson(ctx, id)
}
// ReorderInModule sets sort_order for all lessons in the module. ordered must list every lesson id
// exactly once (e.g. from GET /modules/{moduleId}/lessons) in the desired order.
func (s *Service) ReorderInModule(ctx context.Context, moduleID int64, ordered []int64) error {
if err := s.getModuleOrErr(ctx, moduleID); err != nil {
return err
}
expected, err := s.lessons.ListLessonIDsByModule(ctx, moduleID)
if err != nil {
return err
}
if err := domain.ValidateReorderPermutation(ordered, expected); err != nil {
return err
}
if len(ordered) == 0 {
return nil
}
return s.lessons.ReorderLessonsInModule(ctx, moduleID, ordered)
}

View File

@ -75,6 +75,7 @@ var AllPermissions = []domain.PermissionSeed{
{Key: "lessons.list_by_module", Name: "List Lessons by Module", Description: "List lessons under a module", GroupName: "Lessons"},
{Key: "lessons.update", Name: "Update Lesson", Description: "Update a lesson", GroupName: "Lessons"},
{Key: "lessons.delete", Name: "Delete Lesson", Description: "Delete a lesson", GroupName: "Lessons"},
{Key: "lessons.reorder", Name: "Reorder Lessons", Description: "Set lesson order within a module (batch)", GroupName: "Lessons"},
// LMS progress (current user)
{Key: "lms.get_my_progress", Name: "Get My LMS Progress", Description: "List completed lesson, module, course, and program IDs for the authenticated user", GroupName: "LMS"},
@ -426,7 +427,7 @@ var DefaultRolePermissions = map[string][]string{
"modules.create", "modules.get", "modules.list_by_course", "modules.update", "modules.delete", "modules.reorder",
// Lessons
"lessons.create", "lessons.get", "lessons.list_by_module", "lessons.complete", "lessons.update", "lessons.delete",
"lessons.create", "lessons.get", "lessons.list_by_module", "lessons.complete", "lessons.update", "lessons.delete", "lessons.reorder",
// Practices
"practices.create", "practices.get", "practices.list", "practices.update", "practices.delete",

View File

@ -3,6 +3,7 @@ package handlers
import (
"Yimaru-Backend/internal/domain"
"Yimaru-Backend/internal/services/courses"
"Yimaru-Backend/internal/services/modules"
"Yimaru-Backend/internal/services/programs"
"context"
"errors"
@ -176,3 +177,61 @@ func (h *Handler) ReorderModulesInCourse(c *fiber.Ctx) error {
StatusCode: fiber.StatusOK,
})
}
// ReorderLessonsInModule godoc
// @Summary Reorder lessons within a module
// @Param moduleId path int true "Module ID"
// @Param body body domain.ReorderIDsRequest true "ordered_ids: every lesson id in this module, in the new order"
// @Tags lessons
// @Router /api/v1/modules/{moduleId}/lessons/reorder [put]
func (h *Handler) ReorderLessonsInModule(c *fiber.Ctx) error {
moduleID, err := strconv.ParseInt(c.Params("moduleId"), 10, 64)
if err != nil {
return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{
Message: "Invalid module id",
Error: err.Error(),
})
}
var req domain.ReorderIDsRequest
if err := c.BodyParser(&req); err != nil {
return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{
Message: "Invalid request body",
Error: err.Error(),
})
}
if req.OrderedIDs == nil {
return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{
Message: "ordered_ids is required (use an empty array if the module has no lessons)",
Error: "missing ordered_ids",
})
}
if err := h.lessonSvc.ReorderInModule(c.Context(), moduleID, req.OrderedIDs); err != nil {
if errors.Is(err, modules.ErrModuleNotFound) {
return c.Status(fiber.StatusNotFound).JSON(domain.ErrorResponse{
Message: "Module not found",
Error: err.Error(),
})
}
if errors.Is(err, domain.ErrReorderInvalidIDSet) {
return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{
Message: err.Error(),
Error: "INVALID_REORDER",
})
}
return c.Status(fiber.StatusInternalServerError).JSON(domain.ErrorResponse{
Message: "Failed to reorder lessons",
Error: err.Error(),
})
}
actorID := c.Locals("user_id").(int64)
actorRole := string(c.Locals("role").(domain.Role))
ip := c.IP()
ua := c.Get("User-Agent")
go h.activityLogSvc.RecordAction(context.Background(), &actorID, &actorRole, domain.ActionLessonUpdated, domain.ResourceModule, &moduleID, "Reordered lessons in module", nil, &ip, &ua)
return c.JSON(domain.Response{
Message: "Lessons reordered successfully",
Success: true,
StatusCode: fiber.StatusOK,
})
}

View File

@ -145,6 +145,7 @@ func (a *App) initAppRoutes() {
// /modules/:moduleId/lessons before /modules/:id; /modules/:id/practices before /modules/:id
groupV1.Post("/modules/:moduleId/lessons", a.authMiddleware, a.RequirePermission("lessons.create"), h.CreateLesson)
groupV1.Get("/modules/:moduleId/lessons", a.authMiddleware, a.RequireSubscriptionCategory(domain.SubscriptionCategoryLearnEnglish), a.RequirePermission("lessons.list_by_module"), h.ListLessonsByModule)
groupV1.Put("/modules/:moduleId/lessons/reorder", a.authMiddleware, a.RequirePermission("lessons.reorder"), h.ReorderLessonsInModule)
groupV1.Get("/modules/:id/practices", a.authMiddleware, a.RequireSubscriptionCategory(domain.SubscriptionCategoryLearnEnglish), a.RequirePermission("practices.list"), h.ListPracticesByModule)
groupV1.Get("/modules/:id", a.authMiddleware, a.RequireSubscriptionCategory(domain.SubscriptionCategoryLearnEnglish), a.RequirePermission("modules.get"), h.GetModule)
groupV1.Put("/modules/:id", a.authMiddleware, a.RequirePermission("modules.update"), h.UpdateModule)