learning progress implementation

This commit is contained in:
Yared Yemane 2026-04-23 03:58:27 -07:00
parent dc788c04cb
commit 5b53929d92
49 changed files with 3025 additions and 75 deletions

View File

@ -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,

View File

@ -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;

View File

@ -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);

View File

@ -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

View File

@ -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

View File

@ -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

248
db/query/lms_progress.sql Normal file
View File

@ -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;

View File

@ -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

View File

@ -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"
}

View File

@ -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"
}

View File

@ -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

View File

@ -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
}

View File

@ -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
}

View File

@ -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
}

613
gen/db/lms_progress.sql.go Normal file
View File

@ -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
}

View File

@ -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 {

View File

@ -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
}

View File

@ -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"`
}

View File

@ -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"`
}

View File

@ -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 entitys 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"`
}

View File

@ -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"`
}

View File

@ -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"`
}

View File

@ -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
}

View File

@ -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
}

View File

@ -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
}

View File

@ -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
}

View File

@ -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
}

View File

@ -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) {

View File

@ -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) {

View File

@ -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) {

View File

@ -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
}

View File

@ -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)
}

View File

@ -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
}

View File

@ -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) {

View File

@ -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)
}

View File

@ -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 0100; 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
}

View File

@ -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)
}

View File

@ -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)
}

View File

@ -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",

View File

@ -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,

View File

@ -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,

View File

@ -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,

View File

@ -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,
})
}

View File

@ -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",
})
}

View File

@ -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,
})
}

View File

@ -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,
})
}

View File

@ -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,

View File

@ -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,

View File

@ -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)