learning progress implementation
This commit is contained in:
parent
dc788c04cb
commit
5b53929d92
|
|
@ -19,6 +19,7 @@ import (
|
|||
notificationservice "Yimaru-Backend/internal/services/notification"
|
||||
coursesservice "Yimaru-Backend/internal/services/courses"
|
||||
lessonsservice "Yimaru-Backend/internal/services/lessons"
|
||||
"Yimaru-Backend/internal/services/lmsprogress"
|
||||
moduleservice "Yimaru-Backend/internal/services/modules"
|
||||
practicesservice "Yimaru-Backend/internal/services/practices"
|
||||
programsservice "Yimaru-Backend/internal/services/programs"
|
||||
|
|
@ -404,6 +405,8 @@ func main() {
|
|||
// LMS lessons (under modules)
|
||||
lessonSvc := lessonsservice.NewService(store, store)
|
||||
|
||||
lmsProgressSvc := lmsprogress.NewService(store)
|
||||
|
||||
// LMS practices (under course, module, or lesson)
|
||||
practiceSvc := practicesservice.NewService(store, store, store, store, store, store)
|
||||
|
||||
|
|
@ -452,6 +455,7 @@ func main() {
|
|||
courseSvc,
|
||||
moduleSvc,
|
||||
lessonSvc,
|
||||
lmsProgressSvc,
|
||||
practiceSvc,
|
||||
subscriptionsSvc,
|
||||
arifpaySvc,
|
||||
|
|
|
|||
18
db/migrations/000049_lms_sequential_learning.down.sql
Normal file
18
db/migrations/000049_lms_sequential_learning.down.sql
Normal 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;
|
||||
150
db/migrations/000049_lms_sequential_learning.up.sql
Normal file
150
db/migrations/000049_lms_sequential_learning.up.sql
Normal 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);
|
||||
|
|
@ -1,13 +1,34 @@
|
|||
-- name: CreateCourse :one
|
||||
INSERT INTO courses (program_id, name, description, thumbnail)
|
||||
VALUES ($1, $2, $3, $4)
|
||||
RETURNING *;
|
||||
INSERT INTO courses (program_id, name, description, thumbnail, sort_order)
|
||||
SELECT
|
||||
$1,
|
||||
$2,
|
||||
$3,
|
||||
$4,
|
||||
coalesce((
|
||||
SELECT
|
||||
max(c.sort_order)
|
||||
FROM courses c
|
||||
WHERE
|
||||
c.program_id = $1), 0) + 1
|
||||
RETURNING
|
||||
*;
|
||||
|
||||
-- name: GetCourseByID :one
|
||||
SELECT *
|
||||
FROM courses
|
||||
WHERE id = $1;
|
||||
|
||||
-- name: ListCourseIDsByProgram :many
|
||||
SELECT
|
||||
c.id
|
||||
FROM
|
||||
courses AS c
|
||||
WHERE
|
||||
c.program_id = $1
|
||||
ORDER BY
|
||||
c.id;
|
||||
|
||||
-- name: ListCoursesByProgramID :many
|
||||
SELECT
|
||||
COUNT(*) OVER () AS total_count,
|
||||
|
|
@ -16,11 +37,16 @@ SELECT
|
|||
c.name,
|
||||
c.description,
|
||||
c.thumbnail,
|
||||
c.sort_order,
|
||||
c.created_at,
|
||||
c.updated_at
|
||||
FROM courses c
|
||||
WHERE c.program_id = $1
|
||||
ORDER BY c.created_at DESC
|
||||
FROM
|
||||
courses c
|
||||
WHERE
|
||||
c.program_id = $1
|
||||
ORDER BY
|
||||
c.sort_order ASC,
|
||||
c.id ASC
|
||||
LIMIT $2 OFFSET $3;
|
||||
|
||||
-- name: UpdateCourse :one
|
||||
|
|
@ -29,9 +55,12 @@ SET
|
|||
name = COALESCE(sqlc.narg('name')::varchar, name),
|
||||
description = COALESCE(sqlc.narg('description')::text, description),
|
||||
thumbnail = COALESCE(sqlc.narg('thumbnail')::text, thumbnail),
|
||||
sort_order = coalesce(sqlc.narg('sort_order')::int, sort_order),
|
||||
updated_at = CURRENT_TIMESTAMP
|
||||
WHERE id = sqlc.arg('id')
|
||||
RETURNING *;
|
||||
WHERE
|
||||
id = sqlc.arg('id')
|
||||
RETURNING
|
||||
*;
|
||||
|
||||
-- name: DeleteCourse :exec
|
||||
DELETE FROM courses
|
||||
|
|
|
|||
|
|
@ -1,7 +1,19 @@
|
|||
-- name: CreateLesson :one
|
||||
INSERT INTO lessons (module_id, title, video_url, thumbnail, description)
|
||||
VALUES ($1, $2, $3, $4, $5)
|
||||
RETURNING *;
|
||||
INSERT INTO lessons (module_id, title, video_url, thumbnail, description, sort_order)
|
||||
SELECT
|
||||
$1,
|
||||
$2,
|
||||
$3,
|
||||
$4,
|
||||
$5,
|
||||
coalesce((
|
||||
SELECT
|
||||
max(l.sort_order)
|
||||
FROM lessons l
|
||||
WHERE
|
||||
l.module_id = $1), 0) + 1
|
||||
RETURNING
|
||||
*;
|
||||
|
||||
-- name: GetLessonByID :one
|
||||
SELECT *
|
||||
|
|
@ -17,12 +29,18 @@ SELECT
|
|||
l.video_url,
|
||||
l.thumbnail,
|
||||
l.description,
|
||||
l.sort_order,
|
||||
l.created_at,
|
||||
l.updated_at
|
||||
FROM lessons l
|
||||
WHERE l.module_id = $1
|
||||
ORDER BY l.created_at DESC
|
||||
LIMIT $2 OFFSET $3;
|
||||
FROM
|
||||
lessons l
|
||||
WHERE
|
||||
l.module_id = $1
|
||||
ORDER BY
|
||||
l.sort_order ASC,
|
||||
l.id ASC
|
||||
LIMIT $2
|
||||
OFFSET $3;
|
||||
|
||||
-- name: UpdateLesson :one
|
||||
UPDATE lessons
|
||||
|
|
@ -31,9 +49,12 @@ SET
|
|||
video_url = COALESCE(sqlc.narg('video_url')::text, video_url),
|
||||
thumbnail = COALESCE(sqlc.narg('thumbnail')::text, thumbnail),
|
||||
description = COALESCE(sqlc.narg('description')::text, description),
|
||||
sort_order = coalesce(sqlc.narg('sort_order')::int, sort_order),
|
||||
updated_at = CURRENT_TIMESTAMP
|
||||
WHERE id = sqlc.arg('id')
|
||||
RETURNING *;
|
||||
WHERE
|
||||
id = sqlc.arg('id')
|
||||
RETURNING
|
||||
*;
|
||||
|
||||
-- name: DeleteLesson :exec
|
||||
DELETE FROM lessons
|
||||
|
|
|
|||
|
|
@ -1,13 +1,35 @@
|
|||
-- name: CreateModule :one
|
||||
INSERT INTO modules (program_id, course_id, name, description, icon)
|
||||
VALUES ($1, $2, $3, $4, $5)
|
||||
RETURNING *;
|
||||
INSERT INTO modules (program_id, course_id, name, description, icon, sort_order)
|
||||
SELECT
|
||||
$1,
|
||||
$2,
|
||||
$3,
|
||||
$4,
|
||||
$5,
|
||||
coalesce((
|
||||
SELECT
|
||||
max(m.sort_order)
|
||||
FROM modules m
|
||||
WHERE
|
||||
m.course_id = $2), 0) + 1
|
||||
RETURNING
|
||||
*;
|
||||
|
||||
-- name: GetModuleByID :one
|
||||
SELECT *
|
||||
FROM modules
|
||||
WHERE id = $1;
|
||||
|
||||
-- name: ListModuleIDsByCourse :many
|
||||
SELECT
|
||||
m.id
|
||||
FROM
|
||||
modules AS m
|
||||
WHERE
|
||||
m.course_id = $1
|
||||
ORDER BY
|
||||
m.id;
|
||||
|
||||
-- name: ListModulesByProgramAndCourse :many
|
||||
SELECT
|
||||
COUNT(*) OVER () AS total_count,
|
||||
|
|
@ -17,13 +39,19 @@ SELECT
|
|||
m.name,
|
||||
m.description,
|
||||
m.icon,
|
||||
m.sort_order,
|
||||
m.created_at,
|
||||
m.updated_at
|
||||
FROM modules m
|
||||
WHERE m.program_id = $1
|
||||
AND m.course_id = $2
|
||||
ORDER BY m.created_at DESC
|
||||
LIMIT $3 OFFSET $4;
|
||||
FROM
|
||||
modules m
|
||||
WHERE
|
||||
m.program_id = $1
|
||||
AND m.course_id = $2
|
||||
ORDER BY
|
||||
m.sort_order ASC,
|
||||
m.id ASC
|
||||
LIMIT $3
|
||||
OFFSET $4;
|
||||
|
||||
-- name: UpdateModule :one
|
||||
UPDATE modules
|
||||
|
|
@ -31,9 +59,12 @@ SET
|
|||
name = COALESCE(sqlc.narg('name')::varchar, name),
|
||||
description = COALESCE(sqlc.narg('description')::text, description),
|
||||
icon = COALESCE(sqlc.narg('icon')::text, icon),
|
||||
sort_order = coalesce(sqlc.narg('sort_order')::int, sort_order),
|
||||
updated_at = CURRENT_TIMESTAMP
|
||||
WHERE id = sqlc.arg('id')
|
||||
RETURNING *;
|
||||
WHERE
|
||||
id = sqlc.arg('id')
|
||||
RETURNING
|
||||
*;
|
||||
|
||||
-- name: DeleteModule :exec
|
||||
DELETE FROM modules
|
||||
|
|
|
|||
248
db/query/lms_progress.sql
Normal file
248
db/query/lms_progress.sql
Normal 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;
|
||||
|
|
@ -1,13 +1,29 @@
|
|||
-- name: CreateProgram :one
|
||||
INSERT INTO programs (name, description, thumbnail)
|
||||
VALUES ($1, $2, $3)
|
||||
RETURNING *;
|
||||
INSERT INTO programs (name, description, thumbnail, sort_order)
|
||||
SELECT
|
||||
$1,
|
||||
$2,
|
||||
$3,
|
||||
coalesce((
|
||||
SELECT
|
||||
max(p.sort_order)
|
||||
FROM programs AS p), 0) + 1
|
||||
RETURNING
|
||||
*;
|
||||
|
||||
-- name: GetProgramByID :one
|
||||
SELECT *
|
||||
FROM programs
|
||||
WHERE id = $1;
|
||||
|
||||
-- name: ListAllProgramIDs :many
|
||||
SELECT
|
||||
p.id
|
||||
FROM
|
||||
programs AS p
|
||||
ORDER BY
|
||||
p.id;
|
||||
|
||||
-- name: ListPrograms :many
|
||||
SELECT
|
||||
COUNT(*) OVER () AS total_count,
|
||||
|
|
@ -15,10 +31,11 @@ SELECT
|
|||
p.name,
|
||||
p.description,
|
||||
p.thumbnail,
|
||||
p.sort_order,
|
||||
p.created_at,
|
||||
p.updated_at
|
||||
FROM programs p
|
||||
ORDER BY p.created_at DESC
|
||||
ORDER BY p.sort_order ASC, p.id ASC
|
||||
LIMIT $1 OFFSET $2;
|
||||
|
||||
-- name: UpdateProgram :one
|
||||
|
|
@ -27,9 +44,12 @@ SET
|
|||
name = COALESCE(sqlc.narg('name')::varchar, name),
|
||||
description = COALESCE(sqlc.narg('description')::text, description),
|
||||
thumbnail = COALESCE(sqlc.narg('thumbnail')::text, thumbnail),
|
||||
sort_order = coalesce(sqlc.narg('sort_order')::int, sort_order),
|
||||
updated_at = CURRENT_TIMESTAMP
|
||||
WHERE id = sqlc.arg('id')
|
||||
RETURNING *;
|
||||
WHERE
|
||||
id = sqlc.arg('id')
|
||||
RETURNING
|
||||
*;
|
||||
|
||||
-- name: DeleteProgram :exec
|
||||
DELETE FROM programs
|
||||
|
|
|
|||
175
docs/docs.go
175
docs/docs.go
|
|
@ -800,6 +800,33 @@ const docTemplate = `{
|
|||
}
|
||||
}
|
||||
},
|
||||
"/api/v1/courses/{courseId}/modules/reorder": {
|
||||
"put": {
|
||||
"tags": [
|
||||
"modules"
|
||||
],
|
||||
"summary": "Reorder modules within a course",
|
||||
"parameters": [
|
||||
{
|
||||
"type": "integer",
|
||||
"description": "Course ID",
|
||||
"name": "courseId",
|
||||
"in": "path",
|
||||
"required": true
|
||||
},
|
||||
{
|
||||
"description": "ordered_ids: every module id in this course, in the new order",
|
||||
"name": "body",
|
||||
"in": "body",
|
||||
"required": true,
|
||||
"schema": {
|
||||
"$ref": "#/definitions/domain.ReorderIDsRequest"
|
||||
}
|
||||
}
|
||||
],
|
||||
"responses": {}
|
||||
}
|
||||
},
|
||||
"/api/v1/courses/{id}": {
|
||||
"get": {
|
||||
"produces": [
|
||||
|
|
@ -1530,6 +1557,38 @@ const docTemplate = `{
|
|||
"responses": {}
|
||||
}
|
||||
},
|
||||
"/api/v1/lessons/{id}/complete": {
|
||||
"post": {
|
||||
"description": "Records lesson completion; may cascade to module, course, and program progress for the authenticated user. Learners must meet sequential prerequisites; staff bypass checks.",
|
||||
"tags": [
|
||||
"lessons"
|
||||
],
|
||||
"summary": "Mark a lesson as completed",
|
||||
"parameters": [
|
||||
{
|
||||
"type": "integer",
|
||||
"description": "Lesson ID",
|
||||
"name": "id",
|
||||
"in": "path",
|
||||
"required": true
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "OK",
|
||||
"schema": {
|
||||
"$ref": "#/definitions/domain.Response"
|
||||
}
|
||||
},
|
||||
"403": {
|
||||
"description": "Forbidden",
|
||||
"schema": {
|
||||
"$ref": "#/definitions/domain.ErrorResponse"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/api/v1/lessons/{id}/practices": {
|
||||
"get": {
|
||||
"tags": [
|
||||
|
|
@ -1547,6 +1606,32 @@ const docTemplate = `{
|
|||
"responses": {}
|
||||
}
|
||||
},
|
||||
"/api/v1/lms/progress": {
|
||||
"get": {
|
||||
"description": "Returns completed lesson, module, course, and program IDs for the authenticated user (ordered by completion time, then id).",
|
||||
"produces": [
|
||||
"application/json"
|
||||
],
|
||||
"tags": [
|
||||
"lms"
|
||||
],
|
||||
"summary": "Get my LMS completion history",
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "OK",
|
||||
"schema": {
|
||||
"$ref": "#/definitions/domain.Response"
|
||||
}
|
||||
},
|
||||
"500": {
|
||||
"description": "Internal Server Error",
|
||||
"schema": {
|
||||
"$ref": "#/definitions/domain.ErrorResponse"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/api/v1/logs": {
|
||||
"get": {
|
||||
"description": "Fetches application logs from MongoDB with pagination, level filtering, and search",
|
||||
|
|
@ -2769,6 +2854,46 @@ const docTemplate = `{
|
|||
}
|
||||
}
|
||||
},
|
||||
"/api/v1/programs/reorder": {
|
||||
"put": {
|
||||
"description": "Sets learning order of programs. Body must list every current program id exactly once, in the desired order (index 0 = first in path).",
|
||||
"consumes": [
|
||||
"application/json"
|
||||
],
|
||||
"produces": [
|
||||
"application/json"
|
||||
],
|
||||
"tags": [
|
||||
"programs"
|
||||
],
|
||||
"summary": "Reorder all programs",
|
||||
"parameters": [
|
||||
{
|
||||
"description": "New order: ordered_ids is the full set of program ids",
|
||||
"name": "body",
|
||||
"in": "body",
|
||||
"required": true,
|
||||
"schema": {
|
||||
"$ref": "#/definitions/domain.ReorderIDsRequest"
|
||||
}
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "OK",
|
||||
"schema": {
|
||||
"$ref": "#/definitions/domain.Response"
|
||||
}
|
||||
},
|
||||
"400": {
|
||||
"description": "Bad Request",
|
||||
"schema": {
|
||||
"$ref": "#/definitions/domain.ErrorResponse"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/api/v1/programs/{id}": {
|
||||
"get": {
|
||||
"produces": [
|
||||
|
|
@ -2981,6 +3106,33 @@ const docTemplate = `{
|
|||
}
|
||||
}
|
||||
},
|
||||
"/api/v1/programs/{id}/courses/reorder": {
|
||||
"put": {
|
||||
"tags": [
|
||||
"courses"
|
||||
],
|
||||
"summary": "Reorder courses within a program",
|
||||
"parameters": [
|
||||
{
|
||||
"type": "integer",
|
||||
"description": "Program ID",
|
||||
"name": "id",
|
||||
"in": "path",
|
||||
"required": true
|
||||
},
|
||||
{
|
||||
"description": "ordered_ids: every course id in this program, in the new order",
|
||||
"name": "body",
|
||||
"in": "body",
|
||||
"required": true,
|
||||
"schema": {
|
||||
"$ref": "#/definitions/domain.ReorderIDsRequest"
|
||||
}
|
||||
}
|
||||
],
|
||||
"responses": {}
|
||||
}
|
||||
},
|
||||
"/api/v1/progress/practices/{id}/complete": {
|
||||
"post": {
|
||||
"description": "Marks a practice question set as completed for the authenticated learner",
|
||||
|
|
@ -8492,6 +8644,17 @@ const docTemplate = `{
|
|||
}
|
||||
}
|
||||
},
|
||||
"domain.ReorderIDsRequest": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"ordered_ids": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "integer"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"domain.ResendOtpReq": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
|
|
@ -8749,6 +8912,9 @@ const docTemplate = `{
|
|||
"name": {
|
||||
"type": "string"
|
||||
},
|
||||
"sort_order": {
|
||||
"type": "integer"
|
||||
},
|
||||
"thumbnail": {
|
||||
"type": "string"
|
||||
}
|
||||
|
|
@ -8772,6 +8938,9 @@ const docTemplate = `{
|
|||
"description": {
|
||||
"type": "string"
|
||||
},
|
||||
"sort_order": {
|
||||
"type": "integer"
|
||||
},
|
||||
"thumbnail": {
|
||||
"type": "string"
|
||||
},
|
||||
|
|
@ -8794,6 +8963,9 @@ const docTemplate = `{
|
|||
},
|
||||
"name": {
|
||||
"type": "string"
|
||||
},
|
||||
"sort_order": {
|
||||
"type": "integer"
|
||||
}
|
||||
}
|
||||
},
|
||||
|
|
@ -8829,6 +9001,9 @@ const docTemplate = `{
|
|||
"name": {
|
||||
"type": "string"
|
||||
},
|
||||
"sort_order": {
|
||||
"type": "integer"
|
||||
},
|
||||
"thumbnail": {
|
||||
"type": "string"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -792,6 +792,33 @@
|
|||
}
|
||||
}
|
||||
},
|
||||
"/api/v1/courses/{courseId}/modules/reorder": {
|
||||
"put": {
|
||||
"tags": [
|
||||
"modules"
|
||||
],
|
||||
"summary": "Reorder modules within a course",
|
||||
"parameters": [
|
||||
{
|
||||
"type": "integer",
|
||||
"description": "Course ID",
|
||||
"name": "courseId",
|
||||
"in": "path",
|
||||
"required": true
|
||||
},
|
||||
{
|
||||
"description": "ordered_ids: every module id in this course, in the new order",
|
||||
"name": "body",
|
||||
"in": "body",
|
||||
"required": true,
|
||||
"schema": {
|
||||
"$ref": "#/definitions/domain.ReorderIDsRequest"
|
||||
}
|
||||
}
|
||||
],
|
||||
"responses": {}
|
||||
}
|
||||
},
|
||||
"/api/v1/courses/{id}": {
|
||||
"get": {
|
||||
"produces": [
|
||||
|
|
@ -1522,6 +1549,38 @@
|
|||
"responses": {}
|
||||
}
|
||||
},
|
||||
"/api/v1/lessons/{id}/complete": {
|
||||
"post": {
|
||||
"description": "Records lesson completion; may cascade to module, course, and program progress for the authenticated user. Learners must meet sequential prerequisites; staff bypass checks.",
|
||||
"tags": [
|
||||
"lessons"
|
||||
],
|
||||
"summary": "Mark a lesson as completed",
|
||||
"parameters": [
|
||||
{
|
||||
"type": "integer",
|
||||
"description": "Lesson ID",
|
||||
"name": "id",
|
||||
"in": "path",
|
||||
"required": true
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "OK",
|
||||
"schema": {
|
||||
"$ref": "#/definitions/domain.Response"
|
||||
}
|
||||
},
|
||||
"403": {
|
||||
"description": "Forbidden",
|
||||
"schema": {
|
||||
"$ref": "#/definitions/domain.ErrorResponse"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/api/v1/lessons/{id}/practices": {
|
||||
"get": {
|
||||
"tags": [
|
||||
|
|
@ -1539,6 +1598,32 @@
|
|||
"responses": {}
|
||||
}
|
||||
},
|
||||
"/api/v1/lms/progress": {
|
||||
"get": {
|
||||
"description": "Returns completed lesson, module, course, and program IDs for the authenticated user (ordered by completion time, then id).",
|
||||
"produces": [
|
||||
"application/json"
|
||||
],
|
||||
"tags": [
|
||||
"lms"
|
||||
],
|
||||
"summary": "Get my LMS completion history",
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "OK",
|
||||
"schema": {
|
||||
"$ref": "#/definitions/domain.Response"
|
||||
}
|
||||
},
|
||||
"500": {
|
||||
"description": "Internal Server Error",
|
||||
"schema": {
|
||||
"$ref": "#/definitions/domain.ErrorResponse"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/api/v1/logs": {
|
||||
"get": {
|
||||
"description": "Fetches application logs from MongoDB with pagination, level filtering, and search",
|
||||
|
|
@ -2761,6 +2846,46 @@
|
|||
}
|
||||
}
|
||||
},
|
||||
"/api/v1/programs/reorder": {
|
||||
"put": {
|
||||
"description": "Sets learning order of programs. Body must list every current program id exactly once, in the desired order (index 0 = first in path).",
|
||||
"consumes": [
|
||||
"application/json"
|
||||
],
|
||||
"produces": [
|
||||
"application/json"
|
||||
],
|
||||
"tags": [
|
||||
"programs"
|
||||
],
|
||||
"summary": "Reorder all programs",
|
||||
"parameters": [
|
||||
{
|
||||
"description": "New order: ordered_ids is the full set of program ids",
|
||||
"name": "body",
|
||||
"in": "body",
|
||||
"required": true,
|
||||
"schema": {
|
||||
"$ref": "#/definitions/domain.ReorderIDsRequest"
|
||||
}
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "OK",
|
||||
"schema": {
|
||||
"$ref": "#/definitions/domain.Response"
|
||||
}
|
||||
},
|
||||
"400": {
|
||||
"description": "Bad Request",
|
||||
"schema": {
|
||||
"$ref": "#/definitions/domain.ErrorResponse"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/api/v1/programs/{id}": {
|
||||
"get": {
|
||||
"produces": [
|
||||
|
|
@ -2973,6 +3098,33 @@
|
|||
}
|
||||
}
|
||||
},
|
||||
"/api/v1/programs/{id}/courses/reorder": {
|
||||
"put": {
|
||||
"tags": [
|
||||
"courses"
|
||||
],
|
||||
"summary": "Reorder courses within a program",
|
||||
"parameters": [
|
||||
{
|
||||
"type": "integer",
|
||||
"description": "Program ID",
|
||||
"name": "id",
|
||||
"in": "path",
|
||||
"required": true
|
||||
},
|
||||
{
|
||||
"description": "ordered_ids: every course id in this program, in the new order",
|
||||
"name": "body",
|
||||
"in": "body",
|
||||
"required": true,
|
||||
"schema": {
|
||||
"$ref": "#/definitions/domain.ReorderIDsRequest"
|
||||
}
|
||||
}
|
||||
],
|
||||
"responses": {}
|
||||
}
|
||||
},
|
||||
"/api/v1/progress/practices/{id}/complete": {
|
||||
"post": {
|
||||
"description": "Marks a practice question set as completed for the authenticated learner",
|
||||
|
|
@ -8484,6 +8636,17 @@
|
|||
}
|
||||
}
|
||||
},
|
||||
"domain.ReorderIDsRequest": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"ordered_ids": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "integer"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"domain.ResendOtpReq": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
|
|
@ -8741,6 +8904,9 @@
|
|||
"name": {
|
||||
"type": "string"
|
||||
},
|
||||
"sort_order": {
|
||||
"type": "integer"
|
||||
},
|
||||
"thumbnail": {
|
||||
"type": "string"
|
||||
}
|
||||
|
|
@ -8764,6 +8930,9 @@
|
|||
"description": {
|
||||
"type": "string"
|
||||
},
|
||||
"sort_order": {
|
||||
"type": "integer"
|
||||
},
|
||||
"thumbnail": {
|
||||
"type": "string"
|
||||
},
|
||||
|
|
@ -8786,6 +8955,9 @@
|
|||
},
|
||||
"name": {
|
||||
"type": "string"
|
||||
},
|
||||
"sort_order": {
|
||||
"type": "integer"
|
||||
}
|
||||
}
|
||||
},
|
||||
|
|
@ -8821,6 +8993,9 @@
|
|||
"name": {
|
||||
"type": "string"
|
||||
},
|
||||
"sort_order": {
|
||||
"type": "integer"
|
||||
},
|
||||
"thumbnail": {
|
||||
"type": "string"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -351,6 +351,13 @@ definitions:
|
|||
role:
|
||||
type: string
|
||||
type: object
|
||||
domain.ReorderIDsRequest:
|
||||
properties:
|
||||
ordered_ids:
|
||||
items:
|
||||
type: integer
|
||||
type: array
|
||||
type: object
|
||||
domain.ResendOtpReq:
|
||||
properties:
|
||||
email:
|
||||
|
|
@ -530,6 +537,8 @@ definitions:
|
|||
type: string
|
||||
name:
|
||||
type: string
|
||||
sort_order:
|
||||
type: integer
|
||||
thumbnail:
|
||||
type: string
|
||||
type: object
|
||||
|
|
@ -545,6 +554,8 @@ definitions:
|
|||
properties:
|
||||
description:
|
||||
type: string
|
||||
sort_order:
|
||||
type: integer
|
||||
thumbnail:
|
||||
type: string
|
||||
title:
|
||||
|
|
@ -560,6 +571,8 @@ definitions:
|
|||
type: string
|
||||
name:
|
||||
type: string
|
||||
sort_order:
|
||||
type: integer
|
||||
type: object
|
||||
domain.UpdatePracticeInput:
|
||||
properties:
|
||||
|
|
@ -582,6 +595,8 @@ definitions:
|
|||
type: string
|
||||
name:
|
||||
type: string
|
||||
sort_order:
|
||||
type: integer
|
||||
thumbnail:
|
||||
type: string
|
||||
type: object
|
||||
|
|
@ -2407,6 +2422,24 @@ paths:
|
|||
summary: Create module
|
||||
tags:
|
||||
- modules
|
||||
/api/v1/courses/{courseId}/modules/reorder:
|
||||
put:
|
||||
parameters:
|
||||
- description: Course ID
|
||||
in: path
|
||||
name: courseId
|
||||
required: true
|
||||
type: integer
|
||||
- description: 'ordered_ids: every module id in this course, in the new order'
|
||||
in: body
|
||||
name: body
|
||||
required: true
|
||||
schema:
|
||||
$ref: '#/definitions/domain.ReorderIDsRequest'
|
||||
responses: {}
|
||||
summary: Reorder modules within a course
|
||||
tags:
|
||||
- modules
|
||||
/api/v1/courses/{id}:
|
||||
delete:
|
||||
parameters:
|
||||
|
|
@ -2863,6 +2896,29 @@ paths:
|
|||
responses: {}
|
||||
tags:
|
||||
- lessons
|
||||
/api/v1/lessons/{id}/complete:
|
||||
post:
|
||||
description: Records lesson completion; may cascade to module, course, and program
|
||||
progress for the authenticated user. Learners must meet sequential prerequisites;
|
||||
staff bypass checks.
|
||||
parameters:
|
||||
- description: Lesson ID
|
||||
in: path
|
||||
name: id
|
||||
required: true
|
||||
type: integer
|
||||
responses:
|
||||
"200":
|
||||
description: OK
|
||||
schema:
|
||||
$ref: '#/definitions/domain.Response'
|
||||
"403":
|
||||
description: Forbidden
|
||||
schema:
|
||||
$ref: '#/definitions/domain.ErrorResponse'
|
||||
summary: Mark a lesson as completed
|
||||
tags:
|
||||
- lessons
|
||||
/api/v1/lessons/{id}/practices:
|
||||
get:
|
||||
parameters:
|
||||
|
|
@ -2874,6 +2930,24 @@ paths:
|
|||
responses: {}
|
||||
tags:
|
||||
- practices
|
||||
/api/v1/lms/progress:
|
||||
get:
|
||||
description: Returns completed lesson, module, course, and program IDs for the
|
||||
authenticated user (ordered by completion time, then id).
|
||||
produces:
|
||||
- application/json
|
||||
responses:
|
||||
"200":
|
||||
description: OK
|
||||
schema:
|
||||
$ref: '#/definitions/domain.Response'
|
||||
"500":
|
||||
description: Internal Server Error
|
||||
schema:
|
||||
$ref: '#/definitions/domain.ErrorResponse'
|
||||
summary: Get my LMS completion history
|
||||
tags:
|
||||
- lms
|
||||
/api/v1/logs:
|
||||
get:
|
||||
description: Fetches application logs from MongoDB with pagination, level filtering,
|
||||
|
|
@ -3829,6 +3903,51 @@ paths:
|
|||
summary: Create course
|
||||
tags:
|
||||
- courses
|
||||
/api/v1/programs/{id}/courses/reorder:
|
||||
put:
|
||||
parameters:
|
||||
- description: Program ID
|
||||
in: path
|
||||
name: id
|
||||
required: true
|
||||
type: integer
|
||||
- description: 'ordered_ids: every course id in this program, in the new order'
|
||||
in: body
|
||||
name: body
|
||||
required: true
|
||||
schema:
|
||||
$ref: '#/definitions/domain.ReorderIDsRequest'
|
||||
responses: {}
|
||||
summary: Reorder courses within a program
|
||||
tags:
|
||||
- courses
|
||||
/api/v1/programs/reorder:
|
||||
put:
|
||||
consumes:
|
||||
- application/json
|
||||
description: Sets learning order of programs. Body must list every current program
|
||||
id exactly once, in the desired order (index 0 = first in path).
|
||||
parameters:
|
||||
- description: 'New order: ordered_ids is the full set of program ids'
|
||||
in: body
|
||||
name: body
|
||||
required: true
|
||||
schema:
|
||||
$ref: '#/definitions/domain.ReorderIDsRequest'
|
||||
produces:
|
||||
- application/json
|
||||
responses:
|
||||
"200":
|
||||
description: OK
|
||||
schema:
|
||||
$ref: '#/definitions/domain.Response'
|
||||
"400":
|
||||
description: Bad Request
|
||||
schema:
|
||||
$ref: '#/definitions/domain.ErrorResponse'
|
||||
summary: Reorder all programs
|
||||
tags:
|
||||
- programs
|
||||
/api/v1/progress/practices/{id}/complete:
|
||||
post:
|
||||
description: Marks a practice question set as completed for the authenticated
|
||||
|
|
|
|||
|
|
@ -12,9 +12,20 @@ import (
|
|||
)
|
||||
|
||||
const CreateCourse = `-- name: CreateCourse :one
|
||||
INSERT INTO courses (program_id, name, description, thumbnail)
|
||||
VALUES ($1, $2, $3, $4)
|
||||
RETURNING id, program_id, name, description, thumbnail, created_at, updated_at
|
||||
INSERT INTO courses (program_id, name, description, thumbnail, sort_order)
|
||||
SELECT
|
||||
$1,
|
||||
$2,
|
||||
$3,
|
||||
$4,
|
||||
coalesce((
|
||||
SELECT
|
||||
max(c.sort_order)
|
||||
FROM courses c
|
||||
WHERE
|
||||
c.program_id = $1), 0) + 1
|
||||
RETURNING
|
||||
id, program_id, name, description, thumbnail, created_at, updated_at, sort_order
|
||||
`
|
||||
|
||||
type CreateCourseParams struct {
|
||||
|
|
@ -40,6 +51,7 @@ func (q *Queries) CreateCourse(ctx context.Context, arg CreateCourseParams) (Cou
|
|||
&i.Thumbnail,
|
||||
&i.CreatedAt,
|
||||
&i.UpdatedAt,
|
||||
&i.SortOrder,
|
||||
)
|
||||
return i, err
|
||||
}
|
||||
|
|
@ -55,7 +67,7 @@ func (q *Queries) DeleteCourse(ctx context.Context, id int64) error {
|
|||
}
|
||||
|
||||
const GetCourseByID = `-- name: GetCourseByID :one
|
||||
SELECT id, program_id, name, description, thumbnail, created_at, updated_at
|
||||
SELECT id, program_id, name, description, thumbnail, created_at, updated_at, sort_order
|
||||
FROM courses
|
||||
WHERE id = $1
|
||||
`
|
||||
|
|
@ -71,10 +83,42 @@ func (q *Queries) GetCourseByID(ctx context.Context, id int64) (Course, error) {
|
|||
&i.Thumbnail,
|
||||
&i.CreatedAt,
|
||||
&i.UpdatedAt,
|
||||
&i.SortOrder,
|
||||
)
|
||||
return i, err
|
||||
}
|
||||
|
||||
const ListCourseIDsByProgram = `-- name: ListCourseIDsByProgram :many
|
||||
SELECT
|
||||
c.id
|
||||
FROM
|
||||
courses AS c
|
||||
WHERE
|
||||
c.program_id = $1
|
||||
ORDER BY
|
||||
c.id
|
||||
`
|
||||
|
||||
func (q *Queries) ListCourseIDsByProgram(ctx context.Context, programID int64) ([]int64, error) {
|
||||
rows, err := q.db.Query(ctx, ListCourseIDsByProgram, programID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
var items []int64
|
||||
for rows.Next() {
|
||||
var id int64
|
||||
if err := rows.Scan(&id); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
items = append(items, id)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return items, nil
|
||||
}
|
||||
|
||||
const ListCoursesByProgramID = `-- name: ListCoursesByProgramID :many
|
||||
SELECT
|
||||
COUNT(*) OVER () AS total_count,
|
||||
|
|
@ -83,11 +127,16 @@ SELECT
|
|||
c.name,
|
||||
c.description,
|
||||
c.thumbnail,
|
||||
c.sort_order,
|
||||
c.created_at,
|
||||
c.updated_at
|
||||
FROM courses c
|
||||
WHERE c.program_id = $1
|
||||
ORDER BY c.created_at DESC
|
||||
FROM
|
||||
courses c
|
||||
WHERE
|
||||
c.program_id = $1
|
||||
ORDER BY
|
||||
c.sort_order ASC,
|
||||
c.id ASC
|
||||
LIMIT $2 OFFSET $3
|
||||
`
|
||||
|
||||
|
|
@ -104,6 +153,7 @@ type ListCoursesByProgramIDRow struct {
|
|||
Name string `json:"name"`
|
||||
Description pgtype.Text `json:"description"`
|
||||
Thumbnail pgtype.Text `json:"thumbnail"`
|
||||
SortOrder int32 `json:"sort_order"`
|
||||
CreatedAt pgtype.Timestamptz `json:"created_at"`
|
||||
UpdatedAt pgtype.Timestamptz `json:"updated_at"`
|
||||
}
|
||||
|
|
@ -124,6 +174,7 @@ func (q *Queries) ListCoursesByProgramID(ctx context.Context, arg ListCoursesByP
|
|||
&i.Name,
|
||||
&i.Description,
|
||||
&i.Thumbnail,
|
||||
&i.SortOrder,
|
||||
&i.CreatedAt,
|
||||
&i.UpdatedAt,
|
||||
); err != nil {
|
||||
|
|
@ -143,15 +194,19 @@ SET
|
|||
name = COALESCE($1::varchar, name),
|
||||
description = COALESCE($2::text, description),
|
||||
thumbnail = COALESCE($3::text, thumbnail),
|
||||
sort_order = coalesce($4::int, sort_order),
|
||||
updated_at = CURRENT_TIMESTAMP
|
||||
WHERE id = $4
|
||||
RETURNING id, program_id, name, description, thumbnail, created_at, updated_at
|
||||
WHERE
|
||||
id = $5
|
||||
RETURNING
|
||||
id, program_id, name, description, thumbnail, created_at, updated_at, sort_order
|
||||
`
|
||||
|
||||
type UpdateCourseParams struct {
|
||||
Name pgtype.Text `json:"name"`
|
||||
Description pgtype.Text `json:"description"`
|
||||
Thumbnail pgtype.Text `json:"thumbnail"`
|
||||
SortOrder pgtype.Int4 `json:"sort_order"`
|
||||
ID int64 `json:"id"`
|
||||
}
|
||||
|
||||
|
|
@ -160,6 +215,7 @@ func (q *Queries) UpdateCourse(ctx context.Context, arg UpdateCourseParams) (Cou
|
|||
arg.Name,
|
||||
arg.Description,
|
||||
arg.Thumbnail,
|
||||
arg.SortOrder,
|
||||
arg.ID,
|
||||
)
|
||||
var i Course
|
||||
|
|
@ -171,6 +227,7 @@ func (q *Queries) UpdateCourse(ctx context.Context, arg UpdateCourseParams) (Cou
|
|||
&i.Thumbnail,
|
||||
&i.CreatedAt,
|
||||
&i.UpdatedAt,
|
||||
&i.SortOrder,
|
||||
)
|
||||
return i, err
|
||||
}
|
||||
|
|
|
|||
|
|
@ -12,9 +12,21 @@ import (
|
|||
)
|
||||
|
||||
const CreateLesson = `-- name: CreateLesson :one
|
||||
INSERT INTO lessons (module_id, title, video_url, thumbnail, description)
|
||||
VALUES ($1, $2, $3, $4, $5)
|
||||
RETURNING id, module_id, title, video_url, thumbnail, description, created_at, updated_at
|
||||
INSERT INTO lessons (module_id, title, video_url, thumbnail, description, sort_order)
|
||||
SELECT
|
||||
$1,
|
||||
$2,
|
||||
$3,
|
||||
$4,
|
||||
$5,
|
||||
coalesce((
|
||||
SELECT
|
||||
max(l.sort_order)
|
||||
FROM lessons l
|
||||
WHERE
|
||||
l.module_id = $1), 0) + 1
|
||||
RETURNING
|
||||
id, module_id, title, video_url, thumbnail, description, created_at, updated_at, sort_order
|
||||
`
|
||||
|
||||
type CreateLessonParams struct {
|
||||
|
|
@ -43,6 +55,7 @@ func (q *Queries) CreateLesson(ctx context.Context, arg CreateLessonParams) (Les
|
|||
&i.Description,
|
||||
&i.CreatedAt,
|
||||
&i.UpdatedAt,
|
||||
&i.SortOrder,
|
||||
)
|
||||
return i, err
|
||||
}
|
||||
|
|
@ -58,7 +71,7 @@ func (q *Queries) DeleteLesson(ctx context.Context, id int64) error {
|
|||
}
|
||||
|
||||
const GetLessonByID = `-- name: GetLessonByID :one
|
||||
SELECT id, module_id, title, video_url, thumbnail, description, created_at, updated_at
|
||||
SELECT id, module_id, title, video_url, thumbnail, description, created_at, updated_at, sort_order
|
||||
FROM lessons
|
||||
WHERE id = $1
|
||||
`
|
||||
|
|
@ -75,6 +88,7 @@ func (q *Queries) GetLessonByID(ctx context.Context, id int64) (Lesson, error) {
|
|||
&i.Description,
|
||||
&i.CreatedAt,
|
||||
&i.UpdatedAt,
|
||||
&i.SortOrder,
|
||||
)
|
||||
return i, err
|
||||
}
|
||||
|
|
@ -88,12 +102,18 @@ SELECT
|
|||
l.video_url,
|
||||
l.thumbnail,
|
||||
l.description,
|
||||
l.sort_order,
|
||||
l.created_at,
|
||||
l.updated_at
|
||||
FROM lessons l
|
||||
WHERE l.module_id = $1
|
||||
ORDER BY l.created_at DESC
|
||||
LIMIT $2 OFFSET $3
|
||||
FROM
|
||||
lessons l
|
||||
WHERE
|
||||
l.module_id = $1
|
||||
ORDER BY
|
||||
l.sort_order ASC,
|
||||
l.id ASC
|
||||
LIMIT $2
|
||||
OFFSET $3
|
||||
`
|
||||
|
||||
type ListLessonsByModuleIDParams struct {
|
||||
|
|
@ -110,6 +130,7 @@ type ListLessonsByModuleIDRow struct {
|
|||
VideoUrl pgtype.Text `json:"video_url"`
|
||||
Thumbnail pgtype.Text `json:"thumbnail"`
|
||||
Description pgtype.Text `json:"description"`
|
||||
SortOrder int32 `json:"sort_order"`
|
||||
CreatedAt pgtype.Timestamptz `json:"created_at"`
|
||||
UpdatedAt pgtype.Timestamptz `json:"updated_at"`
|
||||
}
|
||||
|
|
@ -131,6 +152,7 @@ func (q *Queries) ListLessonsByModuleID(ctx context.Context, arg ListLessonsByMo
|
|||
&i.VideoUrl,
|
||||
&i.Thumbnail,
|
||||
&i.Description,
|
||||
&i.SortOrder,
|
||||
&i.CreatedAt,
|
||||
&i.UpdatedAt,
|
||||
); err != nil {
|
||||
|
|
@ -151,9 +173,12 @@ SET
|
|||
video_url = COALESCE($2::text, video_url),
|
||||
thumbnail = COALESCE($3::text, thumbnail),
|
||||
description = COALESCE($4::text, description),
|
||||
sort_order = coalesce($5::int, sort_order),
|
||||
updated_at = CURRENT_TIMESTAMP
|
||||
WHERE id = $5
|
||||
RETURNING id, module_id, title, video_url, thumbnail, description, created_at, updated_at
|
||||
WHERE
|
||||
id = $6
|
||||
RETURNING
|
||||
id, module_id, title, video_url, thumbnail, description, created_at, updated_at, sort_order
|
||||
`
|
||||
|
||||
type UpdateLessonParams struct {
|
||||
|
|
@ -161,6 +186,7 @@ type UpdateLessonParams struct {
|
|||
VideoUrl pgtype.Text `json:"video_url"`
|
||||
Thumbnail pgtype.Text `json:"thumbnail"`
|
||||
Description pgtype.Text `json:"description"`
|
||||
SortOrder pgtype.Int4 `json:"sort_order"`
|
||||
ID int64 `json:"id"`
|
||||
}
|
||||
|
||||
|
|
@ -170,6 +196,7 @@ func (q *Queries) UpdateLesson(ctx context.Context, arg UpdateLessonParams) (Les
|
|||
arg.VideoUrl,
|
||||
arg.Thumbnail,
|
||||
arg.Description,
|
||||
arg.SortOrder,
|
||||
arg.ID,
|
||||
)
|
||||
var i Lesson
|
||||
|
|
@ -182,6 +209,7 @@ func (q *Queries) UpdateLesson(ctx context.Context, arg UpdateLessonParams) (Les
|
|||
&i.Description,
|
||||
&i.CreatedAt,
|
||||
&i.UpdatedAt,
|
||||
&i.SortOrder,
|
||||
)
|
||||
return i, err
|
||||
}
|
||||
|
|
|
|||
|
|
@ -12,9 +12,21 @@ import (
|
|||
)
|
||||
|
||||
const CreateModule = `-- name: CreateModule :one
|
||||
INSERT INTO modules (program_id, course_id, name, description, icon)
|
||||
VALUES ($1, $2, $3, $4, $5)
|
||||
RETURNING id, program_id, course_id, name, description, icon, created_at, updated_at
|
||||
INSERT INTO modules (program_id, course_id, name, description, icon, sort_order)
|
||||
SELECT
|
||||
$1,
|
||||
$2,
|
||||
$3,
|
||||
$4,
|
||||
$5,
|
||||
coalesce((
|
||||
SELECT
|
||||
max(m.sort_order)
|
||||
FROM modules m
|
||||
WHERE
|
||||
m.course_id = $2), 0) + 1
|
||||
RETURNING
|
||||
id, program_id, course_id, name, description, icon, created_at, updated_at, sort_order
|
||||
`
|
||||
|
||||
type CreateModuleParams struct {
|
||||
|
|
@ -43,6 +55,7 @@ func (q *Queries) CreateModule(ctx context.Context, arg CreateModuleParams) (Mod
|
|||
&i.Icon,
|
||||
&i.CreatedAt,
|
||||
&i.UpdatedAt,
|
||||
&i.SortOrder,
|
||||
)
|
||||
return i, err
|
||||
}
|
||||
|
|
@ -58,7 +71,7 @@ func (q *Queries) DeleteModule(ctx context.Context, id int64) error {
|
|||
}
|
||||
|
||||
const GetModuleByID = `-- name: GetModuleByID :one
|
||||
SELECT id, program_id, course_id, name, description, icon, created_at, updated_at
|
||||
SELECT id, program_id, course_id, name, description, icon, created_at, updated_at, sort_order
|
||||
FROM modules
|
||||
WHERE id = $1
|
||||
`
|
||||
|
|
@ -75,10 +88,42 @@ func (q *Queries) GetModuleByID(ctx context.Context, id int64) (Module, error) {
|
|||
&i.Icon,
|
||||
&i.CreatedAt,
|
||||
&i.UpdatedAt,
|
||||
&i.SortOrder,
|
||||
)
|
||||
return i, err
|
||||
}
|
||||
|
||||
const ListModuleIDsByCourse = `-- name: ListModuleIDsByCourse :many
|
||||
SELECT
|
||||
m.id
|
||||
FROM
|
||||
modules AS m
|
||||
WHERE
|
||||
m.course_id = $1
|
||||
ORDER BY
|
||||
m.id
|
||||
`
|
||||
|
||||
func (q *Queries) ListModuleIDsByCourse(ctx context.Context, courseID int64) ([]int64, error) {
|
||||
rows, err := q.db.Query(ctx, ListModuleIDsByCourse, courseID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
var items []int64
|
||||
for rows.Next() {
|
||||
var id int64
|
||||
if err := rows.Scan(&id); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
items = append(items, id)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return items, nil
|
||||
}
|
||||
|
||||
const ListModulesByProgramAndCourse = `-- name: ListModulesByProgramAndCourse :many
|
||||
SELECT
|
||||
COUNT(*) OVER () AS total_count,
|
||||
|
|
@ -88,13 +133,19 @@ SELECT
|
|||
m.name,
|
||||
m.description,
|
||||
m.icon,
|
||||
m.sort_order,
|
||||
m.created_at,
|
||||
m.updated_at
|
||||
FROM modules m
|
||||
WHERE m.program_id = $1
|
||||
AND m.course_id = $2
|
||||
ORDER BY m.created_at DESC
|
||||
LIMIT $3 OFFSET $4
|
||||
FROM
|
||||
modules m
|
||||
WHERE
|
||||
m.program_id = $1
|
||||
AND m.course_id = $2
|
||||
ORDER BY
|
||||
m.sort_order ASC,
|
||||
m.id ASC
|
||||
LIMIT $3
|
||||
OFFSET $4
|
||||
`
|
||||
|
||||
type ListModulesByProgramAndCourseParams struct {
|
||||
|
|
@ -112,6 +163,7 @@ type ListModulesByProgramAndCourseRow struct {
|
|||
Name string `json:"name"`
|
||||
Description pgtype.Text `json:"description"`
|
||||
Icon pgtype.Text `json:"icon"`
|
||||
SortOrder int32 `json:"sort_order"`
|
||||
CreatedAt pgtype.Timestamptz `json:"created_at"`
|
||||
UpdatedAt pgtype.Timestamptz `json:"updated_at"`
|
||||
}
|
||||
|
|
@ -138,6 +190,7 @@ func (q *Queries) ListModulesByProgramAndCourse(ctx context.Context, arg ListMod
|
|||
&i.Name,
|
||||
&i.Description,
|
||||
&i.Icon,
|
||||
&i.SortOrder,
|
||||
&i.CreatedAt,
|
||||
&i.UpdatedAt,
|
||||
); err != nil {
|
||||
|
|
@ -157,15 +210,19 @@ SET
|
|||
name = COALESCE($1::varchar, name),
|
||||
description = COALESCE($2::text, description),
|
||||
icon = COALESCE($3::text, icon),
|
||||
sort_order = coalesce($4::int, sort_order),
|
||||
updated_at = CURRENT_TIMESTAMP
|
||||
WHERE id = $4
|
||||
RETURNING id, program_id, course_id, name, description, icon, created_at, updated_at
|
||||
WHERE
|
||||
id = $5
|
||||
RETURNING
|
||||
id, program_id, course_id, name, description, icon, created_at, updated_at, sort_order
|
||||
`
|
||||
|
||||
type UpdateModuleParams struct {
|
||||
Name pgtype.Text `json:"name"`
|
||||
Description pgtype.Text `json:"description"`
|
||||
Icon pgtype.Text `json:"icon"`
|
||||
SortOrder pgtype.Int4 `json:"sort_order"`
|
||||
ID int64 `json:"id"`
|
||||
}
|
||||
|
||||
|
|
@ -174,6 +231,7 @@ func (q *Queries) UpdateModule(ctx context.Context, arg UpdateModuleParams) (Mod
|
|||
arg.Name,
|
||||
arg.Description,
|
||||
arg.Icon,
|
||||
arg.SortOrder,
|
||||
arg.ID,
|
||||
)
|
||||
var i Module
|
||||
|
|
@ -186,6 +244,7 @@ func (q *Queries) UpdateModule(ctx context.Context, arg UpdateModuleParams) (Mod
|
|||
&i.Icon,
|
||||
&i.CreatedAt,
|
||||
&i.UpdatedAt,
|
||||
&i.SortOrder,
|
||||
)
|
||||
return i, err
|
||||
}
|
||||
|
|
|
|||
613
gen/db/lms_progress.sql.go
Normal file
613
gen/db/lms_progress.sql.go
Normal 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
|
||||
}
|
||||
|
|
@ -30,6 +30,7 @@ type Course struct {
|
|||
Thumbnail pgtype.Text `json:"thumbnail"`
|
||||
CreatedAt pgtype.Timestamptz `json:"created_at"`
|
||||
UpdatedAt pgtype.Timestamptz `json:"updated_at"`
|
||||
SortOrder int32 `json:"sort_order"`
|
||||
}
|
||||
|
||||
type Device struct {
|
||||
|
|
@ -58,6 +59,7 @@ type Lesson struct {
|
|||
Description pgtype.Text `json:"description"`
|
||||
CreatedAt pgtype.Timestamptz `json:"created_at"`
|
||||
UpdatedAt pgtype.Timestamptz `json:"updated_at"`
|
||||
SortOrder int32 `json:"sort_order"`
|
||||
}
|
||||
|
||||
type LevelToSubCourse struct {
|
||||
|
|
@ -80,6 +82,30 @@ type LmsPractice struct {
|
|||
UpdatedAt pgtype.Timestamptz `json:"updated_at"`
|
||||
}
|
||||
|
||||
type LmsUserCourseProgress struct {
|
||||
UserID int64 `json:"user_id"`
|
||||
CourseID int64 `json:"course_id"`
|
||||
CompletedAt pgtype.Timestamptz `json:"completed_at"`
|
||||
}
|
||||
|
||||
type LmsUserLessonProgress struct {
|
||||
UserID int64 `json:"user_id"`
|
||||
LessonID int64 `json:"lesson_id"`
|
||||
CompletedAt pgtype.Timestamptz `json:"completed_at"`
|
||||
}
|
||||
|
||||
type LmsUserModuleProgress struct {
|
||||
UserID int64 `json:"user_id"`
|
||||
ModuleID int64 `json:"module_id"`
|
||||
CompletedAt pgtype.Timestamptz `json:"completed_at"`
|
||||
}
|
||||
|
||||
type LmsUserProgramProgress struct {
|
||||
UserID int64 `json:"user_id"`
|
||||
ProgramID int64 `json:"program_id"`
|
||||
CompletedAt pgtype.Timestamptz `json:"completed_at"`
|
||||
}
|
||||
|
||||
type Module struct {
|
||||
ID int64 `json:"id"`
|
||||
ProgramID int64 `json:"program_id"`
|
||||
|
|
@ -89,6 +115,7 @@ type Module struct {
|
|||
Icon pgtype.Text `json:"icon"`
|
||||
CreatedAt pgtype.Timestamptz `json:"created_at"`
|
||||
UpdatedAt pgtype.Timestamptz `json:"updated_at"`
|
||||
SortOrder int32 `json:"sort_order"`
|
||||
}
|
||||
|
||||
type ModuleToSubCourse struct {
|
||||
|
|
@ -159,6 +186,7 @@ type Program struct {
|
|||
Thumbnail pgtype.Text `json:"thumbnail"`
|
||||
CreatedAt pgtype.Timestamptz `json:"created_at"`
|
||||
UpdatedAt pgtype.Timestamptz `json:"updated_at"`
|
||||
SortOrder int32 `json:"sort_order"`
|
||||
}
|
||||
|
||||
type Question struct {
|
||||
|
|
|
|||
|
|
@ -12,9 +12,17 @@ import (
|
|||
)
|
||||
|
||||
const CreateProgram = `-- name: CreateProgram :one
|
||||
INSERT INTO programs (name, description, thumbnail)
|
||||
VALUES ($1, $2, $3)
|
||||
RETURNING id, name, description, thumbnail, created_at, updated_at
|
||||
INSERT INTO programs (name, description, thumbnail, sort_order)
|
||||
SELECT
|
||||
$1,
|
||||
$2,
|
||||
$3,
|
||||
coalesce((
|
||||
SELECT
|
||||
max(p.sort_order)
|
||||
FROM programs AS p), 0) + 1
|
||||
RETURNING
|
||||
id, name, description, thumbnail, created_at, updated_at, sort_order
|
||||
`
|
||||
|
||||
type CreateProgramParams struct {
|
||||
|
|
@ -33,6 +41,7 @@ func (q *Queries) CreateProgram(ctx context.Context, arg CreateProgramParams) (P
|
|||
&i.Thumbnail,
|
||||
&i.CreatedAt,
|
||||
&i.UpdatedAt,
|
||||
&i.SortOrder,
|
||||
)
|
||||
return i, err
|
||||
}
|
||||
|
|
@ -48,7 +57,7 @@ func (q *Queries) DeleteProgram(ctx context.Context, id int64) error {
|
|||
}
|
||||
|
||||
const GetProgramByID = `-- name: GetProgramByID :one
|
||||
SELECT id, name, description, thumbnail, created_at, updated_at
|
||||
SELECT id, name, description, thumbnail, created_at, updated_at, sort_order
|
||||
FROM programs
|
||||
WHERE id = $1
|
||||
`
|
||||
|
|
@ -63,10 +72,40 @@ func (q *Queries) GetProgramByID(ctx context.Context, id int64) (Program, error)
|
|||
&i.Thumbnail,
|
||||
&i.CreatedAt,
|
||||
&i.UpdatedAt,
|
||||
&i.SortOrder,
|
||||
)
|
||||
return i, err
|
||||
}
|
||||
|
||||
const ListAllProgramIDs = `-- name: ListAllProgramIDs :many
|
||||
SELECT
|
||||
p.id
|
||||
FROM
|
||||
programs AS p
|
||||
ORDER BY
|
||||
p.id
|
||||
`
|
||||
|
||||
func (q *Queries) ListAllProgramIDs(ctx context.Context) ([]int64, error) {
|
||||
rows, err := q.db.Query(ctx, ListAllProgramIDs)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
var items []int64
|
||||
for rows.Next() {
|
||||
var id int64
|
||||
if err := rows.Scan(&id); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
items = append(items, id)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return items, nil
|
||||
}
|
||||
|
||||
const ListPrograms = `-- name: ListPrograms :many
|
||||
SELECT
|
||||
COUNT(*) OVER () AS total_count,
|
||||
|
|
@ -74,10 +113,11 @@ SELECT
|
|||
p.name,
|
||||
p.description,
|
||||
p.thumbnail,
|
||||
p.sort_order,
|
||||
p.created_at,
|
||||
p.updated_at
|
||||
FROM programs p
|
||||
ORDER BY p.created_at DESC
|
||||
ORDER BY p.sort_order ASC, p.id ASC
|
||||
LIMIT $1 OFFSET $2
|
||||
`
|
||||
|
||||
|
|
@ -92,6 +132,7 @@ type ListProgramsRow struct {
|
|||
Name string `json:"name"`
|
||||
Description pgtype.Text `json:"description"`
|
||||
Thumbnail pgtype.Text `json:"thumbnail"`
|
||||
SortOrder int32 `json:"sort_order"`
|
||||
CreatedAt pgtype.Timestamptz `json:"created_at"`
|
||||
UpdatedAt pgtype.Timestamptz `json:"updated_at"`
|
||||
}
|
||||
|
|
@ -111,6 +152,7 @@ func (q *Queries) ListPrograms(ctx context.Context, arg ListProgramsParams) ([]L
|
|||
&i.Name,
|
||||
&i.Description,
|
||||
&i.Thumbnail,
|
||||
&i.SortOrder,
|
||||
&i.CreatedAt,
|
||||
&i.UpdatedAt,
|
||||
); err != nil {
|
||||
|
|
@ -130,15 +172,19 @@ SET
|
|||
name = COALESCE($1::varchar, name),
|
||||
description = COALESCE($2::text, description),
|
||||
thumbnail = COALESCE($3::text, thumbnail),
|
||||
sort_order = coalesce($4::int, sort_order),
|
||||
updated_at = CURRENT_TIMESTAMP
|
||||
WHERE id = $4
|
||||
RETURNING id, name, description, thumbnail, created_at, updated_at
|
||||
WHERE
|
||||
id = $5
|
||||
RETURNING
|
||||
id, name, description, thumbnail, created_at, updated_at, sort_order
|
||||
`
|
||||
|
||||
type UpdateProgramParams struct {
|
||||
Name pgtype.Text `json:"name"`
|
||||
Description pgtype.Text `json:"description"`
|
||||
Thumbnail pgtype.Text `json:"thumbnail"`
|
||||
SortOrder pgtype.Int4 `json:"sort_order"`
|
||||
ID int64 `json:"id"`
|
||||
}
|
||||
|
||||
|
|
@ -147,6 +193,7 @@ func (q *Queries) UpdateProgram(ctx context.Context, arg UpdateProgramParams) (P
|
|||
arg.Name,
|
||||
arg.Description,
|
||||
arg.Thumbnail,
|
||||
arg.SortOrder,
|
||||
arg.ID,
|
||||
)
|
||||
var i Program
|
||||
|
|
@ -157,6 +204,7 @@ func (q *Queries) UpdateProgram(ctx context.Context, arg UpdateProgramParams) (P
|
|||
&i.Thumbnail,
|
||||
&i.CreatedAt,
|
||||
&i.UpdatedAt,
|
||||
&i.SortOrder,
|
||||
)
|
||||
return i, err
|
||||
}
|
||||
|
|
|
|||
|
|
@ -13,8 +13,10 @@ type Course struct {
|
|||
Name string `json:"name"`
|
||||
Description *string `json:"description,omitempty"`
|
||||
Thumbnail *string `json:"thumbnail,omitempty"`
|
||||
SortOrder int `json:"sort_order"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt *time.Time `json:"updated_at,omitempty"`
|
||||
Access *LMSEntityAccess `json:"access,omitempty"`
|
||||
}
|
||||
|
||||
type CreateCourseInput struct {
|
||||
|
|
@ -27,4 +29,5 @@ type UpdateCourseInput struct {
|
|||
Name *string `json:"name,omitempty"`
|
||||
Description *string `json:"description,omitempty"`
|
||||
Thumbnail *string `json:"thumbnail,omitempty"`
|
||||
SortOrder *int `json:"sort_order,omitempty"`
|
||||
}
|
||||
|
|
|
|||
|
|
@ -10,8 +10,10 @@ type Lesson struct {
|
|||
VideoURL *string `json:"video_url,omitempty"`
|
||||
Thumbnail *string `json:"thumbnail,omitempty"`
|
||||
Description *string `json:"description,omitempty"`
|
||||
SortOrder int `json:"sort_order"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt *time.Time `json:"updated_at,omitempty"`
|
||||
Access *LMSEntityAccess `json:"access,omitempty"`
|
||||
}
|
||||
|
||||
type CreateLessonInput struct {
|
||||
|
|
@ -26,4 +28,5 @@ type UpdateLessonInput struct {
|
|||
VideoURL *string `json:"video_url,omitempty"`
|
||||
Thumbnail *string `json:"thumbnail,omitempty"`
|
||||
Description *string `json:"description,omitempty"`
|
||||
SortOrder *int `json:"sort_order,omitempty"`
|
||||
}
|
||||
|
|
|
|||
22
internal/domain/lms_access.go
Normal file
22
internal/domain/lms_access.go
Normal 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 entity’s scope (lesson: 0 or 1 of 1).
|
||||
type LMSEntityAccess struct {
|
||||
IsAccessible bool `json:"is_accessible"`
|
||||
IsCompleted bool `json:"is_completed"`
|
||||
Reason string `json:"reason,omitempty"`
|
||||
CompletedCount int `json:"completed_count"`
|
||||
TotalCount int `json:"total_count"`
|
||||
ProgressPercent int `json:"progress_percent"`
|
||||
}
|
||||
|
||||
// LMSUserProgress lists entity IDs the authenticated user has fully completed
|
||||
// (lessons as marked complete; module/course/program when rollup conditions were met).
|
||||
type LMSUserProgress struct {
|
||||
LessonIDs []int64 `json:"lesson_ids"`
|
||||
ModuleIDs []int64 `json:"module_ids"`
|
||||
CourseIDs []int64 `json:"course_ids"`
|
||||
ProgramIDs []int64 `json:"program_ids"`
|
||||
}
|
||||
|
|
@ -10,8 +10,10 @@ type Module struct {
|
|||
Name string `json:"name"`
|
||||
Description *string `json:"description,omitempty"`
|
||||
Icon *string `json:"icon,omitempty"`
|
||||
SortOrder int `json:"sort_order"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt *time.Time `json:"updated_at,omitempty"`
|
||||
Access *LMSEntityAccess `json:"access,omitempty"`
|
||||
}
|
||||
|
||||
type CreateModuleInput struct {
|
||||
|
|
@ -24,4 +26,5 @@ type UpdateModuleInput struct {
|
|||
Name *string `json:"name,omitempty"`
|
||||
Description *string `json:"description,omitempty"`
|
||||
Icon *string `json:"icon,omitempty"`
|
||||
SortOrder *int `json:"sort_order,omitempty"`
|
||||
}
|
||||
|
|
|
|||
|
|
@ -8,8 +8,10 @@ type Program struct {
|
|||
Name string `json:"name"`
|
||||
Description *string `json:"description,omitempty"`
|
||||
Thumbnail *string `json:"thumbnail,omitempty"`
|
||||
SortOrder int `json:"sort_order"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt *time.Time `json:"updated_at,omitempty"`
|
||||
Access *LMSEntityAccess `json:"access,omitempty"`
|
||||
}
|
||||
|
||||
type CreateProgramInput struct {
|
||||
|
|
@ -22,4 +24,5 @@ type UpdateProgramInput struct {
|
|||
Name *string `json:"name,omitempty"`
|
||||
Description *string `json:"description,omitempty"`
|
||||
Thumbnail *string `json:"thumbnail,omitempty"`
|
||||
SortOrder *int `json:"sort_order,omitempty"`
|
||||
}
|
||||
|
|
|
|||
46
internal/domain/reorder.go
Normal file
46
internal/domain/reorder.go
Normal 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
|
||||
}
|
||||
|
|
@ -9,6 +9,8 @@ type CourseStore interface {
|
|||
CreateCourse(ctx context.Context, programID int64, input domain.CreateCourseInput) (domain.Course, error)
|
||||
GetCourseByID(ctx context.Context, id int64) (domain.Course, error)
|
||||
ListCoursesByProgramID(ctx context.Context, programID int64, limit, offset int32) ([]domain.Course, int64, error)
|
||||
ListCourseIDsByProgram(ctx context.Context, programID int64) ([]int64, error)
|
||||
ReorderCoursesInProgram(ctx context.Context, programID int64, orderedIDs []int64) error
|
||||
UpdateCourse(ctx context.Context, id int64, input domain.UpdateCourseInput) (domain.Course, error)
|
||||
DeleteCourse(ctx context.Context, id int64) error
|
||||
}
|
||||
|
|
|
|||
|
|
@ -9,6 +9,8 @@ type ModuleStore interface {
|
|||
CreateModule(ctx context.Context, programID, courseID int64, input domain.CreateModuleInput) (domain.Module, error)
|
||||
GetModuleByID(ctx context.Context, id int64) (domain.Module, error)
|
||||
ListModulesByProgramAndCourse(ctx context.Context, programID, courseID int64, limit, offset int32) ([]domain.Module, int64, error)
|
||||
ListModuleIDsByCourse(ctx context.Context, courseID int64) ([]int64, error)
|
||||
ReorderModulesInCourse(ctx context.Context, courseID int64, orderedIDs []int64) error
|
||||
UpdateModule(ctx context.Context, id int64, input domain.UpdateModuleInput) (domain.Module, error)
|
||||
DeleteModule(ctx context.Context, id int64) error
|
||||
}
|
||||
|
|
|
|||
|
|
@ -9,6 +9,8 @@ type ProgramStore interface {
|
|||
CreateProgram(ctx context.Context, input domain.CreateProgramInput) (domain.Program, error)
|
||||
GetProgramByID(ctx context.Context, id int64) (domain.Program, error)
|
||||
ListPrograms(ctx context.Context, limit, offset int32) ([]domain.Program, int64, error)
|
||||
ListAllProgramIDs(ctx context.Context) ([]int64, error)
|
||||
ReorderPrograms(ctx context.Context, orderedIDs []int64) error
|
||||
UpdateProgram(ctx context.Context, id int64, input domain.UpdateProgramInput) (domain.Program, error)
|
||||
DeleteProgram(ctx context.Context, id int64) error
|
||||
}
|
||||
|
|
|
|||
87
internal/repository/lms_access.go
Normal file
87
internal/repository/lms_access.go
Normal 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
|
||||
}
|
||||
|
|
@ -24,6 +24,7 @@ func courseToDomain(c dbgen.Course) domain.Course {
|
|||
t := c.UpdatedAt.Time
|
||||
out.UpdatedAt = &t
|
||||
}
|
||||
out.SortOrder = int(c.SortOrder)
|
||||
return out
|
||||
}
|
||||
|
||||
|
|
@ -40,6 +41,10 @@ func (s *Store) CreateCourse(ctx context.Context, programID int64, input domain.
|
|||
return courseToDomain(c), nil
|
||||
}
|
||||
|
||||
func (s *Store) ListCourseIDsByProgram(ctx context.Context, programID int64) ([]int64, error) {
|
||||
return s.queries.ListCourseIDsByProgram(ctx, programID)
|
||||
}
|
||||
|
||||
func (s *Store) GetCourseByID(ctx context.Context, id int64) (domain.Course, error) {
|
||||
c, err := s.queries.GetCourseByID(ctx, id)
|
||||
if err != nil {
|
||||
|
|
@ -77,6 +82,7 @@ func (s *Store) ListCoursesByProgramID(ctx context.Context, programID int64, lim
|
|||
Thumbnail: r.Thumbnail,
|
||||
CreatedAt: r.CreatedAt,
|
||||
UpdatedAt: r.UpdatedAt,
|
||||
SortOrder: r.SortOrder,
|
||||
}))
|
||||
}
|
||||
return out, total, nil
|
||||
|
|
@ -94,6 +100,7 @@ func (s *Store) UpdateCourse(ctx context.Context, id int64, input domain.UpdateC
|
|||
Name: nameText,
|
||||
Description: optionalTextUpdate(input.Description),
|
||||
Thumbnail: optionalTextUpdate(input.Thumbnail),
|
||||
SortOrder: optionalInt4Update(input.SortOrder),
|
||||
})
|
||||
if err != nil {
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
|
|
|
|||
|
|
@ -25,6 +25,7 @@ func lessonToDomain(l dbgen.Lesson) domain.Lesson {
|
|||
t := l.UpdatedAt.Time
|
||||
out.UpdatedAt = &t
|
||||
}
|
||||
out.SortOrder = int(l.SortOrder)
|
||||
return out
|
||||
}
|
||||
|
||||
|
|
@ -80,6 +81,7 @@ func (s *Store) ListLessonsByModuleID(ctx context.Context, moduleID int64, limit
|
|||
Description: r.Description,
|
||||
CreatedAt: r.CreatedAt,
|
||||
UpdatedAt: r.UpdatedAt,
|
||||
SortOrder: r.SortOrder,
|
||||
}))
|
||||
}
|
||||
return out, total, nil
|
||||
|
|
@ -98,6 +100,7 @@ func (s *Store) UpdateLesson(ctx context.Context, id int64, input domain.UpdateL
|
|||
VideoUrl: optionalTextUpdate(input.VideoURL),
|
||||
Thumbnail: optionalTextUpdate(input.Thumbnail),
|
||||
Description: optionalTextUpdate(input.Description),
|
||||
SortOrder: optionalInt4Update(input.SortOrder),
|
||||
})
|
||||
if err != nil {
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
|
|
|
|||
|
|
@ -25,6 +25,7 @@ func moduleToDomain(m dbgen.Module) domain.Module {
|
|||
t := m.UpdatedAt.Time
|
||||
out.UpdatedAt = &t
|
||||
}
|
||||
out.SortOrder = int(m.SortOrder)
|
||||
return out
|
||||
}
|
||||
|
||||
|
|
@ -42,6 +43,10 @@ func (s *Store) CreateModule(ctx context.Context, programID, courseID int64, inp
|
|||
return moduleToDomain(m), nil
|
||||
}
|
||||
|
||||
func (s *Store) ListModuleIDsByCourse(ctx context.Context, courseID int64) ([]int64, error) {
|
||||
return s.queries.ListModuleIDsByCourse(ctx, courseID)
|
||||
}
|
||||
|
||||
func (s *Store) GetModuleByID(ctx context.Context, id int64) (domain.Module, error) {
|
||||
m, err := s.queries.GetModuleByID(ctx, id)
|
||||
if err != nil {
|
||||
|
|
@ -81,6 +86,7 @@ func (s *Store) ListModulesByProgramAndCourse(ctx context.Context, programID, co
|
|||
Icon: r.Icon,
|
||||
CreatedAt: r.CreatedAt,
|
||||
UpdatedAt: r.UpdatedAt,
|
||||
SortOrder: r.SortOrder,
|
||||
}))
|
||||
}
|
||||
return out, total, nil
|
||||
|
|
@ -98,6 +104,7 @@ func (s *Store) UpdateModule(ctx context.Context, id int64, input domain.UpdateM
|
|||
Name: nameText,
|
||||
Description: optionalTextUpdate(input.Description),
|
||||
Icon: optionalTextUpdate(input.Icon),
|
||||
SortOrder: optionalInt4Update(input.SortOrder),
|
||||
})
|
||||
if err != nil {
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
|
|
|
|||
86
internal/repository/lms_progress_tx.go
Normal file
86
internal/repository/lms_progress_tx.go
Normal 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
|
||||
}
|
||||
73
internal/repository/lms_reorder.go
Normal file
73
internal/repository/lms_reorder.go
Normal 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)
|
||||
}
|
||||
33
internal/repository/lms_user_progress_snapshot.go
Normal file
33
internal/repository/lms_user_progress_snapshot.go
Normal 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
|
||||
}
|
||||
|
|
@ -23,6 +23,7 @@ func programToDomain(p dbgen.Program) domain.Program {
|
|||
t := p.UpdatedAt.Time
|
||||
out.UpdatedAt = &t
|
||||
}
|
||||
out.SortOrder = int(p.SortOrder)
|
||||
return out
|
||||
}
|
||||
|
||||
|
|
@ -38,6 +39,10 @@ func (s *Store) CreateProgram(ctx context.Context, input domain.CreateProgramInp
|
|||
return programToDomain(p), nil
|
||||
}
|
||||
|
||||
func (s *Store) ListAllProgramIDs(ctx context.Context) ([]int64, error) {
|
||||
return s.queries.ListAllProgramIDs(ctx)
|
||||
}
|
||||
|
||||
func (s *Store) GetProgramByID(ctx context.Context, id int64) (domain.Program, error) {
|
||||
p, err := s.queries.GetProgramByID(ctx, id)
|
||||
if err != nil {
|
||||
|
|
@ -73,6 +78,7 @@ func (s *Store) ListPrograms(ctx context.Context, limit, offset int32) ([]domain
|
|||
Thumbnail: r.Thumbnail,
|
||||
CreatedAt: r.CreatedAt,
|
||||
UpdatedAt: r.UpdatedAt,
|
||||
SortOrder: r.SortOrder,
|
||||
}))
|
||||
}
|
||||
return out, total, nil
|
||||
|
|
@ -85,6 +91,13 @@ func optionalTextUpdate(val *string) pgtype.Text {
|
|||
return pgtype.Text{String: *val, Valid: true}
|
||||
}
|
||||
|
||||
func optionalInt4Update(v *int) pgtype.Int4 {
|
||||
if v == nil {
|
||||
return pgtype.Int4{Valid: false}
|
||||
}
|
||||
return pgtype.Int4{Int32: int32(*v), Valid: true}
|
||||
}
|
||||
|
||||
func (s *Store) UpdateProgram(ctx context.Context, id int64, input domain.UpdateProgramInput) (domain.Program, error) {
|
||||
var nameText pgtype.Text
|
||||
if input.Name != nil {
|
||||
|
|
@ -97,6 +110,7 @@ func (s *Store) UpdateProgram(ctx context.Context, id int64, input domain.Update
|
|||
Name: nameText,
|
||||
Description: optionalTextUpdate(input.Description),
|
||||
Thumbnail: optionalTextUpdate(input.Thumbnail),
|
||||
SortOrder: optionalInt4Update(input.SortOrder),
|
||||
})
|
||||
if err != nil {
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
|
|
|
|||
|
|
@ -81,3 +81,25 @@ func (s *Service) Delete(ctx context.Context, id int64) error {
|
|||
}
|
||||
return s.courses.DeleteCourse(ctx, id)
|
||||
}
|
||||
|
||||
// ReorderInProgram sets course sort_order under a program. ordered must list every course id in that program
|
||||
// exactly once (e.g. from GET /programs/{id}/courses) in the desired order.
|
||||
func (s *Service) ReorderInProgram(ctx context.Context, programID int64, ordered []int64) error {
|
||||
if _, err := s.programs.GetProgramByID(ctx, programID); err != nil {
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return programs.ErrProgramNotFound
|
||||
}
|
||||
return err
|
||||
}
|
||||
expected, err := s.courses.ListCourseIDsByProgram(ctx, programID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := domain.ValidateReorderPermutation(ordered, expected); err != nil {
|
||||
return err
|
||||
}
|
||||
if len(ordered) == 0 {
|
||||
return nil
|
||||
}
|
||||
return s.courses.ReorderCoursesInProgram(ctx, programID, ordered)
|
||||
}
|
||||
|
|
|
|||
279
internal/services/lmsprogress/service.go
Normal file
279
internal/services/lmsprogress/service.go
Normal 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 0–100; completed
|
||||
// and total are aligned with isCompleted when the entity is fully done.
|
||||
func lmsProgressCounts(completed, total int32, isCompleted bool) (c, t, pct int) {
|
||||
c, t = int(completed), int(total)
|
||||
if t < 0 {
|
||||
t = 0
|
||||
}
|
||||
if c < 0 {
|
||||
c = 0
|
||||
}
|
||||
if isCompleted {
|
||||
if t > 0 {
|
||||
return t, t, 100
|
||||
}
|
||||
return c, t, 100
|
||||
}
|
||||
if t == 0 {
|
||||
return 0, 0, 0
|
||||
}
|
||||
pct = (c * 100) / t
|
||||
if pct > 100 {
|
||||
pct = 100
|
||||
}
|
||||
return c, t, pct
|
||||
}
|
||||
|
||||
func reasonIf(ok bool, r string) string {
|
||||
if ok {
|
||||
return ""
|
||||
}
|
||||
return r
|
||||
}
|
||||
|
|
@ -90,3 +90,22 @@ func (s *Service) Delete(ctx context.Context, id int64) error {
|
|||
}
|
||||
return s.modules.DeleteModule(ctx, id)
|
||||
}
|
||||
|
||||
// ReorderInCourse sets module sort_order under a course. ordered must list every module id in that course
|
||||
// exactly once (e.g. from GET /courses/{id}/modules) in the desired order.
|
||||
func (s *Service) ReorderInCourse(ctx context.Context, courseID int64, ordered []int64) error {
|
||||
if _, err := s.getCourseOrErr(ctx, courseID); err != nil {
|
||||
return err
|
||||
}
|
||||
expected, err := s.modules.ListModuleIDsByCourse(ctx, courseID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := domain.ValidateReorderPermutation(ordered, expected); err != nil {
|
||||
return err
|
||||
}
|
||||
if len(ordered) == 0 {
|
||||
return nil
|
||||
}
|
||||
return s.modules.ReorderModulesInCourse(ctx, courseID, ordered)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -67,3 +67,19 @@ func (s *Service) Delete(ctx context.Context, id int64) error {
|
|||
}
|
||||
return s.store.DeleteProgram(ctx, id)
|
||||
}
|
||||
|
||||
// Reorder sets program sort_order from ordered ids (1..n). ordered must list every program id exactly once
|
||||
// (e.g. from GET /programs) in the desired display / learning order.
|
||||
func (s *Service) Reorder(ctx context.Context, ordered []int64) error {
|
||||
expected, err := s.store.ListAllProgramIDs(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := domain.ValidateReorderPermutation(ordered, expected); err != nil {
|
||||
return err
|
||||
}
|
||||
if len(ordered) == 0 {
|
||||
return nil
|
||||
}
|
||||
return s.store.ReorderPrograms(ctx, ordered)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -27,6 +27,7 @@ var AllPermissions = []domain.PermissionSeed{
|
|||
{Key: "programs.get", Name: "Get Program", Description: "Get a program by ID", GroupName: "Programs"},
|
||||
{Key: "programs.update", Name: "Update Program", Description: "Update a program", GroupName: "Programs"},
|
||||
{Key: "programs.delete", Name: "Delete Program", Description: "Delete a program", GroupName: "Programs"},
|
||||
{Key: "programs.reorder", Name: "Reorder Programs", Description: "Set program order for the learning path (batch)", GroupName: "Programs"},
|
||||
|
||||
// Modules (LMS, under a course)
|
||||
{Key: "modules.create", Name: "Create Module", Description: "Create a module in a course", GroupName: "Modules"},
|
||||
|
|
@ -34,14 +35,19 @@ var AllPermissions = []domain.PermissionSeed{
|
|||
{Key: "modules.list_by_course", Name: "List Modules by Course", Description: "List modules under a program and course", GroupName: "Modules"},
|
||||
{Key: "modules.update", Name: "Update Module", Description: "Update a module", GroupName: "Modules"},
|
||||
{Key: "modules.delete", Name: "Delete Module", Description: "Delete a module", GroupName: "Modules"},
|
||||
{Key: "modules.reorder", Name: "Reorder Modules", Description: "Set module order within a course (batch)", GroupName: "Modules"},
|
||||
|
||||
// Lessons (LMS, under a module)
|
||||
{Key: "lessons.create", Name: "Create Lesson", Description: "Create a lesson in a module", GroupName: "Lessons"},
|
||||
{Key: "lessons.get", Name: "Get Lesson", Description: "Get a lesson by ID", GroupName: "Lessons"},
|
||||
{Key: "lessons.complete", Name: "Complete Lesson", Description: "Mark a lesson as complete (sequential learning progress)", GroupName: "Lessons"},
|
||||
{Key: "lessons.list_by_module", Name: "List Lessons by Module", Description: "List lessons under a module", GroupName: "Lessons"},
|
||||
{Key: "lessons.update", Name: "Update Lesson", Description: "Update a lesson", GroupName: "Lessons"},
|
||||
{Key: "lessons.delete", Name: "Delete Lesson", Description: "Delete a lesson", GroupName: "Lessons"},
|
||||
|
||||
// LMS progress (current user)
|
||||
{Key: "lms.get_my_progress", Name: "Get My LMS Progress", Description: "List completed lesson, module, course, and program IDs for the authenticated user", GroupName: "LMS"},
|
||||
|
||||
// Practices (LMS, scoped to course, module, or lesson)
|
||||
{Key: "practices.create", Name: "Create Practice", Description: "Create a practice", GroupName: "Practices"},
|
||||
{Key: "practices.get", Name: "Get Practice", Description: "Get a practice by ID", GroupName: "Practices"},
|
||||
|
|
@ -273,13 +279,14 @@ var DefaultRolePermissions = map[string][]string{
|
|||
"learning_tree.get", "practices.reorder",
|
||||
|
||||
// Programs
|
||||
"programs.create", "programs.list", "programs.get", "programs.update", "programs.delete",
|
||||
"programs.create", "programs.list", "programs.get", "programs.update", "programs.delete", "programs.reorder",
|
||||
"lms.get_my_progress",
|
||||
|
||||
// Modules
|
||||
"modules.create", "modules.get", "modules.list_by_course", "modules.update", "modules.delete",
|
||||
"modules.create", "modules.get", "modules.list_by_course", "modules.update", "modules.delete", "modules.reorder",
|
||||
|
||||
// Lessons
|
||||
"lessons.create", "lessons.get", "lessons.list_by_module", "lessons.update", "lessons.delete",
|
||||
"lessons.create", "lessons.get", "lessons.list_by_module", "lessons.complete", "lessons.update", "lessons.delete",
|
||||
|
||||
// Practices
|
||||
"practices.create", "practices.get", "practices.list", "practices.update", "practices.delete",
|
||||
|
|
@ -360,13 +367,14 @@ var DefaultRolePermissions = map[string][]string{
|
|||
"course_categories.list", "course_categories.get",
|
||||
"courses.get", "courses.list_by_program",
|
||||
"modules.get", "modules.list_by_course",
|
||||
"lessons.get", "lessons.list_by_module",
|
||||
"lessons.get", "lessons.list_by_module", "lessons.complete",
|
||||
"practices.get", "practices.list",
|
||||
"subcourses.get", "subcourses.list_by_course", "subcourses.list_by_course_list", "subcourses.list_active",
|
||||
"videos.get", "videos.list_by_subcourse", "videos.list_published",
|
||||
"learning_tree.get",
|
||||
|
||||
"programs.list", "programs.get",
|
||||
"lms.get_my_progress",
|
||||
|
||||
// Questions (read + attempt)
|
||||
"questions.list", "questions.search", "questions.get",
|
||||
|
|
@ -413,12 +421,15 @@ var DefaultRolePermissions = map[string][]string{
|
|||
"course_categories.list", "course_categories.get",
|
||||
"courses.get", "courses.list_by_program",
|
||||
"modules.get", "modules.list_by_course",
|
||||
"lessons.get", "lessons.list_by_module",
|
||||
"lessons.get", "lessons.list_by_module", "lessons.complete",
|
||||
"practices.get", "practices.list",
|
||||
"subcourses.get", "subcourses.list_by_course", "subcourses.list_by_course_list", "subcourses.list_active",
|
||||
"videos.get", "videos.list_by_subcourse", "videos.list_published",
|
||||
"learning_tree.get",
|
||||
|
||||
"programs.list", "programs.get",
|
||||
"lms.get_my_progress",
|
||||
|
||||
// Questions (full — instructors create content)
|
||||
"questions.create", "questions.list", "questions.search", "questions.get", "questions.update", "questions.delete",
|
||||
"question_sets.create", "question_sets.list", "question_sets.list_by_owner", "question_sets.get", "question_sets.update", "question_sets.delete",
|
||||
|
|
|
|||
|
|
@ -13,6 +13,7 @@ import (
|
|||
notificationservice "Yimaru-Backend/internal/services/notification"
|
||||
"Yimaru-Backend/internal/services/courses"
|
||||
"Yimaru-Backend/internal/services/lessons"
|
||||
"Yimaru-Backend/internal/services/lmsprogress"
|
||||
"Yimaru-Backend/internal/services/modules"
|
||||
"Yimaru-Backend/internal/services/practices"
|
||||
"Yimaru-Backend/internal/services/programs"
|
||||
|
|
@ -48,6 +49,7 @@ type App struct {
|
|||
courseSvc *courses.Service
|
||||
moduleSvc *modules.Service
|
||||
lessonSvc *lessons.Service
|
||||
lmsProgressSvc *lmsprogress.Service
|
||||
practiceSvc *practices.Service
|
||||
subscriptionsSvc *subscriptions.Service
|
||||
arifpaySvc *arifpay.ArifpayService
|
||||
|
|
@ -84,6 +86,7 @@ func NewApp(
|
|||
courseSvc *courses.Service,
|
||||
moduleSvc *modules.Service,
|
||||
lessonSvc *lessons.Service,
|
||||
lmsProgressSvc *lmsprogress.Service,
|
||||
practiceSvc *practices.Service,
|
||||
subscriptionsSvc *subscriptions.Service,
|
||||
arifpaySvc *arifpay.ArifpayService,
|
||||
|
|
@ -132,6 +135,7 @@ func NewApp(
|
|||
courseSvc: courseSvc,
|
||||
moduleSvc: moduleSvc,
|
||||
lessonSvc: lessonSvc,
|
||||
lmsProgressSvc: lmsProgressSvc,
|
||||
practiceSvc: practiceSvc,
|
||||
subscriptionsSvc: subscriptionsSvc,
|
||||
arifpaySvc: arifpaySvc,
|
||||
|
|
|
|||
|
|
@ -105,6 +105,16 @@ func (h *Handler) ListCoursesByProgram(c *fiber.Ctx) error {
|
|||
Error: err.Error(),
|
||||
})
|
||||
}
|
||||
uid := c.Locals("user_id").(int64)
|
||||
role := c.Locals("role").(domain.Role)
|
||||
for i := range items {
|
||||
if err := h.lmsProgressSvc.ApplyAccessCourse(c.Context(), role, uid, &items[i]); err != nil {
|
||||
return c.Status(fiber.StatusInternalServerError).JSON(domain.ErrorResponse{
|
||||
Message: "Failed to build course list",
|
||||
Error: err.Error(),
|
||||
})
|
||||
}
|
||||
}
|
||||
return c.JSON(domain.Response{
|
||||
Message: "Courses retrieved successfully",
|
||||
Data: fiber.Map{
|
||||
|
|
@ -147,6 +157,17 @@ func (h *Handler) GetCourse(c *fiber.Ctx) error {
|
|||
Error: err.Error(),
|
||||
})
|
||||
}
|
||||
uid := c.Locals("user_id").(int64)
|
||||
role := c.Locals("role").(domain.Role)
|
||||
if err := h.lmsProgressSvc.ApplyAccessCourse(c.Context(), role, uid, &course); err != nil {
|
||||
return c.Status(fiber.StatusInternalServerError).JSON(domain.ErrorResponse{
|
||||
Message: "Failed to evaluate course access",
|
||||
Error: err.Error(),
|
||||
})
|
||||
}
|
||||
if err := lmsBlockIfInaccessible(c, course.Access); err != nil {
|
||||
return err
|
||||
}
|
||||
return c.JSON(domain.Response{
|
||||
Message: "Course retrieved successfully",
|
||||
Data: course,
|
||||
|
|
|
|||
|
|
@ -18,6 +18,7 @@ import (
|
|||
notificationservice "Yimaru-Backend/internal/services/notification"
|
||||
"Yimaru-Backend/internal/services/courses"
|
||||
"Yimaru-Backend/internal/services/lessons"
|
||||
"Yimaru-Backend/internal/services/lmsprogress"
|
||||
"Yimaru-Backend/internal/services/modules"
|
||||
"Yimaru-Backend/internal/services/practices"
|
||||
"Yimaru-Backend/internal/services/programs"
|
||||
|
|
@ -47,6 +48,7 @@ type Handler struct {
|
|||
courseSvc *courses.Service
|
||||
moduleSvc *modules.Service
|
||||
lessonSvc *lessons.Service
|
||||
lmsProgressSvc *lmsprogress.Service
|
||||
practiceSvc *practices.Service
|
||||
subscriptionsSvc *subscriptions.Service
|
||||
arifpaySvc *arifpay.ArifpayService
|
||||
|
|
@ -79,6 +81,7 @@ func New(
|
|||
courseSvc *courses.Service,
|
||||
moduleSvc *modules.Service,
|
||||
lessonSvc *lessons.Service,
|
||||
lmsProgressSvc *lmsprogress.Service,
|
||||
practiceSvc *practices.Service,
|
||||
subscriptionsSvc *subscriptions.Service,
|
||||
arifpaySvc *arifpay.ArifpayService,
|
||||
|
|
@ -110,6 +113,7 @@ func New(
|
|||
courseSvc: courseSvc,
|
||||
moduleSvc: moduleSvc,
|
||||
lessonSvc: lessonSvc,
|
||||
lmsProgressSvc: lmsProgressSvc,
|
||||
practiceSvc: practiceSvc,
|
||||
subscriptionsSvc: subscriptionsSvc,
|
||||
arifpaySvc: arifpaySvc,
|
||||
|
|
|
|||
|
|
@ -97,6 +97,16 @@ func (h *Handler) ListLessonsByModule(c *fiber.Ctx) error {
|
|||
Error: err.Error(),
|
||||
})
|
||||
}
|
||||
uid := c.Locals("user_id").(int64)
|
||||
role := c.Locals("role").(domain.Role)
|
||||
for i := range items {
|
||||
if err := h.lmsProgressSvc.ApplyAccessLesson(c.Context(), role, uid, &items[i]); err != nil {
|
||||
return c.Status(fiber.StatusInternalServerError).JSON(domain.ErrorResponse{
|
||||
Message: "Failed to build lesson list",
|
||||
Error: err.Error(),
|
||||
})
|
||||
}
|
||||
}
|
||||
return c.JSON(domain.Response{
|
||||
Message: "Lessons retrieved successfully",
|
||||
Data: fiber.Map{
|
||||
|
|
@ -135,6 +145,17 @@ func (h *Handler) GetLesson(c *fiber.Ctx) error {
|
|||
Error: err.Error(),
|
||||
})
|
||||
}
|
||||
uid := c.Locals("user_id").(int64)
|
||||
role := c.Locals("role").(domain.Role)
|
||||
if err := h.lmsProgressSvc.ApplyAccessLesson(c.Context(), role, uid, &les); err != nil {
|
||||
return c.Status(fiber.StatusInternalServerError).JSON(domain.ErrorResponse{
|
||||
Message: "Failed to evaluate lesson access",
|
||||
Error: err.Error(),
|
||||
})
|
||||
}
|
||||
if err := lmsBlockIfInaccessible(c, les.Access); err != nil {
|
||||
return err
|
||||
}
|
||||
return c.JSON(domain.Response{
|
||||
Message: "Lesson retrieved successfully",
|
||||
Data: les,
|
||||
|
|
@ -227,3 +248,61 @@ func (h *Handler) DeleteLesson(c *fiber.Ctx) error {
|
|||
StatusCode: fiber.StatusOK,
|
||||
})
|
||||
}
|
||||
|
||||
// CompleteLesson godoc
|
||||
// @Summary Mark a lesson as completed
|
||||
// @Description Records lesson completion; may cascade to module, course, and program progress for the authenticated user. Learners must meet sequential prerequisites; staff bypass checks.
|
||||
// @Tags lessons
|
||||
// @Param id path int true "Lesson ID"
|
||||
// @Success 200 {object} domain.Response
|
||||
// @Failure 403 {object} domain.ErrorResponse
|
||||
// @Router /api/v1/lessons/{id}/complete [post]
|
||||
func (h *Handler) CompleteLesson(c *fiber.Ctx) error {
|
||||
id, err := strconv.ParseInt(c.Params("id"), 10, 64)
|
||||
if err != nil {
|
||||
return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{
|
||||
Message: "Invalid lesson id",
|
||||
Error: err.Error(),
|
||||
})
|
||||
}
|
||||
if _, err := h.lessonSvc.GetByID(c.Context(), id); err != nil {
|
||||
if errors.Is(err, lessons.ErrLessonNotFound) {
|
||||
return c.Status(fiber.StatusNotFound).JSON(domain.ErrorResponse{
|
||||
Message: "Lesson not found",
|
||||
Error: err.Error(),
|
||||
})
|
||||
}
|
||||
return c.Status(fiber.StatusInternalServerError).JSON(domain.ErrorResponse{
|
||||
Message: "Failed to load lesson",
|
||||
Error: err.Error(),
|
||||
})
|
||||
}
|
||||
uid := c.Locals("user_id").(int64)
|
||||
role := c.Locals("role").(domain.Role)
|
||||
if role == domain.RoleStudent {
|
||||
ok, reason, err := h.lmsProgressSvc.CanAccessLesson(c.Context(), uid, id)
|
||||
if err != nil {
|
||||
return c.Status(fiber.StatusInternalServerError).JSON(domain.ErrorResponse{
|
||||
Message: "Failed to verify lesson access",
|
||||
Error: err.Error(),
|
||||
})
|
||||
}
|
||||
if !ok {
|
||||
return c.Status(fiber.StatusForbidden).JSON(domain.ErrorResponse{
|
||||
Message: reason,
|
||||
Error: "LMS_PREREQUISITE_NOT_MET",
|
||||
})
|
||||
}
|
||||
}
|
||||
if err := h.lmsProgressSvc.CompleteLessonForUser(c.Context(), uid, id); err != nil {
|
||||
return c.Status(fiber.StatusInternalServerError).JSON(domain.ErrorResponse{
|
||||
Message: "Failed to record lesson progress",
|
||||
Error: err.Error(),
|
||||
})
|
||||
}
|
||||
return c.JSON(domain.Response{
|
||||
Message: "Lesson marked complete",
|
||||
Success: true,
|
||||
StatusCode: fiber.StatusOK,
|
||||
})
|
||||
}
|
||||
|
|
|
|||
18
internal/web_server/handlers/lms_gating.go
Normal file
18
internal/web_server/handlers/lms_gating.go
Normal 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",
|
||||
})
|
||||
}
|
||||
32
internal/web_server/handlers/lms_progress_handler.go
Normal file
32
internal/web_server/handlers/lms_progress_handler.go
Normal 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,
|
||||
})
|
||||
}
|
||||
178
internal/web_server/handlers/lms_reorder_handler.go
Normal file
178
internal/web_server/handlers/lms_reorder_handler.go
Normal 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,
|
||||
})
|
||||
}
|
||||
|
|
@ -103,6 +103,16 @@ func (h *Handler) ListModulesByCourse(c *fiber.Ctx) error {
|
|||
Error: err.Error(),
|
||||
})
|
||||
}
|
||||
uid := c.Locals("user_id").(int64)
|
||||
role := c.Locals("role").(domain.Role)
|
||||
for i := range items {
|
||||
if err := h.lmsProgressSvc.ApplyAccessModule(c.Context(), role, uid, &items[i]); err != nil {
|
||||
return c.Status(fiber.StatusInternalServerError).JSON(domain.ErrorResponse{
|
||||
Message: "Failed to build module list",
|
||||
Error: err.Error(),
|
||||
})
|
||||
}
|
||||
}
|
||||
return c.JSON(domain.Response{
|
||||
Message: "Modules retrieved successfully",
|
||||
Data: fiber.Map{
|
||||
|
|
@ -142,6 +152,17 @@ func (h *Handler) GetModule(c *fiber.Ctx) error {
|
|||
Error: err.Error(),
|
||||
})
|
||||
}
|
||||
uid := c.Locals("user_id").(int64)
|
||||
role := c.Locals("role").(domain.Role)
|
||||
if err := h.lmsProgressSvc.ApplyAccessModule(c.Context(), role, uid, &mod); err != nil {
|
||||
return c.Status(fiber.StatusInternalServerError).JSON(domain.ErrorResponse{
|
||||
Message: "Failed to evaluate module access",
|
||||
Error: err.Error(),
|
||||
})
|
||||
}
|
||||
if err := lmsBlockIfInaccessible(c, mod.Access); err != nil {
|
||||
return err
|
||||
}
|
||||
return c.JSON(domain.Response{
|
||||
Message: "Module retrieved successfully",
|
||||
Data: mod,
|
||||
|
|
|
|||
|
|
@ -76,6 +76,16 @@ func (h *Handler) ListPrograms(c *fiber.Ctx) error {
|
|||
Error: err.Error(),
|
||||
})
|
||||
}
|
||||
uid := c.Locals("user_id").(int64)
|
||||
role := c.Locals("role").(domain.Role)
|
||||
for i := range items {
|
||||
if err := h.lmsProgressSvc.ApplyAccessProgram(c.Context(), role, uid, &items[i]); err != nil {
|
||||
return c.Status(fiber.StatusInternalServerError).JSON(domain.ErrorResponse{
|
||||
Message: "Failed to build program list",
|
||||
Error: err.Error(),
|
||||
})
|
||||
}
|
||||
}
|
||||
return c.JSON(domain.Response{
|
||||
Message: "Programs retrieved successfully",
|
||||
Data: fiber.Map{
|
||||
|
|
@ -118,6 +128,17 @@ func (h *Handler) GetProgram(c *fiber.Ctx) error {
|
|||
Error: err.Error(),
|
||||
})
|
||||
}
|
||||
uid := c.Locals("user_id").(int64)
|
||||
role := c.Locals("role").(domain.Role)
|
||||
if err := h.lmsProgressSvc.ApplyAccessProgram(c.Context(), role, uid, &p); err != nil {
|
||||
return c.Status(fiber.StatusInternalServerError).JSON(domain.ErrorResponse{
|
||||
Message: "Failed to evaluate program access",
|
||||
Error: err.Error(),
|
||||
})
|
||||
}
|
||||
if err := lmsBlockIfInaccessible(c, p.Access); err != nil {
|
||||
return err
|
||||
}
|
||||
return c.JSON(domain.Response{
|
||||
Message: "Program retrieved successfully",
|
||||
Data: p,
|
||||
|
|
|
|||
|
|
@ -19,6 +19,7 @@ func (a *App) initAppRoutes() {
|
|||
a.courseSvc,
|
||||
a.moduleSvc,
|
||||
a.lessonSvc,
|
||||
a.lmsProgressSvc,
|
||||
a.practiceSvc,
|
||||
a.subscriptionsSvc,
|
||||
a.arifpaySvc,
|
||||
|
|
@ -74,18 +75,22 @@ func (a *App) initAppRoutes() {
|
|||
// Programs (LMS top-level)
|
||||
groupV1.Post("/programs", a.authMiddleware, a.RequirePermission("programs.create"), h.CreateProgram)
|
||||
groupV1.Get("/programs", a.authMiddleware, a.RequirePermission("programs.list"), h.ListPrograms)
|
||||
groupV1.Put("/programs/reorder", a.authMiddleware, a.RequirePermission("programs.reorder"), h.ReorderPrograms)
|
||||
groupV1.Get("/lms/progress", a.authMiddleware, a.RequirePermission("lms.get_my_progress"), h.GetMyLMSProgress)
|
||||
groupV1.Get("/programs/:id", a.authMiddleware, a.RequirePermission("programs.get"), h.GetProgram)
|
||||
groupV1.Put("/programs/:id", a.authMiddleware, a.RequirePermission("programs.update"), h.UpdateProgram)
|
||||
groupV1.Delete("/programs/:id", a.authMiddleware, a.RequirePermission("programs.delete"), h.DeleteProgram)
|
||||
|
||||
// Courses
|
||||
groupV1.Post("/programs/:id/courses", a.authMiddleware, a.RequirePermission("courses.create"), h.CreateCourse)
|
||||
groupV1.Put("/programs/:id/courses/reorder", a.authMiddleware, a.RequirePermission("courses.reorder"), h.ReorderCoursesInProgram)
|
||||
groupV1.Get("/programs/:id/courses", a.authMiddleware, a.RequirePermission("courses.list_by_program"), h.ListCoursesByProgram)
|
||||
groupV1.Get("/courses/:id/practices", a.authMiddleware, a.RequirePermission("practices.list"), h.ListPracticesByCourse)
|
||||
groupV1.Get("/courses/:id", a.authMiddleware, a.RequirePermission("courses.get"), h.GetCourse)
|
||||
groupV1.Put("/courses/:id", a.authMiddleware, a.RequirePermission("courses.update"), h.UpdateCourse)
|
||||
groupV1.Delete("/courses/:id", a.authMiddleware, a.RequirePermission("courses.delete"), h.DeleteCourse)
|
||||
groupV1.Post("/courses/:courseId/modules", a.authMiddleware, a.RequirePermission("modules.create"), h.CreateModule)
|
||||
groupV1.Put("/courses/:courseId/modules/reorder", a.authMiddleware, a.RequirePermission("modules.reorder"), h.ReorderModulesInCourse)
|
||||
groupV1.Get("/courses/:courseId/modules", a.authMiddleware, a.RequirePermission("modules.list_by_course"), h.ListModulesByCourse)
|
||||
|
||||
// /modules/:moduleId/lessons before /modules/:id; /modules/:id/practices before /modules/:id
|
||||
|
|
@ -96,6 +101,7 @@ func (a *App) initAppRoutes() {
|
|||
groupV1.Put("/modules/:id", a.authMiddleware, a.RequirePermission("modules.update"), h.UpdateModule)
|
||||
groupV1.Delete("/modules/:id", a.authMiddleware, a.RequirePermission("modules.delete"), h.DeleteModule)
|
||||
groupV1.Get("/lessons/:id/practices", a.authMiddleware, a.RequirePermission("practices.list"), h.ListPracticesByLesson)
|
||||
groupV1.Post("/lessons/:id/complete", a.authMiddleware, a.RequirePermission("lessons.complete"), h.CompleteLesson)
|
||||
groupV1.Get("/lessons/:id", a.authMiddleware, a.RequirePermission("lessons.get"), h.GetLesson)
|
||||
groupV1.Put("/lessons/:id", a.authMiddleware, a.RequirePermission("lessons.update"), h.UpdateLesson)
|
||||
groupV1.Delete("/lessons/:id", a.authMiddleware, a.RequirePermission("lessons.delete"), h.DeleteLesson)
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user