diff --git a/cmd/main.go b/cmd/main.go index 42e3aa4..fe99d3f 100644 --- a/cmd/main.go +++ b/cmd/main.go @@ -19,6 +19,7 @@ import ( notificationservice "Yimaru-Backend/internal/services/notification" coursesservice "Yimaru-Backend/internal/services/courses" lessonsservice "Yimaru-Backend/internal/services/lessons" + "Yimaru-Backend/internal/services/lmsprogress" moduleservice "Yimaru-Backend/internal/services/modules" practicesservice "Yimaru-Backend/internal/services/practices" programsservice "Yimaru-Backend/internal/services/programs" @@ -404,6 +405,8 @@ func main() { // LMS lessons (under modules) lessonSvc := lessonsservice.NewService(store, store) + lmsProgressSvc := lmsprogress.NewService(store) + // LMS practices (under course, module, or lesson) practiceSvc := practicesservice.NewService(store, store, store, store, store, store) @@ -452,6 +455,7 @@ func main() { courseSvc, moduleSvc, lessonSvc, + lmsProgressSvc, practiceSvc, subscriptionsSvc, arifpaySvc, diff --git a/db/migrations/000049_lms_sequential_learning.down.sql b/db/migrations/000049_lms_sequential_learning.down.sql new file mode 100644 index 0000000..06571d5 --- /dev/null +++ b/db/migrations/000049_lms_sequential_learning.down.sql @@ -0,0 +1,18 @@ +DROP TABLE IF EXISTS lms_user_program_progress; +DROP TABLE IF EXISTS lms_user_course_progress; +DROP TABLE IF EXISTS lms_user_module_progress; +DROP TABLE IF EXISTS lms_user_lesson_progress; + +DROP INDEX IF EXISTS uq_lessons_module_sort; +DROP INDEX IF EXISTS uq_modules_course_sort; +DROP INDEX IF EXISTS uq_courses_program_sort; +DROP INDEX IF EXISTS uq_programs_sort_order; + +ALTER TABLE lessons + DROP COLUMN IF EXISTS sort_order; +ALTER TABLE modules + DROP COLUMN IF EXISTS sort_order; +ALTER TABLE courses + DROP COLUMN IF EXISTS sort_order; +ALTER TABLE programs + DROP COLUMN IF EXISTS sort_order; diff --git a/db/migrations/000049_lms_sequential_learning.up.sql b/db/migrations/000049_lms_sequential_learning.up.sql new file mode 100644 index 0000000..623b40d --- /dev/null +++ b/db/migrations/000049_lms_sequential_learning.up.sql @@ -0,0 +1,150 @@ +-- Sequential order for programs, courses, modules, and lessons (1 = first in each scope). +-- Progress tables mark completion; API enforces prerequisites for learners (STUDENT role). + +ALTER TABLE programs + ADD COLUMN sort_order INTEGER NOT NULL DEFAULT 0; +ALTER TABLE courses + ADD COLUMN sort_order INTEGER NOT NULL DEFAULT 0; +ALTER TABLE modules + ADD COLUMN sort_order INTEGER NOT NULL DEFAULT 0; +ALTER TABLE lessons + ADD COLUMN sort_order INTEGER NOT NULL DEFAULT 0; + +-- Program order (one global sequence): Beginner -> Intermediate -> Advanced; others by id +UPDATE programs +SET sort_order = v.so +FROM ( + VALUES + ('Beginner', 1), + ('Intermediate', 2), + ('Advanced', 3) +) AS v (name, so) +WHERE programs.name = v.name; + +UPDATE programs +SET sort_order = 1000 + r.rn +FROM ( + SELECT + id, + row_number() OVER ( + ORDER BY id + ) AS rn + FROM programs + WHERE + sort_order = 0 +) AS r +WHERE + programs.id = r.id; + +-- CEFR courses: A1..C2; remaining courses in each program: stable order +UPDATE courses +SET sort_order = CASE name + WHEN 'A1' THEN + 1 + WHEN 'A2' THEN + 2 + WHEN 'B1' THEN + 3 + WHEN 'B2' THEN + 4 + WHEN 'C1' THEN + 5 + WHEN 'C2' THEN + 6 + ELSE + 0 +END +WHERE + name IN ('A1', 'A2', 'B1', 'B2', 'C1', 'C2'); + +UPDATE courses c +SET sort_order = 2000 + s.rn +FROM ( + SELECT + id, + row_number() OVER ( + PARTITION BY program_id + ORDER BY + id + ) AS rn + FROM courses + WHERE + sort_order = 0 +) AS s +WHERE + c.id = s.id; + +UPDATE modules m +SET sort_order = r.rn +FROM ( + SELECT + id, + row_number() OVER ( + PARTITION BY course_id + ORDER BY + id + ) AS rn + FROM modules +) AS r +WHERE + m.id = r.id; + +UPDATE lessons l +SET sort_order = r.rn +FROM ( + SELECT + id, + row_number() OVER ( + PARTITION BY module_id + ORDER BY + id + ) AS rn + FROM lessons +) AS r +WHERE + l.id = r.id; + +CREATE UNIQUE INDEX uq_programs_sort_order ON programs (sort_order); +CREATE UNIQUE INDEX uq_courses_program_sort ON courses (program_id, sort_order); +CREATE UNIQUE INDEX uq_modules_course_sort ON modules (course_id, sort_order); +CREATE UNIQUE INDEX uq_lessons_module_sort ON lessons (module_id, sort_order); + +CREATE TABLE lms_user_lesson_progress ( + user_id BIGINT NOT NULL REFERENCES users (id) ON DELETE CASCADE, + lesson_id BIGINT NOT NULL REFERENCES lessons (id) ON DELETE CASCADE, + completed_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP, + PRIMARY KEY (user_id, lesson_id) +); + +CREATE INDEX idx_lms_user_lesson_progress_user ON lms_user_lesson_progress (user_id); +CREATE INDEX idx_lms_user_lesson_progress_lesson ON lms_user_lesson_progress (lesson_id); + +CREATE TABLE lms_user_module_progress ( + user_id BIGINT NOT NULL REFERENCES users (id) ON DELETE CASCADE, + module_id BIGINT NOT NULL REFERENCES modules (id) ON DELETE CASCADE, + completed_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP, + PRIMARY KEY (user_id, module_id) +); + +CREATE INDEX idx_lms_user_module_progress_user ON lms_user_module_progress (user_id); +CREATE INDEX idx_lms_user_module_progress_module ON lms_user_module_progress (module_id); + +CREATE TABLE lms_user_course_progress ( + user_id BIGINT NOT NULL REFERENCES users (id) ON DELETE CASCADE, + course_id BIGINT NOT NULL REFERENCES courses (id) ON DELETE CASCADE, + completed_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP, + PRIMARY KEY (user_id, course_id) +); + +CREATE INDEX idx_lms_user_course_progress_user ON lms_user_course_progress (user_id); +CREATE INDEX idx_lms_user_course_progress_course ON lms_user_course_progress (course_id); + +CREATE TABLE lms_user_program_progress ( + user_id BIGINT NOT NULL REFERENCES users (id) ON DELETE CASCADE, + program_id BIGINT NOT NULL REFERENCES programs (id) ON DELETE CASCADE, + completed_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP, + PRIMARY KEY (user_id, program_id) +); + +CREATE INDEX idx_lms_user_program_progress_user ON lms_user_program_progress (user_id); +CREATE INDEX idx_lms_user_program_progress_program ON lms_user_program_progress (program_id); diff --git a/db/query/lms_courses.sql b/db/query/lms_courses.sql index f248305..56c1e12 100644 --- a/db/query/lms_courses.sql +++ b/db/query/lms_courses.sql @@ -1,13 +1,34 @@ -- name: CreateCourse :one -INSERT INTO courses (program_id, name, description, thumbnail) -VALUES ($1, $2, $3, $4) -RETURNING *; +INSERT INTO courses (program_id, name, description, thumbnail, sort_order) +SELECT + $1, + $2, + $3, + $4, + coalesce(( + SELECT + max(c.sort_order) + FROM courses c + WHERE + c.program_id = $1), 0) + 1 +RETURNING + *; -- name: GetCourseByID :one SELECT * FROM courses WHERE id = $1; +-- name: ListCourseIDsByProgram :many +SELECT + c.id +FROM + courses AS c +WHERE + c.program_id = $1 +ORDER BY + c.id; + -- name: ListCoursesByProgramID :many SELECT COUNT(*) OVER () AS total_count, @@ -16,11 +37,16 @@ SELECT c.name, c.description, c.thumbnail, + c.sort_order, c.created_at, c.updated_at -FROM courses c -WHERE c.program_id = $1 -ORDER BY c.created_at DESC +FROM + courses c +WHERE + c.program_id = $1 +ORDER BY + c.sort_order ASC, + c.id ASC LIMIT $2 OFFSET $3; -- name: UpdateCourse :one @@ -29,9 +55,12 @@ SET name = COALESCE(sqlc.narg('name')::varchar, name), description = COALESCE(sqlc.narg('description')::text, description), thumbnail = COALESCE(sqlc.narg('thumbnail')::text, thumbnail), + sort_order = coalesce(sqlc.narg('sort_order')::int, sort_order), updated_at = CURRENT_TIMESTAMP -WHERE id = sqlc.arg('id') -RETURNING *; +WHERE + id = sqlc.arg('id') +RETURNING + *; -- name: DeleteCourse :exec DELETE FROM courses diff --git a/db/query/lms_lessons.sql b/db/query/lms_lessons.sql index 336a161..e306013 100644 --- a/db/query/lms_lessons.sql +++ b/db/query/lms_lessons.sql @@ -1,7 +1,19 @@ -- name: CreateLesson :one -INSERT INTO lessons (module_id, title, video_url, thumbnail, description) -VALUES ($1, $2, $3, $4, $5) -RETURNING *; +INSERT INTO lessons (module_id, title, video_url, thumbnail, description, sort_order) +SELECT + $1, + $2, + $3, + $4, + $5, + coalesce(( + SELECT + max(l.sort_order) + FROM lessons l + WHERE + l.module_id = $1), 0) + 1 +RETURNING + *; -- name: GetLessonByID :one SELECT * @@ -17,12 +29,18 @@ SELECT l.video_url, l.thumbnail, l.description, + l.sort_order, l.created_at, l.updated_at -FROM lessons l -WHERE l.module_id = $1 -ORDER BY l.created_at DESC -LIMIT $2 OFFSET $3; +FROM + lessons l +WHERE + l.module_id = $1 +ORDER BY + l.sort_order ASC, + l.id ASC +LIMIT $2 +OFFSET $3; -- name: UpdateLesson :one UPDATE lessons @@ -31,9 +49,12 @@ SET video_url = COALESCE(sqlc.narg('video_url')::text, video_url), thumbnail = COALESCE(sqlc.narg('thumbnail')::text, thumbnail), description = COALESCE(sqlc.narg('description')::text, description), + sort_order = coalesce(sqlc.narg('sort_order')::int, sort_order), updated_at = CURRENT_TIMESTAMP -WHERE id = sqlc.arg('id') -RETURNING *; +WHERE + id = sqlc.arg('id') +RETURNING + *; -- name: DeleteLesson :exec DELETE FROM lessons diff --git a/db/query/lms_modules.sql b/db/query/lms_modules.sql index 77b2abc..a389d56 100644 --- a/db/query/lms_modules.sql +++ b/db/query/lms_modules.sql @@ -1,13 +1,35 @@ -- name: CreateModule :one -INSERT INTO modules (program_id, course_id, name, description, icon) -VALUES ($1, $2, $3, $4, $5) -RETURNING *; +INSERT INTO modules (program_id, course_id, name, description, icon, sort_order) +SELECT + $1, + $2, + $3, + $4, + $5, + coalesce(( + SELECT + max(m.sort_order) + FROM modules m + WHERE + m.course_id = $2), 0) + 1 +RETURNING + *; -- name: GetModuleByID :one SELECT * FROM modules WHERE id = $1; +-- name: ListModuleIDsByCourse :many +SELECT + m.id +FROM + modules AS m +WHERE + m.course_id = $1 +ORDER BY + m.id; + -- name: ListModulesByProgramAndCourse :many SELECT COUNT(*) OVER () AS total_count, @@ -17,13 +39,19 @@ SELECT m.name, m.description, m.icon, + m.sort_order, m.created_at, m.updated_at -FROM modules m -WHERE m.program_id = $1 - AND m.course_id = $2 -ORDER BY m.created_at DESC -LIMIT $3 OFFSET $4; +FROM + modules m +WHERE + m.program_id = $1 + AND m.course_id = $2 +ORDER BY + m.sort_order ASC, + m.id ASC +LIMIT $3 +OFFSET $4; -- name: UpdateModule :one UPDATE modules @@ -31,9 +59,12 @@ SET name = COALESCE(sqlc.narg('name')::varchar, name), description = COALESCE(sqlc.narg('description')::text, description), icon = COALESCE(sqlc.narg('icon')::text, icon), + sort_order = coalesce(sqlc.narg('sort_order')::int, sort_order), updated_at = CURRENT_TIMESTAMP -WHERE id = sqlc.arg('id') -RETURNING *; +WHERE + id = sqlc.arg('id') +RETURNING + *; -- name: DeleteModule :exec DELETE FROM modules diff --git a/db/query/lms_progress.sql b/db/query/lms_progress.sql new file mode 100644 index 0000000..56315d1 --- /dev/null +++ b/db/query/lms_progress.sql @@ -0,0 +1,248 @@ +-- name: GetPreviousProgram :one +SELECT + p2.* +FROM + programs AS p1 + INNER JOIN programs AS p2 ON p2.sort_order = p1.sort_order - 1 +WHERE + p1.id = $1; + +-- name: GetPreviousCourseInProgram :one +SELECT + c2.* +FROM + courses AS c1 + INNER JOIN courses AS c2 ON c2.program_id = c1.program_id + AND c2.sort_order = c1.sort_order - 1 +WHERE + c1.id = $1; + +-- name: GetPreviousModuleInCourse :one +SELECT + m2.* +FROM + modules AS m1 + INNER JOIN modules AS m2 ON m2.course_id = m1.course_id + AND m2.sort_order = m1.sort_order - 1 +WHERE + m1.id = $1; + +-- name: GetPreviousLessonInModule :one +SELECT + l2.* +FROM + lessons AS l1 + INNER JOIN lessons AS l2 ON l2.module_id = l1.module_id + AND l2.sort_order = l1.sort_order - 1 +WHERE + l1.id = $1; + +-- name: UserHasProgramProgress :one +SELECT + EXISTS ( + SELECT + 1 + FROM + lms_user_program_progress + WHERE + user_id = $1 + AND program_id = $2) AS v; + +-- name: UserHasCourseProgress :one +SELECT + EXISTS ( + SELECT + 1 + FROM + lms_user_course_progress + WHERE + user_id = $1 + AND course_id = $2) AS v; + +-- name: UserHasModuleProgress :one +SELECT + EXISTS ( + SELECT + 1 + FROM + lms_user_module_progress + WHERE + user_id = $1 + AND module_id = $2) AS v; + +-- name: UserHasLessonProgress :one +SELECT + EXISTS ( + SELECT + 1 + FROM + lms_user_lesson_progress + WHERE + user_id = $1 + AND lesson_id = $2) AS v; + +-- name: InsertUserLessonProgress :exec +INSERT INTO lms_user_lesson_progress (user_id, lesson_id) + VALUES ($1, $2) +ON CONFLICT (user_id, lesson_id) + DO NOTHING; + +-- name: InsertUserModuleProgress :exec +INSERT INTO lms_user_module_progress (user_id, module_id) + VALUES ($1, $2) +ON CONFLICT (user_id, module_id) + DO NOTHING; + +-- name: InsertUserCourseProgress :exec +INSERT INTO lms_user_course_progress (user_id, course_id) + VALUES ($1, $2) +ON CONFLICT (user_id, course_id) + DO NOTHING; + +-- name: InsertUserProgramProgress :exec +INSERT INTO lms_user_program_progress (user_id, program_id) + VALUES ($1, $2) +ON CONFLICT (user_id, program_id) + DO NOTHING; + +-- name: CountLessonsInModule :one +SELECT + count(*)::int AS n +FROM + lessons +WHERE + module_id = $1; + +-- name: CountUserCompletedLessonsInModule :one +SELECT + count(*)::int AS n +FROM + lms_user_lesson_progress ulp + INNER JOIN lessons l ON l.id = ulp.lesson_id +WHERE + l.module_id = $1 + AND ulp.user_id = $2; + +-- name: CountModulesInCourse :one +SELECT + count(*)::int AS n +FROM + modules +WHERE + course_id = $1; + +-- name: CountUserCompletedModulesInCourse :one +SELECT + count(*)::int AS n +FROM + lms_user_module_progress ump + INNER JOIN modules m ON m.id = ump.module_id +WHERE + m.course_id = $1 + AND ump.user_id = $2; + +-- name: CountCoursesInProgram :one +SELECT + count(*)::int AS n +FROM + courses +WHERE + program_id = $1; + +-- name: CountUserCompletedCoursesInProgram :one +SELECT + count(*)::int AS n +FROM + lms_user_course_progress ucp + INNER JOIN courses c ON c.id = ucp.course_id +WHERE + c.program_id = $1 + AND ucp.user_id = $2; + +-- name: ListLMSCompletedLessonIDsByUser :many +SELECT + ulp.lesson_id +FROM + lms_user_lesson_progress AS ulp +WHERE + ulp.user_id = $1 +ORDER BY + ulp.completed_at ASC, + ulp.lesson_id ASC; + +-- name: ListLMSCompletedModuleIDsByUser :many +SELECT + ump.module_id +FROM + lms_user_module_progress AS ump +WHERE + ump.user_id = $1 +ORDER BY + ump.completed_at ASC, + ump.module_id ASC; + +-- name: ListLMSCompletedCourseIDsByUser :many +SELECT + ucp.course_id +FROM + lms_user_course_progress AS ucp +WHERE + ucp.user_id = $1 +ORDER BY + ucp.completed_at ASC, + ucp.course_id ASC; + +-- name: ListLMSCompletedProgramIDsByUser :many +SELECT + upp.program_id +FROM + lms_user_program_progress AS upp +WHERE + upp.user_id = $1 +ORDER BY + upp.completed_at ASC, + upp.program_id ASC; + +-- Lesson-based progress within a course (all modules). +-- name: CountLessonsInCourse :one +SELECT + count(*)::int AS n +FROM + lessons l + INNER JOIN modules m ON m.id = l.module_id +WHERE + m.course_id = $1; + +-- name: CountUserCompletedLessonsInCourse :one +SELECT + count(*)::int AS n +FROM + lms_user_lesson_progress ulp + INNER JOIN lessons l ON l.id = ulp.lesson_id + INNER JOIN modules m ON m.id = l.module_id +WHERE + m.course_id = $1 + AND ulp.user_id = $2; + +-- Lesson-based progress within a program (all courses). +-- name: CountLessonsInProgram :one +SELECT + count(*)::int AS n +FROM + lessons l + INNER JOIN modules m ON m.id = l.module_id + INNER JOIN courses c ON c.id = m.course_id +WHERE + c.program_id = $1; + +-- name: CountUserCompletedLessonsInProgram :one +SELECT + count(*)::int AS n +FROM + lms_user_lesson_progress ulp + INNER JOIN lessons l ON l.id = ulp.lesson_id + INNER JOIN modules m ON m.id = l.module_id + INNER JOIN courses c ON c.id = m.course_id +WHERE + c.program_id = $1 + AND ulp.user_id = $2; diff --git a/db/query/programs.sql b/db/query/programs.sql index d8c1bb1..f2b79bf 100644 --- a/db/query/programs.sql +++ b/db/query/programs.sql @@ -1,13 +1,29 @@ -- name: CreateProgram :one -INSERT INTO programs (name, description, thumbnail) -VALUES ($1, $2, $3) -RETURNING *; +INSERT INTO programs (name, description, thumbnail, sort_order) +SELECT + $1, + $2, + $3, + coalesce(( + SELECT + max(p.sort_order) + FROM programs AS p), 0) + 1 +RETURNING + *; -- name: GetProgramByID :one SELECT * FROM programs WHERE id = $1; +-- name: ListAllProgramIDs :many +SELECT + p.id +FROM + programs AS p +ORDER BY + p.id; + -- name: ListPrograms :many SELECT COUNT(*) OVER () AS total_count, @@ -15,10 +31,11 @@ SELECT p.name, p.description, p.thumbnail, + p.sort_order, p.created_at, p.updated_at FROM programs p -ORDER BY p.created_at DESC +ORDER BY p.sort_order ASC, p.id ASC LIMIT $1 OFFSET $2; -- name: UpdateProgram :one @@ -27,9 +44,12 @@ SET name = COALESCE(sqlc.narg('name')::varchar, name), description = COALESCE(sqlc.narg('description')::text, description), thumbnail = COALESCE(sqlc.narg('thumbnail')::text, thumbnail), + sort_order = coalesce(sqlc.narg('sort_order')::int, sort_order), updated_at = CURRENT_TIMESTAMP -WHERE id = sqlc.arg('id') -RETURNING *; +WHERE + id = sqlc.arg('id') +RETURNING + *; -- name: DeleteProgram :exec DELETE FROM programs diff --git a/docs/docs.go b/docs/docs.go index 067478f..fcb8cf9 100644 --- a/docs/docs.go +++ b/docs/docs.go @@ -800,6 +800,33 @@ const docTemplate = `{ } } }, + "/api/v1/courses/{courseId}/modules/reorder": { + "put": { + "tags": [ + "modules" + ], + "summary": "Reorder modules within a course", + "parameters": [ + { + "type": "integer", + "description": "Course ID", + "name": "courseId", + "in": "path", + "required": true + }, + { + "description": "ordered_ids: every module id in this course, in the new order", + "name": "body", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/domain.ReorderIDsRequest" + } + } + ], + "responses": {} + } + }, "/api/v1/courses/{id}": { "get": { "produces": [ @@ -1530,6 +1557,38 @@ const docTemplate = `{ "responses": {} } }, + "/api/v1/lessons/{id}/complete": { + "post": { + "description": "Records lesson completion; may cascade to module, course, and program progress for the authenticated user. Learners must meet sequential prerequisites; staff bypass checks.", + "tags": [ + "lessons" + ], + "summary": "Mark a lesson as completed", + "parameters": [ + { + "type": "integer", + "description": "Lesson ID", + "name": "id", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/domain.Response" + } + }, + "403": { + "description": "Forbidden", + "schema": { + "$ref": "#/definitions/domain.ErrorResponse" + } + } + } + } + }, "/api/v1/lessons/{id}/practices": { "get": { "tags": [ @@ -1547,6 +1606,32 @@ const docTemplate = `{ "responses": {} } }, + "/api/v1/lms/progress": { + "get": { + "description": "Returns completed lesson, module, course, and program IDs for the authenticated user (ordered by completion time, then id).", + "produces": [ + "application/json" + ], + "tags": [ + "lms" + ], + "summary": "Get my LMS completion history", + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/domain.Response" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/domain.ErrorResponse" + } + } + } + } + }, "/api/v1/logs": { "get": { "description": "Fetches application logs from MongoDB with pagination, level filtering, and search", @@ -2769,6 +2854,46 @@ const docTemplate = `{ } } }, + "/api/v1/programs/reorder": { + "put": { + "description": "Sets learning order of programs. Body must list every current program id exactly once, in the desired order (index 0 = first in path).", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "programs" + ], + "summary": "Reorder all programs", + "parameters": [ + { + "description": "New order: ordered_ids is the full set of program ids", + "name": "body", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/domain.ReorderIDsRequest" + } + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/domain.Response" + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/domain.ErrorResponse" + } + } + } + } + }, "/api/v1/programs/{id}": { "get": { "produces": [ @@ -2981,6 +3106,33 @@ const docTemplate = `{ } } }, + "/api/v1/programs/{id}/courses/reorder": { + "put": { + "tags": [ + "courses" + ], + "summary": "Reorder courses within a program", + "parameters": [ + { + "type": "integer", + "description": "Program ID", + "name": "id", + "in": "path", + "required": true + }, + { + "description": "ordered_ids: every course id in this program, in the new order", + "name": "body", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/domain.ReorderIDsRequest" + } + } + ], + "responses": {} + } + }, "/api/v1/progress/practices/{id}/complete": { "post": { "description": "Marks a practice question set as completed for the authenticated learner", @@ -8492,6 +8644,17 @@ const docTemplate = `{ } } }, + "domain.ReorderIDsRequest": { + "type": "object", + "properties": { + "ordered_ids": { + "type": "array", + "items": { + "type": "integer" + } + } + } + }, "domain.ResendOtpReq": { "type": "object", "properties": { @@ -8749,6 +8912,9 @@ const docTemplate = `{ "name": { "type": "string" }, + "sort_order": { + "type": "integer" + }, "thumbnail": { "type": "string" } @@ -8772,6 +8938,9 @@ const docTemplate = `{ "description": { "type": "string" }, + "sort_order": { + "type": "integer" + }, "thumbnail": { "type": "string" }, @@ -8794,6 +8963,9 @@ const docTemplate = `{ }, "name": { "type": "string" + }, + "sort_order": { + "type": "integer" } } }, @@ -8829,6 +9001,9 @@ const docTemplate = `{ "name": { "type": "string" }, + "sort_order": { + "type": "integer" + }, "thumbnail": { "type": "string" } diff --git a/docs/swagger.json b/docs/swagger.json index 6462d0c..359aba3 100644 --- a/docs/swagger.json +++ b/docs/swagger.json @@ -792,6 +792,33 @@ } } }, + "/api/v1/courses/{courseId}/modules/reorder": { + "put": { + "tags": [ + "modules" + ], + "summary": "Reorder modules within a course", + "parameters": [ + { + "type": "integer", + "description": "Course ID", + "name": "courseId", + "in": "path", + "required": true + }, + { + "description": "ordered_ids: every module id in this course, in the new order", + "name": "body", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/domain.ReorderIDsRequest" + } + } + ], + "responses": {} + } + }, "/api/v1/courses/{id}": { "get": { "produces": [ @@ -1522,6 +1549,38 @@ "responses": {} } }, + "/api/v1/lessons/{id}/complete": { + "post": { + "description": "Records lesson completion; may cascade to module, course, and program progress for the authenticated user. Learners must meet sequential prerequisites; staff bypass checks.", + "tags": [ + "lessons" + ], + "summary": "Mark a lesson as completed", + "parameters": [ + { + "type": "integer", + "description": "Lesson ID", + "name": "id", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/domain.Response" + } + }, + "403": { + "description": "Forbidden", + "schema": { + "$ref": "#/definitions/domain.ErrorResponse" + } + } + } + } + }, "/api/v1/lessons/{id}/practices": { "get": { "tags": [ @@ -1539,6 +1598,32 @@ "responses": {} } }, + "/api/v1/lms/progress": { + "get": { + "description": "Returns completed lesson, module, course, and program IDs for the authenticated user (ordered by completion time, then id).", + "produces": [ + "application/json" + ], + "tags": [ + "lms" + ], + "summary": "Get my LMS completion history", + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/domain.Response" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/domain.ErrorResponse" + } + } + } + } + }, "/api/v1/logs": { "get": { "description": "Fetches application logs from MongoDB with pagination, level filtering, and search", @@ -2761,6 +2846,46 @@ } } }, + "/api/v1/programs/reorder": { + "put": { + "description": "Sets learning order of programs. Body must list every current program id exactly once, in the desired order (index 0 = first in path).", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "programs" + ], + "summary": "Reorder all programs", + "parameters": [ + { + "description": "New order: ordered_ids is the full set of program ids", + "name": "body", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/domain.ReorderIDsRequest" + } + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/domain.Response" + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/domain.ErrorResponse" + } + } + } + } + }, "/api/v1/programs/{id}": { "get": { "produces": [ @@ -2973,6 +3098,33 @@ } } }, + "/api/v1/programs/{id}/courses/reorder": { + "put": { + "tags": [ + "courses" + ], + "summary": "Reorder courses within a program", + "parameters": [ + { + "type": "integer", + "description": "Program ID", + "name": "id", + "in": "path", + "required": true + }, + { + "description": "ordered_ids: every course id in this program, in the new order", + "name": "body", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/domain.ReorderIDsRequest" + } + } + ], + "responses": {} + } + }, "/api/v1/progress/practices/{id}/complete": { "post": { "description": "Marks a practice question set as completed for the authenticated learner", @@ -8484,6 +8636,17 @@ } } }, + "domain.ReorderIDsRequest": { + "type": "object", + "properties": { + "ordered_ids": { + "type": "array", + "items": { + "type": "integer" + } + } + } + }, "domain.ResendOtpReq": { "type": "object", "properties": { @@ -8741,6 +8904,9 @@ "name": { "type": "string" }, + "sort_order": { + "type": "integer" + }, "thumbnail": { "type": "string" } @@ -8764,6 +8930,9 @@ "description": { "type": "string" }, + "sort_order": { + "type": "integer" + }, "thumbnail": { "type": "string" }, @@ -8786,6 +8955,9 @@ }, "name": { "type": "string" + }, + "sort_order": { + "type": "integer" } } }, @@ -8821,6 +8993,9 @@ "name": { "type": "string" }, + "sort_order": { + "type": "integer" + }, "thumbnail": { "type": "string" } diff --git a/docs/swagger.yaml b/docs/swagger.yaml index f6164f7..77f3648 100644 --- a/docs/swagger.yaml +++ b/docs/swagger.yaml @@ -351,6 +351,13 @@ definitions: role: type: string type: object + domain.ReorderIDsRequest: + properties: + ordered_ids: + items: + type: integer + type: array + type: object domain.ResendOtpReq: properties: email: @@ -530,6 +537,8 @@ definitions: type: string name: type: string + sort_order: + type: integer thumbnail: type: string type: object @@ -545,6 +554,8 @@ definitions: properties: description: type: string + sort_order: + type: integer thumbnail: type: string title: @@ -560,6 +571,8 @@ definitions: type: string name: type: string + sort_order: + type: integer type: object domain.UpdatePracticeInput: properties: @@ -582,6 +595,8 @@ definitions: type: string name: type: string + sort_order: + type: integer thumbnail: type: string type: object @@ -2407,6 +2422,24 @@ paths: summary: Create module tags: - modules + /api/v1/courses/{courseId}/modules/reorder: + put: + parameters: + - description: Course ID + in: path + name: courseId + required: true + type: integer + - description: 'ordered_ids: every module id in this course, in the new order' + in: body + name: body + required: true + schema: + $ref: '#/definitions/domain.ReorderIDsRequest' + responses: {} + summary: Reorder modules within a course + tags: + - modules /api/v1/courses/{id}: delete: parameters: @@ -2863,6 +2896,29 @@ paths: responses: {} tags: - lessons + /api/v1/lessons/{id}/complete: + post: + description: Records lesson completion; may cascade to module, course, and program + progress for the authenticated user. Learners must meet sequential prerequisites; + staff bypass checks. + parameters: + - description: Lesson ID + in: path + name: id + required: true + type: integer + responses: + "200": + description: OK + schema: + $ref: '#/definitions/domain.Response' + "403": + description: Forbidden + schema: + $ref: '#/definitions/domain.ErrorResponse' + summary: Mark a lesson as completed + tags: + - lessons /api/v1/lessons/{id}/practices: get: parameters: @@ -2874,6 +2930,24 @@ paths: responses: {} tags: - practices + /api/v1/lms/progress: + get: + description: Returns completed lesson, module, course, and program IDs for the + authenticated user (ordered by completion time, then id). + produces: + - application/json + responses: + "200": + description: OK + schema: + $ref: '#/definitions/domain.Response' + "500": + description: Internal Server Error + schema: + $ref: '#/definitions/domain.ErrorResponse' + summary: Get my LMS completion history + tags: + - lms /api/v1/logs: get: description: Fetches application logs from MongoDB with pagination, level filtering, @@ -3829,6 +3903,51 @@ paths: summary: Create course tags: - courses + /api/v1/programs/{id}/courses/reorder: + put: + parameters: + - description: Program ID + in: path + name: id + required: true + type: integer + - description: 'ordered_ids: every course id in this program, in the new order' + in: body + name: body + required: true + schema: + $ref: '#/definitions/domain.ReorderIDsRequest' + responses: {} + summary: Reorder courses within a program + tags: + - courses + /api/v1/programs/reorder: + put: + consumes: + - application/json + description: Sets learning order of programs. Body must list every current program + id exactly once, in the desired order (index 0 = first in path). + parameters: + - description: 'New order: ordered_ids is the full set of program ids' + in: body + name: body + required: true + schema: + $ref: '#/definitions/domain.ReorderIDsRequest' + produces: + - application/json + responses: + "200": + description: OK + schema: + $ref: '#/definitions/domain.Response' + "400": + description: Bad Request + schema: + $ref: '#/definitions/domain.ErrorResponse' + summary: Reorder all programs + tags: + - programs /api/v1/progress/practices/{id}/complete: post: description: Marks a practice question set as completed for the authenticated diff --git a/gen/db/lms_courses.sql.go b/gen/db/lms_courses.sql.go index 7505e46..90245bd 100644 --- a/gen/db/lms_courses.sql.go +++ b/gen/db/lms_courses.sql.go @@ -12,9 +12,20 @@ import ( ) const CreateCourse = `-- name: CreateCourse :one -INSERT INTO courses (program_id, name, description, thumbnail) -VALUES ($1, $2, $3, $4) -RETURNING id, program_id, name, description, thumbnail, created_at, updated_at +INSERT INTO courses (program_id, name, description, thumbnail, sort_order) +SELECT + $1, + $2, + $3, + $4, + coalesce(( + SELECT + max(c.sort_order) + FROM courses c + WHERE + c.program_id = $1), 0) + 1 +RETURNING + id, program_id, name, description, thumbnail, created_at, updated_at, sort_order ` type CreateCourseParams struct { @@ -40,6 +51,7 @@ func (q *Queries) CreateCourse(ctx context.Context, arg CreateCourseParams) (Cou &i.Thumbnail, &i.CreatedAt, &i.UpdatedAt, + &i.SortOrder, ) return i, err } @@ -55,7 +67,7 @@ func (q *Queries) DeleteCourse(ctx context.Context, id int64) error { } const GetCourseByID = `-- name: GetCourseByID :one -SELECT id, program_id, name, description, thumbnail, created_at, updated_at +SELECT id, program_id, name, description, thumbnail, created_at, updated_at, sort_order FROM courses WHERE id = $1 ` @@ -71,10 +83,42 @@ func (q *Queries) GetCourseByID(ctx context.Context, id int64) (Course, error) { &i.Thumbnail, &i.CreatedAt, &i.UpdatedAt, + &i.SortOrder, ) return i, err } +const ListCourseIDsByProgram = `-- name: ListCourseIDsByProgram :many +SELECT + c.id +FROM + courses AS c +WHERE + c.program_id = $1 +ORDER BY + c.id +` + +func (q *Queries) ListCourseIDsByProgram(ctx context.Context, programID int64) ([]int64, error) { + rows, err := q.db.Query(ctx, ListCourseIDsByProgram, programID) + 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 ListCoursesByProgramID = `-- name: ListCoursesByProgramID :many SELECT COUNT(*) OVER () AS total_count, @@ -83,11 +127,16 @@ SELECT c.name, c.description, c.thumbnail, + c.sort_order, c.created_at, c.updated_at -FROM courses c -WHERE c.program_id = $1 -ORDER BY c.created_at DESC +FROM + courses c +WHERE + c.program_id = $1 +ORDER BY + c.sort_order ASC, + c.id ASC LIMIT $2 OFFSET $3 ` @@ -104,6 +153,7 @@ type ListCoursesByProgramIDRow struct { Name string `json:"name"` Description pgtype.Text `json:"description"` Thumbnail pgtype.Text `json:"thumbnail"` + SortOrder int32 `json:"sort_order"` CreatedAt pgtype.Timestamptz `json:"created_at"` UpdatedAt pgtype.Timestamptz `json:"updated_at"` } @@ -124,6 +174,7 @@ func (q *Queries) ListCoursesByProgramID(ctx context.Context, arg ListCoursesByP &i.Name, &i.Description, &i.Thumbnail, + &i.SortOrder, &i.CreatedAt, &i.UpdatedAt, ); err != nil { @@ -143,15 +194,19 @@ SET name = COALESCE($1::varchar, name), description = COALESCE($2::text, description), thumbnail = COALESCE($3::text, thumbnail), + sort_order = coalesce($4::int, sort_order), updated_at = CURRENT_TIMESTAMP -WHERE id = $4 -RETURNING id, program_id, name, description, thumbnail, created_at, updated_at +WHERE + id = $5 +RETURNING + id, program_id, name, description, thumbnail, created_at, updated_at, sort_order ` type UpdateCourseParams struct { Name pgtype.Text `json:"name"` Description pgtype.Text `json:"description"` Thumbnail pgtype.Text `json:"thumbnail"` + SortOrder pgtype.Int4 `json:"sort_order"` ID int64 `json:"id"` } @@ -160,6 +215,7 @@ func (q *Queries) UpdateCourse(ctx context.Context, arg UpdateCourseParams) (Cou arg.Name, arg.Description, arg.Thumbnail, + arg.SortOrder, arg.ID, ) var i Course @@ -171,6 +227,7 @@ func (q *Queries) UpdateCourse(ctx context.Context, arg UpdateCourseParams) (Cou &i.Thumbnail, &i.CreatedAt, &i.UpdatedAt, + &i.SortOrder, ) return i, err } diff --git a/gen/db/lms_lessons.sql.go b/gen/db/lms_lessons.sql.go index f15bed0..48ac1d4 100644 --- a/gen/db/lms_lessons.sql.go +++ b/gen/db/lms_lessons.sql.go @@ -12,9 +12,21 @@ import ( ) const CreateLesson = `-- name: CreateLesson :one -INSERT INTO lessons (module_id, title, video_url, thumbnail, description) -VALUES ($1, $2, $3, $4, $5) -RETURNING id, module_id, title, video_url, thumbnail, description, created_at, updated_at +INSERT INTO lessons (module_id, title, video_url, thumbnail, description, sort_order) +SELECT + $1, + $2, + $3, + $4, + $5, + coalesce(( + SELECT + max(l.sort_order) + FROM lessons l + WHERE + l.module_id = $1), 0) + 1 +RETURNING + id, module_id, title, video_url, thumbnail, description, created_at, updated_at, sort_order ` type CreateLessonParams struct { @@ -43,6 +55,7 @@ func (q *Queries) CreateLesson(ctx context.Context, arg CreateLessonParams) (Les &i.Description, &i.CreatedAt, &i.UpdatedAt, + &i.SortOrder, ) return i, err } @@ -58,7 +71,7 @@ func (q *Queries) DeleteLesson(ctx context.Context, id int64) error { } const GetLessonByID = `-- name: GetLessonByID :one -SELECT id, module_id, title, video_url, thumbnail, description, created_at, updated_at +SELECT id, module_id, title, video_url, thumbnail, description, created_at, updated_at, sort_order FROM lessons WHERE id = $1 ` @@ -75,6 +88,7 @@ func (q *Queries) GetLessonByID(ctx context.Context, id int64) (Lesson, error) { &i.Description, &i.CreatedAt, &i.UpdatedAt, + &i.SortOrder, ) return i, err } @@ -88,12 +102,18 @@ SELECT l.video_url, l.thumbnail, l.description, + l.sort_order, l.created_at, l.updated_at -FROM lessons l -WHERE l.module_id = $1 -ORDER BY l.created_at DESC -LIMIT $2 OFFSET $3 +FROM + lessons l +WHERE + l.module_id = $1 +ORDER BY + l.sort_order ASC, + l.id ASC +LIMIT $2 +OFFSET $3 ` type ListLessonsByModuleIDParams struct { @@ -110,6 +130,7 @@ type ListLessonsByModuleIDRow struct { VideoUrl pgtype.Text `json:"video_url"` Thumbnail pgtype.Text `json:"thumbnail"` Description pgtype.Text `json:"description"` + SortOrder int32 `json:"sort_order"` CreatedAt pgtype.Timestamptz `json:"created_at"` UpdatedAt pgtype.Timestamptz `json:"updated_at"` } @@ -131,6 +152,7 @@ func (q *Queries) ListLessonsByModuleID(ctx context.Context, arg ListLessonsByMo &i.VideoUrl, &i.Thumbnail, &i.Description, + &i.SortOrder, &i.CreatedAt, &i.UpdatedAt, ); err != nil { @@ -151,9 +173,12 @@ SET video_url = COALESCE($2::text, video_url), thumbnail = COALESCE($3::text, thumbnail), description = COALESCE($4::text, description), + sort_order = coalesce($5::int, sort_order), updated_at = CURRENT_TIMESTAMP -WHERE id = $5 -RETURNING id, module_id, title, video_url, thumbnail, description, created_at, updated_at +WHERE + id = $6 +RETURNING + id, module_id, title, video_url, thumbnail, description, created_at, updated_at, sort_order ` type UpdateLessonParams struct { @@ -161,6 +186,7 @@ type UpdateLessonParams struct { VideoUrl pgtype.Text `json:"video_url"` Thumbnail pgtype.Text `json:"thumbnail"` Description pgtype.Text `json:"description"` + SortOrder pgtype.Int4 `json:"sort_order"` ID int64 `json:"id"` } @@ -170,6 +196,7 @@ func (q *Queries) UpdateLesson(ctx context.Context, arg UpdateLessonParams) (Les arg.VideoUrl, arg.Thumbnail, arg.Description, + arg.SortOrder, arg.ID, ) var i Lesson @@ -182,6 +209,7 @@ func (q *Queries) UpdateLesson(ctx context.Context, arg UpdateLessonParams) (Les &i.Description, &i.CreatedAt, &i.UpdatedAt, + &i.SortOrder, ) return i, err } diff --git a/gen/db/lms_modules.sql.go b/gen/db/lms_modules.sql.go index db67d7e..a019c71 100644 --- a/gen/db/lms_modules.sql.go +++ b/gen/db/lms_modules.sql.go @@ -12,9 +12,21 @@ import ( ) const CreateModule = `-- name: CreateModule :one -INSERT INTO modules (program_id, course_id, name, description, icon) -VALUES ($1, $2, $3, $4, $5) -RETURNING id, program_id, course_id, name, description, icon, created_at, updated_at +INSERT INTO modules (program_id, course_id, name, description, icon, sort_order) +SELECT + $1, + $2, + $3, + $4, + $5, + coalesce(( + SELECT + max(m.sort_order) + FROM modules m + WHERE + m.course_id = $2), 0) + 1 +RETURNING + id, program_id, course_id, name, description, icon, created_at, updated_at, sort_order ` type CreateModuleParams struct { @@ -43,6 +55,7 @@ func (q *Queries) CreateModule(ctx context.Context, arg CreateModuleParams) (Mod &i.Icon, &i.CreatedAt, &i.UpdatedAt, + &i.SortOrder, ) return i, err } @@ -58,7 +71,7 @@ func (q *Queries) DeleteModule(ctx context.Context, id int64) error { } const GetModuleByID = `-- name: GetModuleByID :one -SELECT id, program_id, course_id, name, description, icon, created_at, updated_at +SELECT id, program_id, course_id, name, description, icon, created_at, updated_at, sort_order FROM modules WHERE id = $1 ` @@ -75,10 +88,42 @@ func (q *Queries) GetModuleByID(ctx context.Context, id int64) (Module, error) { &i.Icon, &i.CreatedAt, &i.UpdatedAt, + &i.SortOrder, ) return i, err } +const ListModuleIDsByCourse = `-- name: ListModuleIDsByCourse :many +SELECT + m.id +FROM + modules AS m +WHERE + m.course_id = $1 +ORDER BY + m.id +` + +func (q *Queries) ListModuleIDsByCourse(ctx context.Context, courseID int64) ([]int64, error) { + rows, err := q.db.Query(ctx, ListModuleIDsByCourse, courseID) + 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 ListModulesByProgramAndCourse = `-- name: ListModulesByProgramAndCourse :many SELECT COUNT(*) OVER () AS total_count, @@ -88,13 +133,19 @@ SELECT m.name, m.description, m.icon, + m.sort_order, m.created_at, m.updated_at -FROM modules m -WHERE m.program_id = $1 - AND m.course_id = $2 -ORDER BY m.created_at DESC -LIMIT $3 OFFSET $4 +FROM + modules m +WHERE + m.program_id = $1 + AND m.course_id = $2 +ORDER BY + m.sort_order ASC, + m.id ASC +LIMIT $3 +OFFSET $4 ` type ListModulesByProgramAndCourseParams struct { @@ -112,6 +163,7 @@ type ListModulesByProgramAndCourseRow struct { Name string `json:"name"` Description pgtype.Text `json:"description"` Icon pgtype.Text `json:"icon"` + SortOrder int32 `json:"sort_order"` CreatedAt pgtype.Timestamptz `json:"created_at"` UpdatedAt pgtype.Timestamptz `json:"updated_at"` } @@ -138,6 +190,7 @@ func (q *Queries) ListModulesByProgramAndCourse(ctx context.Context, arg ListMod &i.Name, &i.Description, &i.Icon, + &i.SortOrder, &i.CreatedAt, &i.UpdatedAt, ); err != nil { @@ -157,15 +210,19 @@ SET name = COALESCE($1::varchar, name), description = COALESCE($2::text, description), icon = COALESCE($3::text, icon), + sort_order = coalesce($4::int, sort_order), updated_at = CURRENT_TIMESTAMP -WHERE id = $4 -RETURNING id, program_id, course_id, name, description, icon, created_at, updated_at +WHERE + id = $5 +RETURNING + id, program_id, course_id, name, description, icon, created_at, updated_at, sort_order ` type UpdateModuleParams struct { Name pgtype.Text `json:"name"` Description pgtype.Text `json:"description"` Icon pgtype.Text `json:"icon"` + SortOrder pgtype.Int4 `json:"sort_order"` ID int64 `json:"id"` } @@ -174,6 +231,7 @@ func (q *Queries) UpdateModule(ctx context.Context, arg UpdateModuleParams) (Mod arg.Name, arg.Description, arg.Icon, + arg.SortOrder, arg.ID, ) var i Module @@ -186,6 +244,7 @@ func (q *Queries) UpdateModule(ctx context.Context, arg UpdateModuleParams) (Mod &i.Icon, &i.CreatedAt, &i.UpdatedAt, + &i.SortOrder, ) return i, err } diff --git a/gen/db/lms_progress.sql.go b/gen/db/lms_progress.sql.go new file mode 100644 index 0000000..f4f2b4d --- /dev/null +++ b/gen/db/lms_progress.sql.go @@ -0,0 +1,613 @@ +// Code generated by sqlc. DO NOT EDIT. +// versions: +// sqlc v1.30.0 +// source: lms_progress.sql + +package dbgen + +import ( + "context" +) + +const CountCoursesInProgram = `-- name: CountCoursesInProgram :one +SELECT + count(*)::int AS n +FROM + courses +WHERE + program_id = $1 +` + +func (q *Queries) CountCoursesInProgram(ctx context.Context, programID int64) (int32, error) { + row := q.db.QueryRow(ctx, CountCoursesInProgram, programID) + var n int32 + err := row.Scan(&n) + return n, err +} + +const CountLessonsInCourse = `-- name: CountLessonsInCourse :one +SELECT + count(*)::int AS n +FROM + lessons l + INNER JOIN modules m ON m.id = l.module_id +WHERE + m.course_id = $1 +` + +// Lesson-based progress within a course (all modules). +func (q *Queries) CountLessonsInCourse(ctx context.Context, courseID int64) (int32, error) { + row := q.db.QueryRow(ctx, CountLessonsInCourse, courseID) + var n int32 + err := row.Scan(&n) + return n, err +} + +const CountLessonsInModule = `-- name: CountLessonsInModule :one +SELECT + count(*)::int AS n +FROM + lessons +WHERE + module_id = $1 +` + +func (q *Queries) CountLessonsInModule(ctx context.Context, moduleID int64) (int32, error) { + row := q.db.QueryRow(ctx, CountLessonsInModule, moduleID) + var n int32 + err := row.Scan(&n) + return n, err +} + +const CountLessonsInProgram = `-- name: CountLessonsInProgram :one +SELECT + count(*)::int AS n +FROM + lessons l + INNER JOIN modules m ON m.id = l.module_id + INNER JOIN courses c ON c.id = m.course_id +WHERE + c.program_id = $1 +` + +// Lesson-based progress within a program (all courses). +func (q *Queries) CountLessonsInProgram(ctx context.Context, programID int64) (int32, error) { + row := q.db.QueryRow(ctx, CountLessonsInProgram, programID) + var n int32 + err := row.Scan(&n) + return n, err +} + +const CountModulesInCourse = `-- name: CountModulesInCourse :one +SELECT + count(*)::int AS n +FROM + modules +WHERE + course_id = $1 +` + +func (q *Queries) CountModulesInCourse(ctx context.Context, courseID int64) (int32, error) { + row := q.db.QueryRow(ctx, CountModulesInCourse, courseID) + var n int32 + err := row.Scan(&n) + return n, err +} + +const CountUserCompletedCoursesInProgram = `-- name: CountUserCompletedCoursesInProgram :one +SELECT + count(*)::int AS n +FROM + lms_user_course_progress ucp + INNER JOIN courses c ON c.id = ucp.course_id +WHERE + c.program_id = $1 + AND ucp.user_id = $2 +` + +type CountUserCompletedCoursesInProgramParams struct { + ProgramID int64 `json:"program_id"` + UserID int64 `json:"user_id"` +} + +func (q *Queries) CountUserCompletedCoursesInProgram(ctx context.Context, arg CountUserCompletedCoursesInProgramParams) (int32, error) { + row := q.db.QueryRow(ctx, CountUserCompletedCoursesInProgram, arg.ProgramID, arg.UserID) + var n int32 + err := row.Scan(&n) + return n, err +} + +const CountUserCompletedLessonsInCourse = `-- name: CountUserCompletedLessonsInCourse :one +SELECT + count(*)::int AS n +FROM + lms_user_lesson_progress ulp + INNER JOIN lessons l ON l.id = ulp.lesson_id + INNER JOIN modules m ON m.id = l.module_id +WHERE + m.course_id = $1 + AND ulp.user_id = $2 +` + +type CountUserCompletedLessonsInCourseParams struct { + CourseID int64 `json:"course_id"` + UserID int64 `json:"user_id"` +} + +func (q *Queries) CountUserCompletedLessonsInCourse(ctx context.Context, arg CountUserCompletedLessonsInCourseParams) (int32, error) { + row := q.db.QueryRow(ctx, CountUserCompletedLessonsInCourse, arg.CourseID, arg.UserID) + var n int32 + err := row.Scan(&n) + return n, err +} + +const CountUserCompletedLessonsInModule = `-- name: CountUserCompletedLessonsInModule :one +SELECT + count(*)::int AS n +FROM + lms_user_lesson_progress ulp + INNER JOIN lessons l ON l.id = ulp.lesson_id +WHERE + l.module_id = $1 + AND ulp.user_id = $2 +` + +type CountUserCompletedLessonsInModuleParams struct { + ModuleID int64 `json:"module_id"` + UserID int64 `json:"user_id"` +} + +func (q *Queries) CountUserCompletedLessonsInModule(ctx context.Context, arg CountUserCompletedLessonsInModuleParams) (int32, error) { + row := q.db.QueryRow(ctx, CountUserCompletedLessonsInModule, arg.ModuleID, arg.UserID) + var n int32 + err := row.Scan(&n) + return n, err +} + +const CountUserCompletedLessonsInProgram = `-- name: CountUserCompletedLessonsInProgram :one +SELECT + count(*)::int AS n +FROM + lms_user_lesson_progress ulp + INNER JOIN lessons l ON l.id = ulp.lesson_id + INNER JOIN modules m ON m.id = l.module_id + INNER JOIN courses c ON c.id = m.course_id +WHERE + c.program_id = $1 + AND ulp.user_id = $2 +` + +type CountUserCompletedLessonsInProgramParams struct { + ProgramID int64 `json:"program_id"` + UserID int64 `json:"user_id"` +} + +func (q *Queries) CountUserCompletedLessonsInProgram(ctx context.Context, arg CountUserCompletedLessonsInProgramParams) (int32, error) { + row := q.db.QueryRow(ctx, CountUserCompletedLessonsInProgram, arg.ProgramID, arg.UserID) + var n int32 + err := row.Scan(&n) + return n, err +} + +const CountUserCompletedModulesInCourse = `-- name: CountUserCompletedModulesInCourse :one +SELECT + count(*)::int AS n +FROM + lms_user_module_progress ump + INNER JOIN modules m ON m.id = ump.module_id +WHERE + m.course_id = $1 + AND ump.user_id = $2 +` + +type CountUserCompletedModulesInCourseParams struct { + CourseID int64 `json:"course_id"` + UserID int64 `json:"user_id"` +} + +func (q *Queries) CountUserCompletedModulesInCourse(ctx context.Context, arg CountUserCompletedModulesInCourseParams) (int32, error) { + row := q.db.QueryRow(ctx, CountUserCompletedModulesInCourse, arg.CourseID, arg.UserID) + var n int32 + err := row.Scan(&n) + return n, err +} + +const GetPreviousCourseInProgram = `-- name: GetPreviousCourseInProgram :one +SELECT + c2.id, c2.program_id, c2.name, c2.description, c2.thumbnail, c2.created_at, c2.updated_at, c2.sort_order +FROM + courses AS c1 + INNER JOIN courses AS c2 ON c2.program_id = c1.program_id + AND c2.sort_order = c1.sort_order - 1 +WHERE + c1.id = $1 +` + +func (q *Queries) GetPreviousCourseInProgram(ctx context.Context, id int64) (Course, error) { + row := q.db.QueryRow(ctx, GetPreviousCourseInProgram, id) + var i Course + err := row.Scan( + &i.ID, + &i.ProgramID, + &i.Name, + &i.Description, + &i.Thumbnail, + &i.CreatedAt, + &i.UpdatedAt, + &i.SortOrder, + ) + return i, err +} + +const GetPreviousLessonInModule = `-- name: GetPreviousLessonInModule :one +SELECT + l2.id, l2.module_id, l2.title, l2.video_url, l2.thumbnail, l2.description, l2.created_at, l2.updated_at, l2.sort_order +FROM + lessons AS l1 + INNER JOIN lessons AS l2 ON l2.module_id = l1.module_id + AND l2.sort_order = l1.sort_order - 1 +WHERE + l1.id = $1 +` + +func (q *Queries) GetPreviousLessonInModule(ctx context.Context, id int64) (Lesson, error) { + row := q.db.QueryRow(ctx, GetPreviousLessonInModule, id) + var i Lesson + err := row.Scan( + &i.ID, + &i.ModuleID, + &i.Title, + &i.VideoUrl, + &i.Thumbnail, + &i.Description, + &i.CreatedAt, + &i.UpdatedAt, + &i.SortOrder, + ) + return i, err +} + +const GetPreviousModuleInCourse = `-- name: GetPreviousModuleInCourse :one +SELECT + m2.id, m2.program_id, m2.course_id, m2.name, m2.description, m2.icon, m2.created_at, m2.updated_at, m2.sort_order +FROM + modules AS m1 + INNER JOIN modules AS m2 ON m2.course_id = m1.course_id + AND m2.sort_order = m1.sort_order - 1 +WHERE + m1.id = $1 +` + +func (q *Queries) GetPreviousModuleInCourse(ctx context.Context, id int64) (Module, error) { + row := q.db.QueryRow(ctx, GetPreviousModuleInCourse, id) + var i Module + err := row.Scan( + &i.ID, + &i.ProgramID, + &i.CourseID, + &i.Name, + &i.Description, + &i.Icon, + &i.CreatedAt, + &i.UpdatedAt, + &i.SortOrder, + ) + return i, err +} + +const GetPreviousProgram = `-- name: GetPreviousProgram :one +SELECT + p2.id, p2.name, p2.description, p2.thumbnail, p2.created_at, p2.updated_at, p2.sort_order +FROM + programs AS p1 + INNER JOIN programs AS p2 ON p2.sort_order = p1.sort_order - 1 +WHERE + p1.id = $1 +` + +func (q *Queries) GetPreviousProgram(ctx context.Context, id int64) (Program, error) { + row := q.db.QueryRow(ctx, GetPreviousProgram, id) + var i Program + err := row.Scan( + &i.ID, + &i.Name, + &i.Description, + &i.Thumbnail, + &i.CreatedAt, + &i.UpdatedAt, + &i.SortOrder, + ) + return i, err +} + +const InsertUserCourseProgress = `-- name: InsertUserCourseProgress :exec +INSERT INTO lms_user_course_progress (user_id, course_id) + VALUES ($1, $2) +ON CONFLICT (user_id, course_id) + DO NOTHING +` + +type InsertUserCourseProgressParams struct { + UserID int64 `json:"user_id"` + CourseID int64 `json:"course_id"` +} + +func (q *Queries) InsertUserCourseProgress(ctx context.Context, arg InsertUserCourseProgressParams) error { + _, err := q.db.Exec(ctx, InsertUserCourseProgress, arg.UserID, arg.CourseID) + return err +} + +const InsertUserLessonProgress = `-- name: InsertUserLessonProgress :exec +INSERT INTO lms_user_lesson_progress (user_id, lesson_id) + VALUES ($1, $2) +ON CONFLICT (user_id, lesson_id) + DO NOTHING +` + +type InsertUserLessonProgressParams struct { + UserID int64 `json:"user_id"` + LessonID int64 `json:"lesson_id"` +} + +func (q *Queries) InsertUserLessonProgress(ctx context.Context, arg InsertUserLessonProgressParams) error { + _, err := q.db.Exec(ctx, InsertUserLessonProgress, arg.UserID, arg.LessonID) + return err +} + +const InsertUserModuleProgress = `-- name: InsertUserModuleProgress :exec +INSERT INTO lms_user_module_progress (user_id, module_id) + VALUES ($1, $2) +ON CONFLICT (user_id, module_id) + DO NOTHING +` + +type InsertUserModuleProgressParams struct { + UserID int64 `json:"user_id"` + ModuleID int64 `json:"module_id"` +} + +func (q *Queries) InsertUserModuleProgress(ctx context.Context, arg InsertUserModuleProgressParams) error { + _, err := q.db.Exec(ctx, InsertUserModuleProgress, arg.UserID, arg.ModuleID) + return err +} + +const InsertUserProgramProgress = `-- name: InsertUserProgramProgress :exec +INSERT INTO lms_user_program_progress (user_id, program_id) + VALUES ($1, $2) +ON CONFLICT (user_id, program_id) + DO NOTHING +` + +type InsertUserProgramProgressParams struct { + UserID int64 `json:"user_id"` + ProgramID int64 `json:"program_id"` +} + +func (q *Queries) InsertUserProgramProgress(ctx context.Context, arg InsertUserProgramProgressParams) error { + _, err := q.db.Exec(ctx, InsertUserProgramProgress, arg.UserID, arg.ProgramID) + return err +} + +const ListLMSCompletedCourseIDsByUser = `-- name: ListLMSCompletedCourseIDsByUser :many +SELECT + ucp.course_id +FROM + lms_user_course_progress AS ucp +WHERE + ucp.user_id = $1 +ORDER BY + ucp.completed_at ASC, + ucp.course_id ASC +` + +func (q *Queries) ListLMSCompletedCourseIDsByUser(ctx context.Context, userID int64) ([]int64, error) { + rows, err := q.db.Query(ctx, ListLMSCompletedCourseIDsByUser, userID) + if err != nil { + return nil, err + } + defer rows.Close() + var items []int64 + for rows.Next() { + var course_id int64 + if err := rows.Scan(&course_id); err != nil { + return nil, err + } + items = append(items, course_id) + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + +const ListLMSCompletedLessonIDsByUser = `-- name: ListLMSCompletedLessonIDsByUser :many +SELECT + ulp.lesson_id +FROM + lms_user_lesson_progress AS ulp +WHERE + ulp.user_id = $1 +ORDER BY + ulp.completed_at ASC, + ulp.lesson_id ASC +` + +func (q *Queries) ListLMSCompletedLessonIDsByUser(ctx context.Context, userID int64) ([]int64, error) { + rows, err := q.db.Query(ctx, ListLMSCompletedLessonIDsByUser, userID) + if err != nil { + return nil, err + } + defer rows.Close() + var items []int64 + for rows.Next() { + var lesson_id int64 + if err := rows.Scan(&lesson_id); err != nil { + return nil, err + } + items = append(items, lesson_id) + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + +const ListLMSCompletedModuleIDsByUser = `-- name: ListLMSCompletedModuleIDsByUser :many +SELECT + ump.module_id +FROM + lms_user_module_progress AS ump +WHERE + ump.user_id = $1 +ORDER BY + ump.completed_at ASC, + ump.module_id ASC +` + +func (q *Queries) ListLMSCompletedModuleIDsByUser(ctx context.Context, userID int64) ([]int64, error) { + rows, err := q.db.Query(ctx, ListLMSCompletedModuleIDsByUser, userID) + if err != nil { + return nil, err + } + defer rows.Close() + var items []int64 + for rows.Next() { + var module_id int64 + if err := rows.Scan(&module_id); err != nil { + return nil, err + } + items = append(items, module_id) + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + +const ListLMSCompletedProgramIDsByUser = `-- name: ListLMSCompletedProgramIDsByUser :many +SELECT + upp.program_id +FROM + lms_user_program_progress AS upp +WHERE + upp.user_id = $1 +ORDER BY + upp.completed_at ASC, + upp.program_id ASC +` + +func (q *Queries) ListLMSCompletedProgramIDsByUser(ctx context.Context, userID int64) ([]int64, error) { + rows, err := q.db.Query(ctx, ListLMSCompletedProgramIDsByUser, userID) + if err != nil { + return nil, err + } + defer rows.Close() + var items []int64 + for rows.Next() { + var program_id int64 + if err := rows.Scan(&program_id); err != nil { + return nil, err + } + items = append(items, program_id) + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + +const UserHasCourseProgress = `-- name: UserHasCourseProgress :one +SELECT + EXISTS ( + SELECT + 1 + FROM + lms_user_course_progress + WHERE + user_id = $1 + AND course_id = $2) AS v +` + +type UserHasCourseProgressParams struct { + UserID int64 `json:"user_id"` + CourseID int64 `json:"course_id"` +} + +func (q *Queries) UserHasCourseProgress(ctx context.Context, arg UserHasCourseProgressParams) (bool, error) { + row := q.db.QueryRow(ctx, UserHasCourseProgress, arg.UserID, arg.CourseID) + var v bool + err := row.Scan(&v) + return v, err +} + +const UserHasLessonProgress = `-- name: UserHasLessonProgress :one +SELECT + EXISTS ( + SELECT + 1 + FROM + lms_user_lesson_progress + WHERE + user_id = $1 + AND lesson_id = $2) AS v +` + +type UserHasLessonProgressParams struct { + UserID int64 `json:"user_id"` + LessonID int64 `json:"lesson_id"` +} + +func (q *Queries) UserHasLessonProgress(ctx context.Context, arg UserHasLessonProgressParams) (bool, error) { + row := q.db.QueryRow(ctx, UserHasLessonProgress, arg.UserID, arg.LessonID) + var v bool + err := row.Scan(&v) + return v, err +} + +const UserHasModuleProgress = `-- name: UserHasModuleProgress :one +SELECT + EXISTS ( + SELECT + 1 + FROM + lms_user_module_progress + WHERE + user_id = $1 + AND module_id = $2) AS v +` + +type UserHasModuleProgressParams struct { + UserID int64 `json:"user_id"` + ModuleID int64 `json:"module_id"` +} + +func (q *Queries) UserHasModuleProgress(ctx context.Context, arg UserHasModuleProgressParams) (bool, error) { + row := q.db.QueryRow(ctx, UserHasModuleProgress, arg.UserID, arg.ModuleID) + var v bool + err := row.Scan(&v) + return v, err +} + +const UserHasProgramProgress = `-- name: UserHasProgramProgress :one +SELECT + EXISTS ( + SELECT + 1 + FROM + lms_user_program_progress + WHERE + user_id = $1 + AND program_id = $2) AS v +` + +type UserHasProgramProgressParams struct { + UserID int64 `json:"user_id"` + ProgramID int64 `json:"program_id"` +} + +func (q *Queries) UserHasProgramProgress(ctx context.Context, arg UserHasProgramProgressParams) (bool, error) { + row := q.db.QueryRow(ctx, UserHasProgramProgress, arg.UserID, arg.ProgramID) + var v bool + err := row.Scan(&v) + return v, err +} diff --git a/gen/db/models.go b/gen/db/models.go index 7bcc695..1a2da27 100644 --- a/gen/db/models.go +++ b/gen/db/models.go @@ -30,6 +30,7 @@ type Course struct { Thumbnail pgtype.Text `json:"thumbnail"` CreatedAt pgtype.Timestamptz `json:"created_at"` UpdatedAt pgtype.Timestamptz `json:"updated_at"` + SortOrder int32 `json:"sort_order"` } type Device struct { @@ -58,6 +59,7 @@ type Lesson struct { Description pgtype.Text `json:"description"` CreatedAt pgtype.Timestamptz `json:"created_at"` UpdatedAt pgtype.Timestamptz `json:"updated_at"` + SortOrder int32 `json:"sort_order"` } type LevelToSubCourse struct { @@ -80,6 +82,30 @@ type LmsPractice struct { UpdatedAt pgtype.Timestamptz `json:"updated_at"` } +type LmsUserCourseProgress struct { + UserID int64 `json:"user_id"` + CourseID int64 `json:"course_id"` + CompletedAt pgtype.Timestamptz `json:"completed_at"` +} + +type LmsUserLessonProgress struct { + UserID int64 `json:"user_id"` + LessonID int64 `json:"lesson_id"` + CompletedAt pgtype.Timestamptz `json:"completed_at"` +} + +type LmsUserModuleProgress struct { + UserID int64 `json:"user_id"` + ModuleID int64 `json:"module_id"` + CompletedAt pgtype.Timestamptz `json:"completed_at"` +} + +type LmsUserProgramProgress struct { + UserID int64 `json:"user_id"` + ProgramID int64 `json:"program_id"` + CompletedAt pgtype.Timestamptz `json:"completed_at"` +} + type Module struct { ID int64 `json:"id"` ProgramID int64 `json:"program_id"` @@ -89,6 +115,7 @@ type Module struct { Icon pgtype.Text `json:"icon"` CreatedAt pgtype.Timestamptz `json:"created_at"` UpdatedAt pgtype.Timestamptz `json:"updated_at"` + SortOrder int32 `json:"sort_order"` } type ModuleToSubCourse struct { @@ -159,6 +186,7 @@ type Program struct { Thumbnail pgtype.Text `json:"thumbnail"` CreatedAt pgtype.Timestamptz `json:"created_at"` UpdatedAt pgtype.Timestamptz `json:"updated_at"` + SortOrder int32 `json:"sort_order"` } type Question struct { diff --git a/gen/db/programs.sql.go b/gen/db/programs.sql.go index 9f59a69..96c8ebf 100644 --- a/gen/db/programs.sql.go +++ b/gen/db/programs.sql.go @@ -12,9 +12,17 @@ import ( ) const CreateProgram = `-- name: CreateProgram :one -INSERT INTO programs (name, description, thumbnail) -VALUES ($1, $2, $3) -RETURNING id, name, description, thumbnail, created_at, updated_at +INSERT INTO programs (name, description, thumbnail, sort_order) +SELECT + $1, + $2, + $3, + coalesce(( + SELECT + max(p.sort_order) + FROM programs AS p), 0) + 1 +RETURNING + id, name, description, thumbnail, created_at, updated_at, sort_order ` type CreateProgramParams struct { @@ -33,6 +41,7 @@ func (q *Queries) CreateProgram(ctx context.Context, arg CreateProgramParams) (P &i.Thumbnail, &i.CreatedAt, &i.UpdatedAt, + &i.SortOrder, ) return i, err } @@ -48,7 +57,7 @@ func (q *Queries) DeleteProgram(ctx context.Context, id int64) error { } const GetProgramByID = `-- name: GetProgramByID :one -SELECT id, name, description, thumbnail, created_at, updated_at +SELECT id, name, description, thumbnail, created_at, updated_at, sort_order FROM programs WHERE id = $1 ` @@ -63,10 +72,40 @@ func (q *Queries) GetProgramByID(ctx context.Context, id int64) (Program, error) &i.Thumbnail, &i.CreatedAt, &i.UpdatedAt, + &i.SortOrder, ) return i, err } +const ListAllProgramIDs = `-- name: ListAllProgramIDs :many +SELECT + p.id +FROM + programs AS p +ORDER BY + p.id +` + +func (q *Queries) ListAllProgramIDs(ctx context.Context) ([]int64, error) { + rows, err := q.db.Query(ctx, ListAllProgramIDs) + 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 ListPrograms = `-- name: ListPrograms :many SELECT COUNT(*) OVER () AS total_count, @@ -74,10 +113,11 @@ SELECT p.name, p.description, p.thumbnail, + p.sort_order, p.created_at, p.updated_at FROM programs p -ORDER BY p.created_at DESC +ORDER BY p.sort_order ASC, p.id ASC LIMIT $1 OFFSET $2 ` @@ -92,6 +132,7 @@ type ListProgramsRow struct { Name string `json:"name"` Description pgtype.Text `json:"description"` Thumbnail pgtype.Text `json:"thumbnail"` + SortOrder int32 `json:"sort_order"` CreatedAt pgtype.Timestamptz `json:"created_at"` UpdatedAt pgtype.Timestamptz `json:"updated_at"` } @@ -111,6 +152,7 @@ func (q *Queries) ListPrograms(ctx context.Context, arg ListProgramsParams) ([]L &i.Name, &i.Description, &i.Thumbnail, + &i.SortOrder, &i.CreatedAt, &i.UpdatedAt, ); err != nil { @@ -130,15 +172,19 @@ SET name = COALESCE($1::varchar, name), description = COALESCE($2::text, description), thumbnail = COALESCE($3::text, thumbnail), + sort_order = coalesce($4::int, sort_order), updated_at = CURRENT_TIMESTAMP -WHERE id = $4 -RETURNING id, name, description, thumbnail, created_at, updated_at +WHERE + id = $5 +RETURNING + id, name, description, thumbnail, created_at, updated_at, sort_order ` type UpdateProgramParams struct { Name pgtype.Text `json:"name"` Description pgtype.Text `json:"description"` Thumbnail pgtype.Text `json:"thumbnail"` + SortOrder pgtype.Int4 `json:"sort_order"` ID int64 `json:"id"` } @@ -147,6 +193,7 @@ func (q *Queries) UpdateProgram(ctx context.Context, arg UpdateProgramParams) (P arg.Name, arg.Description, arg.Thumbnail, + arg.SortOrder, arg.ID, ) var i Program @@ -157,6 +204,7 @@ func (q *Queries) UpdateProgram(ctx context.Context, arg UpdateProgramParams) (P &i.Thumbnail, &i.CreatedAt, &i.UpdatedAt, + &i.SortOrder, ) return i, err } diff --git a/internal/domain/course.go b/internal/domain/course.go index e3965e3..2344fa1 100644 --- a/internal/domain/course.go +++ b/internal/domain/course.go @@ -13,8 +13,10 @@ type Course struct { Name string `json:"name"` Description *string `json:"description,omitempty"` Thumbnail *string `json:"thumbnail,omitempty"` + SortOrder int `json:"sort_order"` CreatedAt time.Time `json:"created_at"` UpdatedAt *time.Time `json:"updated_at,omitempty"` + Access *LMSEntityAccess `json:"access,omitempty"` } type CreateCourseInput struct { @@ -27,4 +29,5 @@ type UpdateCourseInput struct { Name *string `json:"name,omitempty"` Description *string `json:"description,omitempty"` Thumbnail *string `json:"thumbnail,omitempty"` + SortOrder *int `json:"sort_order,omitempty"` } diff --git a/internal/domain/lesson.go b/internal/domain/lesson.go index 6651569..4f174b1 100644 --- a/internal/domain/lesson.go +++ b/internal/domain/lesson.go @@ -10,8 +10,10 @@ type Lesson struct { VideoURL *string `json:"video_url,omitempty"` Thumbnail *string `json:"thumbnail,omitempty"` Description *string `json:"description,omitempty"` + SortOrder int `json:"sort_order"` CreatedAt time.Time `json:"created_at"` UpdatedAt *time.Time `json:"updated_at,omitempty"` + Access *LMSEntityAccess `json:"access,omitempty"` } type CreateLessonInput struct { @@ -26,4 +28,5 @@ type UpdateLessonInput struct { VideoURL *string `json:"video_url,omitempty"` Thumbnail *string `json:"thumbnail,omitempty"` Description *string `json:"description,omitempty"` + SortOrder *int `json:"sort_order,omitempty"` } diff --git a/internal/domain/lms_access.go b/internal/domain/lms_access.go new file mode 100644 index 0000000..35d2035 --- /dev/null +++ b/internal/domain/lms_access.go @@ -0,0 +1,22 @@ +package domain + +// LMSEntityAccess describes learner gating for a program, course, module, or lesson. +// It is omitted (nil) for non-learner roles in API responses. +// Progress fields count completed lessons vs total lessons in that entity’s scope (lesson: 0 or 1 of 1). +type LMSEntityAccess struct { + IsAccessible bool `json:"is_accessible"` + IsCompleted bool `json:"is_completed"` + Reason string `json:"reason,omitempty"` + CompletedCount int `json:"completed_count"` + TotalCount int `json:"total_count"` + ProgressPercent int `json:"progress_percent"` +} + +// LMSUserProgress lists entity IDs the authenticated user has fully completed +// (lessons as marked complete; module/course/program when rollup conditions were met). +type LMSUserProgress struct { + LessonIDs []int64 `json:"lesson_ids"` + ModuleIDs []int64 `json:"module_ids"` + CourseIDs []int64 `json:"course_ids"` + ProgramIDs []int64 `json:"program_ids"` +} diff --git a/internal/domain/module.go b/internal/domain/module.go index 39627b8..32f116d 100644 --- a/internal/domain/module.go +++ b/internal/domain/module.go @@ -10,8 +10,10 @@ type Module struct { Name string `json:"name"` Description *string `json:"description,omitempty"` Icon *string `json:"icon,omitempty"` + SortOrder int `json:"sort_order"` CreatedAt time.Time `json:"created_at"` UpdatedAt *time.Time `json:"updated_at,omitempty"` + Access *LMSEntityAccess `json:"access,omitempty"` } type CreateModuleInput struct { @@ -24,4 +26,5 @@ type UpdateModuleInput struct { Name *string `json:"name,omitempty"` Description *string `json:"description,omitempty"` Icon *string `json:"icon,omitempty"` + SortOrder *int `json:"sort_order,omitempty"` } diff --git a/internal/domain/program.go b/internal/domain/program.go index dca0715..da79e49 100644 --- a/internal/domain/program.go +++ b/internal/domain/program.go @@ -8,8 +8,10 @@ type Program struct { Name string `json:"name"` Description *string `json:"description,omitempty"` Thumbnail *string `json:"thumbnail,omitempty"` + SortOrder int `json:"sort_order"` CreatedAt time.Time `json:"created_at"` UpdatedAt *time.Time `json:"updated_at,omitempty"` + Access *LMSEntityAccess `json:"access,omitempty"` } type CreateProgramInput struct { @@ -22,4 +24,5 @@ type UpdateProgramInput struct { Name *string `json:"name,omitempty"` Description *string `json:"description,omitempty"` Thumbnail *string `json:"thumbnail,omitempty"` + SortOrder *int `json:"sort_order,omitempty"` } diff --git a/internal/domain/reorder.go b/internal/domain/reorder.go new file mode 100644 index 0000000..3f5534d --- /dev/null +++ b/internal/domain/reorder.go @@ -0,0 +1,46 @@ +package domain + +import ( + "errors" + "fmt" + "sort" +) + +// ErrReorderInvalidIDSet means ordered_ids is not an exact permutation of the current entities in scope. +var ErrReorderInvalidIDSet = errors.New("ordered_ids must list every id in this scope exactly once, with no duplicates") + +// ReorderIDsRequest is the body for batch reorder endpoints (drag-and-drop UI). +// Send "ordered_ids": [] in display order. Must include every id in that scope (use GET list) when there is at least one entity. +type ReorderIDsRequest struct { + OrderedIDs []int64 `json:"ordered_ids"` +} + +// ValidateReorderPermutation checks that ordered contains the same multiset of ids as expected (new order vs current scope). +func ValidateReorderPermutation(ordered, expected []int64) error { + if len(expected) == 0 { + if len(ordered) == 0 { + return nil + } + return fmt.Errorf("%w: no entities exist in this scope", ErrReorderInvalidIDSet) + } + if len(ordered) != len(expected) { + return fmt.Errorf("%w: want %d ids, got %d", ErrReorderInvalidIDSet, len(expected), len(ordered)) + } + seen := make(map[int64]struct{}, len(ordered)) + for _, id := range ordered { + if _, dup := seen[id]; dup { + return fmt.Errorf("%w: duplicate id %d", ErrReorderInvalidIDSet, id) + } + seen[id] = struct{}{} + } + a := append([]int64(nil), expected...) + b := append([]int64(nil), ordered...) + sort.Slice(a, func(i, j int) bool { return a[i] < a[j] }) + sort.Slice(b, func(i, j int) bool { return b[i] < b[j] }) + for i := range a { + if a[i] != b[i] { + return fmt.Errorf("%w: id set does not match current scope", ErrReorderInvalidIDSet) + } + } + return nil +} diff --git a/internal/ports/lms_course.go b/internal/ports/lms_course.go index b37b53d..ac0137a 100644 --- a/internal/ports/lms_course.go +++ b/internal/ports/lms_course.go @@ -9,6 +9,8 @@ type CourseStore interface { CreateCourse(ctx context.Context, programID int64, input domain.CreateCourseInput) (domain.Course, error) GetCourseByID(ctx context.Context, id int64) (domain.Course, error) ListCoursesByProgramID(ctx context.Context, programID int64, limit, offset int32) ([]domain.Course, int64, error) + ListCourseIDsByProgram(ctx context.Context, programID int64) ([]int64, error) + ReorderCoursesInProgram(ctx context.Context, programID int64, orderedIDs []int64) error UpdateCourse(ctx context.Context, id int64, input domain.UpdateCourseInput) (domain.Course, error) DeleteCourse(ctx context.Context, id int64) error } diff --git a/internal/ports/lms_module.go b/internal/ports/lms_module.go index c50f7b3..f574a9e 100644 --- a/internal/ports/lms_module.go +++ b/internal/ports/lms_module.go @@ -9,6 +9,8 @@ type ModuleStore interface { CreateModule(ctx context.Context, programID, courseID int64, input domain.CreateModuleInput) (domain.Module, error) GetModuleByID(ctx context.Context, id int64) (domain.Module, error) ListModulesByProgramAndCourse(ctx context.Context, programID, courseID int64, limit, offset int32) ([]domain.Module, int64, error) + ListModuleIDsByCourse(ctx context.Context, courseID int64) ([]int64, error) + ReorderModulesInCourse(ctx context.Context, courseID int64, orderedIDs []int64) error UpdateModule(ctx context.Context, id int64, input domain.UpdateModuleInput) (domain.Module, error) DeleteModule(ctx context.Context, id int64) error } diff --git a/internal/ports/program.go b/internal/ports/program.go index 7877769..1157072 100644 --- a/internal/ports/program.go +++ b/internal/ports/program.go @@ -9,6 +9,8 @@ type ProgramStore interface { CreateProgram(ctx context.Context, input domain.CreateProgramInput) (domain.Program, error) GetProgramByID(ctx context.Context, id int64) (domain.Program, error) ListPrograms(ctx context.Context, limit, offset int32) ([]domain.Program, int64, error) + ListAllProgramIDs(ctx context.Context) ([]int64, error) + ReorderPrograms(ctx context.Context, orderedIDs []int64) error UpdateProgram(ctx context.Context, id int64, input domain.UpdateProgramInput) (domain.Program, error) DeleteProgram(ctx context.Context, id int64) error } diff --git a/internal/repository/lms_access.go b/internal/repository/lms_access.go new file mode 100644 index 0000000..753338e --- /dev/null +++ b/internal/repository/lms_access.go @@ -0,0 +1,87 @@ +package repository + +import ( + "context" + + dbgen "Yimaru-Backend/gen/db" +) + +func (s *Store) LmsGetPreviousProgram(ctx context.Context, programID int64) (dbgen.Program, error) { + return s.queries.GetPreviousProgram(ctx, programID) +} + +func (s *Store) LmsGetPreviousCourseInProgram(ctx context.Context, courseID int64) (dbgen.Course, error) { + return s.queries.GetPreviousCourseInProgram(ctx, courseID) +} + +func (s *Store) LmsGetPreviousModuleInCourse(ctx context.Context, moduleID int64) (dbgen.Module, error) { + return s.queries.GetPreviousModuleInCourse(ctx, moduleID) +} + +func (s *Store) LmsGetPreviousLessonInModule(ctx context.Context, lessonID int64) (dbgen.Lesson, error) { + return s.queries.GetPreviousLessonInModule(ctx, lessonID) +} + +func (s *Store) LmsUserHasProgramProgress(ctx context.Context, userID, programID int64) (bool, error) { + return s.queries.UserHasProgramProgress(ctx, dbgen.UserHasProgramProgressParams{UserID: userID, ProgramID: programID}) +} + +func (s *Store) LmsUserHasCourseProgress(ctx context.Context, userID, courseID int64) (bool, error) { + return s.queries.UserHasCourseProgress(ctx, dbgen.UserHasCourseProgressParams{UserID: userID, CourseID: courseID}) +} + +func (s *Store) LmsUserHasModuleProgress(ctx context.Context, userID, moduleID int64) (bool, error) { + return s.queries.UserHasModuleProgress(ctx, dbgen.UserHasModuleProgressParams{UserID: userID, ModuleID: moduleID}) +} + +func (s *Store) LmsUserHasLessonProgress(ctx context.Context, userID, lessonID int64) (bool, error) { + return s.queries.UserHasLessonProgress(ctx, dbgen.UserHasLessonProgressParams{UserID: userID, LessonID: lessonID}) +} + +// LmsUserLessonProgressInModule returns completed and total lesson counts in a module (for progress UI). +func (s *Store) LmsUserLessonProgressInModule(ctx context.Context, userID, moduleID int64) (completed, total int32, err error) { + total, err = s.queries.CountLessonsInModule(ctx, moduleID) + if err != nil { + return 0, 0, err + } + completed, err = s.queries.CountUserCompletedLessonsInModule(ctx, dbgen.CountUserCompletedLessonsInModuleParams{ + ModuleID: moduleID, + UserID: userID, + }) + if err != nil { + return 0, 0, err + } + return completed, total, nil +} + +// LmsUserLessonProgressInCourse returns completed and total lesson counts in a course (all modules). +func (s *Store) LmsUserLessonProgressInCourse(ctx context.Context, userID, courseID int64) (completed, total int32, err error) { + total, err = s.queries.CountLessonsInCourse(ctx, courseID) + if err != nil { + return 0, 0, err + } + completed, err = s.queries.CountUserCompletedLessonsInCourse(ctx, dbgen.CountUserCompletedLessonsInCourseParams{ + CourseID: courseID, + UserID: userID, + }) + if err != nil { + return 0, 0, err + } + return completed, total, nil +} + +// LmsUserLessonProgressInProgram returns completed and total lesson counts in a program (all courses). +func (s *Store) LmsUserLessonProgressInProgram(ctx context.Context, userID, programID int64) (completed, total int32, err error) { + total, err = s.queries.CountLessonsInProgram(ctx, programID) + if err != nil { + return 0, 0, err + } + completed, err = s.queries.CountUserCompletedLessonsInProgram(ctx, dbgen.CountUserCompletedLessonsInProgramParams{ + ProgramID: programID, + UserID: userID, + }) + if err != nil { + return 0, 0, err + } + return completed, total, nil +} diff --git a/internal/repository/lms_courses.go b/internal/repository/lms_courses.go index 321172a..c4f54bc 100644 --- a/internal/repository/lms_courses.go +++ b/internal/repository/lms_courses.go @@ -24,6 +24,7 @@ func courseToDomain(c dbgen.Course) domain.Course { t := c.UpdatedAt.Time out.UpdatedAt = &t } + out.SortOrder = int(c.SortOrder) return out } @@ -40,6 +41,10 @@ func (s *Store) CreateCourse(ctx context.Context, programID int64, input domain. return courseToDomain(c), nil } +func (s *Store) ListCourseIDsByProgram(ctx context.Context, programID int64) ([]int64, error) { + return s.queries.ListCourseIDsByProgram(ctx, programID) +} + func (s *Store) GetCourseByID(ctx context.Context, id int64) (domain.Course, error) { c, err := s.queries.GetCourseByID(ctx, id) if err != nil { @@ -77,6 +82,7 @@ func (s *Store) ListCoursesByProgramID(ctx context.Context, programID int64, lim Thumbnail: r.Thumbnail, CreatedAt: r.CreatedAt, UpdatedAt: r.UpdatedAt, + SortOrder: r.SortOrder, })) } return out, total, nil @@ -94,6 +100,7 @@ func (s *Store) UpdateCourse(ctx context.Context, id int64, input domain.UpdateC Name: nameText, Description: optionalTextUpdate(input.Description), Thumbnail: optionalTextUpdate(input.Thumbnail), + SortOrder: optionalInt4Update(input.SortOrder), }) if err != nil { if errors.Is(err, pgx.ErrNoRows) { diff --git a/internal/repository/lms_lessons.go b/internal/repository/lms_lessons.go index 6aa9620..9c4f106 100644 --- a/internal/repository/lms_lessons.go +++ b/internal/repository/lms_lessons.go @@ -25,6 +25,7 @@ func lessonToDomain(l dbgen.Lesson) domain.Lesson { t := l.UpdatedAt.Time out.UpdatedAt = &t } + out.SortOrder = int(l.SortOrder) return out } @@ -80,6 +81,7 @@ func (s *Store) ListLessonsByModuleID(ctx context.Context, moduleID int64, limit Description: r.Description, CreatedAt: r.CreatedAt, UpdatedAt: r.UpdatedAt, + SortOrder: r.SortOrder, })) } return out, total, nil @@ -98,6 +100,7 @@ func (s *Store) UpdateLesson(ctx context.Context, id int64, input domain.UpdateL VideoUrl: optionalTextUpdate(input.VideoURL), Thumbnail: optionalTextUpdate(input.Thumbnail), Description: optionalTextUpdate(input.Description), + SortOrder: optionalInt4Update(input.SortOrder), }) if err != nil { if errors.Is(err, pgx.ErrNoRows) { diff --git a/internal/repository/lms_modules.go b/internal/repository/lms_modules.go index d6e5c4a..599d2d9 100644 --- a/internal/repository/lms_modules.go +++ b/internal/repository/lms_modules.go @@ -25,6 +25,7 @@ func moduleToDomain(m dbgen.Module) domain.Module { t := m.UpdatedAt.Time out.UpdatedAt = &t } + out.SortOrder = int(m.SortOrder) return out } @@ -42,6 +43,10 @@ func (s *Store) CreateModule(ctx context.Context, programID, courseID int64, inp return moduleToDomain(m), nil } +func (s *Store) ListModuleIDsByCourse(ctx context.Context, courseID int64) ([]int64, error) { + return s.queries.ListModuleIDsByCourse(ctx, courseID) +} + func (s *Store) GetModuleByID(ctx context.Context, id int64) (domain.Module, error) { m, err := s.queries.GetModuleByID(ctx, id) if err != nil { @@ -81,6 +86,7 @@ func (s *Store) ListModulesByProgramAndCourse(ctx context.Context, programID, co Icon: r.Icon, CreatedAt: r.CreatedAt, UpdatedAt: r.UpdatedAt, + SortOrder: r.SortOrder, })) } return out, total, nil @@ -98,6 +104,7 @@ func (s *Store) UpdateModule(ctx context.Context, id int64, input domain.UpdateM Name: nameText, Description: optionalTextUpdate(input.Description), Icon: optionalTextUpdate(input.Icon), + SortOrder: optionalInt4Update(input.SortOrder), }) if err != nil { if errors.Is(err, pgx.ErrNoRows) { diff --git a/internal/repository/lms_progress_tx.go b/internal/repository/lms_progress_tx.go new file mode 100644 index 0000000..98ecf0a --- /dev/null +++ b/internal/repository/lms_progress_tx.go @@ -0,0 +1,86 @@ +package repository + +import ( + "context" + "fmt" + + dbgen "Yimaru-Backend/gen/db" +) + +// CompleteLessonForUser records lesson completion and cascades to module, course, and program when the user +// has fully completed the preceding scope. Runs in a single transaction. +func (s *Store) CompleteLessonForUser(ctx context.Context, userID, lessonID int64) error { + q, tx, err := s.BeginTx(ctx) + if err != nil { + return fmt.Errorf("begin tx: %w", err) + } + defer func() { _ = tx.Rollback(ctx) }() + + if err := q.InsertUserLessonProgress(ctx, dbgen.InsertUserLessonProgressParams{UserID: userID, LessonID: lessonID}); err != nil { + return err + } + lesson, err := q.GetLessonByID(ctx, lessonID) + if err != nil { + return err + } + mod, err := q.GetModuleByID(ctx, lesson.ModuleID) + if err != nil { + return err + } + crs, err := q.GetCourseByID(ctx, mod.CourseID) + if err != nil { + return err + } + nLess, err := q.CountLessonsInModule(ctx, lesson.ModuleID) + if err != nil { + return err + } + nDoneLess, err := q.CountUserCompletedLessonsInModule(ctx, dbgen.CountUserCompletedLessonsInModuleParams{ + ModuleID: lesson.ModuleID, + UserID: userID, + }) + if err != nil { + return err + } + if nLess > 0 && nDoneLess >= nLess { + if err := q.InsertUserModuleProgress(ctx, dbgen.InsertUserModuleProgressParams{UserID: userID, ModuleID: mod.ID}); err != nil { + return err + } + nMods, err := q.CountModulesInCourse(ctx, mod.CourseID) + if err != nil { + return err + } + nDoneMods, err := q.CountUserCompletedModulesInCourse(ctx, dbgen.CountUserCompletedModulesInCourseParams{ + CourseID: mod.CourseID, + UserID: userID, + }) + if err != nil { + return err + } + if nMods > 0 && nDoneMods >= nMods { + if err := q.InsertUserCourseProgress(ctx, dbgen.InsertUserCourseProgressParams{UserID: userID, CourseID: crs.ID}); err != nil { + return err + } + nCr, err := q.CountCoursesInProgram(ctx, crs.ProgramID) + if err != nil { + return err + } + nCrDone, err := q.CountUserCompletedCoursesInProgram(ctx, dbgen.CountUserCompletedCoursesInProgramParams{ + ProgramID: crs.ProgramID, + UserID: userID, + }) + if err != nil { + return err + } + if nCr > 0 && nCrDone >= nCr { + if err := q.InsertUserProgramProgress(ctx, dbgen.InsertUserProgramProgressParams{UserID: userID, ProgramID: crs.ProgramID}); err != nil { + return err + } + } + } + } + if err := tx.Commit(ctx); err != nil { + return fmt.Errorf("commit: %w", err) + } + return nil +} diff --git a/internal/repository/lms_reorder.go b/internal/repository/lms_reorder.go new file mode 100644 index 0000000..651ca8c --- /dev/null +++ b/internal/repository/lms_reorder.go @@ -0,0 +1,73 @@ +package repository + +import ( + "context" + "fmt" +) + +// ReorderPrograms sets sort_order to 1..n in the given order (transactional). +func (s *Store) ReorderPrograms(ctx context.Context, orderedIDs []int64) error { + tx, err := s.conn.Begin(ctx) + if err != nil { + return err + } + defer func() { _ = tx.Rollback(ctx) }() + for i, id := range orderedIDs { + tag, err := tx.Exec(ctx, `UPDATE programs SET sort_order = $1, updated_at = CURRENT_TIMESTAMP WHERE id = $2`, int32(i+1), id) + if err != nil { + return err + } + if tag.RowsAffected() == 0 { + return fmt.Errorf("program id %d not found", id) + } + } + return tx.Commit(ctx) +} + +// ReorderCoursesInProgram sets sort_order for courses under programID (transactional). +func (s *Store) ReorderCoursesInProgram(ctx context.Context, programID int64, orderedIDs []int64) error { + tx, err := s.conn.Begin(ctx) + if err != nil { + return err + } + defer func() { _ = tx.Rollback(ctx) }() + for i, id := range orderedIDs { + tag, err := tx.Exec(ctx, ` +UPDATE courses +SET sort_order = $1, + updated_at = CURRENT_TIMESTAMP +WHERE id = $2 + AND program_id = $3`, int32(i+1), id, programID) + if err != nil { + return err + } + if tag.RowsAffected() == 0 { + return fmt.Errorf("course id %d not in program %d", id, programID) + } + } + return tx.Commit(ctx) +} + +// ReorderModulesInCourse sets sort_order for modules under courseID (transactional). +func (s *Store) ReorderModulesInCourse(ctx context.Context, courseID int64, orderedIDs []int64) error { + tx, err := s.conn.Begin(ctx) + if err != nil { + return err + } + defer func() { _ = tx.Rollback(ctx) }() + for i, id := range orderedIDs { + tag, err := tx.Exec(ctx, ` +UPDATE modules +SET sort_order = $1, + updated_at = CURRENT_TIMESTAMP +WHERE id = $2 + AND course_id = $3`, int32(i+1), id, courseID) + if err != nil { + return err + } + if tag.RowsAffected() == 0 { + return fmt.Errorf("module id %d not in course %d", id, courseID) + } + } + return tx.Commit(ctx) +} diff --git a/internal/repository/lms_user_progress_snapshot.go b/internal/repository/lms_user_progress_snapshot.go new file mode 100644 index 0000000..29ad82b --- /dev/null +++ b/internal/repository/lms_user_progress_snapshot.go @@ -0,0 +1,33 @@ +package repository + +import ( + "context" + + "Yimaru-Backend/internal/domain" +) + +// GetLMSUserProgressSnapshot returns all completed lesson, module, course, and program IDs for a user. +func (s *Store) GetLMSUserProgressSnapshot(ctx context.Context, userID int64) (domain.LMSUserProgress, error) { + lessons, err := s.queries.ListLMSCompletedLessonIDsByUser(ctx, userID) + if err != nil { + return domain.LMSUserProgress{}, err + } + mods, err := s.queries.ListLMSCompletedModuleIDsByUser(ctx, userID) + if err != nil { + return domain.LMSUserProgress{}, err + } + courses, err := s.queries.ListLMSCompletedCourseIDsByUser(ctx, userID) + if err != nil { + return domain.LMSUserProgress{}, err + } + programs, err := s.queries.ListLMSCompletedProgramIDsByUser(ctx, userID) + if err != nil { + return domain.LMSUserProgress{}, err + } + return domain.LMSUserProgress{ + LessonIDs: lessons, + ModuleIDs: mods, + CourseIDs: courses, + ProgramIDs: programs, + }, nil +} diff --git a/internal/repository/programs.go b/internal/repository/programs.go index e7b7161..cfa4191 100644 --- a/internal/repository/programs.go +++ b/internal/repository/programs.go @@ -23,6 +23,7 @@ func programToDomain(p dbgen.Program) domain.Program { t := p.UpdatedAt.Time out.UpdatedAt = &t } + out.SortOrder = int(p.SortOrder) return out } @@ -38,6 +39,10 @@ func (s *Store) CreateProgram(ctx context.Context, input domain.CreateProgramInp return programToDomain(p), nil } +func (s *Store) ListAllProgramIDs(ctx context.Context) ([]int64, error) { + return s.queries.ListAllProgramIDs(ctx) +} + func (s *Store) GetProgramByID(ctx context.Context, id int64) (domain.Program, error) { p, err := s.queries.GetProgramByID(ctx, id) if err != nil { @@ -73,6 +78,7 @@ func (s *Store) ListPrograms(ctx context.Context, limit, offset int32) ([]domain Thumbnail: r.Thumbnail, CreatedAt: r.CreatedAt, UpdatedAt: r.UpdatedAt, + SortOrder: r.SortOrder, })) } return out, total, nil @@ -85,6 +91,13 @@ func optionalTextUpdate(val *string) pgtype.Text { return pgtype.Text{String: *val, Valid: true} } +func optionalInt4Update(v *int) pgtype.Int4 { + if v == nil { + return pgtype.Int4{Valid: false} + } + return pgtype.Int4{Int32: int32(*v), Valid: true} +} + func (s *Store) UpdateProgram(ctx context.Context, id int64, input domain.UpdateProgramInput) (domain.Program, error) { var nameText pgtype.Text if input.Name != nil { @@ -97,6 +110,7 @@ func (s *Store) UpdateProgram(ctx context.Context, id int64, input domain.Update Name: nameText, Description: optionalTextUpdate(input.Description), Thumbnail: optionalTextUpdate(input.Thumbnail), + SortOrder: optionalInt4Update(input.SortOrder), }) if err != nil { if errors.Is(err, pgx.ErrNoRows) { diff --git a/internal/services/courses/service.go b/internal/services/courses/service.go index de6af9a..498741a 100644 --- a/internal/services/courses/service.go +++ b/internal/services/courses/service.go @@ -81,3 +81,25 @@ func (s *Service) Delete(ctx context.Context, id int64) error { } return s.courses.DeleteCourse(ctx, id) } + +// ReorderInProgram sets course sort_order under a program. ordered must list every course id in that program +// exactly once (e.g. from GET /programs/{id}/courses) in the desired order. +func (s *Service) ReorderInProgram(ctx context.Context, programID int64, ordered []int64) error { + if _, err := s.programs.GetProgramByID(ctx, programID); err != nil { + if errors.Is(err, pgx.ErrNoRows) { + return programs.ErrProgramNotFound + } + return err + } + expected, err := s.courses.ListCourseIDsByProgram(ctx, programID) + if err != nil { + return err + } + if err := domain.ValidateReorderPermutation(ordered, expected); err != nil { + return err + } + if len(ordered) == 0 { + return nil + } + return s.courses.ReorderCoursesInProgram(ctx, programID, ordered) +} diff --git a/internal/services/lmsprogress/service.go b/internal/services/lmsprogress/service.go new file mode 100644 index 0000000..8950f76 --- /dev/null +++ b/internal/services/lmsprogress/service.go @@ -0,0 +1,279 @@ +package lmsprogress + +import ( + "context" + "errors" + + "Yimaru-Backend/internal/domain" + "Yimaru-Backend/internal/repository" + + "github.com/jackc/pgx/v5" +) + +const ( + errPrevProgram = "Complete the previous program before accessing this one." + errPrevCourse = "Complete the previous course in this program first." + errPrevModule = "Complete the previous module in this course first." + errPrevLesson = "Complete the previous lesson in this module first." +) + +// Service enforces sequential LMS access for learners and records lesson progress. +type Service struct { + store *repository.Store +} + +func NewService(store *repository.Store) *Service { + return &Service{store: store} +} + +// CompleteLessonForUser records lesson completion and rolls up to module, course, and program when applicable. +func (s *Service) CompleteLessonForUser(ctx context.Context, userID, lessonID int64) error { + return s.store.CompleteLessonForUser(ctx, userID, lessonID) +} + +// GetMyProgress returns completed lesson, module, course, and program IDs for the user. +func (s *Service) GetMyProgress(ctx context.Context, userID int64) (domain.LMSUserProgress, error) { + return s.store.GetLMSUserProgressSnapshot(ctx, userID) +} + +// CanAccessProgram returns whether the user may use content under this program (previous program must be fully completed if any). +func (s *Service) CanAccessProgram(ctx context.Context, userID, programID int64) (ok bool, reason string, err error) { + if _, err := s.store.GetProgramByID(ctx, programID); err != nil { + return false, "", err + } + prev, err := s.store.LmsGetPreviousProgram(ctx, programID) + if err != nil { + if errors.Is(err, pgx.ErrNoRows) { + return true, "", nil + } + return false, "", err + } + has, err := s.store.LmsUserHasProgramProgress(ctx, userID, prev.ID) + if err != nil { + return false, "", err + } + if !has { + return false, errPrevProgram, nil + } + return true, "", nil +} + +// CanAccessCourse requires the parent program to be accessible and the previous course in the program to be completed. +func (s *Service) CanAccessCourse(ctx context.Context, userID, courseID int64) (ok bool, reason string, err error) { + c, err := s.store.GetCourseByID(ctx, courseID) + if err != nil { + return false, "", err + } + ok, reason, err = s.CanAccessProgram(ctx, userID, c.ProgramID) + if err != nil || !ok { + return ok, reason, err + } + prev, err := s.store.LmsGetPreviousCourseInProgram(ctx, courseID) + if err != nil { + if errors.Is(err, pgx.ErrNoRows) { + return true, "", nil + } + return false, "", err + } + has, err := s.store.LmsUserHasCourseProgress(ctx, userID, prev.ID) + if err != nil { + return false, "", err + } + if !has { + return false, errPrevCourse, nil + } + return true, "", nil +} + +// CanAccessModule requires the course (and its program chain) to be accessible and the previous module in the course to be completed. +func (s *Service) CanAccessModule(ctx context.Context, userID, moduleID int64) (ok bool, reason string, err error) { + m, err := s.store.GetModuleByID(ctx, moduleID) + if err != nil { + return false, "", err + } + ok, reason, err = s.CanAccessCourse(ctx, userID, m.CourseID) + if err != nil || !ok { + return ok, reason, err + } + prev, err := s.store.LmsGetPreviousModuleInCourse(ctx, moduleID) + if err != nil { + if errors.Is(err, pgx.ErrNoRows) { + return true, "", nil + } + return false, "", err + } + has, err := s.store.LmsUserHasModuleProgress(ctx, userID, prev.ID) + if err != nil { + return false, "", err + } + if !has { + return false, errPrevModule, nil + } + return true, "", nil +} + +// CanAccessLesson requires the module chain to be accessible and the previous lesson in the module to be completed. +func (s *Service) CanAccessLesson(ctx context.Context, userID, lessonID int64) (ok bool, reason string, err error) { + lesson, err := s.store.GetLessonByID(ctx, lessonID) + if err != nil { + return false, "", err + } + ok, reason, err = s.CanAccessModule(ctx, userID, lesson.ModuleID) + if err != nil || !ok { + return ok, reason, err + } + prev, err := s.store.LmsGetPreviousLessonInModule(ctx, lessonID) + if err != nil { + if errors.Is(err, pgx.ErrNoRows) { + return true, "", nil + } + return false, "", err + } + has, err := s.store.LmsUserHasLessonProgress(ctx, userID, prev.ID) + if err != nil { + return false, "", err + } + if !has { + return false, errPrevLesson, nil + } + return true, "", nil +} + +// ApplyAccessProgram sets p.Access for a learner. Non-learners: clears Access to omit from JSON. +func (s *Service) ApplyAccessProgram(ctx context.Context, role domain.Role, userID int64, p *domain.Program) error { + if role != domain.RoleStudent { + p.Access = nil + return nil + } + ok, reason, err := s.CanAccessProgram(ctx, userID, p.ID) + if err != nil { + return err + } + done, err := s.store.LmsUserHasProgramProgress(ctx, userID, p.ID) + if err != nil { + return err + } + comp, tot, err := s.store.LmsUserLessonProgressInProgram(ctx, userID, p.ID) + if err != nil { + return err + } + c, t, pct := lmsProgressCounts(comp, tot, done) + p.Access = &domain.LMSEntityAccess{ + IsAccessible: ok, IsCompleted: done, Reason: reasonIf(ok, reason), + CompletedCount: c, TotalCount: t, ProgressPercent: pct, + } + return nil +} + +// ApplyAccessCourse sets c.Access for a learner. +func (s *Service) ApplyAccessCourse(ctx context.Context, role domain.Role, userID int64, c *domain.Course) error { + if role != domain.RoleStudent { + c.Access = nil + return nil + } + ok, reason, err := s.CanAccessCourse(ctx, userID, c.ID) + if err != nil { + return err + } + done, err := s.store.LmsUserHasCourseProgress(ctx, userID, c.ID) + if err != nil { + return err + } + comp, tot, err := s.store.LmsUserLessonProgressInCourse(ctx, userID, c.ID) + if err != nil { + return err + } + cc, tt, pct := lmsProgressCounts(comp, tot, done) + c.Access = &domain.LMSEntityAccess{ + IsAccessible: ok, IsCompleted: done, Reason: reasonIf(ok, reason), + CompletedCount: cc, TotalCount: tt, ProgressPercent: pct, + } + return nil +} + +// ApplyAccessModule sets m.Access for a learner. +func (s *Service) ApplyAccessModule(ctx context.Context, role domain.Role, userID int64, m *domain.Module) error { + if role != domain.RoleStudent { + m.Access = nil + return nil + } + ok, reason, err := s.CanAccessModule(ctx, userID, m.ID) + if err != nil { + return err + } + done, err := s.store.LmsUserHasModuleProgress(ctx, userID, m.ID) + if err != nil { + return err + } + comp, tot, err := s.store.LmsUserLessonProgressInModule(ctx, userID, m.ID) + if err != nil { + return err + } + cc, tt, pct := lmsProgressCounts(comp, tot, done) + m.Access = &domain.LMSEntityAccess{ + IsAccessible: ok, IsCompleted: done, Reason: reasonIf(ok, reason), + CompletedCount: cc, TotalCount: tt, ProgressPercent: pct, + } + return nil +} + +// ApplyAccessLesson sets l.Access for a learner. +func (s *Service) ApplyAccessLesson(ctx context.Context, role domain.Role, userID int64, les *domain.Lesson) error { + if role != domain.RoleStudent { + les.Access = nil + return nil + } + ok, reason, err := s.CanAccessLesson(ctx, userID, les.ID) + if err != nil { + return err + } + done, err := s.store.LmsUserHasLessonProgress(ctx, userID, les.ID) + if err != nil { + return err + } + var comp, tot int32 + if done { + comp, tot = 1, 1 + } else { + comp, tot = 0, 1 + } + c, t, pct := lmsProgressCounts(comp, tot, done) + les.Access = &domain.LMSEntityAccess{ + IsAccessible: ok, IsCompleted: done, Reason: reasonIf(ok, reason), + CompletedCount: c, TotalCount: t, ProgressPercent: pct, + } + return nil +} + +// lmsProgressCounts maps DB lesson completion counts to UI fields. Percent is 0–100; completed +// and total are aligned with isCompleted when the entity is fully done. +func lmsProgressCounts(completed, total int32, isCompleted bool) (c, t, pct int) { + c, t = int(completed), int(total) + if t < 0 { + t = 0 + } + if c < 0 { + c = 0 + } + if isCompleted { + if t > 0 { + return t, t, 100 + } + return c, t, 100 + } + if t == 0 { + return 0, 0, 0 + } + pct = (c * 100) / t + if pct > 100 { + pct = 100 + } + return c, t, pct +} + +func reasonIf(ok bool, r string) string { + if ok { + return "" + } + return r +} diff --git a/internal/services/modules/service.go b/internal/services/modules/service.go index f614c7b..3ef95a4 100644 --- a/internal/services/modules/service.go +++ b/internal/services/modules/service.go @@ -90,3 +90,22 @@ func (s *Service) Delete(ctx context.Context, id int64) error { } return s.modules.DeleteModule(ctx, id) } + +// ReorderInCourse sets module sort_order under a course. ordered must list every module id in that course +// exactly once (e.g. from GET /courses/{id}/modules) in the desired order. +func (s *Service) ReorderInCourse(ctx context.Context, courseID int64, ordered []int64) error { + if _, err := s.getCourseOrErr(ctx, courseID); err != nil { + return err + } + expected, err := s.modules.ListModuleIDsByCourse(ctx, courseID) + if err != nil { + return err + } + if err := domain.ValidateReorderPermutation(ordered, expected); err != nil { + return err + } + if len(ordered) == 0 { + return nil + } + return s.modules.ReorderModulesInCourse(ctx, courseID, ordered) +} diff --git a/internal/services/programs/service.go b/internal/services/programs/service.go index 0c73ff9..e99920a 100644 --- a/internal/services/programs/service.go +++ b/internal/services/programs/service.go @@ -67,3 +67,19 @@ func (s *Service) Delete(ctx context.Context, id int64) error { } return s.store.DeleteProgram(ctx, id) } + +// Reorder sets program sort_order from ordered ids (1..n). ordered must list every program id exactly once +// (e.g. from GET /programs) in the desired display / learning order. +func (s *Service) Reorder(ctx context.Context, ordered []int64) error { + expected, err := s.store.ListAllProgramIDs(ctx) + if err != nil { + return err + } + if err := domain.ValidateReorderPermutation(ordered, expected); err != nil { + return err + } + if len(ordered) == 0 { + return nil + } + return s.store.ReorderPrograms(ctx, ordered) +} diff --git a/internal/services/rbac/seeds.go b/internal/services/rbac/seeds.go index 2519c21..71b3e46 100644 --- a/internal/services/rbac/seeds.go +++ b/internal/services/rbac/seeds.go @@ -27,6 +27,7 @@ var AllPermissions = []domain.PermissionSeed{ {Key: "programs.get", Name: "Get Program", Description: "Get a program by ID", GroupName: "Programs"}, {Key: "programs.update", Name: "Update Program", Description: "Update a program", GroupName: "Programs"}, {Key: "programs.delete", Name: "Delete Program", Description: "Delete a program", GroupName: "Programs"}, + {Key: "programs.reorder", Name: "Reorder Programs", Description: "Set program order for the learning path (batch)", GroupName: "Programs"}, // Modules (LMS, under a course) {Key: "modules.create", Name: "Create Module", Description: "Create a module in a course", GroupName: "Modules"}, @@ -34,14 +35,19 @@ var AllPermissions = []domain.PermissionSeed{ {Key: "modules.list_by_course", Name: "List Modules by Course", Description: "List modules under a program and course", GroupName: "Modules"}, {Key: "modules.update", Name: "Update Module", Description: "Update a module", GroupName: "Modules"}, {Key: "modules.delete", Name: "Delete Module", Description: "Delete a module", GroupName: "Modules"}, + {Key: "modules.reorder", Name: "Reorder Modules", Description: "Set module order within a course (batch)", GroupName: "Modules"}, // Lessons (LMS, under a module) {Key: "lessons.create", Name: "Create Lesson", Description: "Create a lesson in a module", GroupName: "Lessons"}, {Key: "lessons.get", Name: "Get Lesson", Description: "Get a lesson by ID", GroupName: "Lessons"}, + {Key: "lessons.complete", Name: "Complete Lesson", Description: "Mark a lesson as complete (sequential learning progress)", 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.delete", Name: "Delete Lesson", Description: "Delete a lesson", 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"}, + // Practices (LMS, scoped to course, module, or lesson) {Key: "practices.create", Name: "Create Practice", Description: "Create a practice", GroupName: "Practices"}, {Key: "practices.get", Name: "Get Practice", Description: "Get a practice by ID", GroupName: "Practices"}, @@ -273,13 +279,14 @@ var DefaultRolePermissions = map[string][]string{ "learning_tree.get", "practices.reorder", // Programs - "programs.create", "programs.list", "programs.get", "programs.update", "programs.delete", + "programs.create", "programs.list", "programs.get", "programs.update", "programs.delete", "programs.reorder", + "lms.get_my_progress", // Modules - "modules.create", "modules.get", "modules.list_by_course", "modules.update", "modules.delete", + "modules.create", "modules.get", "modules.list_by_course", "modules.update", "modules.delete", "modules.reorder", // Lessons - "lessons.create", "lessons.get", "lessons.list_by_module", "lessons.update", "lessons.delete", + "lessons.create", "lessons.get", "lessons.list_by_module", "lessons.complete", "lessons.update", "lessons.delete", // Practices "practices.create", "practices.get", "practices.list", "practices.update", "practices.delete", @@ -360,13 +367,14 @@ var DefaultRolePermissions = map[string][]string{ "course_categories.list", "course_categories.get", "courses.get", "courses.list_by_program", "modules.get", "modules.list_by_course", - "lessons.get", "lessons.list_by_module", + "lessons.get", "lessons.list_by_module", "lessons.complete", "practices.get", "practices.list", "subcourses.get", "subcourses.list_by_course", "subcourses.list_by_course_list", "subcourses.list_active", "videos.get", "videos.list_by_subcourse", "videos.list_published", "learning_tree.get", "programs.list", "programs.get", + "lms.get_my_progress", // Questions (read + attempt) "questions.list", "questions.search", "questions.get", @@ -413,12 +421,15 @@ var DefaultRolePermissions = map[string][]string{ "course_categories.list", "course_categories.get", "courses.get", "courses.list_by_program", "modules.get", "modules.list_by_course", - "lessons.get", "lessons.list_by_module", + "lessons.get", "lessons.list_by_module", "lessons.complete", "practices.get", "practices.list", "subcourses.get", "subcourses.list_by_course", "subcourses.list_by_course_list", "subcourses.list_active", "videos.get", "videos.list_by_subcourse", "videos.list_published", "learning_tree.get", + "programs.list", "programs.get", + "lms.get_my_progress", + // Questions (full — instructors create content) "questions.create", "questions.list", "questions.search", "questions.get", "questions.update", "questions.delete", "question_sets.create", "question_sets.list", "question_sets.list_by_owner", "question_sets.get", "question_sets.update", "question_sets.delete", diff --git a/internal/web_server/app.go b/internal/web_server/app.go index 2524588..60e07f1 100644 --- a/internal/web_server/app.go +++ b/internal/web_server/app.go @@ -13,6 +13,7 @@ import ( notificationservice "Yimaru-Backend/internal/services/notification" "Yimaru-Backend/internal/services/courses" "Yimaru-Backend/internal/services/lessons" + "Yimaru-Backend/internal/services/lmsprogress" "Yimaru-Backend/internal/services/modules" "Yimaru-Backend/internal/services/practices" "Yimaru-Backend/internal/services/programs" @@ -48,6 +49,7 @@ type App struct { courseSvc *courses.Service moduleSvc *modules.Service lessonSvc *lessons.Service + lmsProgressSvc *lmsprogress.Service practiceSvc *practices.Service subscriptionsSvc *subscriptions.Service arifpaySvc *arifpay.ArifpayService @@ -84,6 +86,7 @@ func NewApp( courseSvc *courses.Service, moduleSvc *modules.Service, lessonSvc *lessons.Service, + lmsProgressSvc *lmsprogress.Service, practiceSvc *practices.Service, subscriptionsSvc *subscriptions.Service, arifpaySvc *arifpay.ArifpayService, @@ -132,6 +135,7 @@ func NewApp( courseSvc: courseSvc, moduleSvc: moduleSvc, lessonSvc: lessonSvc, + lmsProgressSvc: lmsProgressSvc, practiceSvc: practiceSvc, subscriptionsSvc: subscriptionsSvc, arifpaySvc: arifpaySvc, diff --git a/internal/web_server/handlers/course_handler.go b/internal/web_server/handlers/course_handler.go index cb53b55..80d4fec 100644 --- a/internal/web_server/handlers/course_handler.go +++ b/internal/web_server/handlers/course_handler.go @@ -105,6 +105,16 @@ func (h *Handler) ListCoursesByProgram(c *fiber.Ctx) error { Error: err.Error(), }) } + uid := c.Locals("user_id").(int64) + role := c.Locals("role").(domain.Role) + for i := range items { + if err := h.lmsProgressSvc.ApplyAccessCourse(c.Context(), role, uid, &items[i]); err != nil { + return c.Status(fiber.StatusInternalServerError).JSON(domain.ErrorResponse{ + Message: "Failed to build course list", + Error: err.Error(), + }) + } + } return c.JSON(domain.Response{ Message: "Courses retrieved successfully", Data: fiber.Map{ @@ -147,6 +157,17 @@ func (h *Handler) GetCourse(c *fiber.Ctx) error { Error: err.Error(), }) } + uid := c.Locals("user_id").(int64) + role := c.Locals("role").(domain.Role) + if err := h.lmsProgressSvc.ApplyAccessCourse(c.Context(), role, uid, &course); err != nil { + return c.Status(fiber.StatusInternalServerError).JSON(domain.ErrorResponse{ + Message: "Failed to evaluate course access", + Error: err.Error(), + }) + } + if err := lmsBlockIfInaccessible(c, course.Access); err != nil { + return err + } return c.JSON(domain.Response{ Message: "Course retrieved successfully", Data: course, diff --git a/internal/web_server/handlers/handlers.go b/internal/web_server/handlers/handlers.go index f803694..4af6a50 100644 --- a/internal/web_server/handlers/handlers.go +++ b/internal/web_server/handlers/handlers.go @@ -18,6 +18,7 @@ import ( notificationservice "Yimaru-Backend/internal/services/notification" "Yimaru-Backend/internal/services/courses" "Yimaru-Backend/internal/services/lessons" + "Yimaru-Backend/internal/services/lmsprogress" "Yimaru-Backend/internal/services/modules" "Yimaru-Backend/internal/services/practices" "Yimaru-Backend/internal/services/programs" @@ -47,6 +48,7 @@ type Handler struct { courseSvc *courses.Service moduleSvc *modules.Service lessonSvc *lessons.Service + lmsProgressSvc *lmsprogress.Service practiceSvc *practices.Service subscriptionsSvc *subscriptions.Service arifpaySvc *arifpay.ArifpayService @@ -79,6 +81,7 @@ func New( courseSvc *courses.Service, moduleSvc *modules.Service, lessonSvc *lessons.Service, + lmsProgressSvc *lmsprogress.Service, practiceSvc *practices.Service, subscriptionsSvc *subscriptions.Service, arifpaySvc *arifpay.ArifpayService, @@ -110,6 +113,7 @@ func New( courseSvc: courseSvc, moduleSvc: moduleSvc, lessonSvc: lessonSvc, + lmsProgressSvc: lmsProgressSvc, practiceSvc: practiceSvc, subscriptionsSvc: subscriptionsSvc, arifpaySvc: arifpaySvc, diff --git a/internal/web_server/handlers/lesson_handler.go b/internal/web_server/handlers/lesson_handler.go index 174a677..531444f 100644 --- a/internal/web_server/handlers/lesson_handler.go +++ b/internal/web_server/handlers/lesson_handler.go @@ -97,6 +97,16 @@ func (h *Handler) ListLessonsByModule(c *fiber.Ctx) error { Error: err.Error(), }) } + uid := c.Locals("user_id").(int64) + role := c.Locals("role").(domain.Role) + for i := range items { + if err := h.lmsProgressSvc.ApplyAccessLesson(c.Context(), role, uid, &items[i]); err != nil { + return c.Status(fiber.StatusInternalServerError).JSON(domain.ErrorResponse{ + Message: "Failed to build lesson list", + Error: err.Error(), + }) + } + } return c.JSON(domain.Response{ Message: "Lessons retrieved successfully", Data: fiber.Map{ @@ -135,6 +145,17 @@ func (h *Handler) GetLesson(c *fiber.Ctx) error { Error: err.Error(), }) } + uid := c.Locals("user_id").(int64) + role := c.Locals("role").(domain.Role) + if err := h.lmsProgressSvc.ApplyAccessLesson(c.Context(), role, uid, &les); err != nil { + return c.Status(fiber.StatusInternalServerError).JSON(domain.ErrorResponse{ + Message: "Failed to evaluate lesson access", + Error: err.Error(), + }) + } + if err := lmsBlockIfInaccessible(c, les.Access); err != nil { + return err + } return c.JSON(domain.Response{ Message: "Lesson retrieved successfully", Data: les, @@ -227,3 +248,61 @@ func (h *Handler) DeleteLesson(c *fiber.Ctx) error { StatusCode: fiber.StatusOK, }) } + +// CompleteLesson godoc +// @Summary Mark a lesson as completed +// @Description Records lesson completion; may cascade to module, course, and program progress for the authenticated user. Learners must meet sequential prerequisites; staff bypass checks. +// @Tags lessons +// @Param id path int true "Lesson ID" +// @Success 200 {object} domain.Response +// @Failure 403 {object} domain.ErrorResponse +// @Router /api/v1/lessons/{id}/complete [post] +func (h *Handler) CompleteLesson(c *fiber.Ctx) error { + id, err := strconv.ParseInt(c.Params("id"), 10, 64) + if err != nil { + return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{ + Message: "Invalid lesson id", + Error: err.Error(), + }) + } + if _, err := h.lessonSvc.GetByID(c.Context(), id); err != nil { + if errors.Is(err, lessons.ErrLessonNotFound) { + return c.Status(fiber.StatusNotFound).JSON(domain.ErrorResponse{ + Message: "Lesson not found", + Error: err.Error(), + }) + } + return c.Status(fiber.StatusInternalServerError).JSON(domain.ErrorResponse{ + Message: "Failed to load lesson", + Error: err.Error(), + }) + } + uid := c.Locals("user_id").(int64) + role := c.Locals("role").(domain.Role) + if role == domain.RoleStudent { + ok, reason, err := h.lmsProgressSvc.CanAccessLesson(c.Context(), uid, id) + if err != nil { + return c.Status(fiber.StatusInternalServerError).JSON(domain.ErrorResponse{ + Message: "Failed to verify lesson access", + Error: err.Error(), + }) + } + if !ok { + return c.Status(fiber.StatusForbidden).JSON(domain.ErrorResponse{ + Message: reason, + Error: "LMS_PREREQUISITE_NOT_MET", + }) + } + } + if err := h.lmsProgressSvc.CompleteLessonForUser(c.Context(), uid, id); err != nil { + return c.Status(fiber.StatusInternalServerError).JSON(domain.ErrorResponse{ + Message: "Failed to record lesson progress", + Error: err.Error(), + }) + } + return c.JSON(domain.Response{ + Message: "Lesson marked complete", + Success: true, + StatusCode: fiber.StatusOK, + }) +} diff --git a/internal/web_server/handlers/lms_gating.go b/internal/web_server/handlers/lms_gating.go new file mode 100644 index 0000000..52ac4b8 --- /dev/null +++ b/internal/web_server/handlers/lms_gating.go @@ -0,0 +1,18 @@ +package handlers + +import ( + "Yimaru-Backend/internal/domain" + + "github.com/gofiber/fiber/v2" +) + +// lmsBlockIfInaccessible returns a 403 response when a learner is blocked; otherwise nil. +func lmsBlockIfInaccessible(c *fiber.Ctx, a *domain.LMSEntityAccess) error { + if a == nil || a.IsAccessible { + return nil + } + return c.Status(fiber.StatusForbidden).JSON(domain.ErrorResponse{ + Message: a.Reason, + Error: "LMS_PREREQUISITE_NOT_MET", + }) +} diff --git a/internal/web_server/handlers/lms_progress_handler.go b/internal/web_server/handlers/lms_progress_handler.go new file mode 100644 index 0000000..5ad33d0 --- /dev/null +++ b/internal/web_server/handlers/lms_progress_handler.go @@ -0,0 +1,32 @@ +package handlers + +import ( + "Yimaru-Backend/internal/domain" + + "github.com/gofiber/fiber/v2" +) + +// GetMyLMSProgress godoc +// @Summary Get my LMS completion history +// @Description Returns completed lesson, module, course, and program IDs for the authenticated user (ordered by completion time, then id). +// @Tags lms +// @Produce json +// @Success 200 {object} domain.Response +// @Failure 500 {object} domain.ErrorResponse +// @Router /api/v1/lms/progress [get] +func (h *Handler) GetMyLMSProgress(c *fiber.Ctx) error { + uid := c.Locals("user_id").(int64) + prog, err := h.lmsProgressSvc.GetMyProgress(c.Context(), uid) + if err != nil { + return c.Status(fiber.StatusInternalServerError).JSON(domain.ErrorResponse{ + Message: "Failed to load learning progress", + Error: err.Error(), + }) + } + return c.JSON(domain.Response{ + Message: "LMS progress retrieved successfully", + Data: prog, + Success: true, + StatusCode: fiber.StatusOK, + }) +} diff --git a/internal/web_server/handlers/lms_reorder_handler.go b/internal/web_server/handlers/lms_reorder_handler.go new file mode 100644 index 0000000..0710c4d --- /dev/null +++ b/internal/web_server/handlers/lms_reorder_handler.go @@ -0,0 +1,178 @@ +package handlers + +import ( + "Yimaru-Backend/internal/domain" + "Yimaru-Backend/internal/services/courses" + "Yimaru-Backend/internal/services/programs" + "context" + "errors" + "strconv" + + "github.com/gofiber/fiber/v2" +) + +// ReorderPrograms godoc +// @Summary Reorder all programs +// @Description Sets learning order of programs. Body must list every current program id exactly once, in the desired order (index 0 = first in path). +// @Tags programs +// @Accept json +// @Produce json +// @Param body body domain.ReorderIDsRequest true "New order: ordered_ids is the full set of program ids" +// @Success 200 {object} domain.Response +// @Failure 400 {object} domain.ErrorResponse +// @Router /api/v1/programs/reorder [put] +func (h *Handler) ReorderPrograms(c *fiber.Ctx) 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 there are no programs)", + Error: "missing ordered_ids", + }) + } + if err := h.programSvc.Reorder(c.Context(), req.OrderedIDs); err != nil { + 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 programs", + 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.ActionProgramUpdated, domain.ResourceProgram, nil, "Reordered programs", nil, &ip, &ua) + + return c.JSON(domain.Response{ + Message: "Programs reordered successfully", + Success: true, + StatusCode: fiber.StatusOK, + }) +} + +// ReorderCoursesInProgram godoc +// @Summary Reorder courses within a program +// @Param id path int true "Program ID" +// @Param body body domain.ReorderIDsRequest true "ordered_ids: every course id in this program, in the new order" +// @Tags courses +// @Router /api/v1/programs/{id}/courses/reorder [put] +func (h *Handler) ReorderCoursesInProgram(c *fiber.Ctx) error { + programID, err := strconv.ParseInt(c.Params("id"), 10, 64) + if err != nil { + return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{ + Message: "Invalid program 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 program has no courses)", + Error: "missing ordered_ids", + }) + } + if err := h.courseSvc.ReorderInProgram(c.Context(), programID, req.OrderedIDs); err != nil { + if errors.Is(err, programs.ErrProgramNotFound) { + return c.Status(fiber.StatusNotFound).JSON(domain.ErrorResponse{ + Message: "Program 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 courses", + Error: err.Error(), + }) + } + actorID := c.Locals("user_id").(int64) + actorRole := string(c.Locals("role").(domain.Role)) + ip := c.IP() + ua := c.Get("User-Agent") + msg := "Reordered courses in program" + go h.activityLogSvc.RecordAction(context.Background(), &actorID, &actorRole, domain.ActionCourseUpdated, domain.ResourceProgram, &programID, msg, nil, &ip, &ua) + + return c.JSON(domain.Response{ + Message: "Courses reordered successfully", + Success: true, + StatusCode: fiber.StatusOK, + }) +} + +// ReorderModulesInCourse godoc +// @Summary Reorder modules within a course +// @Param courseId path int true "Course ID" +// @Param body body domain.ReorderIDsRequest true "ordered_ids: every module id in this course, in the new order" +// @Tags modules +// @Router /api/v1/courses/{courseId}/modules/reorder [put] +func (h *Handler) ReorderModulesInCourse(c *fiber.Ctx) error { + courseID, err := strconv.ParseInt(c.Params("courseId"), 10, 64) + if err != nil { + return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{ + Message: "Invalid course 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 course has no modules)", + Error: "missing ordered_ids", + }) + } + if err := h.moduleSvc.ReorderInCourse(c.Context(), courseID, req.OrderedIDs); err != nil { + if errors.Is(err, courses.ErrCourseNotFound) { + return c.Status(fiber.StatusNotFound).JSON(domain.ErrorResponse{ + Message: "Course 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 modules", + 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.ActionModuleUpdated, domain.ResourceCourse, &courseID, "Reordered modules in course", nil, &ip, &ua) + + return c.JSON(domain.Response{ + Message: "Modules reordered successfully", + Success: true, + StatusCode: fiber.StatusOK, + }) +} diff --git a/internal/web_server/handlers/module_handler.go b/internal/web_server/handlers/module_handler.go index 0160077..06c8228 100644 --- a/internal/web_server/handlers/module_handler.go +++ b/internal/web_server/handlers/module_handler.go @@ -103,6 +103,16 @@ func (h *Handler) ListModulesByCourse(c *fiber.Ctx) error { Error: err.Error(), }) } + uid := c.Locals("user_id").(int64) + role := c.Locals("role").(domain.Role) + for i := range items { + if err := h.lmsProgressSvc.ApplyAccessModule(c.Context(), role, uid, &items[i]); err != nil { + return c.Status(fiber.StatusInternalServerError).JSON(domain.ErrorResponse{ + Message: "Failed to build module list", + Error: err.Error(), + }) + } + } return c.JSON(domain.Response{ Message: "Modules retrieved successfully", Data: fiber.Map{ @@ -142,6 +152,17 @@ func (h *Handler) GetModule(c *fiber.Ctx) error { Error: err.Error(), }) } + uid := c.Locals("user_id").(int64) + role := c.Locals("role").(domain.Role) + if err := h.lmsProgressSvc.ApplyAccessModule(c.Context(), role, uid, &mod); err != nil { + return c.Status(fiber.StatusInternalServerError).JSON(domain.ErrorResponse{ + Message: "Failed to evaluate module access", + Error: err.Error(), + }) + } + if err := lmsBlockIfInaccessible(c, mod.Access); err != nil { + return err + } return c.JSON(domain.Response{ Message: "Module retrieved successfully", Data: mod, diff --git a/internal/web_server/handlers/program_handler.go b/internal/web_server/handlers/program_handler.go index 801ded9..362e028 100644 --- a/internal/web_server/handlers/program_handler.go +++ b/internal/web_server/handlers/program_handler.go @@ -76,6 +76,16 @@ func (h *Handler) ListPrograms(c *fiber.Ctx) error { Error: err.Error(), }) } + uid := c.Locals("user_id").(int64) + role := c.Locals("role").(domain.Role) + for i := range items { + if err := h.lmsProgressSvc.ApplyAccessProgram(c.Context(), role, uid, &items[i]); err != nil { + return c.Status(fiber.StatusInternalServerError).JSON(domain.ErrorResponse{ + Message: "Failed to build program list", + Error: err.Error(), + }) + } + } return c.JSON(domain.Response{ Message: "Programs retrieved successfully", Data: fiber.Map{ @@ -118,6 +128,17 @@ func (h *Handler) GetProgram(c *fiber.Ctx) error { Error: err.Error(), }) } + uid := c.Locals("user_id").(int64) + role := c.Locals("role").(domain.Role) + if err := h.lmsProgressSvc.ApplyAccessProgram(c.Context(), role, uid, &p); err != nil { + return c.Status(fiber.StatusInternalServerError).JSON(domain.ErrorResponse{ + Message: "Failed to evaluate program access", + Error: err.Error(), + }) + } + if err := lmsBlockIfInaccessible(c, p.Access); err != nil { + return err + } return c.JSON(domain.Response{ Message: "Program retrieved successfully", Data: p, diff --git a/internal/web_server/routes.go b/internal/web_server/routes.go index 223e49f..5d8c6b4 100644 --- a/internal/web_server/routes.go +++ b/internal/web_server/routes.go @@ -19,6 +19,7 @@ func (a *App) initAppRoutes() { a.courseSvc, a.moduleSvc, a.lessonSvc, + a.lmsProgressSvc, a.practiceSvc, a.subscriptionsSvc, a.arifpaySvc, @@ -74,18 +75,22 @@ func (a *App) initAppRoutes() { // Programs (LMS top-level) groupV1.Post("/programs", a.authMiddleware, a.RequirePermission("programs.create"), h.CreateProgram) groupV1.Get("/programs", a.authMiddleware, a.RequirePermission("programs.list"), h.ListPrograms) + groupV1.Put("/programs/reorder", a.authMiddleware, a.RequirePermission("programs.reorder"), h.ReorderPrograms) + groupV1.Get("/lms/progress", a.authMiddleware, a.RequirePermission("lms.get_my_progress"), h.GetMyLMSProgress) groupV1.Get("/programs/:id", a.authMiddleware, a.RequirePermission("programs.get"), h.GetProgram) groupV1.Put("/programs/:id", a.authMiddleware, a.RequirePermission("programs.update"), h.UpdateProgram) groupV1.Delete("/programs/:id", a.authMiddleware, a.RequirePermission("programs.delete"), h.DeleteProgram) // Courses groupV1.Post("/programs/:id/courses", a.authMiddleware, a.RequirePermission("courses.create"), h.CreateCourse) + groupV1.Put("/programs/:id/courses/reorder", a.authMiddleware, a.RequirePermission("courses.reorder"), h.ReorderCoursesInProgram) groupV1.Get("/programs/:id/courses", a.authMiddleware, a.RequirePermission("courses.list_by_program"), h.ListCoursesByProgram) groupV1.Get("/courses/:id/practices", a.authMiddleware, a.RequirePermission("practices.list"), h.ListPracticesByCourse) groupV1.Get("/courses/:id", a.authMiddleware, a.RequirePermission("courses.get"), h.GetCourse) groupV1.Put("/courses/:id", a.authMiddleware, a.RequirePermission("courses.update"), h.UpdateCourse) groupV1.Delete("/courses/:id", a.authMiddleware, a.RequirePermission("courses.delete"), h.DeleteCourse) groupV1.Post("/courses/:courseId/modules", a.authMiddleware, a.RequirePermission("modules.create"), h.CreateModule) + groupV1.Put("/courses/:courseId/modules/reorder", a.authMiddleware, a.RequirePermission("modules.reorder"), h.ReorderModulesInCourse) groupV1.Get("/courses/:courseId/modules", a.authMiddleware, a.RequirePermission("modules.list_by_course"), h.ListModulesByCourse) // /modules/:moduleId/lessons before /modules/:id; /modules/:id/practices before /modules/:id @@ -96,6 +101,7 @@ func (a *App) initAppRoutes() { groupV1.Put("/modules/:id", a.authMiddleware, a.RequirePermission("modules.update"), h.UpdateModule) groupV1.Delete("/modules/:id", a.authMiddleware, a.RequirePermission("modules.delete"), h.DeleteModule) groupV1.Get("/lessons/:id/practices", a.authMiddleware, a.RequirePermission("practices.list"), h.ListPracticesByLesson) + groupV1.Post("/lessons/:id/complete", a.authMiddleware, a.RequirePermission("lessons.complete"), h.CompleteLesson) groupV1.Get("/lessons/:id", a.authMiddleware, a.RequirePermission("lessons.get"), h.GetLesson) groupV1.Put("/lessons/:id", a.authMiddleware, a.RequirePermission("lessons.update"), h.UpdateLesson) groupV1.Delete("/lessons/:id", a.authMiddleware, a.RequirePermission("lessons.delete"), h.DeleteLesson)