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