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 l
WHERE l.id = $1; 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 -- name: ListLessonsByModuleID :many
SELECT SELECT
COUNT(*) OVER () AS total_count, COUNT(*) OVER () AS total_count,

View File

@ -124,6 +124,37 @@ func (q *Queries) GetLessonByID(ctx context.Context, id int64) (GetLessonByIDRow
return i, err 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 const ListLessonsByModuleID = `-- name: ListLessonsByModuleID :many
SELECT SELECT
COUNT(*) OVER () AS total_count, 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) CreateLesson(ctx context.Context, moduleID int64, input domain.CreateLessonInput) (domain.Lesson, error)
GetLessonByID(ctx context.Context, id int64) (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) 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) UpdateLesson(ctx context.Context, id int64, input domain.UpdateLessonInput) (domain.Lesson, error)
DeleteLesson(ctx context.Context, id int64) 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 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) { 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{ rows, err := s.queries.ListLessonsByModuleID(ctx, dbgen.ListLessonsByModuleIDParams{
ModuleID: moduleID, ModuleID: moduleID,

View File

@ -71,3 +71,37 @@ WHERE id = $2
} }
return tx.Commit(ctx) 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) 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.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.update", Name: "Update Lesson", Description: "Update a lesson", GroupName: "Lessons"},
{Key: "lessons.delete", Name: "Delete Lesson", Description: "Delete 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) // 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"}, {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", "modules.create", "modules.get", "modules.list_by_course", "modules.update", "modules.delete", "modules.reorder",
// Lessons // 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
"practices.create", "practices.get", "practices.list", "practices.update", "practices.delete", "practices.create", "practices.get", "practices.list", "practices.update", "practices.delete",

View File

@ -3,6 +3,7 @@ package handlers
import ( import (
"Yimaru-Backend/internal/domain" "Yimaru-Backend/internal/domain"
"Yimaru-Backend/internal/services/courses" "Yimaru-Backend/internal/services/courses"
"Yimaru-Backend/internal/services/modules"
"Yimaru-Backend/internal/services/programs" "Yimaru-Backend/internal/services/programs"
"context" "context"
"errors" "errors"
@ -176,3 +177,61 @@ func (h *Handler) ReorderModulesInCourse(c *fiber.Ctx) error {
StatusCode: fiber.StatusOK, 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 // /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.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.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/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.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) groupV1.Put("/modules/:id", a.authMiddleware, a.RequirePermission("modules.update"), h.UpdateModule)