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" notificationservice "Yimaru-Backend/internal/services/notification"
coursesservice "Yimaru-Backend/internal/services/courses" coursesservice "Yimaru-Backend/internal/services/courses"
lessonsservice "Yimaru-Backend/internal/services/lessons" lessonsservice "Yimaru-Backend/internal/services/lessons"
"Yimaru-Backend/internal/services/lmsprogress"
moduleservice "Yimaru-Backend/internal/services/modules" moduleservice "Yimaru-Backend/internal/services/modules"
practicesservice "Yimaru-Backend/internal/services/practices" practicesservice "Yimaru-Backend/internal/services/practices"
programsservice "Yimaru-Backend/internal/services/programs" programsservice "Yimaru-Backend/internal/services/programs"
@ -404,6 +405,8 @@ func main() {
// LMS lessons (under modules) // LMS lessons (under modules)
lessonSvc := lessonsservice.NewService(store, store) lessonSvc := lessonsservice.NewService(store, store)
lmsProgressSvc := lmsprogress.NewService(store)
// LMS practices (under course, module, or lesson) // LMS practices (under course, module, or lesson)
practiceSvc := practicesservice.NewService(store, store, store, store, store, store) practiceSvc := practicesservice.NewService(store, store, store, store, store, store)
@ -452,6 +455,7 @@ func main() {
courseSvc, courseSvc,
moduleSvc, moduleSvc,
lessonSvc, lessonSvc,
lmsProgressSvc,
practiceSvc, practiceSvc,
subscriptionsSvc, subscriptionsSvc,
arifpaySvc, 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 -- name: CreateCourse :one
INSERT INTO courses (program_id, name, description, thumbnail) INSERT INTO courses (program_id, name, description, thumbnail, sort_order)
VALUES ($1, $2, $3, $4) SELECT
RETURNING *; $1,
$2,
$3,
$4,
coalesce((
SELECT
max(c.sort_order)
FROM courses c
WHERE
c.program_id = $1), 0) + 1
RETURNING
*;
-- name: GetCourseByID :one -- name: GetCourseByID :one
SELECT * SELECT *
FROM courses FROM courses
WHERE id = $1; 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 -- name: ListCoursesByProgramID :many
SELECT SELECT
COUNT(*) OVER () AS total_count, COUNT(*) OVER () AS total_count,
@ -16,11 +37,16 @@ SELECT
c.name, c.name,
c.description, c.description,
c.thumbnail, c.thumbnail,
c.sort_order,
c.created_at, c.created_at,
c.updated_at c.updated_at
FROM courses c FROM
WHERE c.program_id = $1 courses c
ORDER BY c.created_at DESC WHERE
c.program_id = $1
ORDER BY
c.sort_order ASC,
c.id ASC
LIMIT $2 OFFSET $3; LIMIT $2 OFFSET $3;
-- name: UpdateCourse :one -- name: UpdateCourse :one
@ -29,9 +55,12 @@ SET
name = COALESCE(sqlc.narg('name')::varchar, name), name = COALESCE(sqlc.narg('name')::varchar, name),
description = COALESCE(sqlc.narg('description')::text, description), description = COALESCE(sqlc.narg('description')::text, description),
thumbnail = COALESCE(sqlc.narg('thumbnail')::text, thumbnail), thumbnail = COALESCE(sqlc.narg('thumbnail')::text, thumbnail),
sort_order = coalesce(sqlc.narg('sort_order')::int, sort_order),
updated_at = CURRENT_TIMESTAMP updated_at = CURRENT_TIMESTAMP
WHERE id = sqlc.arg('id') WHERE
RETURNING *; id = sqlc.arg('id')
RETURNING
*;
-- name: DeleteCourse :exec -- name: DeleteCourse :exec
DELETE FROM courses DELETE FROM courses

View File

@ -1,7 +1,19 @@
-- name: CreateLesson :one -- name: CreateLesson :one
INSERT INTO lessons (module_id, title, video_url, thumbnail, description) INSERT INTO lessons (module_id, title, video_url, thumbnail, description, sort_order)
VALUES ($1, $2, $3, $4, $5) SELECT
RETURNING *; $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 -- name: GetLessonByID :one
SELECT * SELECT *
@ -17,12 +29,18 @@ SELECT
l.video_url, l.video_url,
l.thumbnail, l.thumbnail,
l.description, l.description,
l.sort_order,
l.created_at, l.created_at,
l.updated_at l.updated_at
FROM lessons l FROM
WHERE l.module_id = $1 lessons l
ORDER BY l.created_at DESC WHERE
LIMIT $2 OFFSET $3; l.module_id = $1
ORDER BY
l.sort_order ASC,
l.id ASC
LIMIT $2
OFFSET $3;
-- name: UpdateLesson :one -- name: UpdateLesson :one
UPDATE lessons UPDATE lessons
@ -31,9 +49,12 @@ SET
video_url = COALESCE(sqlc.narg('video_url')::text, video_url), video_url = COALESCE(sqlc.narg('video_url')::text, video_url),
thumbnail = COALESCE(sqlc.narg('thumbnail')::text, thumbnail), thumbnail = COALESCE(sqlc.narg('thumbnail')::text, thumbnail),
description = COALESCE(sqlc.narg('description')::text, description), description = COALESCE(sqlc.narg('description')::text, description),
sort_order = coalesce(sqlc.narg('sort_order')::int, sort_order),
updated_at = CURRENT_TIMESTAMP updated_at = CURRENT_TIMESTAMP
WHERE id = sqlc.arg('id') WHERE
RETURNING *; id = sqlc.arg('id')
RETURNING
*;
-- name: DeleteLesson :exec -- name: DeleteLesson :exec
DELETE FROM lessons DELETE FROM lessons

View File

@ -1,13 +1,35 @@
-- name: CreateModule :one -- name: CreateModule :one
INSERT INTO modules (program_id, course_id, name, description, icon) INSERT INTO modules (program_id, course_id, name, description, icon, sort_order)
VALUES ($1, $2, $3, $4, $5) SELECT
RETURNING *; $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 -- name: GetModuleByID :one
SELECT * SELECT *
FROM modules FROM modules
WHERE id = $1; 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 -- name: ListModulesByProgramAndCourse :many
SELECT SELECT
COUNT(*) OVER () AS total_count, COUNT(*) OVER () AS total_count,
@ -17,13 +39,19 @@ SELECT
m.name, m.name,
m.description, m.description,
m.icon, m.icon,
m.sort_order,
m.created_at, m.created_at,
m.updated_at m.updated_at
FROM modules m FROM
WHERE m.program_id = $1 modules m
AND m.course_id = $2 WHERE
ORDER BY m.created_at DESC m.program_id = $1
LIMIT $3 OFFSET $4; AND m.course_id = $2
ORDER BY
m.sort_order ASC,
m.id ASC
LIMIT $3
OFFSET $4;
-- name: UpdateModule :one -- name: UpdateModule :one
UPDATE modules UPDATE modules
@ -31,9 +59,12 @@ SET
name = COALESCE(sqlc.narg('name')::varchar, name), name = COALESCE(sqlc.narg('name')::varchar, name),
description = COALESCE(sqlc.narg('description')::text, description), description = COALESCE(sqlc.narg('description')::text, description),
icon = COALESCE(sqlc.narg('icon')::text, icon), icon = COALESCE(sqlc.narg('icon')::text, icon),
sort_order = coalesce(sqlc.narg('sort_order')::int, sort_order),
updated_at = CURRENT_TIMESTAMP updated_at = CURRENT_TIMESTAMP
WHERE id = sqlc.arg('id') WHERE
RETURNING *; id = sqlc.arg('id')
RETURNING
*;
-- name: DeleteModule :exec -- name: DeleteModule :exec
DELETE FROM modules 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 -- name: CreateProgram :one
INSERT INTO programs (name, description, thumbnail) INSERT INTO programs (name, description, thumbnail, sort_order)
VALUES ($1, $2, $3) SELECT
RETURNING *; $1,
$2,
$3,
coalesce((
SELECT
max(p.sort_order)
FROM programs AS p), 0) + 1
RETURNING
*;
-- name: GetProgramByID :one -- name: GetProgramByID :one
SELECT * SELECT *
FROM programs FROM programs
WHERE id = $1; WHERE id = $1;
-- name: ListAllProgramIDs :many
SELECT
p.id
FROM
programs AS p
ORDER BY
p.id;
-- name: ListPrograms :many -- name: ListPrograms :many
SELECT SELECT
COUNT(*) OVER () AS total_count, COUNT(*) OVER () AS total_count,
@ -15,10 +31,11 @@ SELECT
p.name, p.name,
p.description, p.description,
p.thumbnail, p.thumbnail,
p.sort_order,
p.created_at, p.created_at,
p.updated_at p.updated_at
FROM programs p FROM programs p
ORDER BY p.created_at DESC ORDER BY p.sort_order ASC, p.id ASC
LIMIT $1 OFFSET $2; LIMIT $1 OFFSET $2;
-- name: UpdateProgram :one -- name: UpdateProgram :one
@ -27,9 +44,12 @@ SET
name = COALESCE(sqlc.narg('name')::varchar, name), name = COALESCE(sqlc.narg('name')::varchar, name),
description = COALESCE(sqlc.narg('description')::text, description), description = COALESCE(sqlc.narg('description')::text, description),
thumbnail = COALESCE(sqlc.narg('thumbnail')::text, thumbnail), thumbnail = COALESCE(sqlc.narg('thumbnail')::text, thumbnail),
sort_order = coalesce(sqlc.narg('sort_order')::int, sort_order),
updated_at = CURRENT_TIMESTAMP updated_at = CURRENT_TIMESTAMP
WHERE id = sqlc.arg('id') WHERE
RETURNING *; id = sqlc.arg('id')
RETURNING
*;
-- name: DeleteProgram :exec -- name: DeleteProgram :exec
DELETE FROM programs 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}": { "/api/v1/courses/{id}": {
"get": { "get": {
"produces": [ "produces": [
@ -1530,6 +1557,38 @@ const docTemplate = `{
"responses": {} "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": { "/api/v1/lessons/{id}/practices": {
"get": { "get": {
"tags": [ "tags": [
@ -1547,6 +1606,32 @@ const docTemplate = `{
"responses": {} "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": { "/api/v1/logs": {
"get": { "get": {
"description": "Fetches application logs from MongoDB with pagination, level filtering, and search", "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}": { "/api/v1/programs/{id}": {
"get": { "get": {
"produces": [ "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": { "/api/v1/progress/practices/{id}/complete": {
"post": { "post": {
"description": "Marks a practice question set as completed for the authenticated learner", "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": { "domain.ResendOtpReq": {
"type": "object", "type": "object",
"properties": { "properties": {
@ -8749,6 +8912,9 @@ const docTemplate = `{
"name": { "name": {
"type": "string" "type": "string"
}, },
"sort_order": {
"type": "integer"
},
"thumbnail": { "thumbnail": {
"type": "string" "type": "string"
} }
@ -8772,6 +8938,9 @@ const docTemplate = `{
"description": { "description": {
"type": "string" "type": "string"
}, },
"sort_order": {
"type": "integer"
},
"thumbnail": { "thumbnail": {
"type": "string" "type": "string"
}, },
@ -8794,6 +8963,9 @@ const docTemplate = `{
}, },
"name": { "name": {
"type": "string" "type": "string"
},
"sort_order": {
"type": "integer"
} }
} }
}, },
@ -8829,6 +9001,9 @@ const docTemplate = `{
"name": { "name": {
"type": "string" "type": "string"
}, },
"sort_order": {
"type": "integer"
},
"thumbnail": { "thumbnail": {
"type": "string" "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}": { "/api/v1/courses/{id}": {
"get": { "get": {
"produces": [ "produces": [
@ -1522,6 +1549,38 @@
"responses": {} "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": { "/api/v1/lessons/{id}/practices": {
"get": { "get": {
"tags": [ "tags": [
@ -1539,6 +1598,32 @@
"responses": {} "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": { "/api/v1/logs": {
"get": { "get": {
"description": "Fetches application logs from MongoDB with pagination, level filtering, and search", "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}": { "/api/v1/programs/{id}": {
"get": { "get": {
"produces": [ "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": { "/api/v1/progress/practices/{id}/complete": {
"post": { "post": {
"description": "Marks a practice question set as completed for the authenticated learner", "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": { "domain.ResendOtpReq": {
"type": "object", "type": "object",
"properties": { "properties": {
@ -8741,6 +8904,9 @@
"name": { "name": {
"type": "string" "type": "string"
}, },
"sort_order": {
"type": "integer"
},
"thumbnail": { "thumbnail": {
"type": "string" "type": "string"
} }
@ -8764,6 +8930,9 @@
"description": { "description": {
"type": "string" "type": "string"
}, },
"sort_order": {
"type": "integer"
},
"thumbnail": { "thumbnail": {
"type": "string" "type": "string"
}, },
@ -8786,6 +8955,9 @@
}, },
"name": { "name": {
"type": "string" "type": "string"
},
"sort_order": {
"type": "integer"
} }
} }
}, },
@ -8821,6 +8993,9 @@
"name": { "name": {
"type": "string" "type": "string"
}, },
"sort_order": {
"type": "integer"
},
"thumbnail": { "thumbnail": {
"type": "string" "type": "string"
} }

View File

@ -351,6 +351,13 @@ definitions:
role: role:
type: string type: string
type: object type: object
domain.ReorderIDsRequest:
properties:
ordered_ids:
items:
type: integer
type: array
type: object
domain.ResendOtpReq: domain.ResendOtpReq:
properties: properties:
email: email:
@ -530,6 +537,8 @@ definitions:
type: string type: string
name: name:
type: string type: string
sort_order:
type: integer
thumbnail: thumbnail:
type: string type: string
type: object type: object
@ -545,6 +554,8 @@ definitions:
properties: properties:
description: description:
type: string type: string
sort_order:
type: integer
thumbnail: thumbnail:
type: string type: string
title: title:
@ -560,6 +571,8 @@ definitions:
type: string type: string
name: name:
type: string type: string
sort_order:
type: integer
type: object type: object
domain.UpdatePracticeInput: domain.UpdatePracticeInput:
properties: properties:
@ -582,6 +595,8 @@ definitions:
type: string type: string
name: name:
type: string type: string
sort_order:
type: integer
thumbnail: thumbnail:
type: string type: string
type: object type: object
@ -2407,6 +2422,24 @@ paths:
summary: Create module summary: Create module
tags: tags:
- modules - 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}: /api/v1/courses/{id}:
delete: delete:
parameters: parameters:
@ -2863,6 +2896,29 @@ paths:
responses: {} responses: {}
tags: tags:
- lessons - 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: /api/v1/lessons/{id}/practices:
get: get:
parameters: parameters:
@ -2874,6 +2930,24 @@ paths:
responses: {} responses: {}
tags: tags:
- practices - 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: /api/v1/logs:
get: get:
description: Fetches application logs from MongoDB with pagination, level filtering, description: Fetches application logs from MongoDB with pagination, level filtering,
@ -3829,6 +3903,51 @@ paths:
summary: Create course summary: Create course
tags: tags:
- courses - 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: /api/v1/progress/practices/{id}/complete:
post: post:
description: Marks a practice question set as completed for the authenticated description: Marks a practice question set as completed for the authenticated

View File

@ -12,9 +12,20 @@ import (
) )
const CreateCourse = `-- name: CreateCourse :one const CreateCourse = `-- name: CreateCourse :one
INSERT INTO courses (program_id, name, description, thumbnail) INSERT INTO courses (program_id, name, description, thumbnail, sort_order)
VALUES ($1, $2, $3, $4) SELECT
RETURNING id, program_id, name, description, thumbnail, created_at, updated_at $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 { type CreateCourseParams struct {
@ -40,6 +51,7 @@ func (q *Queries) CreateCourse(ctx context.Context, arg CreateCourseParams) (Cou
&i.Thumbnail, &i.Thumbnail,
&i.CreatedAt, &i.CreatedAt,
&i.UpdatedAt, &i.UpdatedAt,
&i.SortOrder,
) )
return i, err return i, err
} }
@ -55,7 +67,7 @@ func (q *Queries) DeleteCourse(ctx context.Context, id int64) error {
} }
const GetCourseByID = `-- name: GetCourseByID :one 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 FROM courses
WHERE id = $1 WHERE id = $1
` `
@ -71,10 +83,42 @@ func (q *Queries) GetCourseByID(ctx context.Context, id int64) (Course, error) {
&i.Thumbnail, &i.Thumbnail,
&i.CreatedAt, &i.CreatedAt,
&i.UpdatedAt, &i.UpdatedAt,
&i.SortOrder,
) )
return i, err 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 const ListCoursesByProgramID = `-- name: ListCoursesByProgramID :many
SELECT SELECT
COUNT(*) OVER () AS total_count, COUNT(*) OVER () AS total_count,
@ -83,11 +127,16 @@ SELECT
c.name, c.name,
c.description, c.description,
c.thumbnail, c.thumbnail,
c.sort_order,
c.created_at, c.created_at,
c.updated_at c.updated_at
FROM courses c FROM
WHERE c.program_id = $1 courses c
ORDER BY c.created_at DESC WHERE
c.program_id = $1
ORDER BY
c.sort_order ASC,
c.id ASC
LIMIT $2 OFFSET $3 LIMIT $2 OFFSET $3
` `
@ -104,6 +153,7 @@ type ListCoursesByProgramIDRow struct {
Name string `json:"name"` Name string `json:"name"`
Description pgtype.Text `json:"description"` Description pgtype.Text `json:"description"`
Thumbnail pgtype.Text `json:"thumbnail"` Thumbnail pgtype.Text `json:"thumbnail"`
SortOrder int32 `json:"sort_order"`
CreatedAt pgtype.Timestamptz `json:"created_at"` CreatedAt pgtype.Timestamptz `json:"created_at"`
UpdatedAt pgtype.Timestamptz `json:"updated_at"` UpdatedAt pgtype.Timestamptz `json:"updated_at"`
} }
@ -124,6 +174,7 @@ func (q *Queries) ListCoursesByProgramID(ctx context.Context, arg ListCoursesByP
&i.Name, &i.Name,
&i.Description, &i.Description,
&i.Thumbnail, &i.Thumbnail,
&i.SortOrder,
&i.CreatedAt, &i.CreatedAt,
&i.UpdatedAt, &i.UpdatedAt,
); err != nil { ); err != nil {
@ -143,15 +194,19 @@ SET
name = COALESCE($1::varchar, name), name = COALESCE($1::varchar, name),
description = COALESCE($2::text, description), description = COALESCE($2::text, description),
thumbnail = COALESCE($3::text, thumbnail), thumbnail = COALESCE($3::text, thumbnail),
sort_order = coalesce($4::int, sort_order),
updated_at = CURRENT_TIMESTAMP updated_at = CURRENT_TIMESTAMP
WHERE id = $4 WHERE
RETURNING id, program_id, name, description, thumbnail, created_at, updated_at id = $5
RETURNING
id, program_id, name, description, thumbnail, created_at, updated_at, sort_order
` `
type UpdateCourseParams struct { type UpdateCourseParams struct {
Name pgtype.Text `json:"name"` Name pgtype.Text `json:"name"`
Description pgtype.Text `json:"description"` Description pgtype.Text `json:"description"`
Thumbnail pgtype.Text `json:"thumbnail"` Thumbnail pgtype.Text `json:"thumbnail"`
SortOrder pgtype.Int4 `json:"sort_order"`
ID int64 `json:"id"` ID int64 `json:"id"`
} }
@ -160,6 +215,7 @@ func (q *Queries) UpdateCourse(ctx context.Context, arg UpdateCourseParams) (Cou
arg.Name, arg.Name,
arg.Description, arg.Description,
arg.Thumbnail, arg.Thumbnail,
arg.SortOrder,
arg.ID, arg.ID,
) )
var i Course var i Course
@ -171,6 +227,7 @@ func (q *Queries) UpdateCourse(ctx context.Context, arg UpdateCourseParams) (Cou
&i.Thumbnail, &i.Thumbnail,
&i.CreatedAt, &i.CreatedAt,
&i.UpdatedAt, &i.UpdatedAt,
&i.SortOrder,
) )
return i, err return i, err
} }

View File

@ -12,9 +12,21 @@ import (
) )
const CreateLesson = `-- name: CreateLesson :one const CreateLesson = `-- name: CreateLesson :one
INSERT INTO lessons (module_id, title, video_url, thumbnail, description) INSERT INTO lessons (module_id, title, video_url, thumbnail, description, sort_order)
VALUES ($1, $2, $3, $4, $5) SELECT
RETURNING id, module_id, title, video_url, thumbnail, description, created_at, updated_at $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 { type CreateLessonParams struct {
@ -43,6 +55,7 @@ func (q *Queries) CreateLesson(ctx context.Context, arg CreateLessonParams) (Les
&i.Description, &i.Description,
&i.CreatedAt, &i.CreatedAt,
&i.UpdatedAt, &i.UpdatedAt,
&i.SortOrder,
) )
return i, err return i, err
} }
@ -58,7 +71,7 @@ func (q *Queries) DeleteLesson(ctx context.Context, id int64) error {
} }
const GetLessonByID = `-- name: GetLessonByID :one 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 FROM lessons
WHERE id = $1 WHERE id = $1
` `
@ -75,6 +88,7 @@ func (q *Queries) GetLessonByID(ctx context.Context, id int64) (Lesson, error) {
&i.Description, &i.Description,
&i.CreatedAt, &i.CreatedAt,
&i.UpdatedAt, &i.UpdatedAt,
&i.SortOrder,
) )
return i, err return i, err
} }
@ -88,12 +102,18 @@ SELECT
l.video_url, l.video_url,
l.thumbnail, l.thumbnail,
l.description, l.description,
l.sort_order,
l.created_at, l.created_at,
l.updated_at l.updated_at
FROM lessons l FROM
WHERE l.module_id = $1 lessons l
ORDER BY l.created_at DESC WHERE
LIMIT $2 OFFSET $3 l.module_id = $1
ORDER BY
l.sort_order ASC,
l.id ASC
LIMIT $2
OFFSET $3
` `
type ListLessonsByModuleIDParams struct { type ListLessonsByModuleIDParams struct {
@ -110,6 +130,7 @@ type ListLessonsByModuleIDRow struct {
VideoUrl pgtype.Text `json:"video_url"` VideoUrl pgtype.Text `json:"video_url"`
Thumbnail pgtype.Text `json:"thumbnail"` Thumbnail pgtype.Text `json:"thumbnail"`
Description pgtype.Text `json:"description"` Description pgtype.Text `json:"description"`
SortOrder int32 `json:"sort_order"`
CreatedAt pgtype.Timestamptz `json:"created_at"` CreatedAt pgtype.Timestamptz `json:"created_at"`
UpdatedAt pgtype.Timestamptz `json:"updated_at"` UpdatedAt pgtype.Timestamptz `json:"updated_at"`
} }
@ -131,6 +152,7 @@ func (q *Queries) ListLessonsByModuleID(ctx context.Context, arg ListLessonsByMo
&i.VideoUrl, &i.VideoUrl,
&i.Thumbnail, &i.Thumbnail,
&i.Description, &i.Description,
&i.SortOrder,
&i.CreatedAt, &i.CreatedAt,
&i.UpdatedAt, &i.UpdatedAt,
); err != nil { ); err != nil {
@ -151,9 +173,12 @@ SET
video_url = COALESCE($2::text, video_url), video_url = COALESCE($2::text, video_url),
thumbnail = COALESCE($3::text, thumbnail), thumbnail = COALESCE($3::text, thumbnail),
description = COALESCE($4::text, description), description = COALESCE($4::text, description),
sort_order = coalesce($5::int, sort_order),
updated_at = CURRENT_TIMESTAMP updated_at = CURRENT_TIMESTAMP
WHERE id = $5 WHERE
RETURNING id, module_id, title, video_url, thumbnail, description, created_at, updated_at id = $6
RETURNING
id, module_id, title, video_url, thumbnail, description, created_at, updated_at, sort_order
` `
type UpdateLessonParams struct { type UpdateLessonParams struct {
@ -161,6 +186,7 @@ type UpdateLessonParams struct {
VideoUrl pgtype.Text `json:"video_url"` VideoUrl pgtype.Text `json:"video_url"`
Thumbnail pgtype.Text `json:"thumbnail"` Thumbnail pgtype.Text `json:"thumbnail"`
Description pgtype.Text `json:"description"` Description pgtype.Text `json:"description"`
SortOrder pgtype.Int4 `json:"sort_order"`
ID int64 `json:"id"` ID int64 `json:"id"`
} }
@ -170,6 +196,7 @@ func (q *Queries) UpdateLesson(ctx context.Context, arg UpdateLessonParams) (Les
arg.VideoUrl, arg.VideoUrl,
arg.Thumbnail, arg.Thumbnail,
arg.Description, arg.Description,
arg.SortOrder,
arg.ID, arg.ID,
) )
var i Lesson var i Lesson
@ -182,6 +209,7 @@ func (q *Queries) UpdateLesson(ctx context.Context, arg UpdateLessonParams) (Les
&i.Description, &i.Description,
&i.CreatedAt, &i.CreatedAt,
&i.UpdatedAt, &i.UpdatedAt,
&i.SortOrder,
) )
return i, err return i, err
} }

View File

@ -12,9 +12,21 @@ import (
) )
const CreateModule = `-- name: CreateModule :one const CreateModule = `-- name: CreateModule :one
INSERT INTO modules (program_id, course_id, name, description, icon) INSERT INTO modules (program_id, course_id, name, description, icon, sort_order)
VALUES ($1, $2, $3, $4, $5) SELECT
RETURNING id, program_id, course_id, name, description, icon, created_at, updated_at $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 { type CreateModuleParams struct {
@ -43,6 +55,7 @@ func (q *Queries) CreateModule(ctx context.Context, arg CreateModuleParams) (Mod
&i.Icon, &i.Icon,
&i.CreatedAt, &i.CreatedAt,
&i.UpdatedAt, &i.UpdatedAt,
&i.SortOrder,
) )
return i, err return i, err
} }
@ -58,7 +71,7 @@ func (q *Queries) DeleteModule(ctx context.Context, id int64) error {
} }
const GetModuleByID = `-- name: GetModuleByID :one 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 FROM modules
WHERE id = $1 WHERE id = $1
` `
@ -75,10 +88,42 @@ func (q *Queries) GetModuleByID(ctx context.Context, id int64) (Module, error) {
&i.Icon, &i.Icon,
&i.CreatedAt, &i.CreatedAt,
&i.UpdatedAt, &i.UpdatedAt,
&i.SortOrder,
) )
return i, err 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 const ListModulesByProgramAndCourse = `-- name: ListModulesByProgramAndCourse :many
SELECT SELECT
COUNT(*) OVER () AS total_count, COUNT(*) OVER () AS total_count,
@ -88,13 +133,19 @@ SELECT
m.name, m.name,
m.description, m.description,
m.icon, m.icon,
m.sort_order,
m.created_at, m.created_at,
m.updated_at m.updated_at
FROM modules m FROM
WHERE m.program_id = $1 modules m
AND m.course_id = $2 WHERE
ORDER BY m.created_at DESC m.program_id = $1
LIMIT $3 OFFSET $4 AND m.course_id = $2
ORDER BY
m.sort_order ASC,
m.id ASC
LIMIT $3
OFFSET $4
` `
type ListModulesByProgramAndCourseParams struct { type ListModulesByProgramAndCourseParams struct {
@ -112,6 +163,7 @@ type ListModulesByProgramAndCourseRow struct {
Name string `json:"name"` Name string `json:"name"`
Description pgtype.Text `json:"description"` Description pgtype.Text `json:"description"`
Icon pgtype.Text `json:"icon"` Icon pgtype.Text `json:"icon"`
SortOrder int32 `json:"sort_order"`
CreatedAt pgtype.Timestamptz `json:"created_at"` CreatedAt pgtype.Timestamptz `json:"created_at"`
UpdatedAt pgtype.Timestamptz `json:"updated_at"` UpdatedAt pgtype.Timestamptz `json:"updated_at"`
} }
@ -138,6 +190,7 @@ func (q *Queries) ListModulesByProgramAndCourse(ctx context.Context, arg ListMod
&i.Name, &i.Name,
&i.Description, &i.Description,
&i.Icon, &i.Icon,
&i.SortOrder,
&i.CreatedAt, &i.CreatedAt,
&i.UpdatedAt, &i.UpdatedAt,
); err != nil { ); err != nil {
@ -157,15 +210,19 @@ SET
name = COALESCE($1::varchar, name), name = COALESCE($1::varchar, name),
description = COALESCE($2::text, description), description = COALESCE($2::text, description),
icon = COALESCE($3::text, icon), icon = COALESCE($3::text, icon),
sort_order = coalesce($4::int, sort_order),
updated_at = CURRENT_TIMESTAMP updated_at = CURRENT_TIMESTAMP
WHERE id = $4 WHERE
RETURNING id, program_id, course_id, name, description, icon, created_at, updated_at id = $5
RETURNING
id, program_id, course_id, name, description, icon, created_at, updated_at, sort_order
` `
type UpdateModuleParams struct { type UpdateModuleParams struct {
Name pgtype.Text `json:"name"` Name pgtype.Text `json:"name"`
Description pgtype.Text `json:"description"` Description pgtype.Text `json:"description"`
Icon pgtype.Text `json:"icon"` Icon pgtype.Text `json:"icon"`
SortOrder pgtype.Int4 `json:"sort_order"`
ID int64 `json:"id"` ID int64 `json:"id"`
} }
@ -174,6 +231,7 @@ func (q *Queries) UpdateModule(ctx context.Context, arg UpdateModuleParams) (Mod
arg.Name, arg.Name,
arg.Description, arg.Description,
arg.Icon, arg.Icon,
arg.SortOrder,
arg.ID, arg.ID,
) )
var i Module var i Module
@ -186,6 +244,7 @@ func (q *Queries) UpdateModule(ctx context.Context, arg UpdateModuleParams) (Mod
&i.Icon, &i.Icon,
&i.CreatedAt, &i.CreatedAt,
&i.UpdatedAt, &i.UpdatedAt,
&i.SortOrder,
) )
return i, err 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"` Thumbnail pgtype.Text `json:"thumbnail"`
CreatedAt pgtype.Timestamptz `json:"created_at"` CreatedAt pgtype.Timestamptz `json:"created_at"`
UpdatedAt pgtype.Timestamptz `json:"updated_at"` UpdatedAt pgtype.Timestamptz `json:"updated_at"`
SortOrder int32 `json:"sort_order"`
} }
type Device struct { type Device struct {
@ -58,6 +59,7 @@ type Lesson struct {
Description pgtype.Text `json:"description"` Description pgtype.Text `json:"description"`
CreatedAt pgtype.Timestamptz `json:"created_at"` CreatedAt pgtype.Timestamptz `json:"created_at"`
UpdatedAt pgtype.Timestamptz `json:"updated_at"` UpdatedAt pgtype.Timestamptz `json:"updated_at"`
SortOrder int32 `json:"sort_order"`
} }
type LevelToSubCourse struct { type LevelToSubCourse struct {
@ -80,6 +82,30 @@ type LmsPractice struct {
UpdatedAt pgtype.Timestamptz `json:"updated_at"` 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 { type Module struct {
ID int64 `json:"id"` ID int64 `json:"id"`
ProgramID int64 `json:"program_id"` ProgramID int64 `json:"program_id"`
@ -89,6 +115,7 @@ type Module struct {
Icon pgtype.Text `json:"icon"` Icon pgtype.Text `json:"icon"`
CreatedAt pgtype.Timestamptz `json:"created_at"` CreatedAt pgtype.Timestamptz `json:"created_at"`
UpdatedAt pgtype.Timestamptz `json:"updated_at"` UpdatedAt pgtype.Timestamptz `json:"updated_at"`
SortOrder int32 `json:"sort_order"`
} }
type ModuleToSubCourse struct { type ModuleToSubCourse struct {
@ -159,6 +186,7 @@ type Program struct {
Thumbnail pgtype.Text `json:"thumbnail"` Thumbnail pgtype.Text `json:"thumbnail"`
CreatedAt pgtype.Timestamptz `json:"created_at"` CreatedAt pgtype.Timestamptz `json:"created_at"`
UpdatedAt pgtype.Timestamptz `json:"updated_at"` UpdatedAt pgtype.Timestamptz `json:"updated_at"`
SortOrder int32 `json:"sort_order"`
} }
type Question struct { type Question struct {

View File

@ -12,9 +12,17 @@ import (
) )
const CreateProgram = `-- name: CreateProgram :one const CreateProgram = `-- name: CreateProgram :one
INSERT INTO programs (name, description, thumbnail) INSERT INTO programs (name, description, thumbnail, sort_order)
VALUES ($1, $2, $3) SELECT
RETURNING id, name, description, thumbnail, created_at, updated_at $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 { type CreateProgramParams struct {
@ -33,6 +41,7 @@ func (q *Queries) CreateProgram(ctx context.Context, arg CreateProgramParams) (P
&i.Thumbnail, &i.Thumbnail,
&i.CreatedAt, &i.CreatedAt,
&i.UpdatedAt, &i.UpdatedAt,
&i.SortOrder,
) )
return i, err return i, err
} }
@ -48,7 +57,7 @@ func (q *Queries) DeleteProgram(ctx context.Context, id int64) error {
} }
const GetProgramByID = `-- name: GetProgramByID :one 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 FROM programs
WHERE id = $1 WHERE id = $1
` `
@ -63,10 +72,40 @@ func (q *Queries) GetProgramByID(ctx context.Context, id int64) (Program, error)
&i.Thumbnail, &i.Thumbnail,
&i.CreatedAt, &i.CreatedAt,
&i.UpdatedAt, &i.UpdatedAt,
&i.SortOrder,
) )
return i, err 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 const ListPrograms = `-- name: ListPrograms :many
SELECT SELECT
COUNT(*) OVER () AS total_count, COUNT(*) OVER () AS total_count,
@ -74,10 +113,11 @@ SELECT
p.name, p.name,
p.description, p.description,
p.thumbnail, p.thumbnail,
p.sort_order,
p.created_at, p.created_at,
p.updated_at p.updated_at
FROM programs p FROM programs p
ORDER BY p.created_at DESC ORDER BY p.sort_order ASC, p.id ASC
LIMIT $1 OFFSET $2 LIMIT $1 OFFSET $2
` `
@ -92,6 +132,7 @@ type ListProgramsRow struct {
Name string `json:"name"` Name string `json:"name"`
Description pgtype.Text `json:"description"` Description pgtype.Text `json:"description"`
Thumbnail pgtype.Text `json:"thumbnail"` Thumbnail pgtype.Text `json:"thumbnail"`
SortOrder int32 `json:"sort_order"`
CreatedAt pgtype.Timestamptz `json:"created_at"` CreatedAt pgtype.Timestamptz `json:"created_at"`
UpdatedAt pgtype.Timestamptz `json:"updated_at"` UpdatedAt pgtype.Timestamptz `json:"updated_at"`
} }
@ -111,6 +152,7 @@ func (q *Queries) ListPrograms(ctx context.Context, arg ListProgramsParams) ([]L
&i.Name, &i.Name,
&i.Description, &i.Description,
&i.Thumbnail, &i.Thumbnail,
&i.SortOrder,
&i.CreatedAt, &i.CreatedAt,
&i.UpdatedAt, &i.UpdatedAt,
); err != nil { ); err != nil {
@ -130,15 +172,19 @@ SET
name = COALESCE($1::varchar, name), name = COALESCE($1::varchar, name),
description = COALESCE($2::text, description), description = COALESCE($2::text, description),
thumbnail = COALESCE($3::text, thumbnail), thumbnail = COALESCE($3::text, thumbnail),
sort_order = coalesce($4::int, sort_order),
updated_at = CURRENT_TIMESTAMP updated_at = CURRENT_TIMESTAMP
WHERE id = $4 WHERE
RETURNING id, name, description, thumbnail, created_at, updated_at id = $5
RETURNING
id, name, description, thumbnail, created_at, updated_at, sort_order
` `
type UpdateProgramParams struct { type UpdateProgramParams struct {
Name pgtype.Text `json:"name"` Name pgtype.Text `json:"name"`
Description pgtype.Text `json:"description"` Description pgtype.Text `json:"description"`
Thumbnail pgtype.Text `json:"thumbnail"` Thumbnail pgtype.Text `json:"thumbnail"`
SortOrder pgtype.Int4 `json:"sort_order"`
ID int64 `json:"id"` ID int64 `json:"id"`
} }
@ -147,6 +193,7 @@ func (q *Queries) UpdateProgram(ctx context.Context, arg UpdateProgramParams) (P
arg.Name, arg.Name,
arg.Description, arg.Description,
arg.Thumbnail, arg.Thumbnail,
arg.SortOrder,
arg.ID, arg.ID,
) )
var i Program var i Program
@ -157,6 +204,7 @@ func (q *Queries) UpdateProgram(ctx context.Context, arg UpdateProgramParams) (P
&i.Thumbnail, &i.Thumbnail,
&i.CreatedAt, &i.CreatedAt,
&i.UpdatedAt, &i.UpdatedAt,
&i.SortOrder,
) )
return i, err return i, err
} }

View File

@ -13,8 +13,10 @@ type Course struct {
Name string `json:"name"` Name string `json:"name"`
Description *string `json:"description,omitempty"` Description *string `json:"description,omitempty"`
Thumbnail *string `json:"thumbnail,omitempty"` Thumbnail *string `json:"thumbnail,omitempty"`
SortOrder int `json:"sort_order"`
CreatedAt time.Time `json:"created_at"` CreatedAt time.Time `json:"created_at"`
UpdatedAt *time.Time `json:"updated_at,omitempty"` UpdatedAt *time.Time `json:"updated_at,omitempty"`
Access *LMSEntityAccess `json:"access,omitempty"`
} }
type CreateCourseInput struct { type CreateCourseInput struct {
@ -27,4 +29,5 @@ type UpdateCourseInput struct {
Name *string `json:"name,omitempty"` Name *string `json:"name,omitempty"`
Description *string `json:"description,omitempty"` Description *string `json:"description,omitempty"`
Thumbnail *string `json:"thumbnail,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"` VideoURL *string `json:"video_url,omitempty"`
Thumbnail *string `json:"thumbnail,omitempty"` Thumbnail *string `json:"thumbnail,omitempty"`
Description *string `json:"description,omitempty"` Description *string `json:"description,omitempty"`
SortOrder int `json:"sort_order"`
CreatedAt time.Time `json:"created_at"` CreatedAt time.Time `json:"created_at"`
UpdatedAt *time.Time `json:"updated_at,omitempty"` UpdatedAt *time.Time `json:"updated_at,omitempty"`
Access *LMSEntityAccess `json:"access,omitempty"`
} }
type CreateLessonInput struct { type CreateLessonInput struct {
@ -26,4 +28,5 @@ type UpdateLessonInput struct {
VideoURL *string `json:"video_url,omitempty"` VideoURL *string `json:"video_url,omitempty"`
Thumbnail *string `json:"thumbnail,omitempty"` Thumbnail *string `json:"thumbnail,omitempty"`
Description *string `json:"description,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"` Name string `json:"name"`
Description *string `json:"description,omitempty"` Description *string `json:"description,omitempty"`
Icon *string `json:"icon,omitempty"` Icon *string `json:"icon,omitempty"`
SortOrder int `json:"sort_order"`
CreatedAt time.Time `json:"created_at"` CreatedAt time.Time `json:"created_at"`
UpdatedAt *time.Time `json:"updated_at,omitempty"` UpdatedAt *time.Time `json:"updated_at,omitempty"`
Access *LMSEntityAccess `json:"access,omitempty"`
} }
type CreateModuleInput struct { type CreateModuleInput struct {
@ -24,4 +26,5 @@ type UpdateModuleInput struct {
Name *string `json:"name,omitempty"` Name *string `json:"name,omitempty"`
Description *string `json:"description,omitempty"` Description *string `json:"description,omitempty"`
Icon *string `json:"icon,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"` Name string `json:"name"`
Description *string `json:"description,omitempty"` Description *string `json:"description,omitempty"`
Thumbnail *string `json:"thumbnail,omitempty"` Thumbnail *string `json:"thumbnail,omitempty"`
SortOrder int `json:"sort_order"`
CreatedAt time.Time `json:"created_at"` CreatedAt time.Time `json:"created_at"`
UpdatedAt *time.Time `json:"updated_at,omitempty"` UpdatedAt *time.Time `json:"updated_at,omitempty"`
Access *LMSEntityAccess `json:"access,omitempty"`
} }
type CreateProgramInput struct { type CreateProgramInput struct {
@ -22,4 +24,5 @@ type UpdateProgramInput struct {
Name *string `json:"name,omitempty"` Name *string `json:"name,omitempty"`
Description *string `json:"description,omitempty"` Description *string `json:"description,omitempty"`
Thumbnail *string `json:"thumbnail,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) CreateCourse(ctx context.Context, programID int64, input domain.CreateCourseInput) (domain.Course, error)
GetCourseByID(ctx context.Context, id int64) (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) 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) UpdateCourse(ctx context.Context, id int64, input domain.UpdateCourseInput) (domain.Course, error)
DeleteCourse(ctx context.Context, id int64) 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) CreateModule(ctx context.Context, programID, courseID int64, input domain.CreateModuleInput) (domain.Module, error)
GetModuleByID(ctx context.Context, id int64) (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) 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) UpdateModule(ctx context.Context, id int64, input domain.UpdateModuleInput) (domain.Module, error)
DeleteModule(ctx context.Context, id int64) 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) CreateProgram(ctx context.Context, input domain.CreateProgramInput) (domain.Program, error)
GetProgramByID(ctx context.Context, id int64) (domain.Program, error) GetProgramByID(ctx context.Context, id int64) (domain.Program, error)
ListPrograms(ctx context.Context, limit, offset int32) ([]domain.Program, int64, 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) UpdateProgram(ctx context.Context, id int64, input domain.UpdateProgramInput) (domain.Program, error)
DeleteProgram(ctx context.Context, id int64) 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 t := c.UpdatedAt.Time
out.UpdatedAt = &t out.UpdatedAt = &t
} }
out.SortOrder = int(c.SortOrder)
return out return out
} }
@ -40,6 +41,10 @@ func (s *Store) CreateCourse(ctx context.Context, programID int64, input domain.
return courseToDomain(c), nil 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) { func (s *Store) GetCourseByID(ctx context.Context, id int64) (domain.Course, error) {
c, err := s.queries.GetCourseByID(ctx, id) c, err := s.queries.GetCourseByID(ctx, id)
if err != nil { if err != nil {
@ -77,6 +82,7 @@ func (s *Store) ListCoursesByProgramID(ctx context.Context, programID int64, lim
Thumbnail: r.Thumbnail, Thumbnail: r.Thumbnail,
CreatedAt: r.CreatedAt, CreatedAt: r.CreatedAt,
UpdatedAt: r.UpdatedAt, UpdatedAt: r.UpdatedAt,
SortOrder: r.SortOrder,
})) }))
} }
return out, total, nil return out, total, nil
@ -94,6 +100,7 @@ func (s *Store) UpdateCourse(ctx context.Context, id int64, input domain.UpdateC
Name: nameText, Name: nameText,
Description: optionalTextUpdate(input.Description), Description: optionalTextUpdate(input.Description),
Thumbnail: optionalTextUpdate(input.Thumbnail), Thumbnail: optionalTextUpdate(input.Thumbnail),
SortOrder: optionalInt4Update(input.SortOrder),
}) })
if err != nil { if err != nil {
if errors.Is(err, pgx.ErrNoRows) { if errors.Is(err, pgx.ErrNoRows) {

View File

@ -25,6 +25,7 @@ func lessonToDomain(l dbgen.Lesson) domain.Lesson {
t := l.UpdatedAt.Time t := l.UpdatedAt.Time
out.UpdatedAt = &t out.UpdatedAt = &t
} }
out.SortOrder = int(l.SortOrder)
return out return out
} }
@ -80,6 +81,7 @@ func (s *Store) ListLessonsByModuleID(ctx context.Context, moduleID int64, limit
Description: r.Description, Description: r.Description,
CreatedAt: r.CreatedAt, CreatedAt: r.CreatedAt,
UpdatedAt: r.UpdatedAt, UpdatedAt: r.UpdatedAt,
SortOrder: r.SortOrder,
})) }))
} }
return out, total, nil return out, total, nil
@ -98,6 +100,7 @@ func (s *Store) UpdateLesson(ctx context.Context, id int64, input domain.UpdateL
VideoUrl: optionalTextUpdate(input.VideoURL), VideoUrl: optionalTextUpdate(input.VideoURL),
Thumbnail: optionalTextUpdate(input.Thumbnail), Thumbnail: optionalTextUpdate(input.Thumbnail),
Description: optionalTextUpdate(input.Description), Description: optionalTextUpdate(input.Description),
SortOrder: optionalInt4Update(input.SortOrder),
}) })
if err != nil { if err != nil {
if errors.Is(err, pgx.ErrNoRows) { if errors.Is(err, pgx.ErrNoRows) {

View File

@ -25,6 +25,7 @@ func moduleToDomain(m dbgen.Module) domain.Module {
t := m.UpdatedAt.Time t := m.UpdatedAt.Time
out.UpdatedAt = &t out.UpdatedAt = &t
} }
out.SortOrder = int(m.SortOrder)
return out return out
} }
@ -42,6 +43,10 @@ func (s *Store) CreateModule(ctx context.Context, programID, courseID int64, inp
return moduleToDomain(m), nil 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) { func (s *Store) GetModuleByID(ctx context.Context, id int64) (domain.Module, error) {
m, err := s.queries.GetModuleByID(ctx, id) m, err := s.queries.GetModuleByID(ctx, id)
if err != nil { if err != nil {
@ -81,6 +86,7 @@ func (s *Store) ListModulesByProgramAndCourse(ctx context.Context, programID, co
Icon: r.Icon, Icon: r.Icon,
CreatedAt: r.CreatedAt, CreatedAt: r.CreatedAt,
UpdatedAt: r.UpdatedAt, UpdatedAt: r.UpdatedAt,
SortOrder: r.SortOrder,
})) }))
} }
return out, total, nil return out, total, nil
@ -98,6 +104,7 @@ func (s *Store) UpdateModule(ctx context.Context, id int64, input domain.UpdateM
Name: nameText, Name: nameText,
Description: optionalTextUpdate(input.Description), Description: optionalTextUpdate(input.Description),
Icon: optionalTextUpdate(input.Icon), Icon: optionalTextUpdate(input.Icon),
SortOrder: optionalInt4Update(input.SortOrder),
}) })
if err != nil { if err != nil {
if errors.Is(err, pgx.ErrNoRows) { 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 t := p.UpdatedAt.Time
out.UpdatedAt = &t out.UpdatedAt = &t
} }
out.SortOrder = int(p.SortOrder)
return out return out
} }
@ -38,6 +39,10 @@ func (s *Store) CreateProgram(ctx context.Context, input domain.CreateProgramInp
return programToDomain(p), nil 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) { func (s *Store) GetProgramByID(ctx context.Context, id int64) (domain.Program, error) {
p, err := s.queries.GetProgramByID(ctx, id) p, err := s.queries.GetProgramByID(ctx, id)
if err != nil { if err != nil {
@ -73,6 +78,7 @@ func (s *Store) ListPrograms(ctx context.Context, limit, offset int32) ([]domain
Thumbnail: r.Thumbnail, Thumbnail: r.Thumbnail,
CreatedAt: r.CreatedAt, CreatedAt: r.CreatedAt,
UpdatedAt: r.UpdatedAt, UpdatedAt: r.UpdatedAt,
SortOrder: r.SortOrder,
})) }))
} }
return out, total, nil return out, total, nil
@ -85,6 +91,13 @@ func optionalTextUpdate(val *string) pgtype.Text {
return pgtype.Text{String: *val, Valid: true} 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) { func (s *Store) UpdateProgram(ctx context.Context, id int64, input domain.UpdateProgramInput) (domain.Program, error) {
var nameText pgtype.Text var nameText pgtype.Text
if input.Name != nil { if input.Name != nil {
@ -97,6 +110,7 @@ func (s *Store) UpdateProgram(ctx context.Context, id int64, input domain.Update
Name: nameText, Name: nameText,
Description: optionalTextUpdate(input.Description), Description: optionalTextUpdate(input.Description),
Thumbnail: optionalTextUpdate(input.Thumbnail), Thumbnail: optionalTextUpdate(input.Thumbnail),
SortOrder: optionalInt4Update(input.SortOrder),
}) })
if err != nil { if err != nil {
if errors.Is(err, pgx.ErrNoRows) { 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) 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) 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) 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.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.update", Name: "Update Program", Description: "Update a program", GroupName: "Programs"},
{Key: "programs.delete", Name: "Delete Program", Description: "Delete 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) // Modules (LMS, under a course)
{Key: "modules.create", Name: "Create Module", Description: "Create a module in a course", GroupName: "Modules"}, {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.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.update", Name: "Update Module", Description: "Update a module", GroupName: "Modules"},
{Key: "modules.delete", Name: "Delete Module", Description: "Delete 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) // Lessons (LMS, under a module)
{Key: "lessons.create", Name: "Create Lesson", Description: "Create a lesson in a module", GroupName: "Lessons"}, {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.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.list_by_module", Name: "List Lessons by Module", Description: "List lessons under a module", GroupName: "Lessons"},
{Key: "lessons.update", Name: "Update Lesson", Description: "Update a lesson", GroupName: "Lessons"}, {Key: "lessons.update", Name: "Update Lesson", Description: "Update a lesson", GroupName: "Lessons"},
{Key: "lessons.delete", Name: "Delete Lesson", Description: "Delete a lesson", GroupName: "Lessons"}, {Key: "lessons.delete", Name: "Delete Lesson", Description: "Delete a lesson", GroupName: "Lessons"},
// 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) // Practices (LMS, scoped to course, module, or lesson)
{Key: "practices.create", Name: "Create Practice", Description: "Create a practice", GroupName: "Practices"}, {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"}, {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", "learning_tree.get", "practices.reorder",
// Programs // 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
"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
"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
"practices.create", "practices.get", "practices.list", "practices.update", "practices.delete", "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", "course_categories.list", "course_categories.get",
"courses.get", "courses.list_by_program", "courses.get", "courses.list_by_program",
"modules.get", "modules.list_by_course", "modules.get", "modules.list_by_course",
"lessons.get", "lessons.list_by_module", "lessons.get", "lessons.list_by_module", "lessons.complete",
"practices.get", "practices.list", "practices.get", "practices.list",
"subcourses.get", "subcourses.list_by_course", "subcourses.list_by_course_list", "subcourses.list_active", "subcourses.get", "subcourses.list_by_course", "subcourses.list_by_course_list", "subcourses.list_active",
"videos.get", "videos.list_by_subcourse", "videos.list_published", "videos.get", "videos.list_by_subcourse", "videos.list_published",
"learning_tree.get", "learning_tree.get",
"programs.list", "programs.get", "programs.list", "programs.get",
"lms.get_my_progress",
// Questions (read + attempt) // Questions (read + attempt)
"questions.list", "questions.search", "questions.get", "questions.list", "questions.search", "questions.get",
@ -413,12 +421,15 @@ var DefaultRolePermissions = map[string][]string{
"course_categories.list", "course_categories.get", "course_categories.list", "course_categories.get",
"courses.get", "courses.list_by_program", "courses.get", "courses.list_by_program",
"modules.get", "modules.list_by_course", "modules.get", "modules.list_by_course",
"lessons.get", "lessons.list_by_module", "lessons.get", "lessons.list_by_module", "lessons.complete",
"practices.get", "practices.list", "practices.get", "practices.list",
"subcourses.get", "subcourses.list_by_course", "subcourses.list_by_course_list", "subcourses.list_active", "subcourses.get", "subcourses.list_by_course", "subcourses.list_by_course_list", "subcourses.list_active",
"videos.get", "videos.list_by_subcourse", "videos.list_published", "videos.get", "videos.list_by_subcourse", "videos.list_published",
"learning_tree.get", "learning_tree.get",
"programs.list", "programs.get",
"lms.get_my_progress",
// Questions (full — instructors create content) // Questions (full — instructors create content)
"questions.create", "questions.list", "questions.search", "questions.get", "questions.update", "questions.delete", "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", "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" notificationservice "Yimaru-Backend/internal/services/notification"
"Yimaru-Backend/internal/services/courses" "Yimaru-Backend/internal/services/courses"
"Yimaru-Backend/internal/services/lessons" "Yimaru-Backend/internal/services/lessons"
"Yimaru-Backend/internal/services/lmsprogress"
"Yimaru-Backend/internal/services/modules" "Yimaru-Backend/internal/services/modules"
"Yimaru-Backend/internal/services/practices" "Yimaru-Backend/internal/services/practices"
"Yimaru-Backend/internal/services/programs" "Yimaru-Backend/internal/services/programs"
@ -48,6 +49,7 @@ type App struct {
courseSvc *courses.Service courseSvc *courses.Service
moduleSvc *modules.Service moduleSvc *modules.Service
lessonSvc *lessons.Service lessonSvc *lessons.Service
lmsProgressSvc *lmsprogress.Service
practiceSvc *practices.Service practiceSvc *practices.Service
subscriptionsSvc *subscriptions.Service subscriptionsSvc *subscriptions.Service
arifpaySvc *arifpay.ArifpayService arifpaySvc *arifpay.ArifpayService
@ -84,6 +86,7 @@ func NewApp(
courseSvc *courses.Service, courseSvc *courses.Service,
moduleSvc *modules.Service, moduleSvc *modules.Service,
lessonSvc *lessons.Service, lessonSvc *lessons.Service,
lmsProgressSvc *lmsprogress.Service,
practiceSvc *practices.Service, practiceSvc *practices.Service,
subscriptionsSvc *subscriptions.Service, subscriptionsSvc *subscriptions.Service,
arifpaySvc *arifpay.ArifpayService, arifpaySvc *arifpay.ArifpayService,
@ -132,6 +135,7 @@ func NewApp(
courseSvc: courseSvc, courseSvc: courseSvc,
moduleSvc: moduleSvc, moduleSvc: moduleSvc,
lessonSvc: lessonSvc, lessonSvc: lessonSvc,
lmsProgressSvc: lmsProgressSvc,
practiceSvc: practiceSvc, practiceSvc: practiceSvc,
subscriptionsSvc: subscriptionsSvc, subscriptionsSvc: subscriptionsSvc,
arifpaySvc: arifpaySvc, arifpaySvc: arifpaySvc,

View File

@ -105,6 +105,16 @@ func (h *Handler) ListCoursesByProgram(c *fiber.Ctx) error {
Error: err.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{ return c.JSON(domain.Response{
Message: "Courses retrieved successfully", Message: "Courses retrieved successfully",
Data: fiber.Map{ Data: fiber.Map{
@ -147,6 +157,17 @@ func (h *Handler) GetCourse(c *fiber.Ctx) error {
Error: err.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{ return c.JSON(domain.Response{
Message: "Course retrieved successfully", Message: "Course retrieved successfully",
Data: course, Data: course,

View File

@ -18,6 +18,7 @@ import (
notificationservice "Yimaru-Backend/internal/services/notification" notificationservice "Yimaru-Backend/internal/services/notification"
"Yimaru-Backend/internal/services/courses" "Yimaru-Backend/internal/services/courses"
"Yimaru-Backend/internal/services/lessons" "Yimaru-Backend/internal/services/lessons"
"Yimaru-Backend/internal/services/lmsprogress"
"Yimaru-Backend/internal/services/modules" "Yimaru-Backend/internal/services/modules"
"Yimaru-Backend/internal/services/practices" "Yimaru-Backend/internal/services/practices"
"Yimaru-Backend/internal/services/programs" "Yimaru-Backend/internal/services/programs"
@ -47,6 +48,7 @@ type Handler struct {
courseSvc *courses.Service courseSvc *courses.Service
moduleSvc *modules.Service moduleSvc *modules.Service
lessonSvc *lessons.Service lessonSvc *lessons.Service
lmsProgressSvc *lmsprogress.Service
practiceSvc *practices.Service practiceSvc *practices.Service
subscriptionsSvc *subscriptions.Service subscriptionsSvc *subscriptions.Service
arifpaySvc *arifpay.ArifpayService arifpaySvc *arifpay.ArifpayService
@ -79,6 +81,7 @@ func New(
courseSvc *courses.Service, courseSvc *courses.Service,
moduleSvc *modules.Service, moduleSvc *modules.Service,
lessonSvc *lessons.Service, lessonSvc *lessons.Service,
lmsProgressSvc *lmsprogress.Service,
practiceSvc *practices.Service, practiceSvc *practices.Service,
subscriptionsSvc *subscriptions.Service, subscriptionsSvc *subscriptions.Service,
arifpaySvc *arifpay.ArifpayService, arifpaySvc *arifpay.ArifpayService,
@ -110,6 +113,7 @@ func New(
courseSvc: courseSvc, courseSvc: courseSvc,
moduleSvc: moduleSvc, moduleSvc: moduleSvc,
lessonSvc: lessonSvc, lessonSvc: lessonSvc,
lmsProgressSvc: lmsProgressSvc,
practiceSvc: practiceSvc, practiceSvc: practiceSvc,
subscriptionsSvc: subscriptionsSvc, subscriptionsSvc: subscriptionsSvc,
arifpaySvc: arifpaySvc, arifpaySvc: arifpaySvc,

View File

@ -97,6 +97,16 @@ func (h *Handler) ListLessonsByModule(c *fiber.Ctx) error {
Error: err.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{ return c.JSON(domain.Response{
Message: "Lessons retrieved successfully", Message: "Lessons retrieved successfully",
Data: fiber.Map{ Data: fiber.Map{
@ -135,6 +145,17 @@ func (h *Handler) GetLesson(c *fiber.Ctx) error {
Error: err.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{ return c.JSON(domain.Response{
Message: "Lesson retrieved successfully", Message: "Lesson retrieved successfully",
Data: les, Data: les,
@ -227,3 +248,61 @@ func (h *Handler) DeleteLesson(c *fiber.Ctx) error {
StatusCode: fiber.StatusOK, 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(), 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{ return c.JSON(domain.Response{
Message: "Modules retrieved successfully", Message: "Modules retrieved successfully",
Data: fiber.Map{ Data: fiber.Map{
@ -142,6 +152,17 @@ func (h *Handler) GetModule(c *fiber.Ctx) error {
Error: err.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{ return c.JSON(domain.Response{
Message: "Module retrieved successfully", Message: "Module retrieved successfully",
Data: mod, Data: mod,

View File

@ -76,6 +76,16 @@ func (h *Handler) ListPrograms(c *fiber.Ctx) error {
Error: err.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{ return c.JSON(domain.Response{
Message: "Programs retrieved successfully", Message: "Programs retrieved successfully",
Data: fiber.Map{ Data: fiber.Map{
@ -118,6 +128,17 @@ func (h *Handler) GetProgram(c *fiber.Ctx) error {
Error: err.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{ return c.JSON(domain.Response{
Message: "Program retrieved successfully", Message: "Program retrieved successfully",
Data: p, Data: p,

View File

@ -19,6 +19,7 @@ func (a *App) initAppRoutes() {
a.courseSvc, a.courseSvc,
a.moduleSvc, a.moduleSvc,
a.lessonSvc, a.lessonSvc,
a.lmsProgressSvc,
a.practiceSvc, a.practiceSvc,
a.subscriptionsSvc, a.subscriptionsSvc,
a.arifpaySvc, a.arifpaySvc,
@ -74,18 +75,22 @@ func (a *App) initAppRoutes() {
// Programs (LMS top-level) // Programs (LMS top-level)
groupV1.Post("/programs", a.authMiddleware, a.RequirePermission("programs.create"), h.CreateProgram) groupV1.Post("/programs", a.authMiddleware, a.RequirePermission("programs.create"), h.CreateProgram)
groupV1.Get("/programs", a.authMiddleware, a.RequirePermission("programs.list"), h.ListPrograms) 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.Get("/programs/:id", a.authMiddleware, a.RequirePermission("programs.get"), h.GetProgram)
groupV1.Put("/programs/:id", a.authMiddleware, a.RequirePermission("programs.update"), h.UpdateProgram) groupV1.Put("/programs/:id", a.authMiddleware, a.RequirePermission("programs.update"), h.UpdateProgram)
groupV1.Delete("/programs/:id", a.authMiddleware, a.RequirePermission("programs.delete"), h.DeleteProgram) groupV1.Delete("/programs/:id", a.authMiddleware, a.RequirePermission("programs.delete"), h.DeleteProgram)
// Courses // Courses
groupV1.Post("/programs/:id/courses", a.authMiddleware, a.RequirePermission("courses.create"), h.CreateCourse) 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("/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/practices", a.authMiddleware, a.RequirePermission("practices.list"), h.ListPracticesByCourse)
groupV1.Get("/courses/:id", a.authMiddleware, a.RequirePermission("courses.get"), h.GetCourse) 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.Put("/courses/:id", a.authMiddleware, a.RequirePermission("courses.update"), h.UpdateCourse)
groupV1.Delete("/courses/:id", a.authMiddleware, a.RequirePermission("courses.delete"), h.DeleteCourse) 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.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) 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 // /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.Put("/modules/:id", a.authMiddleware, a.RequirePermission("modules.update"), h.UpdateModule)
groupV1.Delete("/modules/:id", a.authMiddleware, a.RequirePermission("modules.delete"), h.DeleteModule) 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.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.Get("/lessons/:id", a.authMiddleware, a.RequirePermission("lessons.get"), h.GetLesson)
groupV1.Put("/lessons/:id", a.authMiddleware, a.RequirePermission("lessons.update"), h.UpdateLesson) groupV1.Put("/lessons/:id", a.authMiddleware, a.RequirePermission("lessons.update"), h.UpdateLesson)
groupV1.Delete("/lessons/:id", a.authMiddleware, a.RequirePermission("lessons.delete"), h.DeleteLesson) groupV1.Delete("/lessons/:id", a.authMiddleware, a.RequirePermission("lessons.delete"), h.DeleteLesson)