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:
parent
e56bea3abf
commit
dfdb2a52dc
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
})
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user