diff --git a/db/query/lms_lessons.sql b/db/query/lms_lessons.sql index dfc58e5..63f0e0e 100644 --- a/db/query/lms_lessons.sql +++ b/db/query/lms_lessons.sql @@ -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, diff --git a/gen/db/lms_lessons.sql.go b/gen/db/lms_lessons.sql.go index 7bcaace..50da319 100644 --- a/gen/db/lms_lessons.sql.go +++ b/gen/db/lms_lessons.sql.go @@ -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, diff --git a/internal/ports/lms_lesson.go b/internal/ports/lms_lesson.go index 05536e7..0f646d9 100644 --- a/internal/ports/lms_lesson.go +++ b/internal/ports/lms_lesson.go @@ -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 } diff --git a/internal/repository/lms_lessons.go b/internal/repository/lms_lessons.go index 02f90a5..1906936 100644 --- a/internal/repository/lms_lessons.go +++ b/internal/repository/lms_lessons.go @@ -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, diff --git a/internal/repository/lms_reorder.go b/internal/repository/lms_reorder.go index 651ca8c..8093f89 100644 --- a/internal/repository/lms_reorder.go +++ b/internal/repository/lms_reorder.go @@ -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) +} diff --git a/internal/services/lessons/service.go b/internal/services/lessons/service.go index 34104f8..541aae0 100644 --- a/internal/services/lessons/service.go +++ b/internal/services/lessons/service.go @@ -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) +} diff --git a/internal/services/rbac/seeds.go b/internal/services/rbac/seeds.go index e4a25dc..c44a072 100644 --- a/internal/services/rbac/seeds.go +++ b/internal/services/rbac/seeds.go @@ -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", diff --git a/internal/web_server/handlers/lms_reorder_handler.go b/internal/web_server/handlers/lms_reorder_handler.go index 0710c4d..a8396e4 100644 --- a/internal/web_server/handlers/lms_reorder_handler.go +++ b/internal/web_server/handlers/lms_reorder_handler.go @@ -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, + }) +} diff --git a/internal/web_server/routes.go b/internal/web_server/routes.go index 5010c06..3b43ab1 100644 --- a/internal/web_server/routes.go +++ b/internal/web_server/routes.go @@ -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)