Compare commits

...

5 Commits

Author SHA1 Message Date
5b53929d92 learning progress implementation 2026-04-23 03:58:27 -07:00
dc788c04cb updated swagger 2026-04-23 02:11:20 -07:00
6c672c4b20 static data for Courses 2026-04-23 02:07:32 -07:00
9db9c9899a module+lesson+practice implementations 2026-04-23 01:59:20 -07:00
152478a96c added program 2026-04-23 00:59:01 -07:00
104 changed files with 9770 additions and 17181 deletions

View File

@ -14,10 +14,15 @@ import (
"Yimaru-Backend/internal/services/arifpay" "Yimaru-Backend/internal/services/arifpay"
"Yimaru-Backend/internal/services/assessment" "Yimaru-Backend/internal/services/assessment"
"Yimaru-Backend/internal/services/authentication" "Yimaru-Backend/internal/services/authentication"
"Yimaru-Backend/internal/services/course_management"
issuereporting "Yimaru-Backend/internal/services/issue_reporting" issuereporting "Yimaru-Backend/internal/services/issue_reporting"
"Yimaru-Backend/internal/services/messenger" "Yimaru-Backend/internal/services/messenger"
notificationservice "Yimaru-Backend/internal/services/notification" notificationservice "Yimaru-Backend/internal/services/notification"
coursesservice "Yimaru-Backend/internal/services/courses"
lessonsservice "Yimaru-Backend/internal/services/lessons"
"Yimaru-Backend/internal/services/lmsprogress"
moduleservice "Yimaru-Backend/internal/services/modules"
practicesservice "Yimaru-Backend/internal/services/practices"
programsservice "Yimaru-Backend/internal/services/programs"
"Yimaru-Backend/internal/services/questions" "Yimaru-Backend/internal/services/questions"
"Yimaru-Backend/internal/services/recommendation" "Yimaru-Backend/internal/services/recommendation"
"Yimaru-Backend/internal/services/settings" "Yimaru-Backend/internal/services/settings"
@ -360,24 +365,10 @@ func main() {
logger.Info("Vimeo service disabled (VIMEO_ENABLED not set or missing access token)") logger.Info("Vimeo service disabled (VIMEO_ENABLED not set or missing access token)")
} }
// Course management service // CloudConvert service for image/video optimization
courseSvc := course_management.NewService(
repository.NewUserStore(store),
repository.NewCourseStore(store),
repository.NewProgressionStore(store),
notificationSvc,
cfg,
)
// Wire up Vimeo service to course management
if vimeoSvc != nil {
courseSvc.SetVimeoService(vimeoSvc)
}
// CloudConvert service for video compression
var ccSvc *cloudconvertservice.Service var ccSvc *cloudconvertservice.Service
if cfg.CloudConvert.Enabled && cfg.CloudConvert.APIKey != "" { if cfg.CloudConvert.Enabled && cfg.CloudConvert.APIKey != "" {
ccSvc = cloudconvertservice.NewService(cfg.CloudConvert.APIKey, domain.MongoDBLogger) ccSvc = cloudconvertservice.NewService(cfg.CloudConvert.APIKey, domain.MongoDBLogger)
courseSvc.SetCloudConvertService(ccSvc)
logger.Info("CloudConvert service initialized") logger.Info("CloudConvert service initialized")
} else { } else {
logger.Info("CloudConvert service disabled (CLOUDCONVERT_ENABLED not set or missing API key)") logger.Info("CloudConvert service disabled (CLOUDCONVERT_ENABLED not set or missing API key)")
@ -402,6 +393,23 @@ func main() {
// Questions service (unified questions system) // Questions service (unified questions system)
questionsSvc := questions.NewService(store) questionsSvc := questions.NewService(store)
// LMS programs (top-level hierarchy)
programSvc := programsservice.NewService(store)
// LMS courses (under programs)
courseSvc := coursesservice.NewService(store, store)
// LMS modules (under courses)
moduleSvc := moduleservice.NewService(store, store)
// LMS lessons (under modules)
lessonSvc := lessonsservice.NewService(store, store)
lmsProgressSvc := lmsprogress.NewService(store)
// LMS practices (under course, module, or lesson)
practiceSvc := practicesservice.NewService(store, store, store, store, store, store)
// Subscriptions service // Subscriptions service
subscriptionsSvc := subscriptions.NewService(store) subscriptionsSvc := subscriptions.NewService(store)
@ -442,8 +450,13 @@ func main() {
// Initialize and start HTTP server // Initialize and start HTTP server
app := httpserver.NewApp( app := httpserver.NewApp(
assessmentSvc, assessmentSvc,
courseSvc,
questionsSvc, questionsSvc,
programSvc,
courseSvc,
moduleSvc,
lessonSvc,
lmsProgressSvc,
practiceSvc,
subscriptionsSvc, subscriptionsSvc,
arifpaySvc, arifpaySvc,
issueReportingSvc, issueReportingSvc,

View File

@ -1,2 +0,0 @@
-- Intentionally empty: course hierarchy is not seeded from SQL.
-- Use admin/API or migrations to create content.

View File

@ -0,0 +1 @@
-- Restoring the removed course hierarchy is not supported; apply new migrations for the next model.

View File

@ -0,0 +1,46 @@
-- Tear down the legacy course / learning-tree schema so a new hierarchy can be introduced.
BEGIN;
-- Entry-assessment automation on sub_courses (from 000024)
DROP TRIGGER IF EXISTS trg_sub_courses_create_entry_assessment ON sub_courses;
DROP FUNCTION IF EXISTS create_sub_course_entry_assessment();
DROP FUNCTION IF EXISTS clone_default_initial_assessment_items(BIGINT);
DROP INDEX IF EXISTS idx_question_sets_unique_subcourse_initial_assessment;
ALTER TABLE question_sets DROP COLUMN IF EXISTS sub_course_video_id;
-- Dependent objects first
DROP TABLE IF EXISTS user_sub_course_video_progress CASCADE;
DROP TABLE IF EXISTS user_practice_progress CASCADE;
DROP TABLE IF EXISTS sub_course_prerequisites CASCADE;
DROP TABLE IF EXISTS user_sub_course_progress CASCADE;
DROP TABLE IF EXISTS sub_module_practices CASCADE;
DROP TABLE IF EXISTS sub_module_capstones CASCADE;
DROP TABLE IF EXISTS sub_module_lessons CASCADE;
DROP TABLE IF EXISTS sub_module_videos CASCADE;
DROP TABLE IF EXISTS sub_modules CASCADE;
DROP TABLE IF EXISTS module_capstones CASCADE;
DROP TABLE IF EXISTS modules CASCADE;
DROP TABLE IF EXISTS levels CASCADE;
DROP TABLE IF EXISTS sub_course_videos CASCADE;
DROP TABLE IF EXISTS sub_courses CASCADE;
DROP TABLE IF EXISTS course_sub_categories CASCADE;
DROP TABLE IF EXISTS courses CASCADE;
DROP TABLE IF EXISTS course_categories CASCADE;
-- Keep learner practice completion for the questions system (no sub_course column)
CREATE TABLE user_practice_progress (
id BIGSERIAL PRIMARY KEY,
user_id BIGINT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
question_set_id BIGINT NOT NULL REFERENCES question_sets(id) ON DELETE CASCADE,
completed_at TIMESTAMPTZ,
updated_at TIMESTAMPTZ,
created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
UNIQUE (user_id, question_set_id)
);
CREATE INDEX idx_user_practice_progress_user_id ON user_practice_progress(user_id);
COMMIT;

View File

@ -0,0 +1 @@
DROP TABLE IF EXISTS programs;

View File

@ -0,0 +1,11 @@
-- Top-level LMS program (e.g. Beginner / Intermediate / Advanced — labels come from admin config later).
CREATE TABLE programs (
id BIGSERIAL PRIMARY KEY,
name VARCHAR(255) NOT NULL,
description TEXT,
thumbnail TEXT,
created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMPTZ
);
CREATE INDEX idx_programs_created_at ON programs (created_at DESC);

View File

@ -0,0 +1,4 @@
DELETE FROM programs
WHERE (name = 'Beginner' AND description = 'Default program for the beginner level.')
OR (name = 'Intermediate' AND description = 'Default program for the intermediate level.')
OR (name = 'Advanced' AND description = 'Default program for the advanced level.');

View File

@ -0,0 +1,6 @@
-- Default top-level programs (hierarchy: Program → Course → …).
INSERT INTO programs (name, description, thumbnail)
VALUES
('Beginner', 'Default program for the beginner level.', NULL),
('Intermediate', 'Default program for the intermediate level.', NULL),
('Advanced', 'Default program for the advanced level.', NULL);

View File

@ -0,0 +1 @@
DROP TABLE IF EXISTS courses;

View File

@ -0,0 +1,13 @@
-- Courses belong to a Program (CEFR-style labels like A1..C2 will be configured separately).
CREATE TABLE courses (
id BIGSERIAL PRIMARY KEY,
program_id BIGINT NOT NULL REFERENCES programs (id) ON DELETE CASCADE,
name VARCHAR(255) NOT NULL,
description TEXT,
thumbnail TEXT,
created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMPTZ
);
CREATE INDEX idx_courses_program_id ON courses (program_id);
CREATE INDEX idx_courses_program_created ON courses (program_id, created_at DESC);

View File

@ -0,0 +1,2 @@
DROP TABLE IF EXISTS modules;
ALTER TABLE courses DROP CONSTRAINT IF EXISTS courses_program_id_id_key;

View File

@ -0,0 +1,22 @@
-- Modules belong to a Course; program_id is denormalized and enforced with the course by a composite FK.
ALTER TABLE courses
ADD CONSTRAINT courses_program_id_id_key UNIQUE (program_id, id);
CREATE TABLE modules (
id BIGSERIAL PRIMARY KEY,
program_id BIGINT NOT NULL,
course_id BIGINT NOT NULL,
name VARCHAR(255) NOT NULL,
description TEXT,
icon TEXT,
created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMPTZ,
CONSTRAINT modules_course_scope_fkey
FOREIGN KEY (program_id, course_id)
REFERENCES courses (program_id, id)
ON DELETE CASCADE
);
CREATE INDEX idx_modules_course_id ON modules (course_id);
CREATE INDEX idx_modules_program_id ON modules (program_id);
CREATE INDEX idx_modules_program_course_created ON modules (program_id, course_id, created_at DESC);

View File

@ -0,0 +1 @@
DROP TABLE IF EXISTS lessons;

View File

@ -0,0 +1,14 @@
-- Lessons belong to a Module.
CREATE TABLE lessons (
id BIGSERIAL PRIMARY KEY,
module_id BIGINT NOT NULL REFERENCES modules (id) ON DELETE CASCADE,
title VARCHAR(255) NOT NULL,
video_url TEXT,
thumbnail TEXT,
description TEXT,
created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMPTZ
);
CREATE INDEX idx_lessons_module_id ON lessons (module_id);
CREATE INDEX idx_lessons_module_created ON lessons (module_id, created_at DESC);

View File

@ -0,0 +1 @@
DROP TABLE IF EXISTS lms_practices;

View File

@ -0,0 +1,29 @@
-- Practices attach to exactly one of: course, module, or lesson.
CREATE TABLE lms_practices (
id BIGSERIAL PRIMARY KEY,
course_id BIGINT REFERENCES courses (id) ON DELETE CASCADE,
module_id BIGINT REFERENCES modules (id) ON DELETE CASCADE,
lesson_id BIGINT REFERENCES lessons (id) ON DELETE CASCADE,
title VARCHAR(255) NOT NULL,
story_description TEXT,
story_image TEXT,
persona_id BIGINT REFERENCES users (id) ON DELETE SET NULL,
question_set_id BIGINT NOT NULL REFERENCES question_sets (id) ON DELETE RESTRICT,
quick_tips TEXT,
created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMPTZ,
CONSTRAINT lms_practices_one_parent CHECK (
(course_id IS NOT NULL)::int
+ (module_id IS NOT NULL)::int
+ (lesson_id IS NOT NULL)::int
= 1
)
);
CREATE INDEX idx_lms_practices_course_id ON lms_practices (course_id);
CREATE INDEX idx_lms_practices_module_id ON lms_practices (module_id);
CREATE INDEX idx_lms_practices_lesson_id ON lms_practices (lesson_id);
CREATE INDEX idx_lms_practices_question_set_id ON lms_practices (question_set_id);
CREATE INDEX idx_lms_practices_course_created ON lms_practices (course_id, created_at DESC);
CREATE INDEX idx_lms_practices_module_created ON lms_practices (module_id, created_at DESC);
CREATE INDEX idx_lms_practices_lesson_created ON lms_practices (lesson_id, created_at DESC);

View File

@ -0,0 +1,3 @@
DELETE FROM courses
WHERE description = 'Default CEFR level course (system seed).'
AND name IN ('A1', 'A2', 'B1', 'B2', 'C1', 'C2');

View File

@ -0,0 +1,18 @@
-- Default CEFR-style course names per program (custom courses can still be created via the API with any name).
-- Matches hierarchy note on courses: CEFR labels A1..C2, plus ad-hoc names allowed.
INSERT INTO courses (program_id, name, description, thumbnail)
SELECT
p.id,
v.name,
'Default CEFR level course (system seed).',
NULL
FROM programs AS p
CROSS JOIN (
VALUES
('A1'),
('A2'),
('B1'),
('B2'),
('C1'),
('C2')
) AS v (name);

View File

@ -0,0 +1,18 @@
DROP TABLE IF EXISTS lms_user_program_progress;
DROP TABLE IF EXISTS lms_user_course_progress;
DROP TABLE IF EXISTS lms_user_module_progress;
DROP TABLE IF EXISTS lms_user_lesson_progress;
DROP INDEX IF EXISTS uq_lessons_module_sort;
DROP INDEX IF EXISTS uq_modules_course_sort;
DROP INDEX IF EXISTS uq_courses_program_sort;
DROP INDEX IF EXISTS uq_programs_sort_order;
ALTER TABLE lessons
DROP COLUMN IF EXISTS sort_order;
ALTER TABLE modules
DROP COLUMN IF EXISTS sort_order;
ALTER TABLE courses
DROP COLUMN IF EXISTS sort_order;
ALTER TABLE programs
DROP COLUMN IF EXISTS sort_order;

View File

@ -0,0 +1,150 @@
-- Sequential order for programs, courses, modules, and lessons (1 = first in each scope).
-- Progress tables mark completion; API enforces prerequisites for learners (STUDENT role).
ALTER TABLE programs
ADD COLUMN sort_order INTEGER NOT NULL DEFAULT 0;
ALTER TABLE courses
ADD COLUMN sort_order INTEGER NOT NULL DEFAULT 0;
ALTER TABLE modules
ADD COLUMN sort_order INTEGER NOT NULL DEFAULT 0;
ALTER TABLE lessons
ADD COLUMN sort_order INTEGER NOT NULL DEFAULT 0;
-- Program order (one global sequence): Beginner -> Intermediate -> Advanced; others by id
UPDATE programs
SET sort_order = v.so
FROM (
VALUES
('Beginner', 1),
('Intermediate', 2),
('Advanced', 3)
) AS v (name, so)
WHERE programs.name = v.name;
UPDATE programs
SET sort_order = 1000 + r.rn
FROM (
SELECT
id,
row_number() OVER (
ORDER BY id
) AS rn
FROM programs
WHERE
sort_order = 0
) AS r
WHERE
programs.id = r.id;
-- CEFR courses: A1..C2; remaining courses in each program: stable order
UPDATE courses
SET sort_order = CASE name
WHEN 'A1' THEN
1
WHEN 'A2' THEN
2
WHEN 'B1' THEN
3
WHEN 'B2' THEN
4
WHEN 'C1' THEN
5
WHEN 'C2' THEN
6
ELSE
0
END
WHERE
name IN ('A1', 'A2', 'B1', 'B2', 'C1', 'C2');
UPDATE courses c
SET sort_order = 2000 + s.rn
FROM (
SELECT
id,
row_number() OVER (
PARTITION BY program_id
ORDER BY
id
) AS rn
FROM courses
WHERE
sort_order = 0
) AS s
WHERE
c.id = s.id;
UPDATE modules m
SET sort_order = r.rn
FROM (
SELECT
id,
row_number() OVER (
PARTITION BY course_id
ORDER BY
id
) AS rn
FROM modules
) AS r
WHERE
m.id = r.id;
UPDATE lessons l
SET sort_order = r.rn
FROM (
SELECT
id,
row_number() OVER (
PARTITION BY module_id
ORDER BY
id
) AS rn
FROM lessons
) AS r
WHERE
l.id = r.id;
CREATE UNIQUE INDEX uq_programs_sort_order ON programs (sort_order);
CREATE UNIQUE INDEX uq_courses_program_sort ON courses (program_id, sort_order);
CREATE UNIQUE INDEX uq_modules_course_sort ON modules (course_id, sort_order);
CREATE UNIQUE INDEX uq_lessons_module_sort ON lessons (module_id, sort_order);
CREATE TABLE lms_user_lesson_progress (
user_id BIGINT NOT NULL REFERENCES users (id) ON DELETE CASCADE,
lesson_id BIGINT NOT NULL REFERENCES lessons (id) ON DELETE CASCADE,
completed_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (user_id, lesson_id)
);
CREATE INDEX idx_lms_user_lesson_progress_user ON lms_user_lesson_progress (user_id);
CREATE INDEX idx_lms_user_lesson_progress_lesson ON lms_user_lesson_progress (lesson_id);
CREATE TABLE lms_user_module_progress (
user_id BIGINT NOT NULL REFERENCES users (id) ON DELETE CASCADE,
module_id BIGINT NOT NULL REFERENCES modules (id) ON DELETE CASCADE,
completed_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (user_id, module_id)
);
CREATE INDEX idx_lms_user_module_progress_user ON lms_user_module_progress (user_id);
CREATE INDEX idx_lms_user_module_progress_module ON lms_user_module_progress (module_id);
CREATE TABLE lms_user_course_progress (
user_id BIGINT NOT NULL REFERENCES users (id) ON DELETE CASCADE,
course_id BIGINT NOT NULL REFERENCES courses (id) ON DELETE CASCADE,
completed_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (user_id, course_id)
);
CREATE INDEX idx_lms_user_course_progress_user ON lms_user_course_progress (user_id);
CREATE INDEX idx_lms_user_course_progress_course ON lms_user_course_progress (course_id);
CREATE TABLE lms_user_program_progress (
user_id BIGINT NOT NULL REFERENCES users (id) ON DELETE CASCADE,
program_id BIGINT NOT NULL REFERENCES programs (id) ON DELETE CASCADE,
completed_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (user_id, program_id)
);
CREATE INDEX idx_lms_user_program_progress_user ON lms_user_program_progress (user_id);
CREATE INDEX idx_lms_user_program_progress_program ON lms_user_program_progress (program_id);

View File

@ -163,10 +163,10 @@ ORDER BY d.date;
-- name: AnalyticsCourseCounts :one -- name: AnalyticsCourseCounts :one
SELECT SELECT
(SELECT COUNT(*)::bigint FROM course_categories) AS total_categories, 0::bigint AS total_categories,
(SELECT COUNT(*)::bigint FROM courses) AS total_courses, 0::bigint AS total_courses,
(SELECT COUNT(*)::bigint FROM sub_courses) AS total_sub_courses, 0::bigint AS total_sub_courses,
(SELECT COUNT(*)::bigint FROM sub_course_videos) AS total_videos; 0::bigint AS total_videos;
-- ===================== -- =====================
-- Content Analytics -- Content Analytics

View File

@ -1,47 +0,0 @@
-- name: CreateCourseCategory :one
INSERT INTO course_categories (
name,
is_active
)
VALUES ($1, COALESCE($2, true))
RETURNING *;
-- name: GetCourseCategoryByID :one
SELECT *
FROM course_categories
WHERE id = $1;
-- name: GetAllCourseCategories :many
SELECT
COUNT(*) OVER () AS total_count,
id,
name,
is_active,
created_at
FROM course_categories
ORDER BY display_order ASC, created_at DESC
LIMIT sqlc.narg('limit')::INT
OFFSET sqlc.narg('offset')::INT;
-- name: UpdateCourseCategory :exec
UPDATE course_categories
SET
name = COALESCE($1, name),
is_active = COALESCE($2, is_active)
WHERE id = $3;
-- name: DeleteCourseCategory :exec
DELETE FROM course_categories
WHERE id = $1;
-- name: ReorderCourseCategories :exec
UPDATE course_categories
SET display_order = bulk.position
FROM (
SELECT unnest(@ids::BIGINT[]) AS id, unnest(@positions::INT[]) AS position
) AS bulk
WHERE course_categories.id = bulk.id;

View File

@ -1,109 +0,0 @@
-- name: CreateCourse :one
INSERT INTO courses (
category_id,
title,
description,
thumbnail,
intro_video_url,
is_active
)
VALUES ($1, $2, $3, $4, $5, COALESCE($6, true))
RETURNING *;
-- name: GetCourseByID :one
SELECT *
FROM courses
WHERE id = $1;
-- name: GetCoursesByCategory :many
SELECT
COUNT(*) OVER () AS total_count,
id,
category_id,
title,
description,
thumbnail,
intro_video_url,
is_active
FROM courses
WHERE category_id = $1
ORDER BY display_order ASC, id ASC
LIMIT sqlc.narg('limit')::INT
OFFSET sqlc.narg('offset')::INT;
-- name: GetAllCourses :many
SELECT
COUNT(*) OVER () AS total_count,
c.id,
c.category_id,
c.sub_category_id,
c.title,
c.description,
c.thumbnail,
c.intro_video_url,
c.is_active
FROM courses c
ORDER BY c.display_order ASC, c.id ASC
LIMIT sqlc.narg('limit')::INT
OFFSET sqlc.narg('offset')::INT;
-- name: GetHumanLanguageCourses :many
SELECT
COUNT(*) OVER () AS total_count,
c.id,
c.category_id,
c.sub_category_id,
c.title,
c.description,
c.thumbnail,
c.intro_video_url,
c.is_active
FROM courses c
JOIN course_categories cc ON cc.id = c.category_id
WHERE lower(trim(cc.name)) = 'human language'
ORDER BY c.display_order ASC, c.id ASC
LIMIT sqlc.narg('limit')::INT
OFFSET sqlc.narg('offset')::INT;
-- name: GetCoursesBySubCategory :many
SELECT
COUNT(*) OVER () AS total_count,
c.id,
c.category_id,
c.sub_category_id,
c.title,
c.description,
c.thumbnail,
c.intro_video_url,
c.is_active
FROM courses c
WHERE c.sub_category_id = $1
ORDER BY c.display_order ASC, c.id ASC
LIMIT sqlc.narg('limit')::INT
OFFSET sqlc.narg('offset')::INT;
-- name: UpdateCourse :exec
UPDATE courses
SET
title = COALESCE($1, title),
description = COALESCE($2, description),
thumbnail = COALESCE($3, thumbnail),
intro_video_url = COALESCE($4, intro_video_url),
is_active = COALESCE($5, is_active)
WHERE id = $6;
-- name: DeleteCourse :exec
DELETE FROM courses
WHERE id = $1;
-- name: ReorderCourses :exec
UPDATE courses
SET display_order = bulk.position
FROM (
SELECT unnest(@ids::BIGINT[]) AS id, unnest(@positions::INT[]) AS position
) AS bulk
WHERE courses.id = bulk.id;

View File

@ -1,586 +0,0 @@
-- name: GetCoursesWithHierarchy :many
SELECT
cc.id AS category_id,
cc.name AS category_name,
csc.id AS sub_category_id,
csc.name AS sub_category_name,
c.id AS course_id,
c.title AS course_title
FROM course_categories cc
LEFT JOIN course_sub_categories csc ON csc.category_id = cc.id AND csc.is_active = TRUE
LEFT JOIN courses c ON c.sub_category_id = csc.id AND c.is_active = TRUE
WHERE cc.is_active = TRUE
ORDER BY cc.id, csc.display_order, csc.id, c.id;
-- name: GetLevelsByCourseID :many
SELECT *
FROM levels
WHERE course_id = $1
AND is_active = TRUE
ORDER BY display_order ASC, id ASC;
-- name: GetAllLevels :many
SELECT
COUNT(*) OVER () AS total_count,
l.*
FROM levels l
ORDER BY l.display_order ASC, l.id ASC
LIMIT sqlc.narg('limit')::INT
OFFSET sqlc.narg('offset')::INT;
-- name: GetLevelByID :one
SELECT *
FROM levels
WHERE id = $1;
-- name: GetModulesByLevelID :many
SELECT *
FROM modules
WHERE level_id = $1
AND is_active = TRUE
ORDER BY display_order ASC, id ASC;
-- name: GetAllModules :many
SELECT
COUNT(*) OVER () AS total_count,
m.*
FROM modules m
ORDER BY m.display_order ASC, m.id ASC
LIMIT sqlc.narg('limit')::INT
OFFSET sqlc.narg('offset')::INT;
-- name: GetModuleByID :one
SELECT *
FROM modules
WHERE id = $1;
-- name: GetSubModulesByModuleID :many
SELECT *
FROM sub_modules
WHERE module_id = $1
AND is_active = TRUE
ORDER BY display_order ASC, id ASC;
-- name: GetAllSubModules :many
SELECT
COUNT(*) OVER () AS total_count,
sm.*
FROM sub_modules sm
ORDER BY sm.display_order ASC, sm.id ASC
LIMIT sqlc.narg('limit')::INT
OFFSET sqlc.narg('offset')::INT;
-- name: GetSubModuleByID :one
SELECT *
FROM sub_modules
WHERE id = $1;
-- name: GetSubModuleVideos :many
SELECT *
FROM sub_module_videos
WHERE sub_module_id = $1
AND status != 'ARCHIVED'
ORDER BY display_order ASC, id ASC;
-- name: GetSubModuleLessons :many
SELECT *
FROM sub_module_lessons
WHERE sub_module_id = $1
AND is_active = TRUE
ORDER BY display_order ASC, id ASC;
-- name: GetSubModuleLessonsAll :many
SELECT *
FROM sub_module_lessons
WHERE sub_module_id = $1
ORDER BY display_order ASC, id ASC;
-- name: GetSubModuleLessonByID :one
SELECT *
FROM sub_module_lessons
WHERE id = $1;
-- name: GetSubModulePractices :many
SELECT
smp.id,
smp.sub_module_id,
smp.title,
smp.description,
smp.thumbnail,
smp.intro_video_url,
smp.question_set_id,
smp.display_order,
smp.is_active,
smp.inactive_since,
qs.status,
qs.set_type,
(SELECT COUNT(*) FROM question_set_items qsi WHERE qsi.set_id = qs.id) AS question_count
FROM sub_module_practices smp
JOIN question_sets qs ON qs.id = smp.question_set_id
WHERE smp.sub_module_id = $1
AND smp.is_active = TRUE
ORDER BY smp.display_order ASC, smp.id ASC;
-- name: GetSubModulePracticeByID :one
SELECT
smp.id,
smp.sub_module_id,
smp.title,
smp.description,
smp.thumbnail,
smp.intro_video_url,
smp.question_set_id,
smp.display_order,
smp.is_active,
smp.inactive_since,
qs.status,
qs.set_type,
(SELECT COUNT(*) FROM question_set_items qsi WHERE qsi.set_id = qs.id) AS question_count
FROM sub_module_practices smp
JOIN question_sets qs ON qs.id = smp.question_set_id
WHERE smp.is_active = TRUE
AND (smp.id = $1 OR smp.question_set_id = $1)
ORDER BY (smp.id = $1) DESC
LIMIT 1;
-- name: GetSubModuleCapstones :many
SELECT
smc.id,
smc.sub_module_id,
smc.title,
smc.description,
smc.tips,
smc.thumbnail,
smc.question_set_id,
smc.display_order,
smc.is_active,
smc.inactive_since,
qs.status,
qs.set_type,
qs.time_limit_minutes,
qs.passing_score,
qs.shuffle_questions,
(SELECT COUNT(*) FROM question_set_items qsi WHERE qsi.set_id = qs.id) AS question_count
FROM sub_module_capstones smc
JOIN question_sets qs ON qs.id = smc.question_set_id
WHERE smc.sub_module_id = $1
AND smc.is_active = TRUE
AND qs.set_type = 'CAPSTONE'
ORDER BY smc.display_order ASC, smc.id ASC;
-- name: GetSubModuleCapstoneByID :one
SELECT
smc.id,
smc.sub_module_id,
smc.title,
smc.description,
smc.tips,
smc.thumbnail,
smc.question_set_id,
smc.display_order,
smc.is_active,
smc.inactive_since,
qs.status,
qs.set_type,
qs.time_limit_minutes,
qs.passing_score,
qs.shuffle_questions,
(SELECT COUNT(*) FROM question_set_items qsi WHERE qsi.set_id = qs.id) AS question_count
FROM sub_module_capstones smc
JOIN question_sets qs ON qs.id = smc.question_set_id
WHERE smc.id = $1
AND smc.is_active = TRUE
AND qs.set_type = 'CAPSTONE';
-- name: GetFullHierarchyByCourseID :many
SELECT
c.id AS course_id,
c.title AS course_title,
l.id AS level_id,
l.cefr_level,
l.title AS level_title,
l.description AS level_description,
l.thumbnail AS level_thumbnail,
m.id AS module_id,
m.title AS module_title,
m.icon_url AS module_icon_url,
sm.id AS sub_module_id,
sm.title AS sub_module_title,
sm.description AS sub_module_description,
sm.thumbnail AS sub_module_thumbnail,
sm.tips AS sub_module_tips,
sm.display_order AS sub_module_display_order
FROM courses c
LEFT JOIN levels l ON l.course_id = c.id AND l.is_active = TRUE
LEFT JOIN modules m ON m.level_id = l.id AND m.is_active = TRUE
LEFT JOIN sub_modules sm ON sm.module_id = m.id AND sm.is_active = TRUE
WHERE c.id = $1
ORDER BY l.display_order, l.id, m.display_order, m.id, sm.display_order, sm.id;
-- name: CreateCourseSubCategory :one
INSERT INTO course_sub_categories (
category_id,
name,
description,
display_order,
is_active
)
VALUES ($1, $2, $3, COALESCE($4, 0), COALESCE($5, TRUE))
RETURNING *;
-- name: GetCourseSubCategories :many
SELECT
COUNT(*) OVER () AS total_count,
csc.id,
csc.category_id,
cc.name AS category_name,
csc.name,
csc.description,
csc.display_order,
csc.is_active,
csc.created_at
FROM course_sub_categories csc
JOIN course_categories cc ON cc.id = csc.category_id
WHERE csc.is_active = TRUE
ORDER BY csc.display_order ASC, csc.id ASC
LIMIT sqlc.narg('limit')::INT
OFFSET sqlc.narg('offset')::INT;
-- name: GetCourseSubCategoriesByCategoryID :many
SELECT
COUNT(*) OVER () AS total_count,
csc.id,
csc.category_id,
cc.name AS category_name,
csc.name,
csc.description,
csc.display_order,
csc.is_active,
csc.created_at
FROM course_sub_categories csc
JOIN course_categories cc ON cc.id = csc.category_id
WHERE csc.category_id = $1
AND csc.is_active = TRUE
ORDER BY csc.display_order ASC, csc.id ASC
LIMIT sqlc.narg('limit')::INT
OFFSET sqlc.narg('offset')::INT;
-- name: GetHumanLanguageCourseSubCategories :many
SELECT
COUNT(*) OVER () AS total_count,
csc.id,
csc.category_id,
cc.name AS category_name,
csc.name,
csc.description,
csc.display_order,
csc.is_active,
csc.created_at
FROM course_sub_categories csc
JOIN course_categories cc ON cc.id = csc.category_id
WHERE csc.is_active = TRUE
AND cc.is_active = TRUE
AND lower(trim(cc.name)) = 'human language'
ORDER BY csc.display_order ASC, csc.id ASC
LIMIT sqlc.narg('limit')::INT
OFFSET sqlc.narg('offset')::INT;
-- name: CreateLevel :one
INSERT INTO levels (
course_id,
cefr_level,
title,
description,
thumbnail,
display_order,
is_active
)
VALUES ($1, $2, $3, $4, $5, COALESCE($6, 0), COALESCE($7, TRUE))
RETURNING *;
-- name: UpdateLevel :one
UPDATE levels
SET
title = $1,
description = $2,
thumbnail = $3,
display_order = $4,
is_active = $5
WHERE id = $6
RETURNING *;
-- name: CreateModule :one
INSERT INTO modules (
level_id,
title,
description,
icon_url,
display_order,
is_active
)
VALUES ($1, $2, $3, $4, COALESCE($5, 0), COALESCE($6, TRUE))
RETURNING *;
-- name: UpdateModule :one
UPDATE modules
SET
title = $1,
description = $2,
icon_url = $3,
display_order = $4,
is_active = $5
WHERE id = $6
RETURNING *;
-- name: CreateSubModule :one
INSERT INTO sub_modules (
module_id,
title,
description,
thumbnail,
tips,
display_order,
is_active
)
VALUES ($1, $2, $3, $4, $5, COALESCE($6, 0), COALESCE($7, TRUE))
RETURNING *;
-- name: UpdateSubModule :one
UPDATE sub_modules
SET
title = $1,
description = $2,
thumbnail = $3,
tips = $4,
display_order = $5,
is_active = $6
WHERE id = $7
RETURNING *;
-- name: CreateSubModuleVideo :one
INSERT INTO sub_module_videos (
sub_module_id,
title,
description,
video_url,
duration,
resolution,
is_published,
publish_date,
visibility,
instructor_id,
thumbnail,
display_order,
status,
vimeo_id,
vimeo_embed_url,
vimeo_player_html,
vimeo_status,
video_host_provider
)
VALUES (
$1, $2, $3, $4, $5, $6,
COALESCE($7, FALSE), $8, $9, $10, $11,
COALESCE($12, 0), COALESCE($13, 'DRAFT'),
$14, $15, $16, $17, COALESCE($18, 'DIRECT')
)
RETURNING *;
-- name: CreateSubModuleLesson :one
INSERT INTO sub_module_lessons (
sub_module_id,
title,
description,
thumbnail,
teaching_text,
teaching_image_url,
teaching_audio_url,
teaching_video_url,
display_order,
is_active,
inactive_since
)
VALUES (
$1,
$2,
$3,
$4,
$5,
$6,
$7,
$8,
COALESCE($9, 0),
COALESCE($10, TRUE),
CASE WHEN COALESCE($10, TRUE) THEN NULL ELSE NOW() END
)
RETURNING *;
-- name: UpdateSubModuleLesson :one
UPDATE sub_module_lessons
SET
sub_module_id = $1,
title = $2,
description = $3,
thumbnail = $4,
teaching_text = $5,
teaching_image_url = $6,
teaching_audio_url = $7,
teaching_video_url = $8,
display_order = $9,
is_active = $10,
inactive_since = CASE
WHEN $10 THEN NULL
WHEN is_active THEN NOW()
ELSE inactive_since
END
WHERE id = $11
RETURNING *;
-- name: CreateSubModulePractice :one
INSERT INTO sub_module_practices (
sub_module_id,
title,
description,
thumbnail,
intro_video_url,
question_set_id,
display_order,
is_active,
inactive_since
)
VALUES ($1, $2, $3, $4, $5, $6, COALESCE($7, 0), COALESCE($8, TRUE), CASE WHEN COALESCE($8, TRUE) THEN NULL ELSE NOW() END)
RETURNING *;
-- name: CreateSubModuleCapstone :one
INSERT INTO sub_module_capstones (
sub_module_id,
title,
description,
tips,
thumbnail,
question_set_id,
display_order,
is_active,
inactive_since
)
VALUES ($1, $2, $3, $4, $5, $6, COALESCE($7, 0), COALESCE($8, TRUE), CASE WHEN COALESCE($8, TRUE) THEN NULL ELSE NOW() END)
RETURNING *;
-- name: UpdateSubModuleCapstone :one
UPDATE sub_module_capstones
SET
title = $1,
description = $2,
tips = $3,
thumbnail = $4,
display_order = $5,
is_active = $6,
inactive_since = CASE
WHEN $6 THEN NULL
WHEN is_active THEN NOW()
ELSE inactive_since
END
WHERE id = $7
RETURNING *;
-- name: PurgeInactiveSubModuleLessonsBefore :execrows
DELETE FROM sub_module_lessons
WHERE is_active = FALSE
AND inactive_since IS NOT NULL
AND inactive_since < $1;
-- name: PurgeInactiveSubModulePracticesBefore :execrows
DELETE FROM question_sets qs
USING (
SELECT question_set_id
FROM sub_module_practices
WHERE is_active = FALSE
AND inactive_since IS NOT NULL
AND inactive_since < $1
) doomed
WHERE qs.id = doomed.question_set_id;
-- name: PurgeInactiveSubModuleCapstonesBefore :execrows
DELETE FROM question_sets qs
USING (
SELECT question_set_id
FROM sub_module_capstones
WHERE is_active = FALSE
AND inactive_since IS NOT NULL
AND inactive_since < $1
) doomed
WHERE qs.id = doomed.question_set_id;
-- name: GetModuleCapstones :many
SELECT
mc.id,
mc.module_id,
mc.title,
mc.description,
mc.tips,
mc.thumbnail,
mc.question_set_id,
mc.display_order,
mc.is_active,
qs.status,
qs.set_type,
qs.time_limit_minutes,
qs.passing_score,
qs.shuffle_questions,
(SELECT COUNT(*) FROM question_set_items qsi WHERE qsi.set_id = qs.id) AS question_count
FROM module_capstones mc
JOIN question_sets qs ON qs.id = mc.question_set_id
WHERE mc.module_id = $1
AND mc.is_active = TRUE
AND qs.set_type = 'CAPSTONE'
ORDER BY mc.display_order ASC, mc.id ASC;
-- name: GetModuleCapstoneByID :one
SELECT
mc.id,
mc.module_id,
mc.title,
mc.description,
mc.tips,
mc.thumbnail,
mc.question_set_id,
mc.display_order,
mc.is_active,
qs.status,
qs.set_type,
qs.time_limit_minutes,
qs.passing_score,
qs.shuffle_questions,
(SELECT COUNT(*) FROM question_set_items qsi WHERE qsi.set_id = qs.id) AS question_count
FROM module_capstones mc
JOIN question_sets qs ON qs.id = mc.question_set_id
WHERE mc.id = $1
AND mc.is_active = TRUE
AND qs.set_type = 'CAPSTONE';
-- name: CreateModuleCapstone :one
INSERT INTO module_capstones (
module_id,
title,
description,
tips,
thumbnail,
question_set_id,
display_order,
is_active
)
VALUES ($1, $2, $3, $4, $5, $6, COALESCE($7, 0), COALESCE($8, TRUE))
RETURNING *;
-- name: UpdateModuleCapstone :one
UPDATE module_capstones
SET
title = $1,
description = $2,
tips = $3,
thumbnail = $4,
display_order = $5,
is_active = $6
WHERE id = $7
RETURNING *;

67
db/query/lms_courses.sql Normal file
View File

@ -0,0 +1,67 @@
-- name: CreateCourse :one
INSERT INTO courses (program_id, name, description, thumbnail, sort_order)
SELECT
$1,
$2,
$3,
$4,
coalesce((
SELECT
max(c.sort_order)
FROM courses c
WHERE
c.program_id = $1), 0) + 1
RETURNING
*;
-- name: GetCourseByID :one
SELECT *
FROM courses
WHERE id = $1;
-- name: ListCourseIDsByProgram :many
SELECT
c.id
FROM
courses AS c
WHERE
c.program_id = $1
ORDER BY
c.id;
-- name: ListCoursesByProgramID :many
SELECT
COUNT(*) OVER () AS total_count,
c.id,
c.program_id,
c.name,
c.description,
c.thumbnail,
c.sort_order,
c.created_at,
c.updated_at
FROM
courses c
WHERE
c.program_id = $1
ORDER BY
c.sort_order ASC,
c.id ASC
LIMIT $2 OFFSET $3;
-- name: UpdateCourse :one
UPDATE courses
SET
name = COALESCE(sqlc.narg('name')::varchar, name),
description = COALESCE(sqlc.narg('description')::text, description),
thumbnail = COALESCE(sqlc.narg('thumbnail')::text, thumbnail),
sort_order = coalesce(sqlc.narg('sort_order')::int, sort_order),
updated_at = CURRENT_TIMESTAMP
WHERE
id = sqlc.arg('id')
RETURNING
*;
-- name: DeleteCourse :exec
DELETE FROM courses
WHERE id = $1;

61
db/query/lms_lessons.sql Normal file
View File

@ -0,0 +1,61 @@
-- name: CreateLesson :one
INSERT INTO lessons (module_id, title, video_url, thumbnail, description, sort_order)
SELECT
$1,
$2,
$3,
$4,
$5,
coalesce((
SELECT
max(l.sort_order)
FROM lessons l
WHERE
l.module_id = $1), 0) + 1
RETURNING
*;
-- name: GetLessonByID :one
SELECT *
FROM lessons
WHERE id = $1;
-- name: ListLessonsByModuleID :many
SELECT
COUNT(*) OVER () AS total_count,
l.id,
l.module_id,
l.title,
l.video_url,
l.thumbnail,
l.description,
l.sort_order,
l.created_at,
l.updated_at
FROM
lessons l
WHERE
l.module_id = $1
ORDER BY
l.sort_order ASC,
l.id ASC
LIMIT $2
OFFSET $3;
-- name: UpdateLesson :one
UPDATE lessons
SET
title = COALESCE(sqlc.narg('title')::varchar, title),
video_url = COALESCE(sqlc.narg('video_url')::text, video_url),
thumbnail = COALESCE(sqlc.narg('thumbnail')::text, thumbnail),
description = COALESCE(sqlc.narg('description')::text, description),
sort_order = coalesce(sqlc.narg('sort_order')::int, sort_order),
updated_at = CURRENT_TIMESTAMP
WHERE
id = sqlc.arg('id')
RETURNING
*;
-- name: DeleteLesson :exec
DELETE FROM lessons
WHERE id = $1;

71
db/query/lms_modules.sql Normal file
View File

@ -0,0 +1,71 @@
-- name: CreateModule :one
INSERT INTO modules (program_id, course_id, name, description, icon, sort_order)
SELECT
$1,
$2,
$3,
$4,
$5,
coalesce((
SELECT
max(m.sort_order)
FROM modules m
WHERE
m.course_id = $2), 0) + 1
RETURNING
*;
-- name: GetModuleByID :one
SELECT *
FROM modules
WHERE id = $1;
-- name: ListModuleIDsByCourse :many
SELECT
m.id
FROM
modules AS m
WHERE
m.course_id = $1
ORDER BY
m.id;
-- name: ListModulesByProgramAndCourse :many
SELECT
COUNT(*) OVER () AS total_count,
m.id,
m.program_id,
m.course_id,
m.name,
m.description,
m.icon,
m.sort_order,
m.created_at,
m.updated_at
FROM
modules m
WHERE
m.program_id = $1
AND m.course_id = $2
ORDER BY
m.sort_order ASC,
m.id ASC
LIMIT $3
OFFSET $4;
-- name: UpdateModule :one
UPDATE modules
SET
name = COALESCE(sqlc.narg('name')::varchar, name),
description = COALESCE(sqlc.narg('description')::text, description),
icon = COALESCE(sqlc.narg('icon')::text, icon),
sort_order = coalesce(sqlc.narg('sort_order')::int, sort_order),
updated_at = CURRENT_TIMESTAMP
WHERE
id = sqlc.arg('id')
RETURNING
*;
-- name: DeleteModule :exec
DELETE FROM modules
WHERE id = $1;

View File

@ -0,0 +1,88 @@
-- name: CreateLmsPractice :one
INSERT INTO lms_practices (
course_id, module_id, lesson_id,
title, story_description, story_image, persona_id, question_set_id, quick_tips
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)
RETURNING *;
-- name: GetLmsPracticeByID :one
SELECT *
FROM lms_practices
WHERE id = $1;
-- name: ListLmsPracticesByCourseID :many
SELECT
COUNT(*) OVER () AS total_count,
p.id,
p.course_id,
p.module_id,
p.lesson_id,
p.title,
p.story_description,
p.story_image,
p.persona_id,
p.question_set_id,
p.quick_tips,
p.created_at,
p.updated_at
FROM lms_practices p
WHERE p.course_id = $1
ORDER BY p.created_at DESC
LIMIT $2 OFFSET $3;
-- name: ListLmsPracticesByModuleID :many
SELECT
COUNT(*) OVER () AS total_count,
p.id,
p.course_id,
p.module_id,
p.lesson_id,
p.title,
p.story_description,
p.story_image,
p.persona_id,
p.question_set_id,
p.quick_tips,
p.created_at,
p.updated_at
FROM lms_practices p
WHERE p.module_id = $1
ORDER BY p.created_at DESC
LIMIT $2 OFFSET $3;
-- name: ListLmsPracticesByLessonID :many
SELECT
COUNT(*) OVER () AS total_count,
p.id,
p.course_id,
p.module_id,
p.lesson_id,
p.title,
p.story_description,
p.story_image,
p.persona_id,
p.question_set_id,
p.quick_tips,
p.created_at,
p.updated_at
FROM lms_practices p
WHERE p.lesson_id = $1
ORDER BY p.created_at DESC
LIMIT $2 OFFSET $3;
-- name: UpdateLmsPractice :one
UPDATE lms_practices
SET
title = COALESCE(sqlc.narg('title')::varchar, title),
story_description = COALESCE(sqlc.narg('story_description')::text, story_description),
story_image = COALESCE(sqlc.narg('story_image')::text, story_image),
persona_id = COALESCE(sqlc.narg('persona_id')::bigint, persona_id),
question_set_id = COALESCE(sqlc.narg('question_set_id')::bigint, question_set_id),
quick_tips = COALESCE(sqlc.narg('quick_tips')::text, quick_tips),
updated_at = CURRENT_TIMESTAMP
WHERE id = sqlc.arg('id')
RETURNING *;
-- name: DeleteLmsPractice :exec
DELETE FROM lms_practices
WHERE id = $1;

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

@ -0,0 +1,248 @@
-- name: GetPreviousProgram :one
SELECT
p2.*
FROM
programs AS p1
INNER JOIN programs AS p2 ON p2.sort_order = p1.sort_order - 1
WHERE
p1.id = $1;
-- name: GetPreviousCourseInProgram :one
SELECT
c2.*
FROM
courses AS c1
INNER JOIN courses AS c2 ON c2.program_id = c1.program_id
AND c2.sort_order = c1.sort_order - 1
WHERE
c1.id = $1;
-- name: GetPreviousModuleInCourse :one
SELECT
m2.*
FROM
modules AS m1
INNER JOIN modules AS m2 ON m2.course_id = m1.course_id
AND m2.sort_order = m1.sort_order - 1
WHERE
m1.id = $1;
-- name: GetPreviousLessonInModule :one
SELECT
l2.*
FROM
lessons AS l1
INNER JOIN lessons AS l2 ON l2.module_id = l1.module_id
AND l2.sort_order = l1.sort_order - 1
WHERE
l1.id = $1;
-- name: UserHasProgramProgress :one
SELECT
EXISTS (
SELECT
1
FROM
lms_user_program_progress
WHERE
user_id = $1
AND program_id = $2) AS v;
-- name: UserHasCourseProgress :one
SELECT
EXISTS (
SELECT
1
FROM
lms_user_course_progress
WHERE
user_id = $1
AND course_id = $2) AS v;
-- name: UserHasModuleProgress :one
SELECT
EXISTS (
SELECT
1
FROM
lms_user_module_progress
WHERE
user_id = $1
AND module_id = $2) AS v;
-- name: UserHasLessonProgress :one
SELECT
EXISTS (
SELECT
1
FROM
lms_user_lesson_progress
WHERE
user_id = $1
AND lesson_id = $2) AS v;
-- name: InsertUserLessonProgress :exec
INSERT INTO lms_user_lesson_progress (user_id, lesson_id)
VALUES ($1, $2)
ON CONFLICT (user_id, lesson_id)
DO NOTHING;
-- name: InsertUserModuleProgress :exec
INSERT INTO lms_user_module_progress (user_id, module_id)
VALUES ($1, $2)
ON CONFLICT (user_id, module_id)
DO NOTHING;
-- name: InsertUserCourseProgress :exec
INSERT INTO lms_user_course_progress (user_id, course_id)
VALUES ($1, $2)
ON CONFLICT (user_id, course_id)
DO NOTHING;
-- name: InsertUserProgramProgress :exec
INSERT INTO lms_user_program_progress (user_id, program_id)
VALUES ($1, $2)
ON CONFLICT (user_id, program_id)
DO NOTHING;
-- name: CountLessonsInModule :one
SELECT
count(*)::int AS n
FROM
lessons
WHERE
module_id = $1;
-- name: CountUserCompletedLessonsInModule :one
SELECT
count(*)::int AS n
FROM
lms_user_lesson_progress ulp
INNER JOIN lessons l ON l.id = ulp.lesson_id
WHERE
l.module_id = $1
AND ulp.user_id = $2;
-- name: CountModulesInCourse :one
SELECT
count(*)::int AS n
FROM
modules
WHERE
course_id = $1;
-- name: CountUserCompletedModulesInCourse :one
SELECT
count(*)::int AS n
FROM
lms_user_module_progress ump
INNER JOIN modules m ON m.id = ump.module_id
WHERE
m.course_id = $1
AND ump.user_id = $2;
-- name: CountCoursesInProgram :one
SELECT
count(*)::int AS n
FROM
courses
WHERE
program_id = $1;
-- name: CountUserCompletedCoursesInProgram :one
SELECT
count(*)::int AS n
FROM
lms_user_course_progress ucp
INNER JOIN courses c ON c.id = ucp.course_id
WHERE
c.program_id = $1
AND ucp.user_id = $2;
-- name: ListLMSCompletedLessonIDsByUser :many
SELECT
ulp.lesson_id
FROM
lms_user_lesson_progress AS ulp
WHERE
ulp.user_id = $1
ORDER BY
ulp.completed_at ASC,
ulp.lesson_id ASC;
-- name: ListLMSCompletedModuleIDsByUser :many
SELECT
ump.module_id
FROM
lms_user_module_progress AS ump
WHERE
ump.user_id = $1
ORDER BY
ump.completed_at ASC,
ump.module_id ASC;
-- name: ListLMSCompletedCourseIDsByUser :many
SELECT
ucp.course_id
FROM
lms_user_course_progress AS ucp
WHERE
ucp.user_id = $1
ORDER BY
ucp.completed_at ASC,
ucp.course_id ASC;
-- name: ListLMSCompletedProgramIDsByUser :many
SELECT
upp.program_id
FROM
lms_user_program_progress AS upp
WHERE
upp.user_id = $1
ORDER BY
upp.completed_at ASC,
upp.program_id ASC;
-- Lesson-based progress within a course (all modules).
-- name: CountLessonsInCourse :one
SELECT
count(*)::int AS n
FROM
lessons l
INNER JOIN modules m ON m.id = l.module_id
WHERE
m.course_id = $1;
-- name: CountUserCompletedLessonsInCourse :one
SELECT
count(*)::int AS n
FROM
lms_user_lesson_progress ulp
INNER JOIN lessons l ON l.id = ulp.lesson_id
INNER JOIN modules m ON m.id = l.module_id
WHERE
m.course_id = $1
AND ulp.user_id = $2;
-- Lesson-based progress within a program (all courses).
-- name: CountLessonsInProgram :one
SELECT
count(*)::int AS n
FROM
lessons l
INNER JOIN modules m ON m.id = l.module_id
INNER JOIN courses c ON c.id = m.course_id
WHERE
c.program_id = $1;
-- name: CountUserCompletedLessonsInProgram :one
SELECT
count(*)::int AS n
FROM
lms_user_lesson_progress ulp
INNER JOIN lessons l ON l.id = ulp.lesson_id
INNER JOIN modules m ON m.id = l.module_id
INNER JOIN courses c ON c.id = m.course_id
WHERE
c.program_id = $1
AND ulp.user_id = $2;

View File

@ -29,27 +29,16 @@ LIMIT 1;
-- name: MarkPracticeCompleted :execrows -- name: MarkPracticeCompleted :execrows
INSERT INTO user_practice_progress ( INSERT INTO user_practice_progress (
user_id, user_id,
sub_course_id,
question_set_id, question_set_id,
completed_at, completed_at,
updated_at updated_at
) )
SELECT VALUES (
@user_id::BIGINT, @user_id::BIGINT,
CASE @question_set_id::BIGINT,
WHEN qs.owner_type = 'SUB_COURSE' THEN qs.owner_id
WHEN qs.owner_type = 'SUB_MODULE' THEN sm.legacy_sub_course_id
ELSE NULL
END,
qs.id,
CURRENT_TIMESTAMP, CURRENT_TIMESTAMP,
CURRENT_TIMESTAMP CURRENT_TIMESTAMP
FROM question_sets qs )
LEFT JOIN sub_modules sm
ON qs.owner_type = 'SUB_MODULE'
AND qs.owner_id = sm.id
WHERE qs.id = @question_set_id::BIGINT
ON CONFLICT (user_id, question_set_id) DO UPDATE ON CONFLICT (user_id, question_set_id) DO UPDATE
SET completed_at = EXCLUDED.completed_at, SET completed_at = EXCLUDED.completed_at,
updated_at = EXCLUDED.updated_at; updated_at = EXCLUDED.updated_at;

56
db/query/programs.sql Normal file
View File

@ -0,0 +1,56 @@
-- name: CreateProgram :one
INSERT INTO programs (name, description, thumbnail, sort_order)
SELECT
$1,
$2,
$3,
coalesce((
SELECT
max(p.sort_order)
FROM programs AS p), 0) + 1
RETURNING
*;
-- name: GetProgramByID :one
SELECT *
FROM programs
WHERE id = $1;
-- name: ListAllProgramIDs :many
SELECT
p.id
FROM
programs AS p
ORDER BY
p.id;
-- name: ListPrograms :many
SELECT
COUNT(*) OVER () AS total_count,
p.id,
p.name,
p.description,
p.thumbnail,
p.sort_order,
p.created_at,
p.updated_at
FROM programs p
ORDER BY p.sort_order ASC, p.id ASC
LIMIT $1 OFFSET $2;
-- name: UpdateProgram :one
UPDATE programs
SET
name = COALESCE(sqlc.narg('name')::varchar, name),
description = COALESCE(sqlc.narg('description')::text, description),
thumbnail = COALESCE(sqlc.narg('thumbnail')::text, thumbnail),
sort_order = coalesce(sqlc.narg('sort_order')::int, sort_order),
updated_at = CURRENT_TIMESTAMP
WHERE
id = sqlc.arg('id')
RETURNING
*;
-- name: DeleteProgram :exec
DELETE FROM programs
WHERE id = $1;

View File

@ -11,10 +11,9 @@ INSERT INTO question_sets (
passing_score, passing_score,
shuffle_questions, shuffle_questions,
status, status,
sub_course_video_id,
intro_video_url intro_video_url
) )
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, COALESCE($10, false), COALESCE($11, 'DRAFT'), $12, $13) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, COALESCE($10, false), COALESCE($11, 'DRAFT'), $12)
RETURNING *; RETURNING *;
-- name: GetQuestionSetByID :one -- name: GetQuestionSetByID :one
@ -61,9 +60,8 @@ SET
shuffle_questions = COALESCE($7, shuffle_questions), shuffle_questions = COALESCE($7, shuffle_questions),
status = COALESCE($8, status), status = COALESCE($8, status),
intro_video_url = COALESCE($9, intro_video_url), intro_video_url = COALESCE($9, intro_video_url),
sub_course_video_id = COALESCE($10, sub_course_video_id),
updated_at = CURRENT_TIMESTAMP updated_at = CURRENT_TIMESTAMP
WHERE id = $11; WHERE id = $10;
-- name: ArchiveQuestionSet :exec -- name: ArchiveQuestionSet :exec
UPDATE question_sets UPDATE question_sets
@ -82,16 +80,6 @@ WHERE set_type = 'INITIAL_ASSESSMENT'
ORDER BY created_at DESC ORDER BY created_at DESC
LIMIT 1; LIMIT 1;
-- name: GetSubCourseInitialAssessmentSet :one
SELECT *
FROM question_sets
WHERE set_type = 'INITIAL_ASSESSMENT'
AND owner_type = 'SUB_COURSE'
AND owner_id = $1
AND status = 'PUBLISHED'
ORDER BY created_at DESC
LIMIT 1;
-- name: AddUserPersonaToQuestionSet :one -- name: AddUserPersonaToQuestionSet :one
INSERT INTO question_set_personas ( INSERT INTO question_set_personas (
question_set_id, question_set_id,
@ -120,13 +108,6 @@ INNER JOIN question_set_personas qsp ON qsp.user_id = u.id
WHERE qsp.question_set_id = $1 WHERE qsp.question_set_id = $1
ORDER BY qsp.display_order ASC; ORDER BY qsp.display_order ASC;
-- name: UpdateQuestionSetVideoLink :exec
UPDATE question_sets
SET
sub_course_video_id = $1,
updated_at = CURRENT_TIMESTAMP
WHERE id = $2;
-- name: ReorderQuestionSets :exec -- name: ReorderQuestionSets :exec
UPDATE question_sets UPDATE question_sets
SET display_order = bulk.position SET display_order = bulk.position

View File

@ -1,825 +0,0 @@
{
"info": {
"_postman_id": "d8d17a29-5f9c-4f06-95fd-12e9862f97f8",
"name": "Yimaru Backend - Course Management APIs",
"description": "Fully documented Postman collection for all course-management related endpoints in Yimaru Backend.\n\nAuthentication:\n- All endpoints require `Authorization: Bearer {{accessToken}}`.\n\nBase URL:\n- `{{baseUrl}}/api/{{apiVersion}}`\n\nNotes:\n- IDs in path params must be positive integers.\n- Some update endpoints support partial updates.\n- Endpoint-level permission requirements are documented in each request description.",
"schema": "https://schema.getpostman.com/json/collection/v2.1.0/collection.json"
},
"variable": [
{
"key": "baseUrl",
"value": "https://api.yimaruacademy.com",
"type": "string"
},
{
"key": "apiVersion",
"value": "v1",
"type": "string"
},
{
"key": "accessToken",
"value": "",
"type": "string"
},
{
"key": "categoryId",
"value": "1",
"type": "string"
},
{
"key": "subCategoryId",
"value": "1",
"type": "string"
},
{
"key": "courseId",
"value": "1",
"type": "string"
},
{
"key": "levelId",
"value": "1",
"type": "string"
},
{
"key": "moduleId",
"value": "1",
"type": "string"
},
{
"key": "subModuleId",
"value": "1",
"type": "string"
},
{
"key": "videoId",
"value": "1",
"type": "string"
},
{
"key": "questionSetId",
"value": "1",
"type": "string"
},
{
"key": "practiceId",
"value": "1",
"type": "string"
}
],
"auth": {
"type": "bearer",
"bearer": [
{
"key": "token",
"value": "{{accessToken}}",
"type": "string"
}
]
},
"item": [
{
"name": "Categories & Courses",
"item": [
{
"name": "List Course Categories",
"request": {
"method": "GET",
"header": [],
"url": {
"raw": "{{baseUrl}}/api/{{apiVersion}}/course-management/categories",
"host": [
"{{baseUrl}}"
],
"path": [
"api",
"{{apiVersion}}",
"course-management",
"categories"
]
},
"description": "Returns all course categories.\n\nPermission: `learning_tree.get`."
}
},
{
"name": "Create Course Category",
"request": {
"method": "POST",
"header": [
{
"key": "Content-Type",
"value": "application/json"
}
],
"body": {
"mode": "raw",
"raw": "{\n \"name\": \"Grammar\",\n \"is_active\": true\n}"
},
"url": {
"raw": "{{baseUrl}}/api/{{apiVersion}}/course-management/categories",
"host": [
"{{baseUrl}}"
],
"path": [
"api",
"{{apiVersion}}",
"course-management",
"categories"
]
},
"description": "Creates a course category.\n\nRequired fields:\n- `name` (string)\n\nOptional fields:\n- `is_active` (boolean)\n\nPermission: `course_categories.create`."
}
},
{
"name": "Delete Course Category",
"request": {
"method": "DELETE",
"header": [],
"url": {
"raw": "{{baseUrl}}/api/{{apiVersion}}/course-management/categories/{{categoryId}}",
"host": [
"{{baseUrl}}"
],
"path": [
"api",
"{{apiVersion}}",
"course-management",
"categories",
"{{categoryId}}"
]
},
"description": "Deletes a category by ID.\n\nPath params:\n- `categoryId` (int, required)\n\nPermission: `course_categories.delete`."
}
},
{
"name": "List Courses By Category",
"request": {
"method": "GET",
"header": [],
"url": {
"raw": "{{baseUrl}}/api/{{apiVersion}}/course-management/categories/{{categoryId}}/courses?offset=0&limit=50",
"host": [
"{{baseUrl}}"
],
"path": [
"api",
"{{apiVersion}}",
"course-management",
"categories",
"{{categoryId}}",
"courses"
],
"query": [
{
"key": "offset",
"value": "0"
},
{
"key": "limit",
"value": "50"
}
]
},
"description": "Returns all courses under a category.\n\nPath params:\n- `categoryId` (int, required)\n\nQuery params:\n- `offset` (int, optional, default 0)\n- `limit` (int, optional, default 10000)\n\nPermission: `learning_tree.get`."
}
},
{
"name": "Create Course",
"request": {
"method": "POST",
"header": [
{
"key": "Content-Type",
"value": "application/json"
}
],
"body": {
"mode": "raw",
"raw": "{\n \"category_id\": 1,\n \"sub_category_id\": 1,\n \"title\": \"English Basics\",\n \"description\": \"Beginner-level course\",\n \"thumbnail\": \"https://cdn.example.com/course-thumb.jpg\",\n \"intro_video_url\": \"https://cdn.example.com/intro.mp4\",\n \"is_active\": true\n}"
},
"url": {
"raw": "{{baseUrl}}/api/{{apiVersion}}/course-management/courses",
"host": [
"{{baseUrl}}"
],
"path": [
"api",
"{{apiVersion}}",
"course-management",
"courses"
]
},
"description": "Creates a course.\n\nRequired fields:\n- `category_id` (int)\n- `title` (string)\n\nOptional fields:\n- `sub_category_id` (int)\n- `description` (string)\n- `thumbnail` (string URL)\n- `intro_video_url` (string URL)\n- `is_active` (boolean)\n\nPermission: `courses.create`."
}
},
{
"name": "Update Course",
"request": {
"method": "PUT",
"header": [
{
"key": "Content-Type",
"value": "application/json"
}
],
"body": {
"mode": "raw",
"raw": "{\n \"title\": \"English Basics - Updated\",\n \"description\": \"Updated course description\",\n \"thumbnail\": \"https://cdn.example.com/new-thumb.jpg\",\n \"intro_video_url\": \"https://cdn.example.com/new-intro.mp4\",\n \"is_active\": true\n}"
},
"url": {
"raw": "{{baseUrl}}/api/{{apiVersion}}/course-management/courses/{{courseId}}",
"host": [
"{{baseUrl}}"
],
"path": [
"api",
"{{apiVersion}}",
"course-management",
"courses",
"{{courseId}}"
]
},
"description": "Updates an existing course (partial update accepted).\n\nPath params:\n- `courseId` (int, required)\n\nOptional fields:\n- `title`, `description`, `thumbnail`, `intro_video_url`, `is_active`\n\nPermission: `courses.update`."
}
},
{
"name": "Delete Course",
"request": {
"method": "DELETE",
"header": [],
"url": {
"raw": "{{baseUrl}}/api/{{apiVersion}}/course-management/courses/{{courseId}}",
"host": [
"{{baseUrl}}"
],
"path": [
"api",
"{{apiVersion}}",
"course-management",
"courses",
"{{courseId}}"
]
},
"description": "Deletes a course.\n\nPath params:\n- `courseId` (int, required)\n\nPermission: `courses.delete`."
}
},
{
"name": "Update Course Thumbnail",
"request": {
"method": "POST",
"header": [
{
"key": "Content-Type",
"value": "application/json"
}
],
"body": {
"mode": "raw",
"raw": "{\n \"thumbnail_url\": \"https://cdn.example.com/new-thumbnail.jpg\"\n}"
},
"url": {
"raw": "{{baseUrl}}/api/{{apiVersion}}/course-management/courses/{{courseId}}/thumbnail",
"host": [
"{{baseUrl}}"
],
"path": [
"api",
"{{apiVersion}}",
"course-management",
"courses",
"{{courseId}}",
"thumbnail"
]
},
"description": "Updates only the thumbnail of a course.\n\nPath params:\n- `courseId` (int, required)\n\nRequired fields:\n- `thumbnail_url` (string)\n\nPermission: `courses.upload_thumbnail`."
}
}
]
},
{
"name": "Hierarchy & Learning Path",
"item": [
{
"name": "Get Unified Hierarchy",
"request": {
"method": "GET",
"header": [],
"url": {
"raw": "{{baseUrl}}/api/{{apiVersion}}/course-management/hierarchy",
"host": [
"{{baseUrl}}"
],
"path": [
"api",
"{{apiVersion}}",
"course-management",
"hierarchy"
]
},
"description": "Returns global hierarchy data.\n\nPermission: `learning_tree.get`."
}
},
{
"name": "Get Human Language Hierarchy",
"request": {
"method": "GET",
"header": [],
"url": {
"raw": "{{baseUrl}}/api/{{apiVersion}}/course-management/human-language/hierarchy",
"host": [
"{{baseUrl}}"
],
"path": [
"api",
"{{apiVersion}}",
"course-management",
"human-language",
"hierarchy"
]
},
"description": "Alias endpoint for unified hierarchy under human-language path.\n\nPermission: `learning_tree.get`."
}
},
{
"name": "Get Course Hierarchy",
"request": {
"method": "GET",
"header": [],
"url": {
"raw": "{{baseUrl}}/api/{{apiVersion}}/course-management/courses/{{courseId}}/hierarchy",
"host": [
"{{baseUrl}}"
],
"path": [
"api",
"{{apiVersion}}",
"course-management",
"courses",
"{{courseId}}",
"hierarchy"
]
},
"description": "Returns hierarchy nodes for one course.\n\nPath params:\n- `courseId` (int, required)\n\nPermission: `learning_tree.get`."
}
},
{
"name": "Get Course Learning Path",
"request": {
"method": "GET",
"header": [],
"url": {
"raw": "{{baseUrl}}/api/{{apiVersion}}/course-management/courses/{{courseId}}/learning-path",
"host": [
"{{baseUrl}}"
],
"path": [
"api",
"{{apiVersion}}",
"course-management",
"courses",
"{{courseId}}",
"learning-path"
]
},
"description": "Returns learning-path projection for a course including sub-modules, videos, and practices.\n\nPath params:\n- `courseId` (int, required)\n\nPermission: `learning_tree.get`."
}
}
]
},
{
"name": "Sub-Categories, Levels, Modules, Sub-Modules",
"item": [
{
"name": "Create Course Sub-Category",
"request": {
"method": "POST",
"header": [
{
"key": "Content-Type",
"value": "application/json"
}
],
"body": {
"mode": "raw",
"raw": "{\n \"category_id\": 1,\n \"name\": \"Everyday Conversation\",\n \"description\": \"Spoken communication track\",\n \"display_order\": 1,\n \"is_active\": true\n}"
},
"url": {
"raw": "{{baseUrl}}/api/{{apiVersion}}/course-management/sub-categories",
"host": [
"{{baseUrl}}"
],
"path": [
"api",
"{{apiVersion}}",
"course-management",
"sub-categories"
]
},
"description": "Creates a sub-category under a course category.\n\nRequired fields:\n- `category_id` (int)\n- `name` (string)\n\nOptional fields:\n- `description` (string)\n- `display_order` (int)\n- `is_active` (boolean)\n\nPermission: `course_categories.create`."
}
},
{
"name": "Delete Course Sub-Category",
"request": {
"method": "DELETE",
"header": [],
"url": {
"raw": "{{baseUrl}}/api/{{apiVersion}}/course-management/sub-categories/{{subCategoryId}}",
"host": [
"{{baseUrl}}"
],
"path": [
"api",
"{{apiVersion}}",
"course-management",
"sub-categories",
"{{subCategoryId}}"
]
},
"description": "Deletes a sub-category.\n\nPath params:\n- `subCategoryId` (int, required)\n\nPermission: `course_categories.delete`."
}
},
{
"name": "Create Level",
"request": {
"method": "POST",
"header": [
{
"key": "Content-Type",
"value": "application/json"
}
],
"body": {
"mode": "raw",
"raw": "{\n \"course_id\": 1,\n \"cefr_level\": \"A1\",\n \"display_order\": 1,\n \"is_active\": true\n}"
},
"url": {
"raw": "{{baseUrl}}/api/{{apiVersion}}/course-management/levels",
"host": [
"{{baseUrl}}"
],
"path": [
"api",
"{{apiVersion}}",
"course-management",
"levels"
]
},
"description": "Creates a CEFR level under a course.\n\nRequired fields:\n- `course_id` (int)\n- `cefr_level` (one of: A1, A2, A3, B1, B2, B3, C1, C2, C3)\n\nOptional fields:\n- `display_order` (int)\n- `is_active` (boolean)\n\nPermission: `subcourses.create`."
}
},
{
"name": "Create Module",
"request": {
"method": "POST",
"header": [
{
"key": "Content-Type",
"value": "application/json"
}
],
"body": {
"mode": "raw",
"raw": "{\n \"level_id\": 1,\n \"title\": \"Module 1: Introductions\",\n \"description\": \"Core introduction module\",\n \"display_order\": 1,\n \"is_active\": true\n}"
},
"url": {
"raw": "{{baseUrl}}/api/{{apiVersion}}/course-management/modules",
"host": [
"{{baseUrl}}"
],
"path": [
"api",
"{{apiVersion}}",
"course-management",
"modules"
]
},
"description": "Creates a module under a level.\n\nRequired fields:\n- `level_id` (int)\n- `title` (string)\n\nOptional fields:\n- `description`, `display_order`, `is_active`\n\nPermission: `subcourses.create`."
}
},
{
"name": "Delete Module",
"request": {
"method": "DELETE",
"header": [],
"url": {
"raw": "{{baseUrl}}/api/{{apiVersion}}/course-management/modules/{{moduleId}}",
"host": [
"{{baseUrl}}"
],
"path": [
"api",
"{{apiVersion}}",
"course-management",
"modules",
"{{moduleId}}"
]
},
"description": "Deletes a module.\n\nPath params:\n- `moduleId` (int, required)\n\nPermission: `subcourses.delete`."
}
},
{
"name": "Create Sub-Module",
"request": {
"method": "POST",
"header": [
{
"key": "Content-Type",
"value": "application/json"
}
],
"body": {
"mode": "raw",
"raw": "{\n \"module_id\": 1,\n \"title\": \"Sub-Module 1: Greetings\",\n \"description\": \"Greetings and polite expressions\",\n \"display_order\": 1,\n \"is_active\": true\n}"
},
"url": {
"raw": "{{baseUrl}}/api/{{apiVersion}}/course-management/sub-modules",
"host": [
"{{baseUrl}}"
],
"path": [
"api",
"{{apiVersion}}",
"course-management",
"sub-modules"
]
},
"description": "Creates a sub-module under a module.\n\nRequired fields:\n- `module_id` (int)\n- `title` (string)\n\nOptional fields:\n- `description`, `display_order`, `is_active`\n\nPermission: `subcourses.create`."
}
},
{
"name": "Update Sub-Module",
"request": {
"method": "PUT",
"header": [
{
"key": "Content-Type",
"value": "application/json"
}
],
"body": {
"mode": "raw",
"raw": "{\n \"title\": \"Sub-Module 1: Greetings (Updated)\",\n \"description\": \"Updated sub-module description\",\n \"is_active\": true\n}"
},
"url": {
"raw": "{{baseUrl}}/api/{{apiVersion}}/course-management/sub-modules/{{subModuleId}}",
"host": [
"{{baseUrl}}"
],
"path": [
"api",
"{{apiVersion}}",
"course-management",
"sub-modules",
"{{subModuleId}}"
]
},
"description": "Updates a sub-module.\n\nPath params:\n- `subModuleId` (int, required)\n\nOptional fields:\n- `title`, `description`, `is_active`\n\nPermission: `subcourses.update`."
}
},
{
"name": "Delete Sub-Module",
"request": {
"method": "DELETE",
"header": [],
"url": {
"raw": "{{baseUrl}}/api/{{apiVersion}}/course-management/sub-modules/{{subModuleId}}",
"host": [
"{{baseUrl}}"
],
"path": [
"api",
"{{apiVersion}}",
"course-management",
"sub-modules",
"{{subModuleId}}"
]
},
"description": "Deletes a sub-module.\n\nPath params:\n- `subModuleId` (int, required)\n\nPermission: `subcourses.delete`."
}
}
]
},
{
"name": "Sub-Module Videos",
"item": [
{
"name": "Get Sub-Module Videos",
"request": {
"method": "GET",
"header": [],
"url": {
"raw": "{{baseUrl}}/api/{{apiVersion}}/course-management/sub-modules/{{subModuleId}}/videos",
"host": [
"{{baseUrl}}"
],
"path": [
"api",
"{{apiVersion}}",
"course-management",
"sub-modules",
"{{subModuleId}}",
"videos"
]
},
"description": "Lists videos for a given sub-module.\n\nPath params:\n- `subModuleId` (int, required)\n\nPermission: `videos.list`."
}
},
{
"name": "Create Sub-Module Video",
"request": {
"method": "POST",
"header": [
{
"key": "Content-Type",
"value": "application/json"
}
],
"body": {
"mode": "raw",
"raw": "{\n \"sub_module_id\": 1,\n \"title\": \"Greeting Expressions\",\n \"description\": \"Main lesson video\",\n \"video_url\": \"https://cdn.example.com/videos/greetings.mp4\",\n \"duration\": 480,\n \"resolution\": \"1080p\",\n \"visibility\": \"public\",\n \"instructor_id\": \"instructor-123\",\n \"thumbnail\": \"https://cdn.example.com/thumbs/greetings.jpg\",\n \"display_order\": 1,\n \"status\": \"published\"\n}"
},
"url": {
"raw": "{{baseUrl}}/api/{{apiVersion}}/course-management/sub-module-videos",
"host": [
"{{baseUrl}}"
],
"path": [
"api",
"{{apiVersion}}",
"course-management",
"sub-module-videos"
]
},
"description": "Creates a video under a sub-module.\n\nRequired fields:\n- `sub_module_id` (int)\n- `title` (string)\n- `video_url` (string URL)\n\nOptional fields:\n- `description`, `duration`, `resolution`, `visibility`, `instructor_id`, `thumbnail`, `display_order`, `status`\n\nPermission: `videos.create`."
}
},
{
"name": "Update Sub-Module Video",
"request": {
"method": "PUT",
"header": [
{
"key": "Content-Type",
"value": "application/json"
}
],
"body": {
"mode": "raw",
"raw": "{\n \"title\": \"Greeting Expressions - Updated\",\n \"description\": \"Updated video description\",\n \"video_url\": \"https://cdn.example.com/videos/greetings-v2.mp4\"\n}"
},
"url": {
"raw": "{{baseUrl}}/api/{{apiVersion}}/course-management/sub-module-videos/{{videoId}}",
"host": [
"{{baseUrl}}"
],
"path": [
"api",
"{{apiVersion}}",
"course-management",
"sub-module-videos",
"{{videoId}}"
]
},
"description": "Updates a sub-module video.\n\nPath params:\n- `videoId` (int, required)\n\nRequired fields:\n- `title` (string)\n- `video_url` (string URL)\n\nOptional fields:\n- `description`\n\nPermission: `videos.update`."
}
},
{
"name": "Delete Sub-Module Video",
"request": {
"method": "DELETE",
"header": [],
"url": {
"raw": "{{baseUrl}}/api/{{apiVersion}}/course-management/sub-module-videos/{{videoId}}",
"host": [
"{{baseUrl}}"
],
"path": [
"api",
"{{apiVersion}}",
"course-management",
"sub-module-videos",
"{{videoId}}"
]
},
"description": "Deletes a sub-module video.\n\nPath params:\n- `videoId` (int, required)\n\nPermission: `videos.delete`."
}
}
]
},
{
"name": "Lessons & Practices",
"item": [
{
"name": "Attach Sub-Module Lesson (Question Set)",
"request": {
"method": "POST",
"header": [
{
"key": "Content-Type",
"value": "application/json"
}
],
"body": {
"mode": "raw",
"raw": "{\n \"sub_module_id\": 1,\n \"question_set_id\": 1,\n \"intro_video_url\": \"https://cdn.example.com/intro-lesson.mp4\",\n \"display_order\": 1,\n \"is_active\": true\n}"
},
"url": {
"raw": "{{baseUrl}}/api/{{apiVersion}}/course-management/sub-module-lessons",
"host": [
"{{baseUrl}}"
],
"path": [
"api",
"{{apiVersion}}",
"course-management",
"sub-module-lessons"
]
},
"description": "Links a question set lesson to a sub-module.\n\nRequired fields:\n- `sub_module_id` (int)\n- `question_set_id` (int)\n\nOptional fields:\n- `intro_video_url`, `display_order`, `is_active`\n\nPermission: `question_sets.update`."
}
},
{
"name": "Create Sub-Module Practice",
"request": {
"method": "POST",
"header": [
{
"key": "Content-Type",
"value": "application/json"
}
],
"body": {
"mode": "raw",
"raw": "{\n \"sub_module_id\": 1,\n \"title\": \"Practice: Basic Greetings\",\n \"description\": \"Practice set for greetings\",\n \"thumbnail\": \"https://cdn.example.com/practice-thumb.jpg\",\n \"intro_video_url\": \"https://cdn.example.com/practice-intro.mp4\",\n \"question_set_id\": 1,\n \"display_order\": 1,\n \"is_active\": true\n}"
},
"url": {
"raw": "{{baseUrl}}/api/{{apiVersion}}/course-management/sub-module-practices",
"host": [
"{{baseUrl}}"
],
"path": [
"api",
"{{apiVersion}}",
"course-management",
"sub-module-practices"
]
},
"description": "Creates a practice under a sub-module.\n\nRequired fields:\n- `sub_module_id` (int)\n- `title` (string)\n- `question_set_id` (int)\n\nOptional fields:\n- `description`, `thumbnail`, `intro_video_url`, `display_order`, `is_active`\n\nPermission: `question_sets.update`."
}
},
{
"name": "Update Practice",
"request": {
"method": "PUT",
"header": [
{
"key": "Content-Type",
"value": "application/json"
}
],
"body": {
"mode": "raw",
"raw": "{\n \"title\": \"Practice: Basic Greetings - Updated\",\n \"description\": \"Updated practice details\",\n \"persona\": \"student\",\n \"is_active\": true\n}"
},
"url": {
"raw": "{{baseUrl}}/api/{{apiVersion}}/course-management/practices/{{practiceId}}",
"host": [
"{{baseUrl}}"
],
"path": [
"api",
"{{apiVersion}}",
"course-management",
"practices",
"{{practiceId}}"
]
},
"description": "Updates a practice.\n\nPath params:\n- `practiceId` (int, required)\n\nBehavior:\n- If `is_active` is provided, this endpoint updates practice status.\n- Otherwise, `title` is required and metadata update is applied.\n\nPermission: `question_sets.update`."
}
},
{
"name": "Delete Practice",
"request": {
"method": "DELETE",
"header": [],
"url": {
"raw": "{{baseUrl}}/api/{{apiVersion}}/course-management/practices/{{practiceId}}",
"host": [
"{{baseUrl}}"
],
"path": [
"api",
"{{apiVersion}}",
"course-management",
"practices",
"{{practiceId}}"
]
},
"description": "Deletes a practice.\n\nPath params:\n- `practiceId` (int, required)\n\nPermission: `question_sets.delete`."
}
}
]
}
]
}

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@ -12,10 +12,10 @@ import (
const AnalyticsCourseCounts = `-- name: AnalyticsCourseCounts :one const AnalyticsCourseCounts = `-- name: AnalyticsCourseCounts :one
SELECT SELECT
(SELECT COUNT(*)::bigint FROM course_categories) AS total_categories, 0::bigint AS total_categories,
(SELECT COUNT(*)::bigint FROM courses) AS total_courses, 0::bigint AS total_courses,
(SELECT COUNT(*)::bigint FROM sub_courses) AS total_sub_courses, 0::bigint AS total_sub_courses,
(SELECT COUNT(*)::bigint FROM sub_course_videos) AS total_videos 0::bigint AS total_videos
` `
type AnalyticsCourseCountsRow struct { type AnalyticsCourseCountsRow struct {

View File

@ -1,227 +0,0 @@
package dbgen
import (
"context"
"github.com/jackc/pgx/v5/pgtype"
)
func (q *Queries) GetSubModuleByIDCompat(ctx context.Context, id int64) (SubModule, error) {
row := q.db.QueryRow(ctx, `
SELECT id, module_id, title, description, display_order, is_active, created_at, legacy_sub_course_id, thumbnail, tips
FROM sub_modules
WHERE id = $1
`, id)
var i SubModule
err := row.Scan(
&i.ID,
&i.ModuleID,
&i.Title,
&i.Description,
&i.DisplayOrder,
&i.IsActive,
&i.CreatedAt,
&i.LegacySubCourseID,
&i.Thumbnail,
&i.Tips,
)
return i, err
}
func (q *Queries) UpdateSubModuleCompat(ctx context.Context, id int64, title string, description string, thumbnail string, tips string, displayOrder int32, isActive bool) error {
_, err := q.db.Exec(ctx, `
UPDATE sub_modules
SET
title = $1,
description = NULLIF($2, ''),
thumbnail = NULLIF($3, ''),
tips = NULLIF($4, ''),
display_order = $5,
is_active = $6
WHERE id = $7
`, title, description, thumbnail, tips, displayOrder, isActive, id)
return err
}
func (q *Queries) DeleteSubModuleCompat(ctx context.Context, id int64) error {
_, err := q.db.Exec(ctx, `DELETE FROM sub_modules WHERE id = $1`, id)
return err
}
func (q *Queries) DeleteModuleCompat(ctx context.Context, id int64) error {
_, err := q.db.Exec(ctx, `DELETE FROM modules WHERE id = $1`, id)
return err
}
func (q *Queries) UpdateSubModuleVideoCompat(ctx context.Context, id int64, title string, description string, videoURL string) error {
_, err := q.db.Exec(ctx, `
UPDATE sub_module_videos
SET
title = $1,
description = NULLIF($2, ''),
video_url = $3
WHERE id = $4
`, title, description, videoURL, id)
return err
}
func (q *Queries) DeleteSubModuleVideoCompat(ctx context.Context, id int64) error {
_, err := q.db.Exec(ctx, `DELETE FROM sub_module_videos WHERE id = $1`, id)
return err
}
func (q *Queries) UpdatePracticeCompat(ctx context.Context, id int64, title string, description string, persona string) error {
_, err := q.db.Exec(ctx, `
UPDATE question_sets
SET
title = $1,
description = NULLIF($2, ''),
persona = NULLIF($3, ''),
updated_at = CURRENT_TIMESTAMP
WHERE id = $4
`, title, description, persona, id)
if err != nil {
return err
}
_, err = q.db.Exec(ctx, `
UPDATE sub_module_practices
SET
title = $1,
description = NULLIF($2, '')
WHERE question_set_id = $3
`, title, description, id)
return err
}
func (q *Queries) UpdatePracticeStatusCompat(ctx context.Context, id int64, isActive bool) error {
status := "ARCHIVED"
if isActive {
status = "PUBLISHED"
}
_, err := q.db.Exec(ctx, `
UPDATE question_sets
SET
status = $1,
updated_at = CURRENT_TIMESTAMP
WHERE id = $2
`, status, id)
if err != nil {
return err
}
_, err = q.db.Exec(ctx, `
UPDATE sub_module_practices
SET
is_active = $1,
inactive_since = CASE
WHEN $1 THEN NULL
WHEN is_active THEN NOW()
ELSE inactive_since
END
WHERE question_set_id = $2
`, isActive, id)
return err
}
func (q *Queries) DeletePracticeCompat(ctx context.Context, id int64) error {
_, err := q.db.Exec(ctx, `DELETE FROM question_sets WHERE id = $1`, id)
return err
}
// DeleteCapstoneCompat removes the backing question set (and cascades sub_module_capstones).
func (q *Queries) DeleteCapstoneCompat(ctx context.Context, capstoneID int64) error {
_, err := q.db.Exec(ctx, `
DELETE FROM question_sets
WHERE id = (SELECT question_set_id FROM sub_module_capstones WHERE id = $1)
`, capstoneID)
return err
}
// DeleteModuleCapstoneCompat removes the backing question set (and cascades module_capstones).
func (q *Queries) DeleteModuleCapstoneCompat(ctx context.Context, capstoneID int64) error {
_, err := q.db.Exec(ctx, `
DELETE FROM question_sets
WHERE id = (SELECT question_set_id FROM module_capstones WHERE id = $1)
`, capstoneID)
return err
}
func (q *Queries) CreateCourseCompat(
ctx context.Context,
categoryID int64,
subCategoryID *int64,
title string,
description string,
thumbnail string,
introVideoURL string,
isActive bool,
) (Course, error) {
row := q.db.QueryRow(ctx, `
INSERT INTO courses (
category_id,
sub_category_id,
title,
description,
thumbnail,
intro_video_url,
is_active
)
VALUES (
$1,
$2,
$3,
NULLIF($4, ''),
NULLIF($5, ''),
NULLIF($6, ''),
$7
)
RETURNING id, category_id, title, description, is_active, thumbnail, intro_video_url, display_order, sub_category_id
`, categoryID, subCategoryID, title, description, thumbnail, introVideoURL, isActive)
var i Course
err := row.Scan(
&i.ID,
&i.CategoryID,
&i.Title,
&i.Description,
&i.IsActive,
&i.Thumbnail,
&i.IntroVideoUrl,
&i.DisplayOrder,
&i.SubCategoryID,
)
if err != nil {
return Course{}, err
}
if !i.SubCategoryID.Valid {
i.SubCategoryID = pgtype.Int8{Valid: false}
}
return i, nil
}
func (q *Queries) DeleteCourseSubCategoryCompat(ctx context.Context, subCategoryID int64) error {
_, err := q.db.Exec(ctx, `DELETE FROM courses WHERE sub_category_id = $1`, subCategoryID)
if err != nil {
return err
}
_, err = q.db.Exec(ctx, `DELETE FROM course_sub_categories WHERE id = $1`, subCategoryID)
return err
}
func (q *Queries) DeleteCourseCategoryCompat(ctx context.Context, categoryID int64) error {
_, err := q.db.Exec(ctx, `DELETE FROM courses WHERE category_id = $1`, categoryID)
if err != nil {
return err
}
_, err = q.db.Exec(ctx, `DELETE FROM course_sub_categories WHERE category_id = $1`, categoryID)
if err != nil {
return err
}
_, err = q.db.Exec(ctx, `DELETE FROM course_categories WHERE id = $1`, categoryID)
return err
}

View File

@ -1,158 +0,0 @@
// Code generated by sqlc. DO NOT EDIT.
// versions:
// sqlc v1.30.0
// source: course_catagories.sql
package dbgen
import (
"context"
"github.com/jackc/pgx/v5/pgtype"
)
const CreateCourseCategory = `-- name: CreateCourseCategory :one
INSERT INTO course_categories (
name,
is_active
)
VALUES ($1, COALESCE($2, true))
RETURNING id, name, is_active, created_at, display_order
`
type CreateCourseCategoryParams struct {
Name string `json:"name"`
Column2 interface{} `json:"column_2"`
}
func (q *Queries) CreateCourseCategory(ctx context.Context, arg CreateCourseCategoryParams) (CourseCategory, error) {
row := q.db.QueryRow(ctx, CreateCourseCategory, arg.Name, arg.Column2)
var i CourseCategory
err := row.Scan(
&i.ID,
&i.Name,
&i.IsActive,
&i.CreatedAt,
&i.DisplayOrder,
)
return i, err
}
const DeleteCourseCategory = `-- name: DeleteCourseCategory :exec
DELETE FROM course_categories
WHERE id = $1
`
func (q *Queries) DeleteCourseCategory(ctx context.Context, id int64) error {
_, err := q.db.Exec(ctx, DeleteCourseCategory, id)
return err
}
const GetAllCourseCategories = `-- name: GetAllCourseCategories :many
SELECT
COUNT(*) OVER () AS total_count,
id,
name,
is_active,
created_at
FROM course_categories
ORDER BY display_order ASC, created_at DESC
LIMIT $2::INT
OFFSET $1::INT
`
type GetAllCourseCategoriesParams struct {
Offset pgtype.Int4 `json:"offset"`
Limit pgtype.Int4 `json:"limit"`
}
type GetAllCourseCategoriesRow struct {
TotalCount int64 `json:"total_count"`
ID int64 `json:"id"`
Name string `json:"name"`
IsActive bool `json:"is_active"`
CreatedAt pgtype.Timestamptz `json:"created_at"`
}
func (q *Queries) GetAllCourseCategories(ctx context.Context, arg GetAllCourseCategoriesParams) ([]GetAllCourseCategoriesRow, error) {
rows, err := q.db.Query(ctx, GetAllCourseCategories, arg.Offset, arg.Limit)
if err != nil {
return nil, err
}
defer rows.Close()
var items []GetAllCourseCategoriesRow
for rows.Next() {
var i GetAllCourseCategoriesRow
if err := rows.Scan(
&i.TotalCount,
&i.ID,
&i.Name,
&i.IsActive,
&i.CreatedAt,
); err != nil {
return nil, err
}
items = append(items, i)
}
if err := rows.Err(); err != nil {
return nil, err
}
return items, nil
}
const GetCourseCategoryByID = `-- name: GetCourseCategoryByID :one
SELECT id, name, is_active, created_at, display_order
FROM course_categories
WHERE id = $1
`
func (q *Queries) GetCourseCategoryByID(ctx context.Context, id int64) (CourseCategory, error) {
row := q.db.QueryRow(ctx, GetCourseCategoryByID, id)
var i CourseCategory
err := row.Scan(
&i.ID,
&i.Name,
&i.IsActive,
&i.CreatedAt,
&i.DisplayOrder,
)
return i, err
}
const ReorderCourseCategories = `-- name: ReorderCourseCategories :exec
UPDATE course_categories
SET display_order = bulk.position
FROM (
SELECT unnest($1::BIGINT[]) AS id, unnest($2::INT[]) AS position
) AS bulk
WHERE course_categories.id = bulk.id
`
type ReorderCourseCategoriesParams struct {
Ids []int64 `json:"ids"`
Positions []int32 `json:"positions"`
}
func (q *Queries) ReorderCourseCategories(ctx context.Context, arg ReorderCourseCategoriesParams) error {
_, err := q.db.Exec(ctx, ReorderCourseCategories, arg.Ids, arg.Positions)
return err
}
const UpdateCourseCategory = `-- name: UpdateCourseCategory :exec
UPDATE course_categories
SET
name = COALESCE($1, name),
is_active = COALESCE($2, is_active)
WHERE id = $3
`
type UpdateCourseCategoryParams struct {
Name string `json:"name"`
IsActive bool `json:"is_active"`
ID int64 `json:"id"`
}
func (q *Queries) UpdateCourseCategory(ctx context.Context, arg UpdateCourseCategoryParams) error {
_, err := q.db.Exec(ctx, UpdateCourseCategory, arg.Name, arg.IsActive, arg.ID)
return err
}

View File

@ -1,401 +0,0 @@
// Code generated by sqlc. DO NOT EDIT.
// versions:
// sqlc v1.30.0
// source: courses.sql
package dbgen
import (
"context"
"github.com/jackc/pgx/v5/pgtype"
)
const CreateCourse = `-- name: CreateCourse :one
INSERT INTO courses (
category_id,
title,
description,
thumbnail,
intro_video_url,
is_active
)
VALUES ($1, $2, $3, $4, $5, COALESCE($6, true))
RETURNING id, category_id, title, description, is_active, thumbnail, intro_video_url, display_order, sub_category_id
`
type CreateCourseParams struct {
CategoryID int64 `json:"category_id"`
Title string `json:"title"`
Description pgtype.Text `json:"description"`
Thumbnail pgtype.Text `json:"thumbnail"`
IntroVideoUrl pgtype.Text `json:"intro_video_url"`
Column6 interface{} `json:"column_6"`
}
func (q *Queries) CreateCourse(ctx context.Context, arg CreateCourseParams) (Course, error) {
row := q.db.QueryRow(ctx, CreateCourse,
arg.CategoryID,
arg.Title,
arg.Description,
arg.Thumbnail,
arg.IntroVideoUrl,
arg.Column6,
)
var i Course
err := row.Scan(
&i.ID,
&i.CategoryID,
&i.Title,
&i.Description,
&i.IsActive,
&i.Thumbnail,
&i.IntroVideoUrl,
&i.DisplayOrder,
&i.SubCategoryID,
)
return i, err
}
const DeleteCourse = `-- name: DeleteCourse :exec
DELETE FROM courses
WHERE id = $1
`
func (q *Queries) DeleteCourse(ctx context.Context, id int64) error {
_, err := q.db.Exec(ctx, DeleteCourse, id)
return err
}
const GetAllCourses = `-- name: GetAllCourses :many
SELECT
COUNT(*) OVER () AS total_count,
c.id,
c.category_id,
c.sub_category_id,
c.title,
c.description,
c.thumbnail,
c.intro_video_url,
c.is_active
FROM courses c
ORDER BY c.display_order ASC, c.id ASC
LIMIT $2::INT
OFFSET $1::INT
`
type GetAllCoursesParams struct {
Offset pgtype.Int4 `json:"offset"`
Limit pgtype.Int4 `json:"limit"`
}
type GetAllCoursesRow struct {
TotalCount int64 `json:"total_count"`
ID int64 `json:"id"`
CategoryID int64 `json:"category_id"`
SubCategoryID pgtype.Int8 `json:"sub_category_id"`
Title string `json:"title"`
Description pgtype.Text `json:"description"`
Thumbnail pgtype.Text `json:"thumbnail"`
IntroVideoUrl pgtype.Text `json:"intro_video_url"`
IsActive bool `json:"is_active"`
}
func (q *Queries) GetAllCourses(ctx context.Context, arg GetAllCoursesParams) ([]GetAllCoursesRow, error) {
rows, err := q.db.Query(ctx, GetAllCourses, arg.Offset, arg.Limit)
if err != nil {
return nil, err
}
defer rows.Close()
var items []GetAllCoursesRow
for rows.Next() {
var i GetAllCoursesRow
if err := rows.Scan(
&i.TotalCount,
&i.ID,
&i.CategoryID,
&i.SubCategoryID,
&i.Title,
&i.Description,
&i.Thumbnail,
&i.IntroVideoUrl,
&i.IsActive,
); err != nil {
return nil, err
}
items = append(items, i)
}
if err := rows.Err(); err != nil {
return nil, err
}
return items, nil
}
const GetCourseByID = `-- name: GetCourseByID :one
SELECT id, category_id, title, description, is_active, thumbnail, intro_video_url, display_order, sub_category_id
FROM courses
WHERE id = $1
`
func (q *Queries) GetCourseByID(ctx context.Context, id int64) (Course, error) {
row := q.db.QueryRow(ctx, GetCourseByID, id)
var i Course
err := row.Scan(
&i.ID,
&i.CategoryID,
&i.Title,
&i.Description,
&i.IsActive,
&i.Thumbnail,
&i.IntroVideoUrl,
&i.DisplayOrder,
&i.SubCategoryID,
)
return i, err
}
const GetCoursesByCategory = `-- name: GetCoursesByCategory :many
SELECT
COUNT(*) OVER () AS total_count,
id,
category_id,
title,
description,
thumbnail,
intro_video_url,
is_active
FROM courses
WHERE category_id = $1
ORDER BY display_order ASC, id ASC
LIMIT $3::INT
OFFSET $2::INT
`
type GetCoursesByCategoryParams struct {
CategoryID int64 `json:"category_id"`
Offset pgtype.Int4 `json:"offset"`
Limit pgtype.Int4 `json:"limit"`
}
type GetCoursesByCategoryRow struct {
TotalCount int64 `json:"total_count"`
ID int64 `json:"id"`
CategoryID int64 `json:"category_id"`
Title string `json:"title"`
Description pgtype.Text `json:"description"`
Thumbnail pgtype.Text `json:"thumbnail"`
IntroVideoUrl pgtype.Text `json:"intro_video_url"`
IsActive bool `json:"is_active"`
}
func (q *Queries) GetCoursesByCategory(ctx context.Context, arg GetCoursesByCategoryParams) ([]GetCoursesByCategoryRow, error) {
rows, err := q.db.Query(ctx, GetCoursesByCategory, arg.CategoryID, arg.Offset, arg.Limit)
if err != nil {
return nil, err
}
defer rows.Close()
var items []GetCoursesByCategoryRow
for rows.Next() {
var i GetCoursesByCategoryRow
if err := rows.Scan(
&i.TotalCount,
&i.ID,
&i.CategoryID,
&i.Title,
&i.Description,
&i.Thumbnail,
&i.IntroVideoUrl,
&i.IsActive,
); err != nil {
return nil, err
}
items = append(items, i)
}
if err := rows.Err(); err != nil {
return nil, err
}
return items, nil
}
const GetCoursesBySubCategory = `-- name: GetCoursesBySubCategory :many
SELECT
COUNT(*) OVER () AS total_count,
c.id,
c.category_id,
c.sub_category_id,
c.title,
c.description,
c.thumbnail,
c.intro_video_url,
c.is_active
FROM courses c
WHERE c.sub_category_id = $1
ORDER BY c.display_order ASC, c.id ASC
LIMIT $3::INT
OFFSET $2::INT
`
type GetCoursesBySubCategoryParams struct {
SubCategoryID pgtype.Int8 `json:"sub_category_id"`
Offset pgtype.Int4 `json:"offset"`
Limit pgtype.Int4 `json:"limit"`
}
type GetCoursesBySubCategoryRow struct {
TotalCount int64 `json:"total_count"`
ID int64 `json:"id"`
CategoryID int64 `json:"category_id"`
SubCategoryID pgtype.Int8 `json:"sub_category_id"`
Title string `json:"title"`
Description pgtype.Text `json:"description"`
Thumbnail pgtype.Text `json:"thumbnail"`
IntroVideoUrl pgtype.Text `json:"intro_video_url"`
IsActive bool `json:"is_active"`
}
func (q *Queries) GetCoursesBySubCategory(ctx context.Context, arg GetCoursesBySubCategoryParams) ([]GetCoursesBySubCategoryRow, error) {
rows, err := q.db.Query(ctx, GetCoursesBySubCategory, arg.SubCategoryID, arg.Offset, arg.Limit)
if err != nil {
return nil, err
}
defer rows.Close()
var items []GetCoursesBySubCategoryRow
for rows.Next() {
var i GetCoursesBySubCategoryRow
if err := rows.Scan(
&i.TotalCount,
&i.ID,
&i.CategoryID,
&i.SubCategoryID,
&i.Title,
&i.Description,
&i.Thumbnail,
&i.IntroVideoUrl,
&i.IsActive,
); err != nil {
return nil, err
}
items = append(items, i)
}
if err := rows.Err(); err != nil {
return nil, err
}
return items, nil
}
const GetHumanLanguageCourses = `-- name: GetHumanLanguageCourses :many
SELECT
COUNT(*) OVER () AS total_count,
c.id,
c.category_id,
c.sub_category_id,
c.title,
c.description,
c.thumbnail,
c.intro_video_url,
c.is_active
FROM courses c
JOIN course_categories cc ON cc.id = c.category_id
WHERE lower(trim(cc.name)) = 'human language'
ORDER BY c.display_order ASC, c.id ASC
LIMIT $2::INT
OFFSET $1::INT
`
type GetHumanLanguageCoursesParams struct {
Offset pgtype.Int4 `json:"offset"`
Limit pgtype.Int4 `json:"limit"`
}
type GetHumanLanguageCoursesRow struct {
TotalCount int64 `json:"total_count"`
ID int64 `json:"id"`
CategoryID int64 `json:"category_id"`
SubCategoryID pgtype.Int8 `json:"sub_category_id"`
Title string `json:"title"`
Description pgtype.Text `json:"description"`
Thumbnail pgtype.Text `json:"thumbnail"`
IntroVideoUrl pgtype.Text `json:"intro_video_url"`
IsActive bool `json:"is_active"`
}
func (q *Queries) GetHumanLanguageCourses(ctx context.Context, arg GetHumanLanguageCoursesParams) ([]GetHumanLanguageCoursesRow, error) {
rows, err := q.db.Query(ctx, GetHumanLanguageCourses, arg.Offset, arg.Limit)
if err != nil {
return nil, err
}
defer rows.Close()
var items []GetHumanLanguageCoursesRow
for rows.Next() {
var i GetHumanLanguageCoursesRow
if err := rows.Scan(
&i.TotalCount,
&i.ID,
&i.CategoryID,
&i.SubCategoryID,
&i.Title,
&i.Description,
&i.Thumbnail,
&i.IntroVideoUrl,
&i.IsActive,
); err != nil {
return nil, err
}
items = append(items, i)
}
if err := rows.Err(); err != nil {
return nil, err
}
return items, nil
}
const ReorderCourses = `-- name: ReorderCourses :exec
UPDATE courses
SET display_order = bulk.position
FROM (
SELECT unnest($1::BIGINT[]) AS id, unnest($2::INT[]) AS position
) AS bulk
WHERE courses.id = bulk.id
`
type ReorderCoursesParams struct {
Ids []int64 `json:"ids"`
Positions []int32 `json:"positions"`
}
func (q *Queries) ReorderCourses(ctx context.Context, arg ReorderCoursesParams) error {
_, err := q.db.Exec(ctx, ReorderCourses, arg.Ids, arg.Positions)
return err
}
const UpdateCourse = `-- name: UpdateCourse :exec
UPDATE courses
SET
title = COALESCE($1, title),
description = COALESCE($2, description),
thumbnail = COALESCE($3, thumbnail),
intro_video_url = COALESCE($4, intro_video_url),
is_active = COALESCE($5, is_active)
WHERE id = $6
`
type UpdateCourseParams struct {
Title string `json:"title"`
Description pgtype.Text `json:"description"`
Thumbnail pgtype.Text `json:"thumbnail"`
IntroVideoUrl pgtype.Text `json:"intro_video_url"`
IsActive bool `json:"is_active"`
ID int64 `json:"id"`
}
func (q *Queries) UpdateCourse(ctx context.Context, arg UpdateCourseParams) error {
_, err := q.db.Exec(ctx, UpdateCourse,
arg.Title,
arg.Description,
arg.Thumbnail,
arg.IntroVideoUrl,
arg.IsActive,
arg.ID,
)
return err
}

File diff suppressed because it is too large Load Diff

233
gen/db/lms_courses.sql.go Normal file
View File

@ -0,0 +1,233 @@
// Code generated by sqlc. DO NOT EDIT.
// versions:
// sqlc v1.30.0
// source: lms_courses.sql
package dbgen
import (
"context"
"github.com/jackc/pgx/v5/pgtype"
)
const CreateCourse = `-- name: CreateCourse :one
INSERT INTO courses (program_id, name, description, thumbnail, sort_order)
SELECT
$1,
$2,
$3,
$4,
coalesce((
SELECT
max(c.sort_order)
FROM courses c
WHERE
c.program_id = $1), 0) + 1
RETURNING
id, program_id, name, description, thumbnail, created_at, updated_at, sort_order
`
type CreateCourseParams struct {
ProgramID int64 `json:"program_id"`
Name string `json:"name"`
Description pgtype.Text `json:"description"`
Thumbnail pgtype.Text `json:"thumbnail"`
}
func (q *Queries) CreateCourse(ctx context.Context, arg CreateCourseParams) (Course, error) {
row := q.db.QueryRow(ctx, CreateCourse,
arg.ProgramID,
arg.Name,
arg.Description,
arg.Thumbnail,
)
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 DeleteCourse = `-- name: DeleteCourse :exec
DELETE FROM courses
WHERE id = $1
`
func (q *Queries) DeleteCourse(ctx context.Context, id int64) error {
_, err := q.db.Exec(ctx, DeleteCourse, id)
return err
}
const GetCourseByID = `-- name: GetCourseByID :one
SELECT id, program_id, name, description, thumbnail, created_at, updated_at, sort_order
FROM courses
WHERE id = $1
`
func (q *Queries) GetCourseByID(ctx context.Context, id int64) (Course, error) {
row := q.db.QueryRow(ctx, GetCourseByID, 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 ListCourseIDsByProgram = `-- name: ListCourseIDsByProgram :many
SELECT
c.id
FROM
courses AS c
WHERE
c.program_id = $1
ORDER BY
c.id
`
func (q *Queries) ListCourseIDsByProgram(ctx context.Context, programID int64) ([]int64, error) {
rows, err := q.db.Query(ctx, ListCourseIDsByProgram, programID)
if err != nil {
return nil, err
}
defer rows.Close()
var items []int64
for rows.Next() {
var id int64
if err := rows.Scan(&id); err != nil {
return nil, err
}
items = append(items, id)
}
if err := rows.Err(); err != nil {
return nil, err
}
return items, nil
}
const ListCoursesByProgramID = `-- name: ListCoursesByProgramID :many
SELECT
COUNT(*) OVER () AS total_count,
c.id,
c.program_id,
c.name,
c.description,
c.thumbnail,
c.sort_order,
c.created_at,
c.updated_at
FROM
courses c
WHERE
c.program_id = $1
ORDER BY
c.sort_order ASC,
c.id ASC
LIMIT $2 OFFSET $3
`
type ListCoursesByProgramIDParams struct {
ProgramID int64 `json:"program_id"`
Limit int32 `json:"limit"`
Offset int32 `json:"offset"`
}
type ListCoursesByProgramIDRow struct {
TotalCount int64 `json:"total_count"`
ID int64 `json:"id"`
ProgramID int64 `json:"program_id"`
Name string `json:"name"`
Description pgtype.Text `json:"description"`
Thumbnail pgtype.Text `json:"thumbnail"`
SortOrder int32 `json:"sort_order"`
CreatedAt pgtype.Timestamptz `json:"created_at"`
UpdatedAt pgtype.Timestamptz `json:"updated_at"`
}
func (q *Queries) ListCoursesByProgramID(ctx context.Context, arg ListCoursesByProgramIDParams) ([]ListCoursesByProgramIDRow, error) {
rows, err := q.db.Query(ctx, ListCoursesByProgramID, arg.ProgramID, arg.Limit, arg.Offset)
if err != nil {
return nil, err
}
defer rows.Close()
var items []ListCoursesByProgramIDRow
for rows.Next() {
var i ListCoursesByProgramIDRow
if err := rows.Scan(
&i.TotalCount,
&i.ID,
&i.ProgramID,
&i.Name,
&i.Description,
&i.Thumbnail,
&i.SortOrder,
&i.CreatedAt,
&i.UpdatedAt,
); err != nil {
return nil, err
}
items = append(items, i)
}
if err := rows.Err(); err != nil {
return nil, err
}
return items, nil
}
const UpdateCourse = `-- name: UpdateCourse :one
UPDATE courses
SET
name = COALESCE($1::varchar, name),
description = COALESCE($2::text, description),
thumbnail = COALESCE($3::text, thumbnail),
sort_order = coalesce($4::int, sort_order),
updated_at = CURRENT_TIMESTAMP
WHERE
id = $5
RETURNING
id, program_id, name, description, thumbnail, created_at, updated_at, sort_order
`
type UpdateCourseParams struct {
Name pgtype.Text `json:"name"`
Description pgtype.Text `json:"description"`
Thumbnail pgtype.Text `json:"thumbnail"`
SortOrder pgtype.Int4 `json:"sort_order"`
ID int64 `json:"id"`
}
func (q *Queries) UpdateCourse(ctx context.Context, arg UpdateCourseParams) (Course, error) {
row := q.db.QueryRow(ctx, UpdateCourse,
arg.Name,
arg.Description,
arg.Thumbnail,
arg.SortOrder,
arg.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
}

215
gen/db/lms_lessons.sql.go Normal file
View File

@ -0,0 +1,215 @@
// Code generated by sqlc. DO NOT EDIT.
// versions:
// sqlc v1.30.0
// source: lms_lessons.sql
package dbgen
import (
"context"
"github.com/jackc/pgx/v5/pgtype"
)
const CreateLesson = `-- name: CreateLesson :one
INSERT INTO lessons (module_id, title, video_url, thumbnail, description, sort_order)
SELECT
$1,
$2,
$3,
$4,
$5,
coalesce((
SELECT
max(l.sort_order)
FROM lessons l
WHERE
l.module_id = $1), 0) + 1
RETURNING
id, module_id, title, video_url, thumbnail, description, created_at, updated_at, sort_order
`
type CreateLessonParams struct {
ModuleID int64 `json:"module_id"`
Title string `json:"title"`
VideoUrl pgtype.Text `json:"video_url"`
Thumbnail pgtype.Text `json:"thumbnail"`
Description pgtype.Text `json:"description"`
}
func (q *Queries) CreateLesson(ctx context.Context, arg CreateLessonParams) (Lesson, error) {
row := q.db.QueryRow(ctx, CreateLesson,
arg.ModuleID,
arg.Title,
arg.VideoUrl,
arg.Thumbnail,
arg.Description,
)
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 DeleteLesson = `-- name: DeleteLesson :exec
DELETE FROM lessons
WHERE id = $1
`
func (q *Queries) DeleteLesson(ctx context.Context, id int64) error {
_, err := q.db.Exec(ctx, DeleteLesson, id)
return err
}
const GetLessonByID = `-- name: GetLessonByID :one
SELECT id, module_id, title, video_url, thumbnail, description, created_at, updated_at, sort_order
FROM lessons
WHERE id = $1
`
func (q *Queries) GetLessonByID(ctx context.Context, id int64) (Lesson, error) {
row := q.db.QueryRow(ctx, GetLessonByID, 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 ListLessonsByModuleID = `-- name: ListLessonsByModuleID :many
SELECT
COUNT(*) OVER () AS total_count,
l.id,
l.module_id,
l.title,
l.video_url,
l.thumbnail,
l.description,
l.sort_order,
l.created_at,
l.updated_at
FROM
lessons l
WHERE
l.module_id = $1
ORDER BY
l.sort_order ASC,
l.id ASC
LIMIT $2
OFFSET $3
`
type ListLessonsByModuleIDParams struct {
ModuleID int64 `json:"module_id"`
Limit int32 `json:"limit"`
Offset int32 `json:"offset"`
}
type ListLessonsByModuleIDRow struct {
TotalCount int64 `json:"total_count"`
ID int64 `json:"id"`
ModuleID int64 `json:"module_id"`
Title string `json:"title"`
VideoUrl pgtype.Text `json:"video_url"`
Thumbnail pgtype.Text `json:"thumbnail"`
Description pgtype.Text `json:"description"`
SortOrder int32 `json:"sort_order"`
CreatedAt pgtype.Timestamptz `json:"created_at"`
UpdatedAt pgtype.Timestamptz `json:"updated_at"`
}
func (q *Queries) ListLessonsByModuleID(ctx context.Context, arg ListLessonsByModuleIDParams) ([]ListLessonsByModuleIDRow, error) {
rows, err := q.db.Query(ctx, ListLessonsByModuleID, arg.ModuleID, arg.Limit, arg.Offset)
if err != nil {
return nil, err
}
defer rows.Close()
var items []ListLessonsByModuleIDRow
for rows.Next() {
var i ListLessonsByModuleIDRow
if err := rows.Scan(
&i.TotalCount,
&i.ID,
&i.ModuleID,
&i.Title,
&i.VideoUrl,
&i.Thumbnail,
&i.Description,
&i.SortOrder,
&i.CreatedAt,
&i.UpdatedAt,
); err != nil {
return nil, err
}
items = append(items, i)
}
if err := rows.Err(); err != nil {
return nil, err
}
return items, nil
}
const UpdateLesson = `-- name: UpdateLesson :one
UPDATE lessons
SET
title = COALESCE($1::varchar, title),
video_url = COALESCE($2::text, video_url),
thumbnail = COALESCE($3::text, thumbnail),
description = COALESCE($4::text, description),
sort_order = coalesce($5::int, sort_order),
updated_at = CURRENT_TIMESTAMP
WHERE
id = $6
RETURNING
id, module_id, title, video_url, thumbnail, description, created_at, updated_at, sort_order
`
type UpdateLessonParams struct {
Title pgtype.Text `json:"title"`
VideoUrl pgtype.Text `json:"video_url"`
Thumbnail pgtype.Text `json:"thumbnail"`
Description pgtype.Text `json:"description"`
SortOrder pgtype.Int4 `json:"sort_order"`
ID int64 `json:"id"`
}
func (q *Queries) UpdateLesson(ctx context.Context, arg UpdateLessonParams) (Lesson, error) {
row := q.db.QueryRow(ctx, UpdateLesson,
arg.Title,
arg.VideoUrl,
arg.Thumbnail,
arg.Description,
arg.SortOrder,
arg.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
}

250
gen/db/lms_modules.sql.go Normal file
View File

@ -0,0 +1,250 @@
// Code generated by sqlc. DO NOT EDIT.
// versions:
// sqlc v1.30.0
// source: lms_modules.sql
package dbgen
import (
"context"
"github.com/jackc/pgx/v5/pgtype"
)
const CreateModule = `-- name: CreateModule :one
INSERT INTO modules (program_id, course_id, name, description, icon, sort_order)
SELECT
$1,
$2,
$3,
$4,
$5,
coalesce((
SELECT
max(m.sort_order)
FROM modules m
WHERE
m.course_id = $2), 0) + 1
RETURNING
id, program_id, course_id, name, description, icon, created_at, updated_at, sort_order
`
type CreateModuleParams struct {
ProgramID int64 `json:"program_id"`
CourseID int64 `json:"course_id"`
Name string `json:"name"`
Description pgtype.Text `json:"description"`
Icon pgtype.Text `json:"icon"`
}
func (q *Queries) CreateModule(ctx context.Context, arg CreateModuleParams) (Module, error) {
row := q.db.QueryRow(ctx, CreateModule,
arg.ProgramID,
arg.CourseID,
arg.Name,
arg.Description,
arg.Icon,
)
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 DeleteModule = `-- name: DeleteModule :exec
DELETE FROM modules
WHERE id = $1
`
func (q *Queries) DeleteModule(ctx context.Context, id int64) error {
_, err := q.db.Exec(ctx, DeleteModule, id)
return err
}
const GetModuleByID = `-- name: GetModuleByID :one
SELECT id, program_id, course_id, name, description, icon, created_at, updated_at, sort_order
FROM modules
WHERE id = $1
`
func (q *Queries) GetModuleByID(ctx context.Context, id int64) (Module, error) {
row := q.db.QueryRow(ctx, GetModuleByID, 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 ListModuleIDsByCourse = `-- name: ListModuleIDsByCourse :many
SELECT
m.id
FROM
modules AS m
WHERE
m.course_id = $1
ORDER BY
m.id
`
func (q *Queries) ListModuleIDsByCourse(ctx context.Context, courseID int64) ([]int64, error) {
rows, err := q.db.Query(ctx, ListModuleIDsByCourse, courseID)
if err != nil {
return nil, err
}
defer rows.Close()
var items []int64
for rows.Next() {
var id int64
if err := rows.Scan(&id); err != nil {
return nil, err
}
items = append(items, id)
}
if err := rows.Err(); err != nil {
return nil, err
}
return items, nil
}
const ListModulesByProgramAndCourse = `-- name: ListModulesByProgramAndCourse :many
SELECT
COUNT(*) OVER () AS total_count,
m.id,
m.program_id,
m.course_id,
m.name,
m.description,
m.icon,
m.sort_order,
m.created_at,
m.updated_at
FROM
modules m
WHERE
m.program_id = $1
AND m.course_id = $2
ORDER BY
m.sort_order ASC,
m.id ASC
LIMIT $3
OFFSET $4
`
type ListModulesByProgramAndCourseParams struct {
ProgramID int64 `json:"program_id"`
CourseID int64 `json:"course_id"`
Limit int32 `json:"limit"`
Offset int32 `json:"offset"`
}
type ListModulesByProgramAndCourseRow struct {
TotalCount int64 `json:"total_count"`
ID int64 `json:"id"`
ProgramID int64 `json:"program_id"`
CourseID int64 `json:"course_id"`
Name string `json:"name"`
Description pgtype.Text `json:"description"`
Icon pgtype.Text `json:"icon"`
SortOrder int32 `json:"sort_order"`
CreatedAt pgtype.Timestamptz `json:"created_at"`
UpdatedAt pgtype.Timestamptz `json:"updated_at"`
}
func (q *Queries) ListModulesByProgramAndCourse(ctx context.Context, arg ListModulesByProgramAndCourseParams) ([]ListModulesByProgramAndCourseRow, error) {
rows, err := q.db.Query(ctx, ListModulesByProgramAndCourse,
arg.ProgramID,
arg.CourseID,
arg.Limit,
arg.Offset,
)
if err != nil {
return nil, err
}
defer rows.Close()
var items []ListModulesByProgramAndCourseRow
for rows.Next() {
var i ListModulesByProgramAndCourseRow
if err := rows.Scan(
&i.TotalCount,
&i.ID,
&i.ProgramID,
&i.CourseID,
&i.Name,
&i.Description,
&i.Icon,
&i.SortOrder,
&i.CreatedAt,
&i.UpdatedAt,
); err != nil {
return nil, err
}
items = append(items, i)
}
if err := rows.Err(); err != nil {
return nil, err
}
return items, nil
}
const UpdateModule = `-- name: UpdateModule :one
UPDATE modules
SET
name = COALESCE($1::varchar, name),
description = COALESCE($2::text, description),
icon = COALESCE($3::text, icon),
sort_order = coalesce($4::int, sort_order),
updated_at = CURRENT_TIMESTAMP
WHERE
id = $5
RETURNING
id, program_id, course_id, name, description, icon, created_at, updated_at, sort_order
`
type UpdateModuleParams struct {
Name pgtype.Text `json:"name"`
Description pgtype.Text `json:"description"`
Icon pgtype.Text `json:"icon"`
SortOrder pgtype.Int4 `json:"sort_order"`
ID int64 `json:"id"`
}
func (q *Queries) UpdateModule(ctx context.Context, arg UpdateModuleParams) (Module, error) {
row := q.db.QueryRow(ctx, UpdateModule,
arg.Name,
arg.Description,
arg.Icon,
arg.SortOrder,
arg.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
}

381
gen/db/lms_practices.sql.go Normal file
View File

@ -0,0 +1,381 @@
// Code generated by sqlc. DO NOT EDIT.
// versions:
// sqlc v1.30.0
// source: lms_practices.sql
package dbgen
import (
"context"
"github.com/jackc/pgx/v5/pgtype"
)
const CreateLmsPractice = `-- name: CreateLmsPractice :one
INSERT INTO lms_practices (
course_id, module_id, lesson_id,
title, story_description, story_image, persona_id, question_set_id, quick_tips
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)
RETURNING id, course_id, module_id, lesson_id, title, story_description, story_image, persona_id, question_set_id, quick_tips, created_at, updated_at
`
type CreateLmsPracticeParams struct {
CourseID pgtype.Int8 `json:"course_id"`
ModuleID pgtype.Int8 `json:"module_id"`
LessonID pgtype.Int8 `json:"lesson_id"`
Title string `json:"title"`
StoryDescription pgtype.Text `json:"story_description"`
StoryImage pgtype.Text `json:"story_image"`
PersonaID pgtype.Int8 `json:"persona_id"`
QuestionSetID int64 `json:"question_set_id"`
QuickTips pgtype.Text `json:"quick_tips"`
}
func (q *Queries) CreateLmsPractice(ctx context.Context, arg CreateLmsPracticeParams) (LmsPractice, error) {
row := q.db.QueryRow(ctx, CreateLmsPractice,
arg.CourseID,
arg.ModuleID,
arg.LessonID,
arg.Title,
arg.StoryDescription,
arg.StoryImage,
arg.PersonaID,
arg.QuestionSetID,
arg.QuickTips,
)
var i LmsPractice
err := row.Scan(
&i.ID,
&i.CourseID,
&i.ModuleID,
&i.LessonID,
&i.Title,
&i.StoryDescription,
&i.StoryImage,
&i.PersonaID,
&i.QuestionSetID,
&i.QuickTips,
&i.CreatedAt,
&i.UpdatedAt,
)
return i, err
}
const DeleteLmsPractice = `-- name: DeleteLmsPractice :exec
DELETE FROM lms_practices
WHERE id = $1
`
func (q *Queries) DeleteLmsPractice(ctx context.Context, id int64) error {
_, err := q.db.Exec(ctx, DeleteLmsPractice, id)
return err
}
const GetLmsPracticeByID = `-- name: GetLmsPracticeByID :one
SELECT id, course_id, module_id, lesson_id, title, story_description, story_image, persona_id, question_set_id, quick_tips, created_at, updated_at
FROM lms_practices
WHERE id = $1
`
func (q *Queries) GetLmsPracticeByID(ctx context.Context, id int64) (LmsPractice, error) {
row := q.db.QueryRow(ctx, GetLmsPracticeByID, id)
var i LmsPractice
err := row.Scan(
&i.ID,
&i.CourseID,
&i.ModuleID,
&i.LessonID,
&i.Title,
&i.StoryDescription,
&i.StoryImage,
&i.PersonaID,
&i.QuestionSetID,
&i.QuickTips,
&i.CreatedAt,
&i.UpdatedAt,
)
return i, err
}
const ListLmsPracticesByCourseID = `-- name: ListLmsPracticesByCourseID :many
SELECT
COUNT(*) OVER () AS total_count,
p.id,
p.course_id,
p.module_id,
p.lesson_id,
p.title,
p.story_description,
p.story_image,
p.persona_id,
p.question_set_id,
p.quick_tips,
p.created_at,
p.updated_at
FROM lms_practices p
WHERE p.course_id = $1
ORDER BY p.created_at DESC
LIMIT $2 OFFSET $3
`
type ListLmsPracticesByCourseIDParams struct {
CourseID pgtype.Int8 `json:"course_id"`
Limit int32 `json:"limit"`
Offset int32 `json:"offset"`
}
type ListLmsPracticesByCourseIDRow struct {
TotalCount int64 `json:"total_count"`
ID int64 `json:"id"`
CourseID pgtype.Int8 `json:"course_id"`
ModuleID pgtype.Int8 `json:"module_id"`
LessonID pgtype.Int8 `json:"lesson_id"`
Title string `json:"title"`
StoryDescription pgtype.Text `json:"story_description"`
StoryImage pgtype.Text `json:"story_image"`
PersonaID pgtype.Int8 `json:"persona_id"`
QuestionSetID int64 `json:"question_set_id"`
QuickTips pgtype.Text `json:"quick_tips"`
CreatedAt pgtype.Timestamptz `json:"created_at"`
UpdatedAt pgtype.Timestamptz `json:"updated_at"`
}
func (q *Queries) ListLmsPracticesByCourseID(ctx context.Context, arg ListLmsPracticesByCourseIDParams) ([]ListLmsPracticesByCourseIDRow, error) {
rows, err := q.db.Query(ctx, ListLmsPracticesByCourseID, arg.CourseID, arg.Limit, arg.Offset)
if err != nil {
return nil, err
}
defer rows.Close()
var items []ListLmsPracticesByCourseIDRow
for rows.Next() {
var i ListLmsPracticesByCourseIDRow
if err := rows.Scan(
&i.TotalCount,
&i.ID,
&i.CourseID,
&i.ModuleID,
&i.LessonID,
&i.Title,
&i.StoryDescription,
&i.StoryImage,
&i.PersonaID,
&i.QuestionSetID,
&i.QuickTips,
&i.CreatedAt,
&i.UpdatedAt,
); err != nil {
return nil, err
}
items = append(items, i)
}
if err := rows.Err(); err != nil {
return nil, err
}
return items, nil
}
const ListLmsPracticesByLessonID = `-- name: ListLmsPracticesByLessonID :many
SELECT
COUNT(*) OVER () AS total_count,
p.id,
p.course_id,
p.module_id,
p.lesson_id,
p.title,
p.story_description,
p.story_image,
p.persona_id,
p.question_set_id,
p.quick_tips,
p.created_at,
p.updated_at
FROM lms_practices p
WHERE p.lesson_id = $1
ORDER BY p.created_at DESC
LIMIT $2 OFFSET $3
`
type ListLmsPracticesByLessonIDParams struct {
LessonID pgtype.Int8 `json:"lesson_id"`
Limit int32 `json:"limit"`
Offset int32 `json:"offset"`
}
type ListLmsPracticesByLessonIDRow struct {
TotalCount int64 `json:"total_count"`
ID int64 `json:"id"`
CourseID pgtype.Int8 `json:"course_id"`
ModuleID pgtype.Int8 `json:"module_id"`
LessonID pgtype.Int8 `json:"lesson_id"`
Title string `json:"title"`
StoryDescription pgtype.Text `json:"story_description"`
StoryImage pgtype.Text `json:"story_image"`
PersonaID pgtype.Int8 `json:"persona_id"`
QuestionSetID int64 `json:"question_set_id"`
QuickTips pgtype.Text `json:"quick_tips"`
CreatedAt pgtype.Timestamptz `json:"created_at"`
UpdatedAt pgtype.Timestamptz `json:"updated_at"`
}
func (q *Queries) ListLmsPracticesByLessonID(ctx context.Context, arg ListLmsPracticesByLessonIDParams) ([]ListLmsPracticesByLessonIDRow, error) {
rows, err := q.db.Query(ctx, ListLmsPracticesByLessonID, arg.LessonID, arg.Limit, arg.Offset)
if err != nil {
return nil, err
}
defer rows.Close()
var items []ListLmsPracticesByLessonIDRow
for rows.Next() {
var i ListLmsPracticesByLessonIDRow
if err := rows.Scan(
&i.TotalCount,
&i.ID,
&i.CourseID,
&i.ModuleID,
&i.LessonID,
&i.Title,
&i.StoryDescription,
&i.StoryImage,
&i.PersonaID,
&i.QuestionSetID,
&i.QuickTips,
&i.CreatedAt,
&i.UpdatedAt,
); err != nil {
return nil, err
}
items = append(items, i)
}
if err := rows.Err(); err != nil {
return nil, err
}
return items, nil
}
const ListLmsPracticesByModuleID = `-- name: ListLmsPracticesByModuleID :many
SELECT
COUNT(*) OVER () AS total_count,
p.id,
p.course_id,
p.module_id,
p.lesson_id,
p.title,
p.story_description,
p.story_image,
p.persona_id,
p.question_set_id,
p.quick_tips,
p.created_at,
p.updated_at
FROM lms_practices p
WHERE p.module_id = $1
ORDER BY p.created_at DESC
LIMIT $2 OFFSET $3
`
type ListLmsPracticesByModuleIDParams struct {
ModuleID pgtype.Int8 `json:"module_id"`
Limit int32 `json:"limit"`
Offset int32 `json:"offset"`
}
type ListLmsPracticesByModuleIDRow struct {
TotalCount int64 `json:"total_count"`
ID int64 `json:"id"`
CourseID pgtype.Int8 `json:"course_id"`
ModuleID pgtype.Int8 `json:"module_id"`
LessonID pgtype.Int8 `json:"lesson_id"`
Title string `json:"title"`
StoryDescription pgtype.Text `json:"story_description"`
StoryImage pgtype.Text `json:"story_image"`
PersonaID pgtype.Int8 `json:"persona_id"`
QuestionSetID int64 `json:"question_set_id"`
QuickTips pgtype.Text `json:"quick_tips"`
CreatedAt pgtype.Timestamptz `json:"created_at"`
UpdatedAt pgtype.Timestamptz `json:"updated_at"`
}
func (q *Queries) ListLmsPracticesByModuleID(ctx context.Context, arg ListLmsPracticesByModuleIDParams) ([]ListLmsPracticesByModuleIDRow, error) {
rows, err := q.db.Query(ctx, ListLmsPracticesByModuleID, arg.ModuleID, arg.Limit, arg.Offset)
if err != nil {
return nil, err
}
defer rows.Close()
var items []ListLmsPracticesByModuleIDRow
for rows.Next() {
var i ListLmsPracticesByModuleIDRow
if err := rows.Scan(
&i.TotalCount,
&i.ID,
&i.CourseID,
&i.ModuleID,
&i.LessonID,
&i.Title,
&i.StoryDescription,
&i.StoryImage,
&i.PersonaID,
&i.QuestionSetID,
&i.QuickTips,
&i.CreatedAt,
&i.UpdatedAt,
); err != nil {
return nil, err
}
items = append(items, i)
}
if err := rows.Err(); err != nil {
return nil, err
}
return items, nil
}
const UpdateLmsPractice = `-- name: UpdateLmsPractice :one
UPDATE lms_practices
SET
title = COALESCE($1::varchar, title),
story_description = COALESCE($2::text, story_description),
story_image = COALESCE($3::text, story_image),
persona_id = COALESCE($4::bigint, persona_id),
question_set_id = COALESCE($5::bigint, question_set_id),
quick_tips = COALESCE($6::text, quick_tips),
updated_at = CURRENT_TIMESTAMP
WHERE id = $7
RETURNING id, course_id, module_id, lesson_id, title, story_description, story_image, persona_id, question_set_id, quick_tips, created_at, updated_at
`
type UpdateLmsPracticeParams struct {
Title pgtype.Text `json:"title"`
StoryDescription pgtype.Text `json:"story_description"`
StoryImage pgtype.Text `json:"story_image"`
PersonaID pgtype.Int8 `json:"persona_id"`
QuestionSetID pgtype.Int8 `json:"question_set_id"`
QuickTips pgtype.Text `json:"quick_tips"`
ID int64 `json:"id"`
}
func (q *Queries) UpdateLmsPractice(ctx context.Context, arg UpdateLmsPracticeParams) (LmsPractice, error) {
row := q.db.QueryRow(ctx, UpdateLmsPractice,
arg.Title,
arg.StoryDescription,
arg.StoryImage,
arg.PersonaID,
arg.QuestionSetID,
arg.QuickTips,
arg.ID,
)
var i LmsPractice
err := row.Scan(
&i.ID,
&i.CourseID,
&i.ModuleID,
&i.LessonID,
&i.Title,
&i.StoryDescription,
&i.StoryImage,
&i.PersonaID,
&i.QuestionSetID,
&i.QuickTips,
&i.CreatedAt,
&i.UpdatedAt,
)
return i, err
}

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

@ -0,0 +1,613 @@
// Code generated by sqlc. DO NOT EDIT.
// versions:
// sqlc v1.30.0
// source: lms_progress.sql
package dbgen
import (
"context"
)
const CountCoursesInProgram = `-- name: CountCoursesInProgram :one
SELECT
count(*)::int AS n
FROM
courses
WHERE
program_id = $1
`
func (q *Queries) CountCoursesInProgram(ctx context.Context, programID int64) (int32, error) {
row := q.db.QueryRow(ctx, CountCoursesInProgram, programID)
var n int32
err := row.Scan(&n)
return n, err
}
const CountLessonsInCourse = `-- name: CountLessonsInCourse :one
SELECT
count(*)::int AS n
FROM
lessons l
INNER JOIN modules m ON m.id = l.module_id
WHERE
m.course_id = $1
`
// Lesson-based progress within a course (all modules).
func (q *Queries) CountLessonsInCourse(ctx context.Context, courseID int64) (int32, error) {
row := q.db.QueryRow(ctx, CountLessonsInCourse, courseID)
var n int32
err := row.Scan(&n)
return n, err
}
const CountLessonsInModule = `-- name: CountLessonsInModule :one
SELECT
count(*)::int AS n
FROM
lessons
WHERE
module_id = $1
`
func (q *Queries) CountLessonsInModule(ctx context.Context, moduleID int64) (int32, error) {
row := q.db.QueryRow(ctx, CountLessonsInModule, moduleID)
var n int32
err := row.Scan(&n)
return n, err
}
const CountLessonsInProgram = `-- name: CountLessonsInProgram :one
SELECT
count(*)::int AS n
FROM
lessons l
INNER JOIN modules m ON m.id = l.module_id
INNER JOIN courses c ON c.id = m.course_id
WHERE
c.program_id = $1
`
// Lesson-based progress within a program (all courses).
func (q *Queries) CountLessonsInProgram(ctx context.Context, programID int64) (int32, error) {
row := q.db.QueryRow(ctx, CountLessonsInProgram, programID)
var n int32
err := row.Scan(&n)
return n, err
}
const CountModulesInCourse = `-- name: CountModulesInCourse :one
SELECT
count(*)::int AS n
FROM
modules
WHERE
course_id = $1
`
func (q *Queries) CountModulesInCourse(ctx context.Context, courseID int64) (int32, error) {
row := q.db.QueryRow(ctx, CountModulesInCourse, courseID)
var n int32
err := row.Scan(&n)
return n, err
}
const CountUserCompletedCoursesInProgram = `-- name: CountUserCompletedCoursesInProgram :one
SELECT
count(*)::int AS n
FROM
lms_user_course_progress ucp
INNER JOIN courses c ON c.id = ucp.course_id
WHERE
c.program_id = $1
AND ucp.user_id = $2
`
type CountUserCompletedCoursesInProgramParams struct {
ProgramID int64 `json:"program_id"`
UserID int64 `json:"user_id"`
}
func (q *Queries) CountUserCompletedCoursesInProgram(ctx context.Context, arg CountUserCompletedCoursesInProgramParams) (int32, error) {
row := q.db.QueryRow(ctx, CountUserCompletedCoursesInProgram, arg.ProgramID, arg.UserID)
var n int32
err := row.Scan(&n)
return n, err
}
const CountUserCompletedLessonsInCourse = `-- name: CountUserCompletedLessonsInCourse :one
SELECT
count(*)::int AS n
FROM
lms_user_lesson_progress ulp
INNER JOIN lessons l ON l.id = ulp.lesson_id
INNER JOIN modules m ON m.id = l.module_id
WHERE
m.course_id = $1
AND ulp.user_id = $2
`
type CountUserCompletedLessonsInCourseParams struct {
CourseID int64 `json:"course_id"`
UserID int64 `json:"user_id"`
}
func (q *Queries) CountUserCompletedLessonsInCourse(ctx context.Context, arg CountUserCompletedLessonsInCourseParams) (int32, error) {
row := q.db.QueryRow(ctx, CountUserCompletedLessonsInCourse, arg.CourseID, arg.UserID)
var n int32
err := row.Scan(&n)
return n, err
}
const CountUserCompletedLessonsInModule = `-- name: CountUserCompletedLessonsInModule :one
SELECT
count(*)::int AS n
FROM
lms_user_lesson_progress ulp
INNER JOIN lessons l ON l.id = ulp.lesson_id
WHERE
l.module_id = $1
AND ulp.user_id = $2
`
type CountUserCompletedLessonsInModuleParams struct {
ModuleID int64 `json:"module_id"`
UserID int64 `json:"user_id"`
}
func (q *Queries) CountUserCompletedLessonsInModule(ctx context.Context, arg CountUserCompletedLessonsInModuleParams) (int32, error) {
row := q.db.QueryRow(ctx, CountUserCompletedLessonsInModule, arg.ModuleID, arg.UserID)
var n int32
err := row.Scan(&n)
return n, err
}
const CountUserCompletedLessonsInProgram = `-- name: CountUserCompletedLessonsInProgram :one
SELECT
count(*)::int AS n
FROM
lms_user_lesson_progress ulp
INNER JOIN lessons l ON l.id = ulp.lesson_id
INNER JOIN modules m ON m.id = l.module_id
INNER JOIN courses c ON c.id = m.course_id
WHERE
c.program_id = $1
AND ulp.user_id = $2
`
type CountUserCompletedLessonsInProgramParams struct {
ProgramID int64 `json:"program_id"`
UserID int64 `json:"user_id"`
}
func (q *Queries) CountUserCompletedLessonsInProgram(ctx context.Context, arg CountUserCompletedLessonsInProgramParams) (int32, error) {
row := q.db.QueryRow(ctx, CountUserCompletedLessonsInProgram, arg.ProgramID, arg.UserID)
var n int32
err := row.Scan(&n)
return n, err
}
const CountUserCompletedModulesInCourse = `-- name: CountUserCompletedModulesInCourse :one
SELECT
count(*)::int AS n
FROM
lms_user_module_progress ump
INNER JOIN modules m ON m.id = ump.module_id
WHERE
m.course_id = $1
AND ump.user_id = $2
`
type CountUserCompletedModulesInCourseParams struct {
CourseID int64 `json:"course_id"`
UserID int64 `json:"user_id"`
}
func (q *Queries) CountUserCompletedModulesInCourse(ctx context.Context, arg CountUserCompletedModulesInCourseParams) (int32, error) {
row := q.db.QueryRow(ctx, CountUserCompletedModulesInCourse, arg.CourseID, arg.UserID)
var n int32
err := row.Scan(&n)
return n, err
}
const GetPreviousCourseInProgram = `-- name: GetPreviousCourseInProgram :one
SELECT
c2.id, c2.program_id, c2.name, c2.description, c2.thumbnail, c2.created_at, c2.updated_at, c2.sort_order
FROM
courses AS c1
INNER JOIN courses AS c2 ON c2.program_id = c1.program_id
AND c2.sort_order = c1.sort_order - 1
WHERE
c1.id = $1
`
func (q *Queries) GetPreviousCourseInProgram(ctx context.Context, id int64) (Course, error) {
row := q.db.QueryRow(ctx, GetPreviousCourseInProgram, id)
var i Course
err := row.Scan(
&i.ID,
&i.ProgramID,
&i.Name,
&i.Description,
&i.Thumbnail,
&i.CreatedAt,
&i.UpdatedAt,
&i.SortOrder,
)
return i, err
}
const GetPreviousLessonInModule = `-- name: GetPreviousLessonInModule :one
SELECT
l2.id, l2.module_id, l2.title, l2.video_url, l2.thumbnail, l2.description, l2.created_at, l2.updated_at, l2.sort_order
FROM
lessons AS l1
INNER JOIN lessons AS l2 ON l2.module_id = l1.module_id
AND l2.sort_order = l1.sort_order - 1
WHERE
l1.id = $1
`
func (q *Queries) GetPreviousLessonInModule(ctx context.Context, id int64) (Lesson, error) {
row := q.db.QueryRow(ctx, GetPreviousLessonInModule, id)
var i Lesson
err := row.Scan(
&i.ID,
&i.ModuleID,
&i.Title,
&i.VideoUrl,
&i.Thumbnail,
&i.Description,
&i.CreatedAt,
&i.UpdatedAt,
&i.SortOrder,
)
return i, err
}
const GetPreviousModuleInCourse = `-- name: GetPreviousModuleInCourse :one
SELECT
m2.id, m2.program_id, m2.course_id, m2.name, m2.description, m2.icon, m2.created_at, m2.updated_at, m2.sort_order
FROM
modules AS m1
INNER JOIN modules AS m2 ON m2.course_id = m1.course_id
AND m2.sort_order = m1.sort_order - 1
WHERE
m1.id = $1
`
func (q *Queries) GetPreviousModuleInCourse(ctx context.Context, id int64) (Module, error) {
row := q.db.QueryRow(ctx, GetPreviousModuleInCourse, id)
var i Module
err := row.Scan(
&i.ID,
&i.ProgramID,
&i.CourseID,
&i.Name,
&i.Description,
&i.Icon,
&i.CreatedAt,
&i.UpdatedAt,
&i.SortOrder,
)
return i, err
}
const GetPreviousProgram = `-- name: GetPreviousProgram :one
SELECT
p2.id, p2.name, p2.description, p2.thumbnail, p2.created_at, p2.updated_at, p2.sort_order
FROM
programs AS p1
INNER JOIN programs AS p2 ON p2.sort_order = p1.sort_order - 1
WHERE
p1.id = $1
`
func (q *Queries) GetPreviousProgram(ctx context.Context, id int64) (Program, error) {
row := q.db.QueryRow(ctx, GetPreviousProgram, id)
var i Program
err := row.Scan(
&i.ID,
&i.Name,
&i.Description,
&i.Thumbnail,
&i.CreatedAt,
&i.UpdatedAt,
&i.SortOrder,
)
return i, err
}
const InsertUserCourseProgress = `-- name: InsertUserCourseProgress :exec
INSERT INTO lms_user_course_progress (user_id, course_id)
VALUES ($1, $2)
ON CONFLICT (user_id, course_id)
DO NOTHING
`
type InsertUserCourseProgressParams struct {
UserID int64 `json:"user_id"`
CourseID int64 `json:"course_id"`
}
func (q *Queries) InsertUserCourseProgress(ctx context.Context, arg InsertUserCourseProgressParams) error {
_, err := q.db.Exec(ctx, InsertUserCourseProgress, arg.UserID, arg.CourseID)
return err
}
const InsertUserLessonProgress = `-- name: InsertUserLessonProgress :exec
INSERT INTO lms_user_lesson_progress (user_id, lesson_id)
VALUES ($1, $2)
ON CONFLICT (user_id, lesson_id)
DO NOTHING
`
type InsertUserLessonProgressParams struct {
UserID int64 `json:"user_id"`
LessonID int64 `json:"lesson_id"`
}
func (q *Queries) InsertUserLessonProgress(ctx context.Context, arg InsertUserLessonProgressParams) error {
_, err := q.db.Exec(ctx, InsertUserLessonProgress, arg.UserID, arg.LessonID)
return err
}
const InsertUserModuleProgress = `-- name: InsertUserModuleProgress :exec
INSERT INTO lms_user_module_progress (user_id, module_id)
VALUES ($1, $2)
ON CONFLICT (user_id, module_id)
DO NOTHING
`
type InsertUserModuleProgressParams struct {
UserID int64 `json:"user_id"`
ModuleID int64 `json:"module_id"`
}
func (q *Queries) InsertUserModuleProgress(ctx context.Context, arg InsertUserModuleProgressParams) error {
_, err := q.db.Exec(ctx, InsertUserModuleProgress, arg.UserID, arg.ModuleID)
return err
}
const InsertUserProgramProgress = `-- name: InsertUserProgramProgress :exec
INSERT INTO lms_user_program_progress (user_id, program_id)
VALUES ($1, $2)
ON CONFLICT (user_id, program_id)
DO NOTHING
`
type InsertUserProgramProgressParams struct {
UserID int64 `json:"user_id"`
ProgramID int64 `json:"program_id"`
}
func (q *Queries) InsertUserProgramProgress(ctx context.Context, arg InsertUserProgramProgressParams) error {
_, err := q.db.Exec(ctx, InsertUserProgramProgress, arg.UserID, arg.ProgramID)
return err
}
const ListLMSCompletedCourseIDsByUser = `-- name: ListLMSCompletedCourseIDsByUser :many
SELECT
ucp.course_id
FROM
lms_user_course_progress AS ucp
WHERE
ucp.user_id = $1
ORDER BY
ucp.completed_at ASC,
ucp.course_id ASC
`
func (q *Queries) ListLMSCompletedCourseIDsByUser(ctx context.Context, userID int64) ([]int64, error) {
rows, err := q.db.Query(ctx, ListLMSCompletedCourseIDsByUser, userID)
if err != nil {
return nil, err
}
defer rows.Close()
var items []int64
for rows.Next() {
var course_id int64
if err := rows.Scan(&course_id); err != nil {
return nil, err
}
items = append(items, course_id)
}
if err := rows.Err(); err != nil {
return nil, err
}
return items, nil
}
const ListLMSCompletedLessonIDsByUser = `-- name: ListLMSCompletedLessonIDsByUser :many
SELECT
ulp.lesson_id
FROM
lms_user_lesson_progress AS ulp
WHERE
ulp.user_id = $1
ORDER BY
ulp.completed_at ASC,
ulp.lesson_id ASC
`
func (q *Queries) ListLMSCompletedLessonIDsByUser(ctx context.Context, userID int64) ([]int64, error) {
rows, err := q.db.Query(ctx, ListLMSCompletedLessonIDsByUser, userID)
if err != nil {
return nil, err
}
defer rows.Close()
var items []int64
for rows.Next() {
var lesson_id int64
if err := rows.Scan(&lesson_id); err != nil {
return nil, err
}
items = append(items, lesson_id)
}
if err := rows.Err(); err != nil {
return nil, err
}
return items, nil
}
const ListLMSCompletedModuleIDsByUser = `-- name: ListLMSCompletedModuleIDsByUser :many
SELECT
ump.module_id
FROM
lms_user_module_progress AS ump
WHERE
ump.user_id = $1
ORDER BY
ump.completed_at ASC,
ump.module_id ASC
`
func (q *Queries) ListLMSCompletedModuleIDsByUser(ctx context.Context, userID int64) ([]int64, error) {
rows, err := q.db.Query(ctx, ListLMSCompletedModuleIDsByUser, userID)
if err != nil {
return nil, err
}
defer rows.Close()
var items []int64
for rows.Next() {
var module_id int64
if err := rows.Scan(&module_id); err != nil {
return nil, err
}
items = append(items, module_id)
}
if err := rows.Err(); err != nil {
return nil, err
}
return items, nil
}
const ListLMSCompletedProgramIDsByUser = `-- name: ListLMSCompletedProgramIDsByUser :many
SELECT
upp.program_id
FROM
lms_user_program_progress AS upp
WHERE
upp.user_id = $1
ORDER BY
upp.completed_at ASC,
upp.program_id ASC
`
func (q *Queries) ListLMSCompletedProgramIDsByUser(ctx context.Context, userID int64) ([]int64, error) {
rows, err := q.db.Query(ctx, ListLMSCompletedProgramIDsByUser, userID)
if err != nil {
return nil, err
}
defer rows.Close()
var items []int64
for rows.Next() {
var program_id int64
if err := rows.Scan(&program_id); err != nil {
return nil, err
}
items = append(items, program_id)
}
if err := rows.Err(); err != nil {
return nil, err
}
return items, nil
}
const UserHasCourseProgress = `-- name: UserHasCourseProgress :one
SELECT
EXISTS (
SELECT
1
FROM
lms_user_course_progress
WHERE
user_id = $1
AND course_id = $2) AS v
`
type UserHasCourseProgressParams struct {
UserID int64 `json:"user_id"`
CourseID int64 `json:"course_id"`
}
func (q *Queries) UserHasCourseProgress(ctx context.Context, arg UserHasCourseProgressParams) (bool, error) {
row := q.db.QueryRow(ctx, UserHasCourseProgress, arg.UserID, arg.CourseID)
var v bool
err := row.Scan(&v)
return v, err
}
const UserHasLessonProgress = `-- name: UserHasLessonProgress :one
SELECT
EXISTS (
SELECT
1
FROM
lms_user_lesson_progress
WHERE
user_id = $1
AND lesson_id = $2) AS v
`
type UserHasLessonProgressParams struct {
UserID int64 `json:"user_id"`
LessonID int64 `json:"lesson_id"`
}
func (q *Queries) UserHasLessonProgress(ctx context.Context, arg UserHasLessonProgressParams) (bool, error) {
row := q.db.QueryRow(ctx, UserHasLessonProgress, arg.UserID, arg.LessonID)
var v bool
err := row.Scan(&v)
return v, err
}
const UserHasModuleProgress = `-- name: UserHasModuleProgress :one
SELECT
EXISTS (
SELECT
1
FROM
lms_user_module_progress
WHERE
user_id = $1
AND module_id = $2) AS v
`
type UserHasModuleProgressParams struct {
UserID int64 `json:"user_id"`
ModuleID int64 `json:"module_id"`
}
func (q *Queries) UserHasModuleProgress(ctx context.Context, arg UserHasModuleProgressParams) (bool, error) {
row := q.db.QueryRow(ctx, UserHasModuleProgress, arg.UserID, arg.ModuleID)
var v bool
err := row.Scan(&v)
return v, err
}
const UserHasProgramProgress = `-- name: UserHasProgramProgress :one
SELECT
EXISTS (
SELECT
1
FROM
lms_user_program_progress
WHERE
user_id = $1
AND program_id = $2) AS v
`
type UserHasProgramProgressParams struct {
UserID int64 `json:"user_id"`
ProgramID int64 `json:"program_id"`
}
func (q *Queries) UserHasProgramProgress(ctx context.Context, arg UserHasProgramProgressParams) (bool, error) {
row := q.db.QueryRow(ctx, UserHasProgramProgress, arg.UserID, arg.ProgramID)
var v bool
err := row.Scan(&v)
return v, err
}

View File

@ -24,32 +24,13 @@ type ActivityLog struct {
type Course struct { type Course struct {
ID int64 `json:"id"` ID int64 `json:"id"`
CategoryID int64 `json:"category_id"` ProgramID int64 `json:"program_id"`
Title string `json:"title"` Name string `json:"name"`
Description pgtype.Text `json:"description"` Description pgtype.Text `json:"description"`
IsActive bool `json:"is_active"`
Thumbnail pgtype.Text `json:"thumbnail"` Thumbnail pgtype.Text `json:"thumbnail"`
IntroVideoUrl pgtype.Text `json:"intro_video_url"`
DisplayOrder int32 `json:"display_order"`
SubCategoryID pgtype.Int8 `json:"sub_category_id"`
}
type CourseCategory struct {
ID int64 `json:"id"`
Name string `json:"name"`
IsActive bool `json:"is_active"`
CreatedAt pgtype.Timestamptz `json:"created_at"`
DisplayOrder int32 `json:"display_order"`
}
type CourseSubCategory struct {
ID int64 `json:"id"`
CategoryID int64 `json:"category_id"`
Name string `json:"name"`
Description pgtype.Text `json:"description"`
IsActive bool `json:"is_active"`
DisplayOrder int32 `json:"display_order"`
CreatedAt pgtype.Timestamptz `json:"created_at"` CreatedAt pgtype.Timestamptz `json:"created_at"`
UpdatedAt pgtype.Timestamptz `json:"updated_at"`
SortOrder int32 `json:"sort_order"`
} }
type Device struct { type Device struct {
@ -69,16 +50,16 @@ type GlobalSetting struct {
UpdatedAt pgtype.Timestamptz `json:"updated_at"` UpdatedAt pgtype.Timestamptz `json:"updated_at"`
} }
type Level struct { type Lesson struct {
ID int64 `json:"id"` ID int64 `json:"id"`
CourseID int64 `json:"course_id"` ModuleID int64 `json:"module_id"`
CefrLevel string `json:"cefr_level"`
DisplayOrder int32 `json:"display_order"`
IsActive bool `json:"is_active"`
CreatedAt pgtype.Timestamptz `json:"created_at"`
Title string `json:"title"` Title string `json:"title"`
Description pgtype.Text `json:"description"` VideoUrl pgtype.Text `json:"video_url"`
Thumbnail pgtype.Text `json:"thumbnail"` Thumbnail pgtype.Text `json:"thumbnail"`
Description pgtype.Text `json:"description"`
CreatedAt pgtype.Timestamptz `json:"created_at"`
UpdatedAt pgtype.Timestamptz `json:"updated_at"`
SortOrder int32 `json:"sort_order"`
} }
type LevelToSubCourse struct { type LevelToSubCourse struct {
@ -86,28 +67,55 @@ type LevelToSubCourse struct {
SubCourseID int64 `json:"sub_course_id"` SubCourseID int64 `json:"sub_course_id"`
} }
type Module struct { type LmsPractice struct {
ID int64 `json:"id"` ID int64 `json:"id"`
LevelID int64 `json:"level_id"` CourseID pgtype.Int8 `json:"course_id"`
ModuleID pgtype.Int8 `json:"module_id"`
LessonID pgtype.Int8 `json:"lesson_id"`
Title string `json:"title"` Title string `json:"title"`
Description pgtype.Text `json:"description"` StoryDescription pgtype.Text `json:"story_description"`
DisplayOrder int32 `json:"display_order"` StoryImage pgtype.Text `json:"story_image"`
IsActive bool `json:"is_active"` PersonaID pgtype.Int8 `json:"persona_id"`
QuestionSetID int64 `json:"question_set_id"`
QuickTips pgtype.Text `json:"quick_tips"`
CreatedAt pgtype.Timestamptz `json:"created_at"` CreatedAt pgtype.Timestamptz `json:"created_at"`
IconUrl pgtype.Text `json:"icon_url"` UpdatedAt pgtype.Timestamptz `json:"updated_at"`
} }
type ModuleCapstone struct { type LmsUserCourseProgress struct {
ID int64 `json:"id"` 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"` ModuleID int64 `json:"module_id"`
Title string `json:"title"` CompletedAt pgtype.Timestamptz `json:"completed_at"`
}
type LmsUserProgramProgress struct {
UserID int64 `json:"user_id"`
ProgramID int64 `json:"program_id"`
CompletedAt pgtype.Timestamptz `json:"completed_at"`
}
type Module struct {
ID int64 `json:"id"`
ProgramID int64 `json:"program_id"`
CourseID int64 `json:"course_id"`
Name string `json:"name"`
Description pgtype.Text `json:"description"` Description pgtype.Text `json:"description"`
Tips pgtype.Text `json:"tips"` Icon pgtype.Text `json:"icon"`
Thumbnail pgtype.Text `json:"thumbnail"`
QuestionSetID int64 `json:"question_set_id"`
DisplayOrder int32 `json:"display_order"`
IsActive bool `json:"is_active"`
CreatedAt pgtype.Timestamptz `json:"created_at"` CreatedAt pgtype.Timestamptz `json:"created_at"`
UpdatedAt pgtype.Timestamptz `json:"updated_at"`
SortOrder int32 `json:"sort_order"`
} }
type ModuleToSubCourse struct { type ModuleToSubCourse struct {
@ -171,6 +179,16 @@ type Permission struct {
CreatedAt pgtype.Timestamptz `json:"created_at"` CreatedAt pgtype.Timestamptz `json:"created_at"`
} }
type Program struct {
ID int64 `json:"id"`
Name string `json:"name"`
Description pgtype.Text `json:"description"`
Thumbnail pgtype.Text `json:"thumbnail"`
CreatedAt pgtype.Timestamptz `json:"created_at"`
UpdatedAt pgtype.Timestamptz `json:"updated_at"`
SortOrder int32 `json:"sort_order"`
}
type Question struct { type Question struct {
ID int64 `json:"id"` ID int64 `json:"id"`
QuestionText string `json:"question_text"` QuestionText string `json:"question_text"`
@ -218,7 +236,6 @@ type QuestionSet struct {
Status string `json:"status"` Status string `json:"status"`
CreatedAt pgtype.Timestamptz `json:"created_at"` CreatedAt pgtype.Timestamptz `json:"created_at"`
UpdatedAt pgtype.Timestamptz `json:"updated_at"` UpdatedAt pgtype.Timestamptz `json:"updated_at"`
SubCourseVideoID pgtype.Int8 `json:"sub_course_video_id"`
DisplayOrder int32 `json:"display_order"` DisplayOrder int32 `json:"display_order"`
IntroVideoUrl pgtype.Text `json:"intro_video_url"` IntroVideoUrl pgtype.Text `json:"intro_video_url"`
} }
@ -315,132 +332,6 @@ type ScheduledNotification struct {
UpdatedAt pgtype.Timestamptz `json:"updated_at"` UpdatedAt pgtype.Timestamptz `json:"updated_at"`
} }
type SubCourse struct {
ID int64 `json:"id"`
CourseID int64 `json:"course_id"`
Title string `json:"title"`
Description pgtype.Text `json:"description"`
Thumbnail pgtype.Text `json:"thumbnail"`
DisplayOrder int32 `json:"display_order"`
Level string `json:"level"`
IsActive bool `json:"is_active"`
SubLevel string `json:"sub_level"`
}
type SubCoursePrerequisite struct {
ID int64 `json:"id"`
SubCourseID int64 `json:"sub_course_id"`
PrerequisiteSubCourseID int64 `json:"prerequisite_sub_course_id"`
CreatedAt pgtype.Timestamptz `json:"created_at"`
}
type SubCourseVideo struct {
ID int64 `json:"id"`
SubCourseID int64 `json:"sub_course_id"`
Title string `json:"title"`
Description pgtype.Text `json:"description"`
VideoUrl string `json:"video_url"`
Duration int32 `json:"duration"`
Resolution pgtype.Text `json:"resolution"`
IsPublished bool `json:"is_published"`
PublishDate pgtype.Timestamptz `json:"publish_date"`
Visibility pgtype.Text `json:"visibility"`
InstructorID pgtype.Text `json:"instructor_id"`
Thumbnail pgtype.Text `json:"thumbnail"`
DisplayOrder int32 `json:"display_order"`
Status string `json:"status"`
// Vimeo video ID for videos hosted on Vimeo
VimeoID pgtype.Text `json:"vimeo_id"`
// Vimeo player embed URL
VimeoEmbedUrl pgtype.Text `json:"vimeo_embed_url"`
// Vimeo iframe embed HTML code
VimeoPlayerHtml pgtype.Text `json:"vimeo_player_html"`
// Vimeo video status: pending, uploading, transcoding, available, error
VimeoStatus pgtype.Text `json:"vimeo_status"`
// Video hosting provider: DIRECT or VIMEO
VideoHostProvider pgtype.Text `json:"video_host_provider"`
}
type SubModule struct {
ID int64 `json:"id"`
ModuleID int64 `json:"module_id"`
Title string `json:"title"`
Description pgtype.Text `json:"description"`
DisplayOrder int32 `json:"display_order"`
IsActive bool `json:"is_active"`
CreatedAt pgtype.Timestamptz `json:"created_at"`
LegacySubCourseID pgtype.Int8 `json:"legacy_sub_course_id"`
Thumbnail pgtype.Text `json:"thumbnail"`
Tips pgtype.Text `json:"tips"`
}
type SubModuleCapstone struct {
ID int64 `json:"id"`
SubModuleID int64 `json:"sub_module_id"`
Title string `json:"title"`
Description pgtype.Text `json:"description"`
Tips pgtype.Text `json:"tips"`
Thumbnail pgtype.Text `json:"thumbnail"`
QuestionSetID int64 `json:"question_set_id"`
DisplayOrder int32 `json:"display_order"`
IsActive bool `json:"is_active"`
CreatedAt pgtype.Timestamptz `json:"created_at"`
InactiveSince pgtype.Timestamptz `json:"inactive_since"`
}
type SubModuleLesson struct {
ID int64 `json:"id"`
SubModuleID int64 `json:"sub_module_id"`
DisplayOrder int32 `json:"display_order"`
IsActive bool `json:"is_active"`
CreatedAt pgtype.Timestamptz `json:"created_at"`
Title string `json:"title"`
Description pgtype.Text `json:"description"`
Thumbnail pgtype.Text `json:"thumbnail"`
TeachingText pgtype.Text `json:"teaching_text"`
TeachingImageUrl pgtype.Text `json:"teaching_image_url"`
TeachingAudioUrl pgtype.Text `json:"teaching_audio_url"`
TeachingVideoUrl pgtype.Text `json:"teaching_video_url"`
InactiveSince pgtype.Timestamptz `json:"inactive_since"`
}
type SubModulePractice struct {
ID int64 `json:"id"`
SubModuleID int64 `json:"sub_module_id"`
QuestionSetID int64 `json:"question_set_id"`
IntroVideoUrl pgtype.Text `json:"intro_video_url"`
DisplayOrder int32 `json:"display_order"`
IsActive bool `json:"is_active"`
CreatedAt pgtype.Timestamptz `json:"created_at"`
Title string `json:"title"`
Description pgtype.Text `json:"description"`
Thumbnail pgtype.Text `json:"thumbnail"`
InactiveSince pgtype.Timestamptz `json:"inactive_since"`
}
type SubModuleVideo struct {
ID int64 `json:"id"`
SubModuleID int64 `json:"sub_module_id"`
Title string `json:"title"`
Description pgtype.Text `json:"description"`
VideoUrl string `json:"video_url"`
Duration pgtype.Int4 `json:"duration"`
Resolution pgtype.Text `json:"resolution"`
IsPublished bool `json:"is_published"`
PublishDate pgtype.Timestamptz `json:"publish_date"`
Visibility pgtype.Text `json:"visibility"`
InstructorID pgtype.Text `json:"instructor_id"`
Thumbnail pgtype.Text `json:"thumbnail"`
DisplayOrder int32 `json:"display_order"`
Status string `json:"status"`
VimeoID pgtype.Text `json:"vimeo_id"`
VimeoEmbedUrl pgtype.Text `json:"vimeo_embed_url"`
VimeoPlayerHtml pgtype.Text `json:"vimeo_player_html"`
VimeoStatus pgtype.Text `json:"vimeo_status"`
VideoHostProvider pgtype.Text `json:"video_host_provider"`
CreatedAt pgtype.Timestamptz `json:"created_at"`
}
type SubscriptionPlan struct { type SubscriptionPlan struct {
ID int64 `json:"id"` ID int64 `json:"id"`
Name string `json:"name"` Name string `json:"name"`
@ -540,33 +431,10 @@ type UserAudioResponse struct {
type UserPracticeProgress struct { type UserPracticeProgress struct {
ID int64 `json:"id"` ID int64 `json:"id"`
UserID int64 `json:"user_id"` UserID int64 `json:"user_id"`
SubCourseID pgtype.Int8 `json:"sub_course_id"`
QuestionSetID int64 `json:"question_set_id"` QuestionSetID int64 `json:"question_set_id"`
CompletedAt pgtype.Timestamp `json:"completed_at"`
CreatedAt pgtype.Timestamp `json:"created_at"`
UpdatedAt pgtype.Timestamp `json:"updated_at"`
}
type UserSubCourseProgress struct {
ID int64 `json:"id"`
UserID int64 `json:"user_id"`
SubCourseID int64 `json:"sub_course_id"`
Status string `json:"status"`
ProgressPercentage int16 `json:"progress_percentage"`
StartedAt pgtype.Timestamptz `json:"started_at"`
CompletedAt pgtype.Timestamptz `json:"completed_at"` CompletedAt pgtype.Timestamptz `json:"completed_at"`
CreatedAt pgtype.Timestamptz `json:"created_at"`
UpdatedAt pgtype.Timestamptz `json:"updated_at"` UpdatedAt pgtype.Timestamptz `json:"updated_at"`
} CreatedAt pgtype.Timestamptz `json:"created_at"`
type UserSubCourseVideoProgress struct {
ID int64 `json:"id"`
UserID int64 `json:"user_id"`
SubCourseID int64 `json:"sub_course_id"`
VideoID int64 `json:"video_id"`
CompletedAt pgtype.Timestamp `json:"completed_at"`
CreatedAt pgtype.Timestamp `json:"created_at"`
UpdatedAt pgtype.Timestamp `json:"updated_at"`
} }
type UserSubscription struct { type UserSubscription struct {

View File

@ -59,26 +59,16 @@ func (q *Queries) GetFirstIncompletePreviousPractice(ctx context.Context, arg Ge
const MarkPracticeCompleted = `-- name: MarkPracticeCompleted :execrows const MarkPracticeCompleted = `-- name: MarkPracticeCompleted :execrows
INSERT INTO user_practice_progress ( INSERT INTO user_practice_progress (
user_id, user_id,
sub_course_id,
question_set_id, question_set_id,
completed_at, completed_at,
updated_at updated_at
) )
SELECT VALUES (
$1::BIGINT, $1::BIGINT,
CASE $2::BIGINT,
WHEN qs.owner_type = 'SUB_COURSE' THEN qs.owner_id
WHEN qs.owner_type = 'SUB_MODULE' THEN sm.legacy_sub_course_id
ELSE NULL
END,
qs.id,
CURRENT_TIMESTAMP, CURRENT_TIMESTAMP,
CURRENT_TIMESTAMP CURRENT_TIMESTAMP
FROM question_sets qs )
LEFT JOIN sub_modules sm
ON qs.owner_type = 'SUB_MODULE'
AND qs.owner_id = sm.id
WHERE qs.id = $2::BIGINT
ON CONFLICT (user_id, question_set_id) DO UPDATE ON CONFLICT (user_id, question_set_id) DO UPDATE
SET completed_at = EXCLUDED.completed_at, SET completed_at = EXCLUDED.completed_at,
updated_at = EXCLUDED.updated_at updated_at = EXCLUDED.updated_at

210
gen/db/programs.sql.go Normal file
View File

@ -0,0 +1,210 @@
// Code generated by sqlc. DO NOT EDIT.
// versions:
// sqlc v1.30.0
// source: programs.sql
package dbgen
import (
"context"
"github.com/jackc/pgx/v5/pgtype"
)
const CreateProgram = `-- name: CreateProgram :one
INSERT INTO programs (name, description, thumbnail, sort_order)
SELECT
$1,
$2,
$3,
coalesce((
SELECT
max(p.sort_order)
FROM programs AS p), 0) + 1
RETURNING
id, name, description, thumbnail, created_at, updated_at, sort_order
`
type CreateProgramParams struct {
Name string `json:"name"`
Description pgtype.Text `json:"description"`
Thumbnail pgtype.Text `json:"thumbnail"`
}
func (q *Queries) CreateProgram(ctx context.Context, arg CreateProgramParams) (Program, error) {
row := q.db.QueryRow(ctx, CreateProgram, arg.Name, arg.Description, arg.Thumbnail)
var i Program
err := row.Scan(
&i.ID,
&i.Name,
&i.Description,
&i.Thumbnail,
&i.CreatedAt,
&i.UpdatedAt,
&i.SortOrder,
)
return i, err
}
const DeleteProgram = `-- name: DeleteProgram :exec
DELETE FROM programs
WHERE id = $1
`
func (q *Queries) DeleteProgram(ctx context.Context, id int64) error {
_, err := q.db.Exec(ctx, DeleteProgram, id)
return err
}
const GetProgramByID = `-- name: GetProgramByID :one
SELECT id, name, description, thumbnail, created_at, updated_at, sort_order
FROM programs
WHERE id = $1
`
func (q *Queries) GetProgramByID(ctx context.Context, id int64) (Program, error) {
row := q.db.QueryRow(ctx, GetProgramByID, 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 ListAllProgramIDs = `-- name: ListAllProgramIDs :many
SELECT
p.id
FROM
programs AS p
ORDER BY
p.id
`
func (q *Queries) ListAllProgramIDs(ctx context.Context) ([]int64, error) {
rows, err := q.db.Query(ctx, ListAllProgramIDs)
if err != nil {
return nil, err
}
defer rows.Close()
var items []int64
for rows.Next() {
var id int64
if err := rows.Scan(&id); err != nil {
return nil, err
}
items = append(items, id)
}
if err := rows.Err(); err != nil {
return nil, err
}
return items, nil
}
const ListPrograms = `-- name: ListPrograms :many
SELECT
COUNT(*) OVER () AS total_count,
p.id,
p.name,
p.description,
p.thumbnail,
p.sort_order,
p.created_at,
p.updated_at
FROM programs p
ORDER BY p.sort_order ASC, p.id ASC
LIMIT $1 OFFSET $2
`
type ListProgramsParams struct {
Limit int32 `json:"limit"`
Offset int32 `json:"offset"`
}
type ListProgramsRow struct {
TotalCount int64 `json:"total_count"`
ID int64 `json:"id"`
Name string `json:"name"`
Description pgtype.Text `json:"description"`
Thumbnail pgtype.Text `json:"thumbnail"`
SortOrder int32 `json:"sort_order"`
CreatedAt pgtype.Timestamptz `json:"created_at"`
UpdatedAt pgtype.Timestamptz `json:"updated_at"`
}
func (q *Queries) ListPrograms(ctx context.Context, arg ListProgramsParams) ([]ListProgramsRow, error) {
rows, err := q.db.Query(ctx, ListPrograms, arg.Limit, arg.Offset)
if err != nil {
return nil, err
}
defer rows.Close()
var items []ListProgramsRow
for rows.Next() {
var i ListProgramsRow
if err := rows.Scan(
&i.TotalCount,
&i.ID,
&i.Name,
&i.Description,
&i.Thumbnail,
&i.SortOrder,
&i.CreatedAt,
&i.UpdatedAt,
); err != nil {
return nil, err
}
items = append(items, i)
}
if err := rows.Err(); err != nil {
return nil, err
}
return items, nil
}
const UpdateProgram = `-- name: UpdateProgram :one
UPDATE programs
SET
name = COALESCE($1::varchar, name),
description = COALESCE($2::text, description),
thumbnail = COALESCE($3::text, thumbnail),
sort_order = coalesce($4::int, sort_order),
updated_at = CURRENT_TIMESTAMP
WHERE
id = $5
RETURNING
id, name, description, thumbnail, created_at, updated_at, sort_order
`
type UpdateProgramParams struct {
Name pgtype.Text `json:"name"`
Description pgtype.Text `json:"description"`
Thumbnail pgtype.Text `json:"thumbnail"`
SortOrder pgtype.Int4 `json:"sort_order"`
ID int64 `json:"id"`
}
func (q *Queries) UpdateProgram(ctx context.Context, arg UpdateProgramParams) (Program, error) {
row := q.db.QueryRow(ctx, UpdateProgram,
arg.Name,
arg.Description,
arg.Thumbnail,
arg.SortOrder,
arg.ID,
)
var i Program
err := row.Scan(
&i.ID,
&i.Name,
&i.Description,
&i.Thumbnail,
&i.CreatedAt,
&i.UpdatedAt,
&i.SortOrder,
)
return i, err
}

View File

@ -295,7 +295,7 @@ func (q *Queries) GetQuestionSetItemsPaginated(ctx context.Context, arg GetQuest
} }
const GetQuestionSetsContainingQuestion = `-- name: GetQuestionSetsContainingQuestion :many const GetQuestionSetsContainingQuestion = `-- name: GetQuestionSetsContainingQuestion :many
SELECT qs.id, qs.title, qs.description, qs.set_type, qs.owner_type, qs.owner_id, qs.banner_image, qs.persona, qs.time_limit_minutes, qs.passing_score, qs.shuffle_questions, qs.status, qs.created_at, qs.updated_at, qs.sub_course_video_id, qs.display_order, qs.intro_video_url SELECT qs.id, qs.title, qs.description, qs.set_type, qs.owner_type, qs.owner_id, qs.banner_image, qs.persona, qs.time_limit_minutes, qs.passing_score, qs.shuffle_questions, qs.status, qs.created_at, qs.updated_at, qs.display_order, qs.intro_video_url
FROM question_sets qs FROM question_sets qs
JOIN question_set_items qsi ON qsi.set_id = qs.id JOIN question_set_items qsi ON qsi.set_id = qs.id
WHERE qsi.question_id = $1 WHERE qsi.question_id = $1
@ -326,7 +326,6 @@ func (q *Queries) GetQuestionSetsContainingQuestion(ctx context.Context, questio
&i.Status, &i.Status,
&i.CreatedAt, &i.CreatedAt,
&i.UpdatedAt, &i.UpdatedAt,
&i.SubCourseVideoID,
&i.DisplayOrder, &i.DisplayOrder,
&i.IntroVideoUrl, &i.IntroVideoUrl,
); err != nil { ); err != nil {

View File

@ -64,11 +64,10 @@ INSERT INTO question_sets (
passing_score, passing_score,
shuffle_questions, shuffle_questions,
status, status,
sub_course_video_id,
intro_video_url intro_video_url
) )
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, COALESCE($10, false), COALESCE($11, 'DRAFT'), $12, $13) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, COALESCE($10, false), COALESCE($11, 'DRAFT'), $12)
RETURNING id, title, description, set_type, owner_type, owner_id, banner_image, persona, time_limit_minutes, passing_score, shuffle_questions, status, created_at, updated_at, sub_course_video_id, display_order, intro_video_url RETURNING id, title, description, set_type, owner_type, owner_id, banner_image, persona, time_limit_minutes, passing_score, shuffle_questions, status, created_at, updated_at, display_order, intro_video_url
` `
type CreateQuestionSetParams struct { type CreateQuestionSetParams struct {
@ -83,7 +82,6 @@ type CreateQuestionSetParams struct {
PassingScore pgtype.Int4 `json:"passing_score"` PassingScore pgtype.Int4 `json:"passing_score"`
Column10 interface{} `json:"column_10"` Column10 interface{} `json:"column_10"`
Column11 interface{} `json:"column_11"` Column11 interface{} `json:"column_11"`
SubCourseVideoID pgtype.Int8 `json:"sub_course_video_id"`
IntroVideoUrl pgtype.Text `json:"intro_video_url"` IntroVideoUrl pgtype.Text `json:"intro_video_url"`
} }
@ -100,7 +98,6 @@ func (q *Queries) CreateQuestionSet(ctx context.Context, arg CreateQuestionSetPa
arg.PassingScore, arg.PassingScore,
arg.Column10, arg.Column10,
arg.Column11, arg.Column11,
arg.SubCourseVideoID,
arg.IntroVideoUrl, arg.IntroVideoUrl,
) )
var i QuestionSet var i QuestionSet
@ -119,7 +116,6 @@ func (q *Queries) CreateQuestionSet(ctx context.Context, arg CreateQuestionSetPa
&i.Status, &i.Status,
&i.CreatedAt, &i.CreatedAt,
&i.UpdatedAt, &i.UpdatedAt,
&i.SubCourseVideoID,
&i.DisplayOrder, &i.DisplayOrder,
&i.IntroVideoUrl, &i.IntroVideoUrl,
) )
@ -137,7 +133,7 @@ func (q *Queries) DeleteQuestionSet(ctx context.Context, id int64) error {
} }
const GetInitialAssessmentSet = `-- name: GetInitialAssessmentSet :one const GetInitialAssessmentSet = `-- name: GetInitialAssessmentSet :one
SELECT id, title, description, set_type, owner_type, owner_id, banner_image, persona, time_limit_minutes, passing_score, shuffle_questions, status, created_at, updated_at, sub_course_video_id, display_order, intro_video_url SELECT id, title, description, set_type, owner_type, owner_id, banner_image, persona, time_limit_minutes, passing_score, shuffle_questions, status, created_at, updated_at, display_order, intro_video_url
FROM question_sets FROM question_sets
WHERE set_type = 'INITIAL_ASSESSMENT' WHERE set_type = 'INITIAL_ASSESSMENT'
AND status = 'PUBLISHED' AND status = 'PUBLISHED'
@ -163,7 +159,6 @@ func (q *Queries) GetInitialAssessmentSet(ctx context.Context) (QuestionSet, err
&i.Status, &i.Status,
&i.CreatedAt, &i.CreatedAt,
&i.UpdatedAt, &i.UpdatedAt,
&i.SubCourseVideoID,
&i.DisplayOrder, &i.DisplayOrder,
&i.IntroVideoUrl, &i.IntroVideoUrl,
) )
@ -171,7 +166,7 @@ func (q *Queries) GetInitialAssessmentSet(ctx context.Context) (QuestionSet, err
} }
const GetPublishedQuestionSetsByOwner = `-- name: GetPublishedQuestionSetsByOwner :many const GetPublishedQuestionSetsByOwner = `-- name: GetPublishedQuestionSetsByOwner :many
SELECT id, title, description, set_type, owner_type, owner_id, banner_image, persona, time_limit_minutes, passing_score, shuffle_questions, status, created_at, updated_at, sub_course_video_id, display_order, intro_video_url SELECT id, title, description, set_type, owner_type, owner_id, banner_image, persona, time_limit_minutes, passing_score, shuffle_questions, status, created_at, updated_at, display_order, intro_video_url
FROM question_sets FROM question_sets
WHERE owner_type = $1 WHERE owner_type = $1
AND owner_id = $2 AND owner_id = $2
@ -208,7 +203,6 @@ func (q *Queries) GetPublishedQuestionSetsByOwner(ctx context.Context, arg GetPu
&i.Status, &i.Status,
&i.CreatedAt, &i.CreatedAt,
&i.UpdatedAt, &i.UpdatedAt,
&i.SubCourseVideoID,
&i.DisplayOrder, &i.DisplayOrder,
&i.IntroVideoUrl, &i.IntroVideoUrl,
); err != nil { ); err != nil {
@ -223,7 +217,7 @@ func (q *Queries) GetPublishedQuestionSetsByOwner(ctx context.Context, arg GetPu
} }
const GetQuestionSetByID = `-- name: GetQuestionSetByID :one const GetQuestionSetByID = `-- name: GetQuestionSetByID :one
SELECT id, title, description, set_type, owner_type, owner_id, banner_image, persona, time_limit_minutes, passing_score, shuffle_questions, status, created_at, updated_at, sub_course_video_id, display_order, intro_video_url SELECT id, title, description, set_type, owner_type, owner_id, banner_image, persona, time_limit_minutes, passing_score, shuffle_questions, status, created_at, updated_at, display_order, intro_video_url
FROM question_sets FROM question_sets
WHERE id = $1 WHERE id = $1
` `
@ -246,7 +240,6 @@ func (q *Queries) GetQuestionSetByID(ctx context.Context, id int64) (QuestionSet
&i.Status, &i.Status,
&i.CreatedAt, &i.CreatedAt,
&i.UpdatedAt, &i.UpdatedAt,
&i.SubCourseVideoID,
&i.DisplayOrder, &i.DisplayOrder,
&i.IntroVideoUrl, &i.IntroVideoUrl,
) )
@ -254,7 +247,7 @@ func (q *Queries) GetQuestionSetByID(ctx context.Context, id int64) (QuestionSet
} }
const GetQuestionSetsByOwner = `-- name: GetQuestionSetsByOwner :many const GetQuestionSetsByOwner = `-- name: GetQuestionSetsByOwner :many
SELECT id, title, description, set_type, owner_type, owner_id, banner_image, persona, time_limit_minutes, passing_score, shuffle_questions, status, created_at, updated_at, sub_course_video_id, display_order, intro_video_url SELECT id, title, description, set_type, owner_type, owner_id, banner_image, persona, time_limit_minutes, passing_score, shuffle_questions, status, created_at, updated_at, display_order, intro_video_url
FROM question_sets FROM question_sets
WHERE owner_type = $1 WHERE owner_type = $1
AND owner_id = $2 AND owner_id = $2
@ -291,7 +284,6 @@ func (q *Queries) GetQuestionSetsByOwner(ctx context.Context, arg GetQuestionSet
&i.Status, &i.Status,
&i.CreatedAt, &i.CreatedAt,
&i.UpdatedAt, &i.UpdatedAt,
&i.SubCourseVideoID,
&i.DisplayOrder, &i.DisplayOrder,
&i.IntroVideoUrl, &i.IntroVideoUrl,
); err != nil { ); err != nil {
@ -308,7 +300,7 @@ func (q *Queries) GetQuestionSetsByOwner(ctx context.Context, arg GetQuestionSet
const GetQuestionSetsByType = `-- name: GetQuestionSetsByType :many const GetQuestionSetsByType = `-- name: GetQuestionSetsByType :many
SELECT SELECT
COUNT(*) OVER () AS total_count, COUNT(*) OVER () AS total_count,
qs.id, qs.title, qs.description, qs.set_type, qs.owner_type, qs.owner_id, qs.banner_image, qs.persona, qs.time_limit_minutes, qs.passing_score, qs.shuffle_questions, qs.status, qs.created_at, qs.updated_at, qs.sub_course_video_id, qs.display_order, qs.intro_video_url qs.id, qs.title, qs.description, qs.set_type, qs.owner_type, qs.owner_id, qs.banner_image, qs.persona, qs.time_limit_minutes, qs.passing_score, qs.shuffle_questions, qs.status, qs.created_at, qs.updated_at, qs.display_order, qs.intro_video_url
FROM question_sets qs FROM question_sets qs
WHERE set_type = $1 WHERE set_type = $1
AND status != 'ARCHIVED' AND status != 'ARCHIVED'
@ -339,7 +331,6 @@ type GetQuestionSetsByTypeRow struct {
Status string `json:"status"` Status string `json:"status"`
CreatedAt pgtype.Timestamptz `json:"created_at"` CreatedAt pgtype.Timestamptz `json:"created_at"`
UpdatedAt pgtype.Timestamptz `json:"updated_at"` UpdatedAt pgtype.Timestamptz `json:"updated_at"`
SubCourseVideoID pgtype.Int8 `json:"sub_course_video_id"`
DisplayOrder int32 `json:"display_order"` DisplayOrder int32 `json:"display_order"`
IntroVideoUrl pgtype.Text `json:"intro_video_url"` IntroVideoUrl pgtype.Text `json:"intro_video_url"`
} }
@ -369,7 +360,6 @@ func (q *Queries) GetQuestionSetsByType(ctx context.Context, arg GetQuestionSets
&i.Status, &i.Status,
&i.CreatedAt, &i.CreatedAt,
&i.UpdatedAt, &i.UpdatedAt,
&i.SubCourseVideoID,
&i.DisplayOrder, &i.DisplayOrder,
&i.IntroVideoUrl, &i.IntroVideoUrl,
); err != nil { ); err != nil {
@ -383,42 +373,6 @@ func (q *Queries) GetQuestionSetsByType(ctx context.Context, arg GetQuestionSets
return items, nil return items, nil
} }
const GetSubCourseInitialAssessmentSet = `-- name: GetSubCourseInitialAssessmentSet :one
SELECT id, title, description, set_type, owner_type, owner_id, banner_image, persona, time_limit_minutes, passing_score, shuffle_questions, status, created_at, updated_at, sub_course_video_id, display_order, intro_video_url
FROM question_sets
WHERE set_type = 'INITIAL_ASSESSMENT'
AND owner_type = 'SUB_COURSE'
AND owner_id = $1
AND status = 'PUBLISHED'
ORDER BY created_at DESC
LIMIT 1
`
func (q *Queries) GetSubCourseInitialAssessmentSet(ctx context.Context, ownerID pgtype.Int8) (QuestionSet, error) {
row := q.db.QueryRow(ctx, GetSubCourseInitialAssessmentSet, ownerID)
var i QuestionSet
err := row.Scan(
&i.ID,
&i.Title,
&i.Description,
&i.SetType,
&i.OwnerType,
&i.OwnerID,
&i.BannerImage,
&i.Persona,
&i.TimeLimitMinutes,
&i.PassingScore,
&i.ShuffleQuestions,
&i.Status,
&i.CreatedAt,
&i.UpdatedAt,
&i.SubCourseVideoID,
&i.DisplayOrder,
&i.IntroVideoUrl,
)
return i, err
}
const GetUserPersonasByQuestionSetID = `-- name: GetUserPersonasByQuestionSetID :many const GetUserPersonasByQuestionSetID = `-- name: GetUserPersonasByQuestionSetID :many
SELECT SELECT
u.id, u.id,
@ -519,9 +473,8 @@ SET
shuffle_questions = COALESCE($7, shuffle_questions), shuffle_questions = COALESCE($7, shuffle_questions),
status = COALESCE($8, status), status = COALESCE($8, status),
intro_video_url = COALESCE($9, intro_video_url), intro_video_url = COALESCE($9, intro_video_url),
sub_course_video_id = COALESCE($10, sub_course_video_id),
updated_at = CURRENT_TIMESTAMP updated_at = CURRENT_TIMESTAMP
WHERE id = $11 WHERE id = $10
` `
type UpdateQuestionSetParams struct { type UpdateQuestionSetParams struct {
@ -534,7 +487,6 @@ type UpdateQuestionSetParams struct {
ShuffleQuestions bool `json:"shuffle_questions"` ShuffleQuestions bool `json:"shuffle_questions"`
Status string `json:"status"` Status string `json:"status"`
IntroVideoUrl pgtype.Text `json:"intro_video_url"` IntroVideoUrl pgtype.Text `json:"intro_video_url"`
SubCourseVideoID pgtype.Int8 `json:"sub_course_video_id"`
ID int64 `json:"id"` ID int64 `json:"id"`
} }
@ -549,26 +501,7 @@ func (q *Queries) UpdateQuestionSet(ctx context.Context, arg UpdateQuestionSetPa
arg.ShuffleQuestions, arg.ShuffleQuestions,
arg.Status, arg.Status,
arg.IntroVideoUrl, arg.IntroVideoUrl,
arg.SubCourseVideoID,
arg.ID, arg.ID,
) )
return err return err
} }
const UpdateQuestionSetVideoLink = `-- name: UpdateQuestionSetVideoLink :exec
UPDATE question_sets
SET
sub_course_video_id = $1,
updated_at = CURRENT_TIMESTAMP
WHERE id = $2
`
type UpdateQuestionSetVideoLinkParams struct {
SubCourseVideoID pgtype.Int8 `json:"sub_course_video_id"`
ID int64 `json:"id"`
}
func (q *Queries) UpdateQuestionSetVideoLink(ctx context.Context, arg UpdateQuestionSetVideoLinkParams) error {
_, err := q.db.Exec(ctx, UpdateQuestionSetVideoLink, arg.SubCourseVideoID, arg.ID)
return err
}

View File

@ -45,6 +45,18 @@ const (
ActionIssueCreated ActivityAction = "ISSUE_CREATED" ActionIssueCreated ActivityAction = "ISSUE_CREATED"
ActionIssueStatusUpdated ActivityAction = "ISSUE_STATUS_UPDATED" ActionIssueStatusUpdated ActivityAction = "ISSUE_STATUS_UPDATED"
ActionIssueDeleted ActivityAction = "ISSUE_DELETED" ActionIssueDeleted ActivityAction = "ISSUE_DELETED"
ActionProgramCreated ActivityAction = "PROGRAM_CREATED"
ActionProgramUpdated ActivityAction = "PROGRAM_UPDATED"
ActionProgramDeleted ActivityAction = "PROGRAM_DELETED"
ActionModuleCreated ActivityAction = "MODULE_CREATED"
ActionModuleUpdated ActivityAction = "MODULE_UPDATED"
ActionModuleDeleted ActivityAction = "MODULE_DELETED"
ActionLessonCreated ActivityAction = "LESSON_CREATED"
ActionLessonUpdated ActivityAction = "LESSON_UPDATED"
ActionLessonDeleted ActivityAction = "LESSON_DELETED"
ActionPracticeCreated ActivityAction = "PRACTICE_CREATED"
ActionPracticeUpdated ActivityAction = "PRACTICE_UPDATED"
ActionPracticeDeleted ActivityAction = "PRACTICE_DELETED"
) )
type ResourceType string type ResourceType string
@ -62,6 +74,10 @@ const (
ResourceQuestion ResourceType = "QUESTION" ResourceQuestion ResourceType = "QUESTION"
ResourceQuestionSet ResourceType = "QUESTION_SET" ResourceQuestionSet ResourceType = "QUESTION_SET"
ResourceIssue ResourceType = "ISSUE" ResourceIssue ResourceType = "ISSUE"
ResourceProgram ResourceType = "PROGRAM"
ResourceModule ResourceType = "MODULE"
ResourceLesson ResourceType = "LESSON"
ResourcePractice ResourceType = "PRACTICE"
) )
type ActivityLog struct { type ActivityLog struct {

33
internal/domain/course.go Normal file
View File

@ -0,0 +1,33 @@
package domain
import "time"
// DefaultCEFRCourseNames are the standard course names seeded for each program (migration 000048).
// Creating a course via the API may use any of these or a custom name.
var DefaultCEFRCourseNames = []string{"A1", "A2", "B1", "B2", "C1", "C2"}
// Course belongs to a Program.
type Course struct {
ID int64 `json:"id"`
ProgramID int64 `json:"program_id"`
Name string `json:"name"`
Description *string `json:"description,omitempty"`
Thumbnail *string `json:"thumbnail,omitempty"`
SortOrder int `json:"sort_order"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt *time.Time `json:"updated_at,omitempty"`
Access *LMSEntityAccess `json:"access,omitempty"`
}
type CreateCourseInput struct {
Name string `json:"name" validate:"required"`
Description *string `json:"description,omitempty"`
Thumbnail *string `json:"thumbnail,omitempty"`
}
type UpdateCourseInput struct {
Name *string `json:"name,omitempty"`
Description *string `json:"description,omitempty"`
Thumbnail *string `json:"thumbnail,omitempty"`
SortOrder *int `json:"sort_order,omitempty"`
}

View File

@ -1,170 +0,0 @@
package domain
import "time"
type SubCourseLevel string
const (
SubCourseLevelBeginner SubCourseLevel = "BEGINNER"
SubCourseLevelIntermediate SubCourseLevel = "INTERMEDIATE"
SubCourseLevelAdvanced SubCourseLevel = "ADVANCED"
)
type SubCourseSubLevel string
const (
SubCourseSubLevelA1 SubCourseSubLevel = "A1"
SubCourseSubLevelA2 SubCourseSubLevel = "A2"
SubCourseSubLevelA3 SubCourseSubLevel = "A3"
SubCourseSubLevelB1 SubCourseSubLevel = "B1"
SubCourseSubLevelB2 SubCourseSubLevel = "B2"
SubCourseSubLevelB3 SubCourseSubLevel = "B3"
SubCourseSubLevelC1 SubCourseSubLevel = "C1"
SubCourseSubLevelC2 SubCourseSubLevel = "C2"
SubCourseSubLevelC3 SubCourseSubLevel = "C3"
)
type ContentStatus string
const (
ContentStatusDraft ContentStatus = "DRAFT"
ContentStatusPublished ContentStatus = "PUBLISHED"
ContentStatusInactive ContentStatus = "INACTIVE"
ContentStatusArchived ContentStatus = "ARCHIVED"
)
type TreeSubCourse struct {
ID int64
Title string
Level string
SubLevel string
}
type TreeCourse struct {
ID int64
Title string
SubCourses []TreeSubCourse
}
type CourseCategory struct {
ID int64
Name string
IsActive bool
CreatedAt time.Time
}
type Course struct {
ID int64
CategoryID int64
Title string
Description *string
Thumbnail *string
IntroVideoURL *string
IsActive bool
}
type SubCourse struct {
ID int64
CourseID int64
Title string
Description *string
Thumbnail *string
DisplayOrder int32
Level string
SubLevel string
IsActive bool
}
type SubCourseVideo struct {
ID int64
SubCourseID int64
Title string
Description *string
VideoURL string
Duration int32
Resolution *string
InstructorID *string
Thumbnail *string
Visibility *string
DisplayOrder int32
IsPublished bool
PublishDate *time.Time
Status string
// Vimeo-specific fields
VimeoID *string
VimeoEmbedURL *string
VimeoPlayerHTML *string
VimeoStatus *string
}
type VideoHostProvider string
const (
VideoHostProviderDirect VideoHostProvider = "DIRECT"
VideoHostProviderVimeo VideoHostProvider = "VIMEO"
)
type VideoAccessBlock struct {
VideoID int64
Title string
DisplayOrder int32
}
// Learning Path types — full nested structure for a course
type LearningPathVideo struct {
ID int64 `json:"id"`
Title string `json:"title"`
Description *string `json:"description,omitempty"`
VideoURL string `json:"video_url"`
Duration int32 `json:"duration"`
Resolution *string `json:"resolution,omitempty"`
DisplayOrder int32 `json:"display_order"`
VimeoID *string `json:"vimeo_id,omitempty"`
VimeoEmbedURL *string `json:"vimeo_embed_url,omitempty"`
VideoHostProvider *string `json:"video_host_provider,omitempty"`
}
type LearningPathPractice struct {
ID int64 `json:"id"`
Title string `json:"title"`
Description *string `json:"description,omitempty"`
Persona *string `json:"persona,omitempty"`
Status string `json:"status"`
IntroVideoURL *string `json:"intro_video_url,omitempty"`
QuestionCount int64 `json:"question_count"`
}
type LearningPathPrerequisite struct {
SubCourseID int64 `json:"sub_course_id"`
Title string `json:"title"`
Level string `json:"level"`
SubLevel string `json:"sub_level"`
}
type LearningPathSubCourse struct {
ID int64 `json:"id"`
Title string `json:"title"`
Description *string `json:"description,omitempty"`
Thumbnail *string `json:"thumbnail,omitempty"`
Tips *string `json:"tips,omitempty"`
DisplayOrder int32 `json:"display_order"`
Level string `json:"level"`
SubLevel string `json:"sub_level"`
PrerequisiteCount int64 `json:"prerequisite_count"`
VideoCount int64 `json:"video_count"`
PracticeCount int64 `json:"practice_count"`
Prerequisites []LearningPathPrerequisite `json:"prerequisites"`
Videos []LearningPathVideo `json:"videos"`
Practices []LearningPathPractice `json:"practices"`
}
type LearningPath struct {
CourseID int64 `json:"course_id"`
CourseTitle string `json:"course_title"`
Description *string `json:"description,omitempty"`
Thumbnail *string `json:"thumbnail,omitempty"`
IntroVideoURL *string `json:"intro_video_url,omitempty"`
CategoryID int64 `json:"category_id"`
CategoryName string `json:"category_name"`
SubCourses []LearningPathSubCourse `json:"sub_courses"`
}

32
internal/domain/lesson.go Normal file
View File

@ -0,0 +1,32 @@
package domain
import "time"
// Lesson belongs to a Module.
type Lesson struct {
ID int64 `json:"id"`
ModuleID int64 `json:"module_id"`
Title string `json:"title"`
VideoURL *string `json:"video_url,omitempty"`
Thumbnail *string `json:"thumbnail,omitempty"`
Description *string `json:"description,omitempty"`
SortOrder int `json:"sort_order"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt *time.Time `json:"updated_at,omitempty"`
Access *LMSEntityAccess `json:"access,omitempty"`
}
type CreateLessonInput struct {
Title string `json:"title" validate:"required"`
VideoURL *string `json:"video_url,omitempty"`
Thumbnail *string `json:"thumbnail,omitempty"`
Description *string `json:"description,omitempty"`
}
type UpdateLessonInput struct {
Title *string `json:"title,omitempty"`
VideoURL *string `json:"video_url,omitempty"`
Thumbnail *string `json:"thumbnail,omitempty"`
Description *string `json:"description,omitempty"`
SortOrder *int `json:"sort_order,omitempty"`
}

View File

@ -0,0 +1,22 @@
package domain
// LMSEntityAccess describes learner gating for a program, course, module, or lesson.
// It is omitted (nil) for non-learner roles in API responses.
// Progress fields count completed lessons vs total lessons in that entitys scope (lesson: 0 or 1 of 1).
type LMSEntityAccess struct {
IsAccessible bool `json:"is_accessible"`
IsCompleted bool `json:"is_completed"`
Reason string `json:"reason,omitempty"`
CompletedCount int `json:"completed_count"`
TotalCount int `json:"total_count"`
ProgressPercent int `json:"progress_percent"`
}
// LMSUserProgress lists entity IDs the authenticated user has fully completed
// (lessons as marked complete; module/course/program when rollup conditions were met).
type LMSUserProgress struct {
LessonIDs []int64 `json:"lesson_ids"`
ModuleIDs []int64 `json:"module_ids"`
CourseIDs []int64 `json:"course_ids"`
ProgramIDs []int64 `json:"program_ids"`
}

30
internal/domain/module.go Normal file
View File

@ -0,0 +1,30 @@
package domain
import "time"
// Module belongs to a Course. program_id is the courses program (stored for querying; not required from the client on create).
type Module struct {
ID int64 `json:"id"`
ProgramID int64 `json:"program_id"`
CourseID int64 `json:"course_id"`
Name string `json:"name"`
Description *string `json:"description,omitempty"`
Icon *string `json:"icon,omitempty"`
SortOrder int `json:"sort_order"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt *time.Time `json:"updated_at,omitempty"`
Access *LMSEntityAccess `json:"access,omitempty"`
}
type CreateModuleInput struct {
Name string `json:"name" validate:"required"`
Description *string `json:"description,omitempty"`
Icon *string `json:"icon,omitempty"`
}
type UpdateModuleInput struct {
Name *string `json:"name,omitempty"`
Description *string `json:"description,omitempty"`
Icon *string `json:"icon,omitempty"`
SortOrder *int `json:"sort_order,omitempty"`
}

View File

@ -0,0 +1,47 @@
package domain
import "time"
// ParentKind identifies which hierarchy entity owns a practice (exactly one).
type ParentKind string
const (
ParentKindCourse ParentKind = "COURSE"
ParentKindModule ParentKind = "MODULE"
ParentKindLesson ParentKind = "LESSON"
)
// Practice is question-set content (story, persona, tips) scoped to a course, module, or lesson.
type Practice struct {
ID int64 `json:"id"`
ParentKind ParentKind `json:"parent_kind"`
ParentID int64 `json:"parent_id"`
Title string `json:"title"`
StoryDescription *string `json:"story_description,omitempty"`
StoryImage *string `json:"story_image,omitempty"`
PersonaID *int64 `json:"persona_id,omitempty"`
QuestionSetID int64 `json:"question_set_id"`
QuickTips *string `json:"quick_tips,omitempty"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt *time.Time `json:"updated_at,omitempty"`
}
type CreatePracticeInput struct {
ParentKind ParentKind `json:"parent_kind" validate:"required,oneof=COURSE MODULE LESSON"`
ParentID int64 `json:"parent_id" validate:"required,gt=0"`
Title string `json:"title" validate:"required"`
StoryDescription *string `json:"story_description,omitempty"`
StoryImage *string `json:"story_image,omitempty"`
PersonaID *int64 `json:"persona_id,omitempty"`
QuestionSetID int64 `json:"question_set_id" validate:"required,gt=0"`
QuickTips *string `json:"quick_tips,omitempty"`
}
type UpdatePracticeInput struct {
Title *string `json:"title,omitempty"`
StoryDescription *string `json:"story_description,omitempty"`
StoryImage *string `json:"story_image,omitempty"`
PersonaID *int64 `json:"persona_id,omitempty"`
QuestionSetID *int64 `json:"question_set_id,omitempty"`
QuickTips *string `json:"quick_tips,omitempty"`
}

View File

@ -0,0 +1,28 @@
package domain
import "time"
// Program is the top-level container in the LMS hierarchy (e.g. tracks like Beginner / Intermediate / Advanced).
type Program struct {
ID int64 `json:"id"`
Name string `json:"name"`
Description *string `json:"description,omitempty"`
Thumbnail *string `json:"thumbnail,omitempty"`
SortOrder int `json:"sort_order"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt *time.Time `json:"updated_at,omitempty"`
Access *LMSEntityAccess `json:"access,omitempty"`
}
type CreateProgramInput struct {
Name string `json:"name" validate:"required"`
Description *string `json:"description,omitempty"`
Thumbnail *string `json:"thumbnail,omitempty"`
}
type UpdateProgramInput struct {
Name *string `json:"name,omitempty"`
Description *string `json:"description,omitempty"`
Thumbnail *string `json:"thumbnail,omitempty"`
SortOrder *int `json:"sort_order,omitempty"`
}

View File

@ -1,84 +0,0 @@
package domain
import (
"errors"
"time"
)
var (
ErrPrerequisiteNotMet = errors.New("prerequisites not completed")
ErrProgressNotFound = errors.New("progress record not found")
ErrPrerequisiteExists = errors.New("prerequisite already exists")
ErrSelfPrerequisite = errors.New("sub-course cannot be its own prerequisite")
ErrSubCourseAlreadyStarted = errors.New("sub-course already started")
)
type SubCoursePrerequisite struct {
ID int64
SubCourseID int64
PrerequisiteSubCourseID int64
PrerequisiteTitle string
PrerequisiteLevel string
PrerequisiteDisplayOrder int32
CreatedAt time.Time
}
type SubCourseDependent struct {
ID int64
SubCourseID int64
PrerequisiteSubCourseID int64
DependentTitle string
DependentLevel string
CreatedAt time.Time
}
type ProgressStatus string
const (
ProgressStatusNotStarted ProgressStatus = "NOT_STARTED"
ProgressStatusInProgress ProgressStatus = "IN_PROGRESS"
ProgressStatusCompleted ProgressStatus = "COMPLETED"
)
type UserSubCourseProgress struct {
ID int64
UserID int64
SubCourseID int64
Status ProgressStatus
ProgressPercentage int16
StartedAt *time.Time
CompletedAt *time.Time
CreatedAt time.Time
UpdatedAt *time.Time
}
type SubCourseWithProgress struct {
SubCourseID int64
Title string
Description *string
Thumbnail *string
DisplayOrder int32
Level string
IsActive bool
ProgressStatus ProgressStatus
ProgressPercentage int16
StartedAt *time.Time
CompletedAt *time.Time
UnmetPrerequisitesCount int64
IsLocked bool
}
type UserCourseProgressItem struct {
ID int64
UserID int64
SubCourseID int64
Status ProgressStatus
ProgressPercentage int16
StartedAt *time.Time
CompletedAt *time.Time
CreatedAt time.Time
UpdatedAt *time.Time
SubCourseTitle string
SubCourseLevel string
SubCourseDisplayOrder int32
}

View File

@ -104,7 +104,6 @@ type QuestionSet struct {
PassingScore *int32 PassingScore *int32
ShuffleQuestions bool ShuffleQuestions bool
Status string Status string
SubCourseVideoID *int64
IntroVideoURL *string IntroVideoURL *string
UserPersonas []UserPersona UserPersonas []UserPersona
CreatedAt time.Time CreatedAt time.Time
@ -173,7 +172,6 @@ type CreateQuestionSetInput struct {
PassingScore *int32 PassingScore *int32
ShuffleQuestions *bool ShuffleQuestions *bool
Status *string Status *string
SubCourseVideoID *int64
IntroVideoURL *string IntroVideoURL *string
} }

View File

@ -0,0 +1,46 @@
package domain
import (
"errors"
"fmt"
"sort"
)
// ErrReorderInvalidIDSet means ordered_ids is not an exact permutation of the current entities in scope.
var ErrReorderInvalidIDSet = errors.New("ordered_ids must list every id in this scope exactly once, with no duplicates")
// ReorderIDsRequest is the body for batch reorder endpoints (drag-and-drop UI).
// Send "ordered_ids": [] in display order. Must include every id in that scope (use GET list) when there is at least one entity.
type ReorderIDsRequest struct {
OrderedIDs []int64 `json:"ordered_ids"`
}
// ValidateReorderPermutation checks that ordered contains the same multiset of ids as expected (new order vs current scope).
func ValidateReorderPermutation(ordered, expected []int64) error {
if len(expected) == 0 {
if len(ordered) == 0 {
return nil
}
return fmt.Errorf("%w: no entities exist in this scope", ErrReorderInvalidIDSet)
}
if len(ordered) != len(expected) {
return fmt.Errorf("%w: want %d ids, got %d", ErrReorderInvalidIDSet, len(expected), len(ordered))
}
seen := make(map[int64]struct{}, len(ordered))
for _, id := range ordered {
if _, dup := seen[id]; dup {
return fmt.Errorf("%w: duplicate id %d", ErrReorderInvalidIDSet, id)
}
seen[id] = struct{}{}
}
a := append([]int64(nil), expected...)
b := append([]int64(nil), ordered...)
sort.Slice(a, func(i, j int) bool { return a[i] < a[j] })
sort.Slice(b, func(i, j int) bool { return b[i] < b[j] })
for i := range a {
if a[i] != b[i] {
return fmt.Errorf("%w: id set does not match current scope", ErrReorderInvalidIDSet)
}
}
return nil
}

View File

@ -1,216 +0,0 @@
package ports
import (
"Yimaru-Backend/internal/domain"
"context"
)
type CourseStore interface {
// Course Categories
CreateCourseCategory(
ctx context.Context,
name string,
) (domain.CourseCategory, error)
GetCourseCategoryByID(
ctx context.Context,
id int64,
) (domain.CourseCategory, error)
GetAllCourseCategories(
ctx context.Context,
limit int32,
offset int32,
) ([]domain.CourseCategory, int64, error)
UpdateCourseCategory(
ctx context.Context,
id int64,
name *string,
isActive *bool,
) error
DeleteCourseCategory(
ctx context.Context,
id int64,
) error
// Courses
CreateCourse(
ctx context.Context,
categoryID int64,
title string,
description *string,
thumbnail *string,
introVideoURL *string,
) (domain.Course, error)
GetCourseByID(
ctx context.Context,
id int64,
) (domain.Course, error)
GetCoursesByCategory(
ctx context.Context,
categoryID int64,
limit int32,
offset int32,
) ([]domain.Course, int64, error)
UpdateCourse(
ctx context.Context,
id int64,
title *string,
description *string,
thumbnail *string,
introVideoURL *string,
isActive *bool,
) error
DeleteCourse(
ctx context.Context,
id int64,
) error
// Sub-courses
CreateSubCourse(
ctx context.Context,
courseID int64,
title string,
description *string,
thumbnail *string,
displayOrder *int32,
level string,
subLevel string,
) (domain.SubCourse, error)
GetSubCourseByID(
ctx context.Context,
id int64,
) (domain.SubCourse, error)
GetSubCoursesByCourse(
ctx context.Context,
courseID int64,
) ([]domain.SubCourse, int64, error)
ListSubCoursesByCourse(
ctx context.Context,
courseID int64,
) ([]domain.SubCourse, error)
ListActiveSubCourses(
ctx context.Context,
) ([]domain.SubCourse, error)
UpdateSubCourse(
ctx context.Context,
id int64,
title *string,
description *string,
thumbnail *string,
displayOrder *int32,
level *string,
subLevel *string,
isActive *bool,
) error
DeactivateSubCourse(
ctx context.Context,
id int64,
) error
DeleteSubCourse(
ctx context.Context,
id int64,
) (domain.SubCourse, error)
// Sub-course Videos
CreateSubCourseVideo(
ctx context.Context,
subCourseID int64,
title string,
description *string,
videoURL string,
duration int32,
resolution *string,
instructorID *string,
thumbnail *string,
visibility *string,
displayOrder *int32,
status *string,
vimeoID *string,
vimeoEmbedURL *string,
vimeoPlayerHTML *string,
vimeoStatus *string,
videoHostProvider *string,
) (domain.SubCourseVideo, error)
GetSubCourseVideoByID(
ctx context.Context,
id int64,
) (domain.SubCourseVideo, error)
GetVideosBySubCourse(
ctx context.Context,
subCourseID int64,
) ([]domain.SubCourseVideo, int64, error)
GetPublishedVideosBySubCourse(
ctx context.Context,
subCourseID int64,
) ([]domain.SubCourseVideo, error)
GetFirstIncompletePreviousVideo(
ctx context.Context,
userID int64,
videoID int64,
) (*domain.VideoAccessBlock, error)
MarkVideoCompleted(
ctx context.Context,
userID int64,
videoID int64,
) error
PublishSubCourseVideo(
ctx context.Context,
videoID int64,
) error
UpdateSubCourseVideo(
ctx context.Context,
id int64,
title *string,
description *string,
videoURL *string,
duration *int32,
resolution *string,
visibility *string,
thumbnail *string,
displayOrder *int32,
status *string,
) error
ArchiveSubCourseVideo(
ctx context.Context,
id int64,
) error
DeleteSubCourseVideo(
ctx context.Context,
id int64,
) error
// Vimeo integration
UpdateVimeoStatus(ctx context.Context, videoID int64, status string) error
GetVideoByVimeoID(ctx context.Context, vimeoID string) (domain.SubCourseVideo, error)
// Learning Tree
GetFullLearningTree(ctx context.Context) ([]domain.TreeCourse, error)
// Learning Path (full nested structure for a course)
GetCourseLearningPath(ctx context.Context, courseID int64) (domain.LearningPath, error)
// Reorder (drag-and-drop support)
ReorderCourseCategories(ctx context.Context, ids []int64, positions []int32) error
ReorderCourses(ctx context.Context, ids []int64, positions []int32) error
ReorderSubCourses(ctx context.Context, ids []int64, positions []int32) error
ReorderSubCourseVideos(ctx context.Context, ids []int64, positions []int32) error
ReorderQuestionSets(ctx context.Context, ids []int64, positions []int32) error
}
type ProgressionStore interface {
// Prerequisites (admin)
AddSubCoursePrerequisite(ctx context.Context, subCourseID, prerequisiteSubCourseID int64) error
RemoveSubCoursePrerequisite(ctx context.Context, subCourseID, prerequisiteSubCourseID int64) error
GetSubCoursePrerequisites(ctx context.Context, subCourseID int64) ([]domain.SubCoursePrerequisite, error)
GetSubCourseDependents(ctx context.Context, prerequisiteSubCourseID int64) ([]domain.SubCourseDependent, error)
CountUnmetPrerequisites(ctx context.Context, subCourseID, userID int64) (int64, error)
DeleteAllPrerequisitesForSubCourse(ctx context.Context, subCourseID int64) error
// User progress
StartSubCourseProgress(ctx context.Context, userID, subCourseID int64) (domain.UserSubCourseProgress, error)
UpdateSubCourseProgress(ctx context.Context, userID, subCourseID int64, percentage int16) error
CompleteSubCourse(ctx context.Context, userID, subCourseID int64) error
RecalculateSubCourseProgress(ctx context.Context, userID, subCourseID int64) error
GetUserSubCourseProgress(ctx context.Context, userID, subCourseID int64) (domain.UserSubCourseProgress, error)
GetUserCourseProgress(ctx context.Context, userID, courseID int64) ([]domain.UserCourseProgressItem, error)
GetSubCoursesWithProgressByCourse(ctx context.Context, userID, courseID int64) ([]domain.SubCourseWithProgress, error)
}

View File

@ -0,0 +1,16 @@
package ports
import (
"Yimaru-Backend/internal/domain"
"context"
)
type CourseStore interface {
CreateCourse(ctx context.Context, programID int64, input domain.CreateCourseInput) (domain.Course, error)
GetCourseByID(ctx context.Context, id int64) (domain.Course, error)
ListCoursesByProgramID(ctx context.Context, programID int64, limit, offset int32) ([]domain.Course, int64, error)
ListCourseIDsByProgram(ctx context.Context, programID int64) ([]int64, error)
ReorderCoursesInProgram(ctx context.Context, programID int64, orderedIDs []int64) error
UpdateCourse(ctx context.Context, id int64, input domain.UpdateCourseInput) (domain.Course, error)
DeleteCourse(ctx context.Context, id int64) error
}

View File

@ -0,0 +1,14 @@
package ports
import (
"Yimaru-Backend/internal/domain"
"context"
)
type LessonStore interface {
CreateLesson(ctx context.Context, moduleID int64, input domain.CreateLessonInput) (domain.Lesson, error)
GetLessonByID(ctx context.Context, id int64) (domain.Lesson, error)
ListLessonsByModuleID(ctx context.Context, moduleID int64, limit, offset int32) ([]domain.Lesson, int64, error)
UpdateLesson(ctx context.Context, id int64, input domain.UpdateLessonInput) (domain.Lesson, error)
DeleteLesson(ctx context.Context, id int64) error
}

View File

@ -0,0 +1,16 @@
package ports
import (
"Yimaru-Backend/internal/domain"
"context"
)
type ModuleStore interface {
CreateModule(ctx context.Context, programID, courseID int64, input domain.CreateModuleInput) (domain.Module, error)
GetModuleByID(ctx context.Context, id int64) (domain.Module, error)
ListModulesByProgramAndCourse(ctx context.Context, programID, courseID int64, limit, offset int32) ([]domain.Module, int64, error)
ListModuleIDsByCourse(ctx context.Context, courseID int64) ([]int64, error)
ReorderModulesInCourse(ctx context.Context, courseID int64, orderedIDs []int64) error
UpdateModule(ctx context.Context, id int64, input domain.UpdateModuleInput) (domain.Module, error)
DeleteModule(ctx context.Context, id int64) error
}

View File

@ -0,0 +1,31 @@
package ports
import (
"Yimaru-Backend/internal/domain"
"context"
)
// QuestionSetByID is implemented by the questions store.
type QuestionSetByID interface {
GetQuestionSetByID(ctx context.Context, id int64) (domain.QuestionSet, error)
}
// UserByID is implemented by the user store.
type UserByID interface {
GetUserByID(ctx context.Context, id int64) (domain.User, error)
}
type LmsPracticeStore interface {
// courseID, moduleID, lessonID: exactly one non-nil, matching in.ParentKind / in.ParentID.
CreateLmsPractice(
ctx context.Context,
in domain.CreatePracticeInput,
courseID, moduleID, lessonID *int64,
) (domain.Practice, error)
GetLmsPracticeByID(ctx context.Context, id int64) (domain.Practice, error)
ListLmsPracticesByCourseID(ctx context.Context, courseID int64, limit, offset int32) ([]domain.Practice, int64, error)
ListLmsPracticesByModuleID(ctx context.Context, moduleID int64, limit, offset int32) ([]domain.Practice, int64, error)
ListLmsPracticesByLessonID(ctx context.Context, lessonID int64, limit, offset int32) ([]domain.Practice, int64, error)
UpdateLmsPractice(ctx context.Context, id int64, input domain.UpdatePracticeInput) (domain.Practice, error)
DeleteLmsPractice(ctx context.Context, id int64) error
}

16
internal/ports/program.go Normal file
View File

@ -0,0 +1,16 @@
package ports
import (
"Yimaru-Backend/internal/domain"
"context"
)
type ProgramStore interface {
CreateProgram(ctx context.Context, input domain.CreateProgramInput) (domain.Program, error)
GetProgramByID(ctx context.Context, id int64) (domain.Program, error)
ListPrograms(ctx context.Context, limit, offset int32) ([]domain.Program, int64, error)
ListAllProgramIDs(ctx context.Context) ([]int64, error)
ReorderPrograms(ctx context.Context, orderedIDs []int64) error
UpdateProgram(ctx context.Context, id int64, input domain.UpdateProgramInput) (domain.Program, error)
DeleteProgram(ctx context.Context, id int64) error
}

View File

@ -37,7 +37,6 @@ type QuestionStore interface {
GetQuestionSetsByType(ctx context.Context, setType string, limit, offset int32) ([]domain.QuestionSet, int64, error) GetQuestionSetsByType(ctx context.Context, setType string, limit, offset int32) ([]domain.QuestionSet, int64, error)
GetPublishedQuestionSetsByOwner(ctx context.Context, ownerType string, ownerID int64) ([]domain.QuestionSet, error) GetPublishedQuestionSetsByOwner(ctx context.Context, ownerType string, ownerID int64) ([]domain.QuestionSet, error)
GetInitialAssessmentSet(ctx context.Context) (domain.QuestionSet, error) GetInitialAssessmentSet(ctx context.Context) (domain.QuestionSet, error)
GetSubCourseInitialAssessmentSet(ctx context.Context, subCourseID int64) (domain.QuestionSet, error)
GetFirstIncompletePreviousPractice(ctx context.Context, userID int64, questionSetID int64) (*domain.PracticeAccessBlock, error) GetFirstIncompletePreviousPractice(ctx context.Context, userID int64, questionSetID int64) (*domain.PracticeAccessBlock, error)
MarkPracticeCompleted(ctx context.Context, userID int64, questionSetID int64) error MarkPracticeCompleted(ctx context.Context, userID int64, questionSetID int64) error
UpdateQuestionSet(ctx context.Context, id int64, input domain.CreateQuestionSetInput) error UpdateQuestionSet(ctx context.Context, id int64, input domain.CreateQuestionSetInput) error

View File

@ -1,128 +0,0 @@
package repository
import (
dbgen "Yimaru-Backend/gen/db"
"Yimaru-Backend/internal/domain"
"context"
"github.com/jackc/pgx/v5/pgtype"
)
func NewCourseStore(s *Store) *Store { return s }
func NewProgressionStore(s *Store) *Store { return s }
func (s *Store) CreateCourseCategory(
ctx context.Context,
name string,
) (domain.CourseCategory, error) {
row, err := s.queries.CreateCourseCategory(ctx, dbgen.CreateCourseCategoryParams{
Name: name,
Column2: true,
})
if err != nil {
return domain.CourseCategory{}, err
}
return domain.CourseCategory{
ID: row.ID,
Name: row.Name,
IsActive: row.IsActive,
CreatedAt: row.CreatedAt.Time,
}, nil
}
func (s *Store) GetCourseCategoryByID(
ctx context.Context,
id int64,
) (domain.CourseCategory, error) {
row, err := s.queries.GetCourseCategoryByID(ctx, id)
if err != nil {
return domain.CourseCategory{}, err
}
return domain.CourseCategory{
ID: row.ID,
Name: row.Name,
IsActive: row.IsActive,
CreatedAt: row.CreatedAt.Time,
}, nil
}
func (s *Store) GetAllCourseCategories(
ctx context.Context,
limit int32,
offset int32,
) ([]domain.CourseCategory, int64, error) {
rows, err := s.queries.GetAllCourseCategories(ctx, dbgen.GetAllCourseCategoriesParams{
Limit: pgtype.Int4{Int32: limit},
Offset: pgtype.Int4{Int32: offset},
})
if err != nil {
return nil, 0, err
}
var (
categories []domain.CourseCategory
totalCount int64
)
for i, row := range rows {
if i == 0 {
totalCount = row.TotalCount
}
categories = append(categories, domain.CourseCategory{
ID: row.ID,
Name: row.Name,
IsActive: row.IsActive,
CreatedAt: row.CreatedAt.Time,
})
}
return categories, totalCount, nil
}
func (s *Store) UpdateCourseCategory(
ctx context.Context,
id int64,
name *string,
isActive *bool,
) error {
var (
nameVal string
isActiveVal bool
)
if name != nil {
nameVal = *name
}
if isActive != nil {
isActiveVal = *isActive
}
return s.queries.UpdateCourseCategory(ctx, dbgen.UpdateCourseCategoryParams{
Name: nameVal,
IsActive: isActiveVal,
ID: id,
})
}
func (s *Store) DeleteCourseCategory(
ctx context.Context,
id int64,
) error {
return s.queries.DeleteCourseCategory(ctx, id)
}
func (s *Store) ReorderCourseCategories(ctx context.Context, ids []int64, positions []int32) error {
return s.queries.ReorderCourseCategories(ctx, dbgen.ReorderCourseCategoriesParams{
Ids: ids,
Positions: positions,
})
}

View File

@ -1,173 +0,0 @@
package repository
import (
dbgen "Yimaru-Backend/gen/db"
"Yimaru-Backend/internal/domain"
"context"
"github.com/jackc/pgx/v5/pgtype"
)
func (s *Store) CreateCourse(
ctx context.Context,
categoryID int64,
title string,
description *string,
thumbnail *string,
introVideoURL *string,
) (domain.Course, error) {
var descVal, thumbVal, introVideoVal string
if description != nil {
descVal = *description
}
if thumbnail != nil {
thumbVal = *thumbnail
}
if introVideoURL != nil {
introVideoVal = *introVideoURL
}
row, err := s.queries.CreateCourse(ctx, dbgen.CreateCourseParams{
CategoryID: categoryID,
Title: title,
Description: pgtype.Text{String: descVal, Valid: description != nil},
Thumbnail: pgtype.Text{String: thumbVal, Valid: thumbnail != nil},
IntroVideoUrl: pgtype.Text{String: introVideoVal, Valid: introVideoURL != nil},
Column6: true,
})
if err != nil {
return domain.Course{}, err
}
return mapCourse(row), nil
}
func (s *Store) GetCourseByID(
ctx context.Context,
id int64,
) (domain.Course, error) {
row, err := s.queries.GetCourseByID(ctx, id)
if err != nil {
return domain.Course{}, err
}
return mapCourse(row), nil
}
func (s *Store) GetCoursesByCategory(
ctx context.Context,
categoryID int64,
limit int32,
offset int32,
) ([]domain.Course, int64, error) {
rows, err := s.queries.GetCoursesByCategory(ctx, dbgen.GetCoursesByCategoryParams{
CategoryID: categoryID,
Limit: pgtype.Int4{Int32: limit},
Offset: pgtype.Int4{Int32: offset},
})
if err != nil {
return nil, 0, err
}
var (
courses []domain.Course
totalCount int64
)
for i, row := range rows {
if i == 0 {
totalCount = row.TotalCount
}
courses = append(courses, domain.Course{
ID: row.ID,
CategoryID: row.CategoryID,
Title: row.Title,
Description: ptrText(row.Description),
Thumbnail: ptrText(row.Thumbnail),
IntroVideoURL: ptrText(row.IntroVideoUrl),
IsActive: row.IsActive,
})
}
return courses, totalCount, nil
}
func (s *Store) UpdateCourse(
ctx context.Context,
id int64,
title *string,
description *string,
thumbnail *string,
introVideoURL *string,
isActive *bool,
) error {
var (
titleVal string
descriptionVal string
thumbnailVal string
introVideoVal string
isActiveVal bool
)
if title != nil {
titleVal = *title
}
if description != nil {
descriptionVal = *description
}
if thumbnail != nil {
thumbnailVal = *thumbnail
}
if introVideoURL != nil {
introVideoVal = *introVideoURL
}
if isActive != nil {
isActiveVal = *isActive
}
return s.queries.UpdateCourse(ctx, dbgen.UpdateCourseParams{
Title: titleVal,
Description: pgtype.Text{String: descriptionVal, Valid: description != nil},
Thumbnail: pgtype.Text{String: thumbnailVal, Valid: thumbnail != nil},
IntroVideoUrl: pgtype.Text{String: introVideoVal, Valid: introVideoURL != nil},
IsActive: isActiveVal,
ID: id,
})
}
func (s *Store) DeleteCourse(
ctx context.Context,
id int64,
) error {
return s.queries.DeleteCourse(ctx, id)
}
func mapCourse(row dbgen.Course) domain.Course {
return domain.Course{
ID: row.ID,
CategoryID: row.CategoryID,
Title: row.Title,
Description: ptrText(row.Description),
Thumbnail: ptrText(row.Thumbnail),
IntroVideoURL: ptrText(row.IntroVideoUrl),
IsActive: row.IsActive,
}
}
func (s *Store) ReorderCourses(ctx context.Context, ids []int64, positions []int32) error {
return s.queries.ReorderCourses(ctx, dbgen.ReorderCoursesParams{
Ids: ids,
Positions: positions,
})
}
func ptrText(t pgtype.Text) *string {
if t.Valid {
return &t.String
}
return nil
}

View File

@ -0,0 +1,87 @@
package repository
import (
"context"
dbgen "Yimaru-Backend/gen/db"
)
func (s *Store) LmsGetPreviousProgram(ctx context.Context, programID int64) (dbgen.Program, error) {
return s.queries.GetPreviousProgram(ctx, programID)
}
func (s *Store) LmsGetPreviousCourseInProgram(ctx context.Context, courseID int64) (dbgen.Course, error) {
return s.queries.GetPreviousCourseInProgram(ctx, courseID)
}
func (s *Store) LmsGetPreviousModuleInCourse(ctx context.Context, moduleID int64) (dbgen.Module, error) {
return s.queries.GetPreviousModuleInCourse(ctx, moduleID)
}
func (s *Store) LmsGetPreviousLessonInModule(ctx context.Context, lessonID int64) (dbgen.Lesson, error) {
return s.queries.GetPreviousLessonInModule(ctx, lessonID)
}
func (s *Store) LmsUserHasProgramProgress(ctx context.Context, userID, programID int64) (bool, error) {
return s.queries.UserHasProgramProgress(ctx, dbgen.UserHasProgramProgressParams{UserID: userID, ProgramID: programID})
}
func (s *Store) LmsUserHasCourseProgress(ctx context.Context, userID, courseID int64) (bool, error) {
return s.queries.UserHasCourseProgress(ctx, dbgen.UserHasCourseProgressParams{UserID: userID, CourseID: courseID})
}
func (s *Store) LmsUserHasModuleProgress(ctx context.Context, userID, moduleID int64) (bool, error) {
return s.queries.UserHasModuleProgress(ctx, dbgen.UserHasModuleProgressParams{UserID: userID, ModuleID: moduleID})
}
func (s *Store) LmsUserHasLessonProgress(ctx context.Context, userID, lessonID int64) (bool, error) {
return s.queries.UserHasLessonProgress(ctx, dbgen.UserHasLessonProgressParams{UserID: userID, LessonID: lessonID})
}
// LmsUserLessonProgressInModule returns completed and total lesson counts in a module (for progress UI).
func (s *Store) LmsUserLessonProgressInModule(ctx context.Context, userID, moduleID int64) (completed, total int32, err error) {
total, err = s.queries.CountLessonsInModule(ctx, moduleID)
if err != nil {
return 0, 0, err
}
completed, err = s.queries.CountUserCompletedLessonsInModule(ctx, dbgen.CountUserCompletedLessonsInModuleParams{
ModuleID: moduleID,
UserID: userID,
})
if err != nil {
return 0, 0, err
}
return completed, total, nil
}
// LmsUserLessonProgressInCourse returns completed and total lesson counts in a course (all modules).
func (s *Store) LmsUserLessonProgressInCourse(ctx context.Context, userID, courseID int64) (completed, total int32, err error) {
total, err = s.queries.CountLessonsInCourse(ctx, courseID)
if err != nil {
return 0, 0, err
}
completed, err = s.queries.CountUserCompletedLessonsInCourse(ctx, dbgen.CountUserCompletedLessonsInCourseParams{
CourseID: courseID,
UserID: userID,
})
if err != nil {
return 0, 0, err
}
return completed, total, nil
}
// LmsUserLessonProgressInProgram returns completed and total lesson counts in a program (all courses).
func (s *Store) LmsUserLessonProgressInProgram(ctx context.Context, userID, programID int64) (completed, total int32, err error) {
total, err = s.queries.CountLessonsInProgram(ctx, programID)
if err != nil {
return 0, 0, err
}
completed, err = s.queries.CountUserCompletedLessonsInProgram(ctx, dbgen.CountUserCompletedLessonsInProgramParams{
ProgramID: programID,
UserID: userID,
})
if err != nil {
return 0, 0, err
}
return completed, total, nil
}

View File

@ -0,0 +1,116 @@
package repository
import (
"context"
"errors"
dbgen "Yimaru-Backend/gen/db"
"Yimaru-Backend/internal/domain"
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgtype"
)
func courseToDomain(c dbgen.Course) domain.Course {
out := domain.Course{
ID: c.ID,
ProgramID: c.ProgramID,
Name: c.Name,
}
out.Description = fromPgText(c.Description)
out.Thumbnail = fromPgText(c.Thumbnail)
out.CreatedAt = c.CreatedAt.Time
if c.UpdatedAt.Valid {
t := c.UpdatedAt.Time
out.UpdatedAt = &t
}
out.SortOrder = int(c.SortOrder)
return out
}
func (s *Store) CreateCourse(ctx context.Context, programID int64, input domain.CreateCourseInput) (domain.Course, error) {
c, err := s.queries.CreateCourse(ctx, dbgen.CreateCourseParams{
ProgramID: programID,
Name: input.Name,
Description: toPgText(input.Description),
Thumbnail: toPgText(input.Thumbnail),
})
if err != nil {
return domain.Course{}, err
}
return courseToDomain(c), nil
}
func (s *Store) ListCourseIDsByProgram(ctx context.Context, programID int64) ([]int64, error) {
return s.queries.ListCourseIDsByProgram(ctx, programID)
}
func (s *Store) GetCourseByID(ctx context.Context, id int64) (domain.Course, error) {
c, err := s.queries.GetCourseByID(ctx, id)
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return domain.Course{}, pgx.ErrNoRows
}
return domain.Course{}, err
}
return courseToDomain(c), nil
}
func (s *Store) ListCoursesByProgramID(ctx context.Context, programID int64, limit, offset int32) ([]domain.Course, int64, error) {
rows, err := s.queries.ListCoursesByProgramID(ctx, dbgen.ListCoursesByProgramIDParams{
ProgramID: programID,
Limit: limit,
Offset: offset,
})
if err != nil {
return nil, 0, err
}
if len(rows) == 0 {
return []domain.Course{}, 0, nil
}
var total int64
out := make([]domain.Course, 0, len(rows))
for i, r := range rows {
if i == 0 {
total = r.TotalCount
}
out = append(out, courseToDomain(dbgen.Course{
ID: r.ID,
ProgramID: r.ProgramID,
Name: r.Name,
Description: r.Description,
Thumbnail: r.Thumbnail,
CreatedAt: r.CreatedAt,
UpdatedAt: r.UpdatedAt,
SortOrder: r.SortOrder,
}))
}
return out, total, nil
}
func (s *Store) UpdateCourse(ctx context.Context, id int64, input domain.UpdateCourseInput) (domain.Course, error) {
var nameText pgtype.Text
if input.Name != nil {
nameText = pgtype.Text{String: *input.Name, Valid: true}
} else {
nameText = pgtype.Text{Valid: false}
}
c, err := s.queries.UpdateCourse(ctx, dbgen.UpdateCourseParams{
ID: id,
Name: nameText,
Description: optionalTextUpdate(input.Description),
Thumbnail: optionalTextUpdate(input.Thumbnail),
SortOrder: optionalInt4Update(input.SortOrder),
})
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return domain.Course{}, pgx.ErrNoRows
}
return domain.Course{}, err
}
return courseToDomain(c), nil
}
func (s *Store) DeleteCourse(ctx context.Context, id int64) error {
return s.queries.DeleteCourse(ctx, id)
}

View File

@ -0,0 +1,116 @@
package repository
import (
"context"
"errors"
dbgen "Yimaru-Backend/gen/db"
"Yimaru-Backend/internal/domain"
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgtype"
)
func lessonToDomain(l dbgen.Lesson) domain.Lesson {
out := domain.Lesson{
ID: l.ID,
ModuleID: l.ModuleID,
Title: l.Title,
}
out.VideoURL = fromPgText(l.VideoUrl)
out.Thumbnail = fromPgText(l.Thumbnail)
out.Description = fromPgText(l.Description)
out.CreatedAt = l.CreatedAt.Time
if l.UpdatedAt.Valid {
t := l.UpdatedAt.Time
out.UpdatedAt = &t
}
out.SortOrder = int(l.SortOrder)
return out
}
func (s *Store) CreateLesson(ctx context.Context, moduleID int64, input domain.CreateLessonInput) (domain.Lesson, error) {
l, err := s.queries.CreateLesson(ctx, dbgen.CreateLessonParams{
ModuleID: moduleID,
Title: input.Title,
VideoUrl: toPgText(input.VideoURL),
Thumbnail: toPgText(input.Thumbnail),
Description: toPgText(input.Description),
})
if err != nil {
return domain.Lesson{}, err
}
return lessonToDomain(l), nil
}
func (s *Store) GetLessonByID(ctx context.Context, id int64) (domain.Lesson, error) {
l, err := s.queries.GetLessonByID(ctx, id)
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return domain.Lesson{}, pgx.ErrNoRows
}
return domain.Lesson{}, err
}
return lessonToDomain(l), nil
}
func (s *Store) ListLessonsByModuleID(ctx context.Context, moduleID int64, limit, offset int32) ([]domain.Lesson, int64, error) {
rows, err := s.queries.ListLessonsByModuleID(ctx, dbgen.ListLessonsByModuleIDParams{
ModuleID: moduleID,
Limit: limit,
Offset: offset,
})
if err != nil {
return nil, 0, err
}
if len(rows) == 0 {
return []domain.Lesson{}, 0, nil
}
var total int64
out := make([]domain.Lesson, 0, len(rows))
for i, r := range rows {
if i == 0 {
total = r.TotalCount
}
out = append(out, lessonToDomain(dbgen.Lesson{
ID: r.ID,
ModuleID: r.ModuleID,
Title: r.Title,
VideoUrl: r.VideoUrl,
Thumbnail: r.Thumbnail,
Description: r.Description,
CreatedAt: r.CreatedAt,
UpdatedAt: r.UpdatedAt,
SortOrder: r.SortOrder,
}))
}
return out, total, nil
}
func (s *Store) UpdateLesson(ctx context.Context, id int64, input domain.UpdateLessonInput) (domain.Lesson, error) {
var titleText pgtype.Text
if input.Title != nil {
titleText = pgtype.Text{String: *input.Title, Valid: true}
} else {
titleText = pgtype.Text{Valid: false}
}
l, err := s.queries.UpdateLesson(ctx, dbgen.UpdateLessonParams{
ID: id,
Title: titleText,
VideoUrl: optionalTextUpdate(input.VideoURL),
Thumbnail: optionalTextUpdate(input.Thumbnail),
Description: optionalTextUpdate(input.Description),
SortOrder: optionalInt4Update(input.SortOrder),
})
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return domain.Lesson{}, pgx.ErrNoRows
}
return domain.Lesson{}, err
}
return lessonToDomain(l), nil
}
func (s *Store) DeleteLesson(ctx context.Context, id int64) error {
return s.queries.DeleteLesson(ctx, id)
}

View File

@ -0,0 +1,120 @@
package repository
import (
"context"
"errors"
dbgen "Yimaru-Backend/gen/db"
"Yimaru-Backend/internal/domain"
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgtype"
)
func moduleToDomain(m dbgen.Module) domain.Module {
out := domain.Module{
ID: m.ID,
ProgramID: m.ProgramID,
CourseID: m.CourseID,
Name: m.Name,
}
out.Description = fromPgText(m.Description)
out.Icon = fromPgText(m.Icon)
out.CreatedAt = m.CreatedAt.Time
if m.UpdatedAt.Valid {
t := m.UpdatedAt.Time
out.UpdatedAt = &t
}
out.SortOrder = int(m.SortOrder)
return out
}
func (s *Store) CreateModule(ctx context.Context, programID, courseID int64, input domain.CreateModuleInput) (domain.Module, error) {
m, err := s.queries.CreateModule(ctx, dbgen.CreateModuleParams{
ProgramID: programID,
CourseID: courseID,
Name: input.Name,
Description: toPgText(input.Description),
Icon: toPgText(input.Icon),
})
if err != nil {
return domain.Module{}, err
}
return moduleToDomain(m), nil
}
func (s *Store) ListModuleIDsByCourse(ctx context.Context, courseID int64) ([]int64, error) {
return s.queries.ListModuleIDsByCourse(ctx, courseID)
}
func (s *Store) GetModuleByID(ctx context.Context, id int64) (domain.Module, error) {
m, err := s.queries.GetModuleByID(ctx, id)
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return domain.Module{}, pgx.ErrNoRows
}
return domain.Module{}, err
}
return moduleToDomain(m), nil
}
func (s *Store) ListModulesByProgramAndCourse(ctx context.Context, programID, courseID int64, limit, offset int32) ([]domain.Module, int64, error) {
rows, err := s.queries.ListModulesByProgramAndCourse(ctx, dbgen.ListModulesByProgramAndCourseParams{
ProgramID: programID,
CourseID: courseID,
Limit: limit,
Offset: offset,
})
if err != nil {
return nil, 0, err
}
if len(rows) == 0 {
return []domain.Module{}, 0, nil
}
var total int64
out := make([]domain.Module, 0, len(rows))
for i, r := range rows {
if i == 0 {
total = r.TotalCount
}
out = append(out, moduleToDomain(dbgen.Module{
ID: r.ID,
ProgramID: r.ProgramID,
CourseID: r.CourseID,
Name: r.Name,
Description: r.Description,
Icon: r.Icon,
CreatedAt: r.CreatedAt,
UpdatedAt: r.UpdatedAt,
SortOrder: r.SortOrder,
}))
}
return out, total, nil
}
func (s *Store) UpdateModule(ctx context.Context, id int64, input domain.UpdateModuleInput) (domain.Module, error) {
var nameText pgtype.Text
if input.Name != nil {
nameText = pgtype.Text{String: *input.Name, Valid: true}
} else {
nameText = pgtype.Text{Valid: false}
}
m, err := s.queries.UpdateModule(ctx, dbgen.UpdateModuleParams{
ID: id,
Name: nameText,
Description: optionalTextUpdate(input.Description),
Icon: optionalTextUpdate(input.Icon),
SortOrder: optionalInt4Update(input.SortOrder),
})
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return domain.Module{}, pgx.ErrNoRows
}
return domain.Module{}, err
}
return moduleToDomain(m), nil
}
func (s *Store) DeleteModule(ctx context.Context, id int64) error {
return s.queries.DeleteModule(ctx, id)
}

View File

@ -0,0 +1,232 @@
package repository
import (
"context"
"errors"
dbgen "Yimaru-Backend/gen/db"
"Yimaru-Backend/internal/domain"
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgtype"
)
func int64PtrToPg8(p *int64) pgtype.Int8 {
if p == nil {
return pgtype.Int8{Valid: false}
}
return pgtype.Int8{Int64: *p, Valid: true}
}
func fromPgInt8ID(c pgtype.Int8) *int64 {
if !c.Valid {
return nil
}
v := c.Int64
return &v
}
func lmsPracticeToDomain(p dbgen.LmsPractice) domain.Practice {
out := domain.Practice{
ID: p.ID,
Title: p.Title,
QuestionSetID: p.QuestionSetID,
}
if p.CourseID.Valid {
out.ParentKind = domain.ParentKindCourse
out.ParentID = p.CourseID.Int64
} else if p.ModuleID.Valid {
out.ParentKind = domain.ParentKindModule
out.ParentID = p.ModuleID.Int64
} else if p.LessonID.Valid {
out.ParentKind = domain.ParentKindLesson
out.ParentID = p.LessonID.Int64
}
out.StoryDescription = fromPgText(p.StoryDescription)
out.StoryImage = fromPgText(p.StoryImage)
out.QuickTips = fromPgText(p.QuickTips)
out.PersonaID = fromPgInt8ID(p.PersonaID)
out.CreatedAt = p.CreatedAt.Time
if p.UpdatedAt.Valid {
t := p.UpdatedAt.Time
out.UpdatedAt = &t
}
return out
}
func lmsFromListRow(
id, qid int64, title string,
cid, mid, lid pgtype.Int8,
sd, si, qt pgtype.Text, pid pgtype.Int8,
ca, ua pgtype.Timestamptz,
) domain.Practice {
return lmsPracticeToDomain(dbgen.LmsPractice{
ID: id,
CourseID: cid,
ModuleID: mid,
LessonID: lid,
Title: title,
StoryDescription: sd,
StoryImage: si,
PersonaID: pid,
QuestionSetID: qid,
QuickTips: qt,
CreatedAt: ca,
UpdatedAt: ua,
})
}
// CreateLmsPractice sets exactly one of courseID, moduleID, lessonID (non-nil).
func (s *Store) CreateLmsPractice(
ctx context.Context,
in domain.CreatePracticeInput,
courseID, moduleID, lessonID *int64,
) (domain.Practice, error) {
p, err := s.queries.CreateLmsPractice(ctx, dbgen.CreateLmsPracticeParams{
CourseID: int64PtrToPg8(courseID),
ModuleID: int64PtrToPg8(moduleID),
LessonID: int64PtrToPg8(lessonID),
Title: in.Title,
StoryDescription: toPgText(in.StoryDescription),
StoryImage: toPgText(in.StoryImage),
PersonaID: int64PtrToPg8(in.PersonaID),
QuestionSetID: in.QuestionSetID,
QuickTips: toPgText(in.QuickTips),
})
if err != nil {
return domain.Practice{}, err
}
return lmsPracticeToDomain(p), nil
}
func (s *Store) GetLmsPracticeByID(ctx context.Context, id int64) (domain.Practice, error) {
p, err := s.queries.GetLmsPracticeByID(ctx, id)
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return domain.Practice{}, pgx.ErrNoRows
}
return domain.Practice{}, err
}
return lmsPracticeToDomain(p), nil
}
func (s *Store) ListLmsPracticesByCourseID(ctx context.Context, courseID int64, limit, offset int32) ([]domain.Practice, int64, error) {
rows, err := s.queries.ListLmsPracticesByCourseID(ctx, dbgen.ListLmsPracticesByCourseIDParams{
CourseID: pgtype.Int8{Int64: courseID, Valid: true},
Limit: limit,
Offset: offset,
})
if err != nil {
return nil, 0, err
}
if len(rows) == 0 {
return []domain.Practice{}, 0, nil
}
var total int64
out := make([]domain.Practice, 0, len(rows))
for i, r := range rows {
if i == 0 {
total = r.TotalCount
}
out = append(out, lmsFromListRow(
r.ID, r.QuestionSetID, r.Title,
r.CourseID, r.ModuleID, r.LessonID,
r.StoryDescription, r.StoryImage, r.QuickTips,
r.PersonaID, r.CreatedAt, r.UpdatedAt,
))
}
return out, total, nil
}
func (s *Store) ListLmsPracticesByModuleID(ctx context.Context, moduleID int64, limit, offset int32) ([]domain.Practice, int64, error) {
rows, err := s.queries.ListLmsPracticesByModuleID(ctx, dbgen.ListLmsPracticesByModuleIDParams{
ModuleID: pgtype.Int8{Int64: moduleID, Valid: true},
Limit: limit,
Offset: offset,
})
if err != nil {
return nil, 0, err
}
if len(rows) == 0 {
return []domain.Practice{}, 0, nil
}
var total int64
out := make([]domain.Practice, 0, len(rows))
for i, r := range rows {
if i == 0 {
total = r.TotalCount
}
out = append(out, lmsFromListRow(
r.ID, r.QuestionSetID, r.Title,
r.CourseID, r.ModuleID, r.LessonID,
r.StoryDescription, r.StoryImage, r.QuickTips,
r.PersonaID, r.CreatedAt, r.UpdatedAt,
))
}
return out, total, nil
}
func (s *Store) ListLmsPracticesByLessonID(ctx context.Context, lessonID int64, limit, offset int32) ([]domain.Practice, int64, error) {
rows, err := s.queries.ListLmsPracticesByLessonID(ctx, dbgen.ListLmsPracticesByLessonIDParams{
LessonID: pgtype.Int8{Int64: lessonID, Valid: true},
Limit: limit,
Offset: offset,
})
if err != nil {
return nil, 0, err
}
if len(rows) == 0 {
return []domain.Practice{}, 0, nil
}
var total int64
out := make([]domain.Practice, 0, len(rows))
for i, r := range rows {
if i == 0 {
total = r.TotalCount
}
out = append(out, lmsFromListRow(
r.ID, r.QuestionSetID, r.Title,
r.CourseID, r.ModuleID, r.LessonID,
r.StoryDescription, r.StoryImage, r.QuickTips,
r.PersonaID, r.CreatedAt, r.UpdatedAt,
))
}
return out, total, nil
}
func optionalInt8UpdateID(val *int64) pgtype.Int8 {
if val == nil {
return pgtype.Int8{Valid: false}
}
return pgtype.Int8{Int64: *val, Valid: true}
}
func (s *Store) UpdateLmsPractice(ctx context.Context, id int64, input domain.UpdatePracticeInput) (domain.Practice, error) {
var titleText pgtype.Text
if input.Title != nil {
titleText = pgtype.Text{String: *input.Title, Valid: true}
} else {
titleText = pgtype.Text{Valid: false}
}
qs := optionalInt8UpdateID(input.QuestionSetID)
p, err := s.queries.UpdateLmsPractice(ctx, dbgen.UpdateLmsPracticeParams{
ID: id,
Title: titleText,
StoryDescription: optionalTextUpdate(input.StoryDescription),
StoryImage: optionalTextUpdate(input.StoryImage),
PersonaID: optionalInt8UpdateID(input.PersonaID),
QuestionSetID: qs,
QuickTips: optionalTextUpdate(input.QuickTips),
})
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return domain.Practice{}, pgx.ErrNoRows
}
return domain.Practice{}, err
}
return lmsPracticeToDomain(p), nil
}
func (s *Store) DeleteLmsPractice(ctx context.Context, id int64) error {
return s.queries.DeleteLmsPractice(ctx, id)
}

View File

@ -0,0 +1,86 @@
package repository
import (
"context"
"fmt"
dbgen "Yimaru-Backend/gen/db"
)
// CompleteLessonForUser records lesson completion and cascades to module, course, and program when the user
// has fully completed the preceding scope. Runs in a single transaction.
func (s *Store) CompleteLessonForUser(ctx context.Context, userID, lessonID int64) error {
q, tx, err := s.BeginTx(ctx)
if err != nil {
return fmt.Errorf("begin tx: %w", err)
}
defer func() { _ = tx.Rollback(ctx) }()
if err := q.InsertUserLessonProgress(ctx, dbgen.InsertUserLessonProgressParams{UserID: userID, LessonID: lessonID}); err != nil {
return err
}
lesson, err := q.GetLessonByID(ctx, lessonID)
if err != nil {
return err
}
mod, err := q.GetModuleByID(ctx, lesson.ModuleID)
if err != nil {
return err
}
crs, err := q.GetCourseByID(ctx, mod.CourseID)
if err != nil {
return err
}
nLess, err := q.CountLessonsInModule(ctx, lesson.ModuleID)
if err != nil {
return err
}
nDoneLess, err := q.CountUserCompletedLessonsInModule(ctx, dbgen.CountUserCompletedLessonsInModuleParams{
ModuleID: lesson.ModuleID,
UserID: userID,
})
if err != nil {
return err
}
if nLess > 0 && nDoneLess >= nLess {
if err := q.InsertUserModuleProgress(ctx, dbgen.InsertUserModuleProgressParams{UserID: userID, ModuleID: mod.ID}); err != nil {
return err
}
nMods, err := q.CountModulesInCourse(ctx, mod.CourseID)
if err != nil {
return err
}
nDoneMods, err := q.CountUserCompletedModulesInCourse(ctx, dbgen.CountUserCompletedModulesInCourseParams{
CourseID: mod.CourseID,
UserID: userID,
})
if err != nil {
return err
}
if nMods > 0 && nDoneMods >= nMods {
if err := q.InsertUserCourseProgress(ctx, dbgen.InsertUserCourseProgressParams{UserID: userID, CourseID: crs.ID}); err != nil {
return err
}
nCr, err := q.CountCoursesInProgram(ctx, crs.ProgramID)
if err != nil {
return err
}
nCrDone, err := q.CountUserCompletedCoursesInProgram(ctx, dbgen.CountUserCompletedCoursesInProgramParams{
ProgramID: crs.ProgramID,
UserID: userID,
})
if err != nil {
return err
}
if nCr > 0 && nCrDone >= nCr {
if err := q.InsertUserProgramProgress(ctx, dbgen.InsertUserProgramProgressParams{UserID: userID, ProgramID: crs.ProgramID}); err != nil {
return err
}
}
}
}
if err := tx.Commit(ctx); err != nil {
return fmt.Errorf("commit: %w", err)
}
return nil
}

View File

@ -0,0 +1,73 @@
package repository
import (
"context"
"fmt"
)
// ReorderPrograms sets sort_order to 1..n in the given order (transactional).
func (s *Store) ReorderPrograms(ctx context.Context, orderedIDs []int64) error {
tx, err := s.conn.Begin(ctx)
if err != nil {
return err
}
defer func() { _ = tx.Rollback(ctx) }()
for i, id := range orderedIDs {
tag, err := tx.Exec(ctx, `UPDATE programs SET sort_order = $1, updated_at = CURRENT_TIMESTAMP WHERE id = $2`, int32(i+1), id)
if err != nil {
return err
}
if tag.RowsAffected() == 0 {
return fmt.Errorf("program id %d not found", id)
}
}
return tx.Commit(ctx)
}
// ReorderCoursesInProgram sets sort_order for courses under programID (transactional).
func (s *Store) ReorderCoursesInProgram(ctx context.Context, programID int64, orderedIDs []int64) error {
tx, err := s.conn.Begin(ctx)
if err != nil {
return err
}
defer func() { _ = tx.Rollback(ctx) }()
for i, id := range orderedIDs {
tag, err := tx.Exec(ctx, `
UPDATE courses
SET sort_order = $1,
updated_at = CURRENT_TIMESTAMP
WHERE id = $2
AND program_id = $3`, int32(i+1), id, programID)
if err != nil {
return err
}
if tag.RowsAffected() == 0 {
return fmt.Errorf("course id %d not in program %d", id, programID)
}
}
return tx.Commit(ctx)
}
// ReorderModulesInCourse sets sort_order for modules under courseID (transactional).
func (s *Store) ReorderModulesInCourse(ctx context.Context, courseID int64, orderedIDs []int64) error {
tx, err := s.conn.Begin(ctx)
if err != nil {
return err
}
defer func() { _ = tx.Rollback(ctx) }()
for i, id := range orderedIDs {
tag, err := tx.Exec(ctx, `
UPDATE modules
SET sort_order = $1,
updated_at = CURRENT_TIMESTAMP
WHERE id = $2
AND course_id = $3`, int32(i+1), id, courseID)
if err != nil {
return err
}
if tag.RowsAffected() == 0 {
return fmt.Errorf("module id %d not in course %d", id, courseID)
}
}
return tx.Commit(ctx)
}

View File

@ -0,0 +1,33 @@
package repository
import (
"context"
"Yimaru-Backend/internal/domain"
)
// GetLMSUserProgressSnapshot returns all completed lesson, module, course, and program IDs for a user.
func (s *Store) GetLMSUserProgressSnapshot(ctx context.Context, userID int64) (domain.LMSUserProgress, error) {
lessons, err := s.queries.ListLMSCompletedLessonIDsByUser(ctx, userID)
if err != nil {
return domain.LMSUserProgress{}, err
}
mods, err := s.queries.ListLMSCompletedModuleIDsByUser(ctx, userID)
if err != nil {
return domain.LMSUserProgress{}, err
}
courses, err := s.queries.ListLMSCompletedCourseIDsByUser(ctx, userID)
if err != nil {
return domain.LMSUserProgress{}, err
}
programs, err := s.queries.ListLMSCompletedProgramIDsByUser(ctx, userID)
if err != nil {
return domain.LMSUserProgress{}, err
}
return domain.LMSUserProgress{
LessonIDs: lessons,
ModuleIDs: mods,
CourseIDs: courses,
ProgramIDs: programs,
}, nil
}

View File

@ -0,0 +1,126 @@
package repository
import (
"context"
"errors"
dbgen "Yimaru-Backend/gen/db"
"Yimaru-Backend/internal/domain"
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgtype"
)
func programToDomain(p dbgen.Program) domain.Program {
out := domain.Program{
ID: p.ID,
Name: p.Name,
}
out.Description = fromPgText(p.Description)
out.Thumbnail = fromPgText(p.Thumbnail)
out.CreatedAt = p.CreatedAt.Time
if p.UpdatedAt.Valid {
t := p.UpdatedAt.Time
out.UpdatedAt = &t
}
out.SortOrder = int(p.SortOrder)
return out
}
func (s *Store) CreateProgram(ctx context.Context, input domain.CreateProgramInput) (domain.Program, error) {
p, err := s.queries.CreateProgram(ctx, dbgen.CreateProgramParams{
Name: input.Name,
Description: toPgText(input.Description),
Thumbnail: toPgText(input.Thumbnail),
})
if err != nil {
return domain.Program{}, err
}
return programToDomain(p), nil
}
func (s *Store) ListAllProgramIDs(ctx context.Context) ([]int64, error) {
return s.queries.ListAllProgramIDs(ctx)
}
func (s *Store) GetProgramByID(ctx context.Context, id int64) (domain.Program, error) {
p, err := s.queries.GetProgramByID(ctx, id)
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return domain.Program{}, pgx.ErrNoRows
}
return domain.Program{}, err
}
return programToDomain(p), nil
}
func (s *Store) ListPrograms(ctx context.Context, limit, offset int32) ([]domain.Program, int64, error) {
rows, err := s.queries.ListPrograms(ctx, dbgen.ListProgramsParams{
Limit: limit,
Offset: offset,
})
if err != nil {
return nil, 0, err
}
if len(rows) == 0 {
return []domain.Program{}, 0, nil
}
var total int64
out := make([]domain.Program, 0, len(rows))
for i, r := range rows {
if i == 0 {
total = r.TotalCount
}
out = append(out, programToDomain(dbgen.Program{
ID: r.ID,
Name: r.Name,
Description: r.Description,
Thumbnail: r.Thumbnail,
CreatedAt: r.CreatedAt,
UpdatedAt: r.UpdatedAt,
SortOrder: r.SortOrder,
}))
}
return out, total, nil
}
func optionalTextUpdate(val *string) pgtype.Text {
if val == nil {
return pgtype.Text{Valid: false}
}
return pgtype.Text{String: *val, Valid: true}
}
func optionalInt4Update(v *int) pgtype.Int4 {
if v == nil {
return pgtype.Int4{Valid: false}
}
return pgtype.Int4{Int32: int32(*v), Valid: true}
}
func (s *Store) UpdateProgram(ctx context.Context, id int64, input domain.UpdateProgramInput) (domain.Program, error) {
var nameText pgtype.Text
if input.Name != nil {
nameText = pgtype.Text{String: *input.Name, Valid: true}
} else {
nameText = pgtype.Text{Valid: false}
}
p, err := s.queries.UpdateProgram(ctx, dbgen.UpdateProgramParams{
ID: id,
Name: nameText,
Description: optionalTextUpdate(input.Description),
Thumbnail: optionalTextUpdate(input.Thumbnail),
SortOrder: optionalInt4Update(input.SortOrder),
})
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return domain.Program{}, pgx.ErrNoRows
}
return domain.Program{}, err
}
return programToDomain(p), nil
}
func (s *Store) DeleteProgram(ctx context.Context, id int64) error {
return s.queries.DeleteProgram(ctx, id)
}

View File

@ -123,7 +123,6 @@ func questionSetToDomain(qs dbgen.QuestionSet) domain.QuestionSet {
PassingScore: fromPgInt4(qs.PassingScore), PassingScore: fromPgInt4(qs.PassingScore),
ShuffleQuestions: qs.ShuffleQuestions, ShuffleQuestions: qs.ShuffleQuestions,
Status: qs.Status, Status: qs.Status,
SubCourseVideoID: fromPgInt8(qs.SubCourseVideoID),
IntroVideoURL: fromPgText(qs.IntroVideoUrl), IntroVideoURL: fromPgText(qs.IntroVideoUrl),
CreatedAt: qs.CreatedAt.Time, CreatedAt: qs.CreatedAt.Time,
UpdatedAt: timePtr(qs.UpdatedAt), UpdatedAt: timePtr(qs.UpdatedAt),
@ -542,7 +541,6 @@ func (s *Store) CreateQuestionSet(ctx context.Context, input domain.CreateQuesti
PassingScore: toPgInt4(input.PassingScore), PassingScore: toPgInt4(input.PassingScore),
Column10: shuffleQuestions, Column10: shuffleQuestions,
Column11: status, Column11: status,
SubCourseVideoID: toPgInt8(input.SubCourseVideoID),
IntroVideoUrl: toPgText(input.IntroVideoURL), IntroVideoUrl: toPgText(input.IntroVideoURL),
}) })
if err != nil { if err != nil {
@ -604,7 +602,6 @@ func (s *Store) GetQuestionSetsByType(ctx context.Context, setType string, limit
PassingScore: fromPgInt4(r.PassingScore), PassingScore: fromPgInt4(r.PassingScore),
ShuffleQuestions: r.ShuffleQuestions, ShuffleQuestions: r.ShuffleQuestions,
Status: r.Status, Status: r.Status,
SubCourseVideoID: fromPgInt8(r.SubCourseVideoID),
IntroVideoURL: fromPgText(r.IntroVideoUrl), IntroVideoURL: fromPgText(r.IntroVideoUrl),
CreatedAt: r.CreatedAt.Time, CreatedAt: r.CreatedAt.Time,
UpdatedAt: timePtr(r.UpdatedAt), UpdatedAt: timePtr(r.UpdatedAt),
@ -637,14 +634,6 @@ func (s *Store) GetInitialAssessmentSet(ctx context.Context) (domain.QuestionSet
return questionSetToDomain(qs), nil return questionSetToDomain(qs), nil
} }
func (s *Store) GetSubCourseInitialAssessmentSet(ctx context.Context, subCourseID int64) (domain.QuestionSet, error) {
qs, err := s.queries.GetSubCourseInitialAssessmentSet(ctx, pgtype.Int8{Int64: subCourseID, Valid: true})
if err != nil {
return domain.QuestionSet{}, err
}
return questionSetToDomain(qs), nil
}
func (s *Store) GetFirstIncompletePreviousPractice(ctx context.Context, userID int64, questionSetID int64) (*domain.PracticeAccessBlock, error) { func (s *Store) GetFirstIncompletePreviousPractice(ctx context.Context, userID int64, questionSetID int64) (*domain.PracticeAccessBlock, error) {
row, err := s.queries.GetFirstIncompletePreviousPractice(ctx, dbgen.GetFirstIncompletePreviousPracticeParams{ row, err := s.queries.GetFirstIncompletePreviousPractice(ctx, dbgen.GetFirstIncompletePreviousPracticeParams{
UserID: userID, UserID: userID,
@ -692,7 +681,6 @@ func (s *Store) UpdateQuestionSet(ctx context.Context, id int64, input domain.Cr
ShuffleQuestions: shuffleQuestions, ShuffleQuestions: shuffleQuestions,
Status: status, Status: status,
IntroVideoUrl: toPgText(input.IntroVideoURL), IntroVideoUrl: toPgText(input.IntroVideoURL),
SubCourseVideoID: toPgInt8(input.SubCourseVideoID),
}) })
} }

View File

@ -1,51 +0,0 @@
package course_management
import (
"Yimaru-Backend/internal/config"
"Yimaru-Backend/internal/ports"
cloudconvertservice "Yimaru-Backend/internal/services/cloudconvert"
notificationservice "Yimaru-Backend/internal/services/notification"
vimeoservice "Yimaru-Backend/internal/services/vimeo"
)
type Service struct {
userStore ports.UserStore
courseStore interface{}
progressionStore interface{}
notificationSvc *notificationservice.Service
vimeoSvc *vimeoservice.Service
cloudConvertSvc *cloudconvertservice.Service
config *config.Config
}
func NewService(
userStore ports.UserStore,
courseStore interface{},
progressionStore interface{},
notificationSvc *notificationservice.Service,
cfg *config.Config,
) *Service {
return &Service{
userStore: userStore,
courseStore: courseStore,
progressionStore: progressionStore,
notificationSvc: notificationSvc,
config: cfg,
}
}
func (s *Service) SetVimeoService(vimeoSvc *vimeoservice.Service) {
s.vimeoSvc = vimeoSvc
}
func (s *Service) HasVimeoService() bool {
return s.vimeoSvc != nil
}
func (s *Service) SetCloudConvertService(ccSvc *cloudconvertservice.Service) {
s.cloudConvertSvc = ccSvc
}
func (s *Service) HasCloudConvertService() bool {
return s.cloudConvertSvc != nil
}

View File

@ -0,0 +1,105 @@
package courses
import (
"Yimaru-Backend/internal/domain"
"Yimaru-Backend/internal/ports"
"Yimaru-Backend/internal/services/programs"
"context"
"errors"
"github.com/jackc/pgx/v5"
)
var ErrCourseNotFound = errors.New("course not found")
type Service struct {
courses ports.CourseStore
programs ports.ProgramStore
}
func NewService(courses ports.CourseStore, programs ports.ProgramStore) *Service {
return &Service{courses: courses, programs: programs}
}
func (s *Service) Create(ctx context.Context, programID int64, input domain.CreateCourseInput) (domain.Course, error) {
if _, err := s.programs.GetProgramByID(ctx, programID); err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return domain.Course{}, programs.ErrProgramNotFound
}
return domain.Course{}, err
}
return s.courses.CreateCourse(ctx, programID, input)
}
func (s *Service) GetByID(ctx context.Context, id int64) (domain.Course, error) {
c, err := s.courses.GetCourseByID(ctx, id)
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return domain.Course{}, ErrCourseNotFound
}
return domain.Course{}, err
}
return c, nil
}
func (s *Service) ListByProgram(ctx context.Context, programID int64, limit, offset int32) ([]domain.Course, int64, error) {
if _, err := s.programs.GetProgramByID(ctx, programID); err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return nil, 0, programs.ErrProgramNotFound
}
return nil, 0, err
}
if limit <= 0 {
limit = 20
}
if limit > 200 {
limit = 200
}
if offset < 0 {
offset = 0
}
return s.courses.ListCoursesByProgramID(ctx, programID, limit, offset)
}
func (s *Service) Update(ctx context.Context, id int64, input domain.UpdateCourseInput) (domain.Course, error) {
c, err := s.courses.UpdateCourse(ctx, id, input)
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return domain.Course{}, ErrCourseNotFound
}
return domain.Course{}, err
}
return c, nil
}
func (s *Service) Delete(ctx context.Context, id int64) error {
if _, err := s.courses.GetCourseByID(ctx, id); err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return ErrCourseNotFound
}
return err
}
return s.courses.DeleteCourse(ctx, id)
}
// ReorderInProgram sets course sort_order under a program. ordered must list every course id in that program
// exactly once (e.g. from GET /programs/{id}/courses) in the desired order.
func (s *Service) ReorderInProgram(ctx context.Context, programID int64, ordered []int64) error {
if _, err := s.programs.GetProgramByID(ctx, programID); err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return programs.ErrProgramNotFound
}
return err
}
expected, err := s.courses.ListCourseIDsByProgram(ctx, programID)
if err != nil {
return err
}
if err := domain.ValidateReorderPermutation(ordered, expected); err != nil {
return err
}
if len(ordered) == 0 {
return nil
}
return s.courses.ReorderCoursesInProgram(ctx, programID, ordered)
}

View File

@ -0,0 +1,88 @@
package lessons
import (
"Yimaru-Backend/internal/domain"
"Yimaru-Backend/internal/ports"
"Yimaru-Backend/internal/services/modules"
"context"
"errors"
"github.com/jackc/pgx/v5"
)
var ErrLessonNotFound = errors.New("lesson not found")
type Service struct {
lessons ports.LessonStore
modules ports.ModuleStore
}
func NewService(lessons ports.LessonStore, modules ports.ModuleStore) *Service {
return &Service{lessons: lessons, modules: modules}
}
func (s *Service) getModuleOrErr(ctx context.Context, moduleID int64) error {
_, err := s.modules.GetModuleByID(ctx, moduleID)
if err == nil {
return nil
}
if errors.Is(err, pgx.ErrNoRows) {
return modules.ErrModuleNotFound
}
return err
}
func (s *Service) Create(ctx context.Context, moduleID int64, input domain.CreateLessonInput) (domain.Lesson, error) {
if err := s.getModuleOrErr(ctx, moduleID); err != nil {
return domain.Lesson{}, err
}
return s.lessons.CreateLesson(ctx, moduleID, input)
}
func (s *Service) GetByID(ctx context.Context, id int64) (domain.Lesson, error) {
l, err := s.lessons.GetLessonByID(ctx, id)
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return domain.Lesson{}, ErrLessonNotFound
}
return domain.Lesson{}, err
}
return l, nil
}
func (s *Service) ListByModule(ctx context.Context, moduleID int64, limit, offset int32) ([]domain.Lesson, int64, error) {
if err := s.getModuleOrErr(ctx, moduleID); err != nil {
return nil, 0, err
}
if limit <= 0 {
limit = 20
}
if limit > 200 {
limit = 200
}
if offset < 0 {
offset = 0
}
return s.lessons.ListLessonsByModuleID(ctx, moduleID, limit, offset)
}
func (s *Service) Update(ctx context.Context, id int64, input domain.UpdateLessonInput) (domain.Lesson, error) {
l, err := s.lessons.UpdateLesson(ctx, id, input)
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return domain.Lesson{}, ErrLessonNotFound
}
return domain.Lesson{}, err
}
return l, nil
}
func (s *Service) Delete(ctx context.Context, id int64) error {
if _, err := s.lessons.GetLessonByID(ctx, id); err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return ErrLessonNotFound
}
return err
}
return s.lessons.DeleteLesson(ctx, id)
}

View File

@ -0,0 +1,279 @@
package lmsprogress
import (
"context"
"errors"
"Yimaru-Backend/internal/domain"
"Yimaru-Backend/internal/repository"
"github.com/jackc/pgx/v5"
)
const (
errPrevProgram = "Complete the previous program before accessing this one."
errPrevCourse = "Complete the previous course in this program first."
errPrevModule = "Complete the previous module in this course first."
errPrevLesson = "Complete the previous lesson in this module first."
)
// Service enforces sequential LMS access for learners and records lesson progress.
type Service struct {
store *repository.Store
}
func NewService(store *repository.Store) *Service {
return &Service{store: store}
}
// CompleteLessonForUser records lesson completion and rolls up to module, course, and program when applicable.
func (s *Service) CompleteLessonForUser(ctx context.Context, userID, lessonID int64) error {
return s.store.CompleteLessonForUser(ctx, userID, lessonID)
}
// GetMyProgress returns completed lesson, module, course, and program IDs for the user.
func (s *Service) GetMyProgress(ctx context.Context, userID int64) (domain.LMSUserProgress, error) {
return s.store.GetLMSUserProgressSnapshot(ctx, userID)
}
// CanAccessProgram returns whether the user may use content under this program (previous program must be fully completed if any).
func (s *Service) CanAccessProgram(ctx context.Context, userID, programID int64) (ok bool, reason string, err error) {
if _, err := s.store.GetProgramByID(ctx, programID); err != nil {
return false, "", err
}
prev, err := s.store.LmsGetPreviousProgram(ctx, programID)
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return true, "", nil
}
return false, "", err
}
has, err := s.store.LmsUserHasProgramProgress(ctx, userID, prev.ID)
if err != nil {
return false, "", err
}
if !has {
return false, errPrevProgram, nil
}
return true, "", nil
}
// CanAccessCourse requires the parent program to be accessible and the previous course in the program to be completed.
func (s *Service) CanAccessCourse(ctx context.Context, userID, courseID int64) (ok bool, reason string, err error) {
c, err := s.store.GetCourseByID(ctx, courseID)
if err != nil {
return false, "", err
}
ok, reason, err = s.CanAccessProgram(ctx, userID, c.ProgramID)
if err != nil || !ok {
return ok, reason, err
}
prev, err := s.store.LmsGetPreviousCourseInProgram(ctx, courseID)
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return true, "", nil
}
return false, "", err
}
has, err := s.store.LmsUserHasCourseProgress(ctx, userID, prev.ID)
if err != nil {
return false, "", err
}
if !has {
return false, errPrevCourse, nil
}
return true, "", nil
}
// CanAccessModule requires the course (and its program chain) to be accessible and the previous module in the course to be completed.
func (s *Service) CanAccessModule(ctx context.Context, userID, moduleID int64) (ok bool, reason string, err error) {
m, err := s.store.GetModuleByID(ctx, moduleID)
if err != nil {
return false, "", err
}
ok, reason, err = s.CanAccessCourse(ctx, userID, m.CourseID)
if err != nil || !ok {
return ok, reason, err
}
prev, err := s.store.LmsGetPreviousModuleInCourse(ctx, moduleID)
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return true, "", nil
}
return false, "", err
}
has, err := s.store.LmsUserHasModuleProgress(ctx, userID, prev.ID)
if err != nil {
return false, "", err
}
if !has {
return false, errPrevModule, nil
}
return true, "", nil
}
// CanAccessLesson requires the module chain to be accessible and the previous lesson in the module to be completed.
func (s *Service) CanAccessLesson(ctx context.Context, userID, lessonID int64) (ok bool, reason string, err error) {
lesson, err := s.store.GetLessonByID(ctx, lessonID)
if err != nil {
return false, "", err
}
ok, reason, err = s.CanAccessModule(ctx, userID, lesson.ModuleID)
if err != nil || !ok {
return ok, reason, err
}
prev, err := s.store.LmsGetPreviousLessonInModule(ctx, lessonID)
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return true, "", nil
}
return false, "", err
}
has, err := s.store.LmsUserHasLessonProgress(ctx, userID, prev.ID)
if err != nil {
return false, "", err
}
if !has {
return false, errPrevLesson, nil
}
return true, "", nil
}
// ApplyAccessProgram sets p.Access for a learner. Non-learners: clears Access to omit from JSON.
func (s *Service) ApplyAccessProgram(ctx context.Context, role domain.Role, userID int64, p *domain.Program) error {
if role != domain.RoleStudent {
p.Access = nil
return nil
}
ok, reason, err := s.CanAccessProgram(ctx, userID, p.ID)
if err != nil {
return err
}
done, err := s.store.LmsUserHasProgramProgress(ctx, userID, p.ID)
if err != nil {
return err
}
comp, tot, err := s.store.LmsUserLessonProgressInProgram(ctx, userID, p.ID)
if err != nil {
return err
}
c, t, pct := lmsProgressCounts(comp, tot, done)
p.Access = &domain.LMSEntityAccess{
IsAccessible: ok, IsCompleted: done, Reason: reasonIf(ok, reason),
CompletedCount: c, TotalCount: t, ProgressPercent: pct,
}
return nil
}
// ApplyAccessCourse sets c.Access for a learner.
func (s *Service) ApplyAccessCourse(ctx context.Context, role domain.Role, userID int64, c *domain.Course) error {
if role != domain.RoleStudent {
c.Access = nil
return nil
}
ok, reason, err := s.CanAccessCourse(ctx, userID, c.ID)
if err != nil {
return err
}
done, err := s.store.LmsUserHasCourseProgress(ctx, userID, c.ID)
if err != nil {
return err
}
comp, tot, err := s.store.LmsUserLessonProgressInCourse(ctx, userID, c.ID)
if err != nil {
return err
}
cc, tt, pct := lmsProgressCounts(comp, tot, done)
c.Access = &domain.LMSEntityAccess{
IsAccessible: ok, IsCompleted: done, Reason: reasonIf(ok, reason),
CompletedCount: cc, TotalCount: tt, ProgressPercent: pct,
}
return nil
}
// ApplyAccessModule sets m.Access for a learner.
func (s *Service) ApplyAccessModule(ctx context.Context, role domain.Role, userID int64, m *domain.Module) error {
if role != domain.RoleStudent {
m.Access = nil
return nil
}
ok, reason, err := s.CanAccessModule(ctx, userID, m.ID)
if err != nil {
return err
}
done, err := s.store.LmsUserHasModuleProgress(ctx, userID, m.ID)
if err != nil {
return err
}
comp, tot, err := s.store.LmsUserLessonProgressInModule(ctx, userID, m.ID)
if err != nil {
return err
}
cc, tt, pct := lmsProgressCounts(comp, tot, done)
m.Access = &domain.LMSEntityAccess{
IsAccessible: ok, IsCompleted: done, Reason: reasonIf(ok, reason),
CompletedCount: cc, TotalCount: tt, ProgressPercent: pct,
}
return nil
}
// ApplyAccessLesson sets l.Access for a learner.
func (s *Service) ApplyAccessLesson(ctx context.Context, role domain.Role, userID int64, les *domain.Lesson) error {
if role != domain.RoleStudent {
les.Access = nil
return nil
}
ok, reason, err := s.CanAccessLesson(ctx, userID, les.ID)
if err != nil {
return err
}
done, err := s.store.LmsUserHasLessonProgress(ctx, userID, les.ID)
if err != nil {
return err
}
var comp, tot int32
if done {
comp, tot = 1, 1
} else {
comp, tot = 0, 1
}
c, t, pct := lmsProgressCounts(comp, tot, done)
les.Access = &domain.LMSEntityAccess{
IsAccessible: ok, IsCompleted: done, Reason: reasonIf(ok, reason),
CompletedCount: c, TotalCount: t, ProgressPercent: pct,
}
return nil
}
// lmsProgressCounts maps DB lesson completion counts to UI fields. Percent is 0100; completed
// and total are aligned with isCompleted when the entity is fully done.
func lmsProgressCounts(completed, total int32, isCompleted bool) (c, t, pct int) {
c, t = int(completed), int(total)
if t < 0 {
t = 0
}
if c < 0 {
c = 0
}
if isCompleted {
if t > 0 {
return t, t, 100
}
return c, t, 100
}
if t == 0 {
return 0, 0, 0
}
pct = (c * 100) / t
if pct > 100 {
pct = 100
}
return c, t, pct
}
func reasonIf(ok bool, r string) string {
if ok {
return ""
}
return r
}

View File

@ -0,0 +1,111 @@
package modules
import (
"Yimaru-Backend/internal/domain"
"Yimaru-Backend/internal/ports"
"Yimaru-Backend/internal/services/courses"
"context"
"errors"
"github.com/jackc/pgx/v5"
)
var ErrModuleNotFound = errors.New("module not found")
type Service struct {
modules ports.ModuleStore
courses ports.CourseStore
}
func NewService(modules ports.ModuleStore, courses ports.CourseStore) *Service {
return &Service{modules: modules, courses: courses}
}
func (s *Service) getCourseOrErr(ctx context.Context, courseID int64) (domain.Course, error) {
c, err := s.courses.GetCourseByID(ctx, courseID)
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return domain.Course{}, courses.ErrCourseNotFound
}
return domain.Course{}, err
}
return c, nil
}
// Create loads the course and stores program_id from it (parent program is not taken from the URL).
func (s *Service) Create(ctx context.Context, courseID int64, input domain.CreateModuleInput) (domain.Module, error) {
c, err := s.getCourseOrErr(ctx, courseID)
if err != nil {
return domain.Module{}, err
}
return s.modules.CreateModule(ctx, c.ProgramID, courseID, input)
}
func (s *Service) GetByID(ctx context.Context, id int64) (domain.Module, error) {
m, err := s.modules.GetModuleByID(ctx, id)
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return domain.Module{}, ErrModuleNotFound
}
return domain.Module{}, err
}
return m, nil
}
// ListByCourse loads the course and lists modules for its program_id and course_id.
func (s *Service) ListByCourse(ctx context.Context, courseID int64, limit, offset int32) ([]domain.Module, int64, error) {
c, err := s.getCourseOrErr(ctx, courseID)
if err != nil {
return nil, 0, err
}
if limit <= 0 {
limit = 20
}
if limit > 200 {
limit = 200
}
if offset < 0 {
offset = 0
}
return s.modules.ListModulesByProgramAndCourse(ctx, c.ProgramID, courseID, limit, offset)
}
func (s *Service) Update(ctx context.Context, id int64, input domain.UpdateModuleInput) (domain.Module, error) {
m, err := s.modules.UpdateModule(ctx, id, input)
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return domain.Module{}, ErrModuleNotFound
}
return domain.Module{}, err
}
return m, nil
}
func (s *Service) Delete(ctx context.Context, id int64) error {
if _, err := s.modules.GetModuleByID(ctx, id); err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return ErrModuleNotFound
}
return err
}
return s.modules.DeleteModule(ctx, id)
}
// ReorderInCourse sets module sort_order under a course. ordered must list every module id in that course
// exactly once (e.g. from GET /courses/{id}/modules) in the desired order.
func (s *Service) ReorderInCourse(ctx context.Context, courseID int64, ordered []int64) error {
if _, err := s.getCourseOrErr(ctx, courseID); err != nil {
return err
}
expected, err := s.modules.ListModuleIDsByCourse(ctx, courseID)
if err != nil {
return err
}
if err := domain.ValidateReorderPermutation(ordered, expected); err != nil {
return err
}
if len(ordered) == 0 {
return nil
}
return s.modules.ReorderModulesInCourse(ctx, courseID, ordered)
}

View File

@ -0,0 +1,204 @@
package practices
import (
"Yimaru-Backend/internal/domain"
"Yimaru-Backend/internal/ports"
"Yimaru-Backend/internal/services/courses"
"Yimaru-Backend/internal/services/lessons"
"Yimaru-Backend/internal/services/modules"
"context"
"errors"
"github.com/jackc/pgx/v5"
)
var (
ErrPracticeNotFound = errors.New("practice not found")
ErrQuestionSetNotFound = errors.New("question set not found")
ErrInvalidPracticeParent = errors.New("parent_kind and parent_id do not match an allowed parent")
)
type Service struct {
practices ports.LmsPracticeStore
courses ports.CourseStore
modules ports.ModuleStore
lessons ports.LessonStore
qs ports.QuestionSetByID
users ports.UserByID
}
func NewService(
practices ports.LmsPracticeStore,
courses ports.CourseStore,
modules ports.ModuleStore,
lessons ports.LessonStore,
qs ports.QuestionSetByID,
users ports.UserByID,
) *Service {
return &Service{
practices: practices,
courses: courses,
modules: modules,
lessons: lessons,
qs: qs,
users: users,
}
}
func (s *Service) validateQuestionSet(ctx context.Context, id int64) error {
_, err := s.qs.GetQuestionSetByID(ctx, id)
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return ErrQuestionSetNotFound
}
return err
}
return nil
}
func (s *Service) validatePersonaUser(ctx context.Context, id int64) error {
_, err := s.users.GetUserByID(ctx, id)
if err != nil {
if errors.Is(err, domain.ErrUserNotFound) {
return domain.ErrUserNotFound
}
return err
}
return nil
}
func (s *Service) resolveParent(ctx context.Context, in domain.CreatePracticeInput) (courseID, moduleID, lessonID *int64, err error) {
pid := in.ParentID
switch in.ParentKind {
case domain.ParentKindCourse:
if _, e := s.courses.GetCourseByID(ctx, pid); e != nil {
if errors.Is(e, pgx.ErrNoRows) {
return nil, nil, nil, courses.ErrCourseNotFound
}
return nil, nil, nil, e
}
return &pid, nil, nil, nil
case domain.ParentKindModule:
if _, e := s.modules.GetModuleByID(ctx, pid); e != nil {
if errors.Is(e, pgx.ErrNoRows) {
return nil, nil, nil, modules.ErrModuleNotFound
}
return nil, nil, nil, e
}
return nil, &pid, nil, nil
case domain.ParentKindLesson:
if _, e := s.lessons.GetLessonByID(ctx, pid); e != nil {
if errors.Is(e, pgx.ErrNoRows) {
return nil, nil, nil, lessons.ErrLessonNotFound
}
return nil, nil, nil, e
}
return nil, nil, &pid, nil
default:
return nil, nil, nil, ErrInvalidPracticeParent
}
}
func (s *Service) Create(ctx context.Context, in domain.CreatePracticeInput) (domain.Practice, error) {
if err := s.validateQuestionSet(ctx, in.QuestionSetID); err != nil {
return domain.Practice{}, err
}
if in.PersonaID != nil {
if err := s.validatePersonaUser(ctx, *in.PersonaID); err != nil {
return domain.Practice{}, err
}
}
courseID, moduleID, lessonID, err := s.resolveParent(ctx, in)
if err != nil {
return domain.Practice{}, err
}
return s.practices.CreateLmsPractice(ctx, in, courseID, moduleID, lessonID)
}
func (s *Service) GetByID(ctx context.Context, id int64) (domain.Practice, error) {
p, err := s.practices.GetLmsPracticeByID(ctx, id)
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return domain.Practice{}, ErrPracticeNotFound
}
return domain.Practice{}, err
}
return p, nil
}
func clampPracticePage(limit, offset int32) (int32, int32) {
if limit <= 0 {
limit = 20
}
if limit > 200 {
limit = 200
}
if offset < 0 {
offset = 0
}
return limit, offset
}
func (s *Service) ListByCourse(ctx context.Context, courseID int64, limit, offset int32) ([]domain.Practice, int64, error) {
if _, err := s.courses.GetCourseByID(ctx, courseID); err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return nil, 0, courses.ErrCourseNotFound
}
return nil, 0, err
}
limit, offset = clampPracticePage(limit, offset)
return s.practices.ListLmsPracticesByCourseID(ctx, courseID, limit, offset)
}
func (s *Service) ListByModule(ctx context.Context, moduleID int64, limit, offset int32) ([]domain.Practice, int64, error) {
if _, err := s.modules.GetModuleByID(ctx, moduleID); err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return nil, 0, modules.ErrModuleNotFound
}
return nil, 0, err
}
limit, offset = clampPracticePage(limit, offset)
return s.practices.ListLmsPracticesByModuleID(ctx, moduleID, limit, offset)
}
func (s *Service) ListByLesson(ctx context.Context, lessonID int64, limit, offset int32) ([]domain.Practice, int64, error) {
if _, err := s.lessons.GetLessonByID(ctx, lessonID); err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return nil, 0, lessons.ErrLessonNotFound
}
return nil, 0, err
}
limit, offset = clampPracticePage(limit, offset)
return s.practices.ListLmsPracticesByLessonID(ctx, lessonID, limit, offset)
}
func (s *Service) Update(ctx context.Context, id int64, input domain.UpdatePracticeInput) (domain.Practice, error) {
if input.QuestionSetID != nil {
if err := s.validateQuestionSet(ctx, *input.QuestionSetID); err != nil {
return domain.Practice{}, err
}
}
if input.PersonaID != nil {
if err := s.validatePersonaUser(ctx, *input.PersonaID); err != nil {
return domain.Practice{}, err
}
}
p, err := s.practices.UpdateLmsPractice(ctx, id, input)
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return domain.Practice{}, ErrPracticeNotFound
}
return domain.Practice{}, err
}
return p, nil
}
func (s *Service) Delete(ctx context.Context, id int64) error {
if _, err := s.practices.GetLmsPracticeByID(ctx, id); err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return ErrPracticeNotFound
}
return err
}
return s.practices.DeleteLmsPractice(ctx, id)
}

View File

@ -0,0 +1,85 @@
package programs
import (
"Yimaru-Backend/internal/domain"
"Yimaru-Backend/internal/ports"
"context"
"errors"
"github.com/jackc/pgx/v5"
)
var ErrProgramNotFound = errors.New("program not found")
type Service struct {
store ports.ProgramStore
}
func NewService(store ports.ProgramStore) *Service {
return &Service{store: store}
}
func (s *Service) Create(ctx context.Context, input domain.CreateProgramInput) (domain.Program, error) {
return s.store.CreateProgram(ctx, input)
}
func (s *Service) GetByID(ctx context.Context, id int64) (domain.Program, error) {
p, err := s.store.GetProgramByID(ctx, id)
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return domain.Program{}, ErrProgramNotFound
}
return domain.Program{}, err
}
return p, nil
}
func (s *Service) List(ctx context.Context, limit, offset int32) ([]domain.Program, int64, error) {
if limit <= 0 {
limit = 20
}
if limit > 200 {
limit = 200
}
if offset < 0 {
offset = 0
}
return s.store.ListPrograms(ctx, limit, offset)
}
func (s *Service) Update(ctx context.Context, id int64, input domain.UpdateProgramInput) (domain.Program, error) {
p, err := s.store.UpdateProgram(ctx, id, input)
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return domain.Program{}, ErrProgramNotFound
}
return domain.Program{}, err
}
return p, nil
}
func (s *Service) Delete(ctx context.Context, id int64) error {
if _, err := s.store.GetProgramByID(ctx, id); err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return ErrProgramNotFound
}
return err
}
return s.store.DeleteProgram(ctx, id)
}
// Reorder sets program sort_order from ordered ids (1..n). ordered must list every program id exactly once
// (e.g. from GET /programs) in the desired display / learning order.
func (s *Service) Reorder(ctx context.Context, ordered []int64) error {
expected, err := s.store.ListAllProgramIDs(ctx)
if err != nil {
return err
}
if err := domain.ValidateReorderPermutation(ordered, expected); err != nil {
return err
}
if len(ordered) == 0 {
return nil
}
return s.store.ReorderPrograms(ctx, ordered)
}

View File

@ -120,10 +120,6 @@ func (s *Service) GetInitialAssessmentSet(ctx context.Context) (domain.QuestionS
return s.questionStore.GetInitialAssessmentSet(ctx) return s.questionStore.GetInitialAssessmentSet(ctx)
} }
func (s *Service) GetSubCourseInitialAssessmentSet(ctx context.Context, subCourseID int64) (domain.QuestionSet, error) {
return s.questionStore.GetSubCourseInitialAssessmentSet(ctx, subCourseID)
}
func (s *Service) GetFirstIncompletePreviousPractice(ctx context.Context, userID int64, questionSetID int64) (*domain.PracticeAccessBlock, error) { func (s *Service) GetFirstIncompletePreviousPractice(ctx context.Context, userID int64, questionSetID int64) (*domain.PracticeAccessBlock, error) {
return s.questionStore.GetFirstIncompletePreviousPractice(ctx, userID, questionSetID) return s.questionStore.GetFirstIncompletePreviousPractice(ctx, userID, questionSetID)
} }

View File

@ -15,11 +15,46 @@ var AllPermissions = []domain.PermissionSeed{
{Key: "courses.create", Name: "Create Course", Description: "Create a new course", GroupName: "Courses"}, {Key: "courses.create", Name: "Create Course", Description: "Create a new course", GroupName: "Courses"},
{Key: "courses.get", Name: "Get Course", Description: "Get a course by ID", GroupName: "Courses"}, {Key: "courses.get", Name: "Get Course", Description: "Get a course by ID", GroupName: "Courses"},
{Key: "courses.list_by_category", Name: "List Courses by Category", Description: "List courses by category", GroupName: "Courses"}, {Key: "courses.list_by_category", Name: "List Courses by Category", Description: "List courses by category", GroupName: "Courses"},
{Key: "courses.list_by_program", Name: "List Courses by Program", Description: "List courses under a program", GroupName: "Courses"},
{Key: "courses.update", Name: "Update Course", Description: "Update a course", GroupName: "Courses"}, {Key: "courses.update", Name: "Update Course", Description: "Update a course", GroupName: "Courses"},
{Key: "courses.upload_thumbnail", Name: "Upload Course Thumbnail", Description: "Upload course thumbnail image", GroupName: "Courses"}, {Key: "courses.upload_thumbnail", Name: "Upload Course Thumbnail", Description: "Upload course thumbnail image", GroupName: "Courses"},
{Key: "courses.delete", Name: "Delete Course", Description: "Delete a course", GroupName: "Courses"}, {Key: "courses.delete", Name: "Delete Course", Description: "Delete a course", GroupName: "Courses"},
{Key: "courses.reorder", Name: "Reorder Courses", Description: "Reorder courses", GroupName: "Courses"}, {Key: "courses.reorder", Name: "Reorder Courses", Description: "Reorder courses", GroupName: "Courses"},
// Programs (LMS top-level)
{Key: "programs.create", Name: "Create Program", Description: "Create a program", GroupName: "Programs"},
{Key: "programs.list", Name: "List Programs", Description: "List programs", 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.delete", Name: "Delete Program", Description: "Delete a program", GroupName: "Programs"},
{Key: "programs.reorder", Name: "Reorder Programs", Description: "Set program order for the learning path (batch)", GroupName: "Programs"},
// Modules (LMS, under a course)
{Key: "modules.create", Name: "Create Module", Description: "Create a module in a course", GroupName: "Modules"},
{Key: "modules.get", Name: "Get Module", Description: "Get a module by ID", 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.delete", Name: "Delete Module", Description: "Delete a module", GroupName: "Modules"},
{Key: "modules.reorder", Name: "Reorder Modules", Description: "Set module order within a course (batch)", GroupName: "Modules"},
// Lessons (LMS, under a module)
{Key: "lessons.create", Name: "Create Lesson", Description: "Create a lesson in a module", GroupName: "Lessons"},
{Key: "lessons.get", Name: "Get Lesson", Description: "Get a lesson by ID", GroupName: "Lessons"},
{Key: "lessons.complete", Name: "Complete Lesson", Description: "Mark a lesson as complete (sequential learning progress)", GroupName: "Lessons"},
{Key: "lessons.list_by_module", Name: "List Lessons by Module", Description: "List lessons under a module", GroupName: "Lessons"},
{Key: "lessons.update", Name: "Update Lesson", Description: "Update a lesson", GroupName: "Lessons"},
{Key: "lessons.delete", Name: "Delete Lesson", Description: "Delete a lesson", GroupName: "Lessons"},
// LMS progress (current user)
{Key: "lms.get_my_progress", Name: "Get My LMS Progress", Description: "List completed lesson, module, course, and program IDs for the authenticated user", GroupName: "LMS"},
// Practices (LMS, scoped to course, module, or lesson)
{Key: "practices.create", Name: "Create Practice", Description: "Create a practice", GroupName: "Practices"},
{Key: "practices.get", Name: "Get Practice", Description: "Get a practice by ID", GroupName: "Practices"},
{Key: "practices.list", Name: "List Practices", Description: "List practices by course, module, or lesson", GroupName: "Practices"},
{Key: "practices.update", Name: "Update Practice", Description: "Update a practice", GroupName: "Practices"},
{Key: "practices.delete", Name: "Delete Practice", Description: "Delete a practice", GroupName: "Practices"},
// Course Management - Sub-courses // Course Management - Sub-courses
{Key: "subcourses.create", Name: "Create Sub-course", Description: "Create a new sub-course", GroupName: "Sub-courses"}, {Key: "subcourses.create", Name: "Create Sub-course", Description: "Create a new sub-course", GroupName: "Sub-courses"},
{Key: "subcourses.get", Name: "Get Sub-course", Description: "Get a sub-course by ID", GroupName: "Sub-courses"}, {Key: "subcourses.get", Name: "Get Sub-course", Description: "Get a sub-course by ID", GroupName: "Sub-courses"},
@ -236,13 +271,26 @@ var DefaultRolePermissions = map[string][]string{
"ADMIN": { "ADMIN": {
// Course Management (full access) // Course Management (full access)
"course_categories.create", "course_categories.list", "course_categories.get", "course_categories.update", "course_categories.delete", "course_categories.reorder", "course_categories.create", "course_categories.list", "course_categories.get", "course_categories.update", "course_categories.delete", "course_categories.reorder",
"courses.create", "courses.get", "courses.list_by_category", "courses.update", "courses.upload_thumbnail", "courses.delete", "courses.reorder", "courses.create", "courses.get", "courses.list_by_category", "courses.list_by_program", "courses.update", "courses.upload_thumbnail", "courses.delete", "courses.reorder",
"subcourses.create", "subcourses.get", "subcourses.list_by_course", "subcourses.list_by_course_list", "subcourses.list_active", "subcourses.create", "subcourses.get", "subcourses.list_by_course", "subcourses.list_by_course_list", "subcourses.list_active",
"subcourses.update", "subcourses.upload_thumbnail", "subcourses.deactivate", "subcourses.delete", "subcourses.reorder", "subcourses.update", "subcourses.upload_thumbnail", "subcourses.deactivate", "subcourses.delete", "subcourses.reorder",
"videos.create", "videos.create_vimeo", "videos.upload", "videos.import_vimeo", "videos.get", "videos.create", "videos.create_vimeo", "videos.upload", "videos.import_vimeo", "videos.get",
"videos.list_by_subcourse", "videos.list_published", "videos.publish", "videos.update", "videos.delete", "videos.reorder", "videos.list_by_subcourse", "videos.list_published", "videos.publish", "videos.update", "videos.delete", "videos.reorder",
"learning_tree.get", "practices.reorder", "learning_tree.get", "practices.reorder",
// Programs
"programs.create", "programs.list", "programs.get", "programs.update", "programs.delete", "programs.reorder",
"lms.get_my_progress",
// Modules
"modules.create", "modules.get", "modules.list_by_course", "modules.update", "modules.delete", "modules.reorder",
// Lessons
"lessons.create", "lessons.get", "lessons.list_by_module", "lessons.complete", "lessons.update", "lessons.delete",
// Practices
"practices.create", "practices.get", "practices.list", "practices.update", "practices.delete",
// Questions (full access) // Questions (full access)
"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",
@ -317,11 +365,17 @@ var DefaultRolePermissions = map[string][]string{
"STUDENT": { "STUDENT": {
// Course browsing // Course browsing
"course_categories.list", "course_categories.get", "course_categories.list", "course_categories.get",
"courses.get", "courses.list_by_category", "courses.get", "courses.list_by_program",
"modules.get", "modules.list_by_course",
"lessons.get", "lessons.list_by_module", "lessons.complete",
"practices.get", "practices.list",
"subcourses.get", "subcourses.list_by_course", "subcourses.list_by_course_list", "subcourses.list_active", "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 (read + attempt) // Questions (read + attempt)
"questions.list", "questions.search", "questions.get", "questions.list", "questions.search", "questions.get",
"question_sets.list", "question_sets.list_by_owner", "question_sets.get", "question_sets.list", "question_sets.list_by_owner", "question_sets.get",
@ -365,11 +419,17 @@ var DefaultRolePermissions = map[string][]string{
"INSTRUCTOR": { "INSTRUCTOR": {
// Course browsing + management // Course browsing + management
"course_categories.list", "course_categories.get", "course_categories.list", "course_categories.get",
"courses.get", "courses.list_by_category", "courses.get", "courses.list_by_program",
"modules.get", "modules.list_by_course",
"lessons.get", "lessons.list_by_module", "lessons.complete",
"practices.get", "practices.list",
"subcourses.get", "subcourses.list_by_course", "subcourses.list_by_course_list", "subcourses.list_active", "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",
@ -413,11 +473,16 @@ var DefaultRolePermissions = map[string][]string{
"SUPPORT": { "SUPPORT": {
// Course browsing (read-only) // Course browsing (read-only)
"course_categories.list", "course_categories.get", "course_categories.list", "course_categories.get",
"courses.get", "courses.list_by_category", "courses.get", "courses.list_by_program",
"modules.get", "modules.list_by_course",
"lessons.get", "lessons.list_by_module",
"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",
// Questions (read) // Questions (read)
"questions.list", "questions.search", "questions.get", "questions.list", "questions.search", "questions.get",
"question_sets.list", "question_sets.list_by_owner", "question_sets.get", "question_sets.list", "question_sets.list_by_owner", "question_sets.get",

View File

@ -8,10 +8,15 @@ import (
"Yimaru-Backend/internal/services/assessment" "Yimaru-Backend/internal/services/assessment"
"Yimaru-Backend/internal/services/authentication" "Yimaru-Backend/internal/services/authentication"
cloudconvertservice "Yimaru-Backend/internal/services/cloudconvert" cloudconvertservice "Yimaru-Backend/internal/services/cloudconvert"
"Yimaru-Backend/internal/services/course_management"
minioservice "Yimaru-Backend/internal/services/minio" minioservice "Yimaru-Backend/internal/services/minio"
issuereporting "Yimaru-Backend/internal/services/issue_reporting" issuereporting "Yimaru-Backend/internal/services/issue_reporting"
notificationservice "Yimaru-Backend/internal/services/notification" notificationservice "Yimaru-Backend/internal/services/notification"
"Yimaru-Backend/internal/services/courses"
"Yimaru-Backend/internal/services/lessons"
"Yimaru-Backend/internal/services/lmsprogress"
"Yimaru-Backend/internal/services/modules"
"Yimaru-Backend/internal/services/practices"
"Yimaru-Backend/internal/services/programs"
"Yimaru-Backend/internal/services/questions" "Yimaru-Backend/internal/services/questions"
ratingsservice "Yimaru-Backend/internal/services/ratings" ratingsservice "Yimaru-Backend/internal/services/ratings"
rbacservice "Yimaru-Backend/internal/services/rbac" rbacservice "Yimaru-Backend/internal/services/rbac"
@ -35,13 +40,17 @@ import (
"github.com/bytedance/sonic" "github.com/bytedance/sonic"
"github.com/gofiber/fiber/v2" "github.com/gofiber/fiber/v2"
"github.com/gofiber/fiber/v2/middleware/cors" "github.com/gofiber/fiber/v2/middleware/cors"
"github.com/jackc/pgx/v5/pgtype"
) )
type App struct { type App struct {
assessmentSvc *assessment.Service assessmentSvc *assessment.Service
courseSvc *course_management.Service
questionsSvc *questions.Service questionsSvc *questions.Service
programSvc *programs.Service
courseSvc *courses.Service
moduleSvc *modules.Service
lessonSvc *lessons.Service
lmsProgressSvc *lmsprogress.Service
practiceSvc *practices.Service
subscriptionsSvc *subscriptions.Service subscriptionsSvc *subscriptions.Service
arifpaySvc *arifpay.ArifpayService arifpaySvc *arifpay.ArifpayService
issueReportingSvc *issuereporting.Service issueReportingSvc *issuereporting.Service
@ -68,13 +77,17 @@ type App struct {
analyticsDB *dbgen.Queries analyticsDB *dbgen.Queries
rbacSvc *rbacservice.Service rbacSvc *rbacservice.Service
stopPurgeWorker context.CancelFunc stopPurgeWorker context.CancelFunc
stopInactiveSubModuleContentPurge context.CancelFunc
} }
func NewApp( func NewApp(
assessmentSvc *assessment.Service, assessmentSvc *assessment.Service,
courseSvc *course_management.Service,
questionsSvc *questions.Service, questionsSvc *questions.Service,
programSvc *programs.Service,
courseSvc *courses.Service,
moduleSvc *modules.Service,
lessonSvc *lessons.Service,
lmsProgressSvc *lmsprogress.Service,
practiceSvc *practices.Service,
subscriptionsSvc *subscriptions.Service, subscriptionsSvc *subscriptions.Service,
arifpaySvc *arifpay.ArifpayService, arifpaySvc *arifpay.ArifpayService,
issueReportingSvc *issuereporting.Service, issueReportingSvc *issuereporting.Service,
@ -117,8 +130,13 @@ func NewApp(
s := &App{ s := &App{
assessmentSvc: assessmentSvc, assessmentSvc: assessmentSvc,
courseSvc: courseSvc,
questionsSvc: questionsSvc, questionsSvc: questionsSvc,
programSvc: programSvc,
courseSvc: courseSvc,
moduleSvc: moduleSvc,
lessonSvc: lessonSvc,
lmsProgressSvc: lmsProgressSvc,
practiceSvc: practiceSvc,
subscriptionsSvc: subscriptionsSvc, subscriptionsSvc: subscriptionsSvc,
arifpaySvc: arifpaySvc, arifpaySvc: arifpaySvc,
vimeoSvc: vimeoSvc, vimeoSvc: vimeoSvc,
@ -154,8 +172,6 @@ func NewApp(
func (a *App) Run() error { func (a *App) Run() error {
a.startAccountDeletionPurgeWorker() a.startAccountDeletionPurgeWorker()
defer a.stopAccountDeletionPurgeWorker() defer a.stopAccountDeletionPurgeWorker()
a.startInactiveSubModuleContentPurgeWorker()
defer a.stopInactiveSubModuleContentPurgeWorker()
return a.fiber.Listen(fmt.Sprintf(":%d", a.port)) return a.fiber.Listen(fmt.Sprintf(":%d", a.port))
} }
@ -220,86 +236,3 @@ func (a *App) runAccountDeletionPurgeOnce(ctx context.Context, batchSize int32)
a.logger.Info("account deletion purge run completed", "deleted_count", deletedCount, "batch_size", batchSize) a.logger.Info("account deletion purge run completed", "deleted_count", deletedCount, "batch_size", batchSize)
} }
} }
func (a *App) startInactiveSubModuleContentPurgeWorker() {
if a.cfg == nil || !a.cfg.InactiveSubModuleContentPurgeEnabled {
a.logger.Info("inactive submodule content purge worker disabled")
return
}
interval := a.cfg.InactiveSubModuleContentPurgeInterval
if interval <= 0 {
interval = 24 * time.Hour
}
retentionDays := a.cfg.InactiveSubModuleContentRetentionDays
if retentionDays < 1 {
retentionDays = 30
}
retention := time.Duration(retentionDays) * 24 * time.Hour
ctx, cancel := context.WithCancel(context.Background())
a.stopInactiveSubModuleContentPurge = cancel
a.logger.Info(
"starting inactive submodule content purge worker",
"interval", interval.String(),
"retention_days", retentionDays,
)
go func() {
a.runInactiveSubModuleContentPurgeOnce(ctx, retention)
ticker := time.NewTicker(interval)
defer ticker.Stop()
for {
select {
case <-ctx.Done():
a.logger.Info("inactive submodule content purge worker stopped")
return
case <-ticker.C:
a.runInactiveSubModuleContentPurgeOnce(ctx, retention)
}
}
}()
}
func (a *App) stopInactiveSubModuleContentPurgeWorker() {
if a.stopInactiveSubModuleContentPurge != nil {
a.stopInactiveSubModuleContentPurge()
}
}
func (a *App) runInactiveSubModuleContentPurgeOnce(ctx context.Context, retention time.Duration) {
cutoff := time.Now().Add(-retention)
cutoffParam := pgtype.Timestamptz{Time: cutoff, Valid: true}
nLessons, err := a.analyticsDB.PurgeInactiveSubModuleLessonsBefore(ctx, cutoffParam)
if err != nil {
a.logger.Error("purge inactive submodule lessons failed", "error", err)
return
}
nPractices, err := a.analyticsDB.PurgeInactiveSubModulePracticesBefore(ctx, cutoffParam)
if err != nil {
a.logger.Error("purge inactive submodule practices failed", "error", err)
return
}
nCapstones, err := a.analyticsDB.PurgeInactiveSubModuleCapstonesBefore(ctx, cutoffParam)
if err != nil {
a.logger.Error("purge inactive submodule capstones failed", "error", err)
return
}
if nLessons > 0 || nPractices > 0 || nCapstones > 0 {
a.logger.Info(
"inactive submodule content purge run completed",
"lessons_deleted", nLessons,
"practice_question_sets_deleted", nPractices,
"capstone_question_sets_deleted", nCapstones,
"cutoff", cutoff.UTC().Format(time.RFC3339),
)
}
}

View File

@ -0,0 +1,270 @@
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"
)
// CreateCourse godoc
// @Summary Create course
// @Description Create a course under a program
// @Tags courses
// @Accept json
// @Produce json
// @Param id path int true "Program ID"
// @Param body body domain.CreateCourseInput true "Course"
// @Success 201 {object} domain.Response
// @Failure 400 {object} domain.ErrorResponse
// @Failure 404 {object} domain.ErrorResponse
// @Router /api/v1/programs/{id}/courses [post]
func (h *Handler) CreateCourse(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.CreateCourseInput
if err := c.BodyParser(&req); err != nil {
return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{
Message: "Invalid request body",
Error: err.Error(),
})
}
if valErrs, ok := h.validator.Validate(c, req); !ok {
return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{
Message: "Validation failed",
Error: firstValidationError(valErrs),
})
}
course, err := h.courseSvc.Create(c.Context(), programID, req)
if err != nil {
if errors.Is(err, programs.ErrProgramNotFound) {
return c.Status(fiber.StatusNotFound).JSON(domain.ErrorResponse{
Message: "Program not found",
Error: err.Error(),
})
}
return c.Status(fiber.StatusInternalServerError).JSON(domain.ErrorResponse{
Message: "Failed to create course",
Error: err.Error(),
})
}
actorID := c.Locals("user_id").(int64)
actorRole := string(c.Locals("role").(domain.Role))
ip := c.IP()
ua := c.Get("User-Agent")
rid := course.ID
go h.activityLogSvc.RecordAction(context.Background(), &actorID, &actorRole, domain.ActionCourseCreated, domain.ResourceCourse, &rid, "Created course: "+course.Name, nil, &ip, &ua)
return c.Status(fiber.StatusCreated).JSON(domain.Response{
Message: "Course created successfully",
Data: course,
Success: true,
StatusCode: fiber.StatusCreated,
})
}
// ListCoursesByProgram godoc
// @Summary List courses by program
// @Tags courses
// @Produce json
// @Param id path int true "Program ID"
// @Param limit query int false "Page size" default(20)
// @Param offset query int false "Offset" default(0)
// @Success 200 {object} domain.Response
// @Failure 404 {object} domain.ErrorResponse
// @Router /api/v1/programs/{id}/courses [get]
func (h *Handler) ListCoursesByProgram(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(),
})
}
limit, _ := strconv.Atoi(c.Query("limit", "20"))
offset, _ := strconv.Atoi(c.Query("offset", "0"))
items, total, err := h.courseSvc.ListByProgram(c.Context(), programID, int32(limit), int32(offset))
if err != nil {
if errors.Is(err, programs.ErrProgramNotFound) {
return c.Status(fiber.StatusNotFound).JSON(domain.ErrorResponse{
Message: "Program not found",
Error: err.Error(),
})
}
return c.Status(fiber.StatusInternalServerError).JSON(domain.ErrorResponse{
Message: "Failed to list courses",
Error: err.Error(),
})
}
uid := c.Locals("user_id").(int64)
role := c.Locals("role").(domain.Role)
for i := range items {
if err := h.lmsProgressSvc.ApplyAccessCourse(c.Context(), role, uid, &items[i]); err != nil {
return c.Status(fiber.StatusInternalServerError).JSON(domain.ErrorResponse{
Message: "Failed to build course list",
Error: err.Error(),
})
}
}
return c.JSON(domain.Response{
Message: "Courses retrieved successfully",
Data: fiber.Map{
"courses": items,
"total_count": total,
"limit": limit,
"offset": offset,
},
Success: true,
StatusCode: fiber.StatusOK,
})
}
// GetCourse godoc
// @Summary Get course by ID
// @Tags courses
// @Produce json
// @Param id path int true "Course ID"
// @Success 200 {object} domain.Response
// @Failure 404 {object} domain.ErrorResponse
// @Router /api/v1/courses/{id} [get]
func (h *Handler) GetCourse(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 course id",
Error: err.Error(),
})
}
course, err := h.courseSvc.GetByID(c.Context(), id)
if err != nil {
if errors.Is(err, courses.ErrCourseNotFound) {
return c.Status(fiber.StatusNotFound).JSON(domain.ErrorResponse{
Message: "Course not found",
Error: err.Error(),
})
}
return c.Status(fiber.StatusInternalServerError).JSON(domain.ErrorResponse{
Message: "Failed to load course",
Error: err.Error(),
})
}
uid := c.Locals("user_id").(int64)
role := c.Locals("role").(domain.Role)
if err := h.lmsProgressSvc.ApplyAccessCourse(c.Context(), role, uid, &course); err != nil {
return c.Status(fiber.StatusInternalServerError).JSON(domain.ErrorResponse{
Message: "Failed to evaluate course access",
Error: err.Error(),
})
}
if err := lmsBlockIfInaccessible(c, course.Access); err != nil {
return err
}
return c.JSON(domain.Response{
Message: "Course retrieved successfully",
Data: course,
Success: true,
StatusCode: fiber.StatusOK,
})
}
// UpdateCourse godoc
// @Summary Update course
// @Tags courses
// @Accept json
// @Produce json
// @Param id path int true "Course ID"
// @Param body body domain.UpdateCourseInput true "Fields to update"
// @Success 200 {object} domain.Response
// @Failure 404 {object} domain.ErrorResponse
// @Router /api/v1/courses/{id} [put]
func (h *Handler) UpdateCourse(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 course id",
Error: err.Error(),
})
}
var req domain.UpdateCourseInput
if err := c.BodyParser(&req); err != nil {
return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{
Message: "Invalid request body",
Error: err.Error(),
})
}
course, err := h.courseSvc.Update(c.Context(), id, req)
if err != nil {
if errors.Is(err, courses.ErrCourseNotFound) {
return c.Status(fiber.StatusNotFound).JSON(domain.ErrorResponse{
Message: "Course not found",
Error: err.Error(),
})
}
return c.Status(fiber.StatusInternalServerError).JSON(domain.ErrorResponse{
Message: "Failed to update course",
Error: err.Error(),
})
}
actorID := c.Locals("user_id").(int64)
actorRole := string(c.Locals("role").(domain.Role))
ip := c.IP()
ua := c.Get("User-Agent")
rid := course.ID
go h.activityLogSvc.RecordAction(context.Background(), &actorID, &actorRole, domain.ActionCourseUpdated, domain.ResourceCourse, &rid, "Updated course: "+course.Name, nil, &ip, &ua)
return c.JSON(domain.Response{
Message: "Course updated successfully",
Data: course,
Success: true,
StatusCode: fiber.StatusOK,
})
}
// DeleteCourse godoc
// @Summary Delete course
// @Tags courses
// @Param id path int true "Course ID"
// @Success 200 {object} domain.Response
// @Failure 404 {object} domain.ErrorResponse
// @Router /api/v1/courses/{id} [delete]
func (h *Handler) DeleteCourse(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 course id",
Error: err.Error(),
})
}
if err := h.courseSvc.Delete(c.Context(), id); err != nil {
if errors.Is(err, courses.ErrCourseNotFound) {
return c.Status(fiber.StatusNotFound).JSON(domain.ErrorResponse{
Message: "Course not found",
Error: err.Error(),
})
}
return c.Status(fiber.StatusInternalServerError).JSON(domain.ErrorResponse{
Message: "Failed to delete course",
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.ActionCourseDeleted, domain.ResourceCourse, &id, "Deleted course", nil, &ip, &ua)
return c.JSON(domain.Response{
Message: "Course deleted successfully",
Success: true,
StatusCode: fiber.StatusOK,
})
}

View File

@ -10,13 +10,18 @@ import (
"Yimaru-Backend/internal/services/arifpay" "Yimaru-Backend/internal/services/arifpay"
"Yimaru-Backend/internal/services/assessment" "Yimaru-Backend/internal/services/assessment"
"Yimaru-Backend/internal/services/authentication" "Yimaru-Backend/internal/services/authentication"
course_management "Yimaru-Backend/internal/services/course_management"
cloudconvertservice "Yimaru-Backend/internal/services/cloudconvert" cloudconvertservice "Yimaru-Backend/internal/services/cloudconvert"
issuereporting "Yimaru-Backend/internal/services/issue_reporting" issuereporting "Yimaru-Backend/internal/services/issue_reporting"
minioservice "Yimaru-Backend/internal/services/minio" minioservice "Yimaru-Backend/internal/services/minio"
ratingsservice "Yimaru-Backend/internal/services/ratings" ratingsservice "Yimaru-Backend/internal/services/ratings"
rbacservice "Yimaru-Backend/internal/services/rbac" rbacservice "Yimaru-Backend/internal/services/rbac"
notificationservice "Yimaru-Backend/internal/services/notification" notificationservice "Yimaru-Backend/internal/services/notification"
"Yimaru-Backend/internal/services/courses"
"Yimaru-Backend/internal/services/lessons"
"Yimaru-Backend/internal/services/lmsprogress"
"Yimaru-Backend/internal/services/modules"
"Yimaru-Backend/internal/services/practices"
"Yimaru-Backend/internal/services/programs"
"Yimaru-Backend/internal/services/questions" "Yimaru-Backend/internal/services/questions"
"Yimaru-Backend/internal/services/recommendation" "Yimaru-Backend/internal/services/recommendation"
"Yimaru-Backend/internal/services/subscriptions" "Yimaru-Backend/internal/services/subscriptions"
@ -38,8 +43,13 @@ import (
type Handler struct { type Handler struct {
assessmentSvc *assessment.Service assessmentSvc *assessment.Service
courseMgmtSvc *course_management.Service
questionsSvc *questions.Service questionsSvc *questions.Service
programSvc *programs.Service
courseSvc *courses.Service
moduleSvc *modules.Service
lessonSvc *lessons.Service
lmsProgressSvc *lmsprogress.Service
practiceSvc *practices.Service
subscriptionsSvc *subscriptions.Service subscriptionsSvc *subscriptions.Service
arifpaySvc *arifpay.ArifpayService arifpaySvc *arifpay.ArifpayService
logger *slog.Logger logger *slog.Logger
@ -66,8 +76,13 @@ type Handler struct {
func New( func New(
assessmentSvc *assessment.Service, assessmentSvc *assessment.Service,
courseMgmtSvc *course_management.Service,
questionsSvc *questions.Service, questionsSvc *questions.Service,
programSvc *programs.Service,
courseSvc *courses.Service,
moduleSvc *modules.Service,
lessonSvc *lessons.Service,
lmsProgressSvc *lmsprogress.Service,
practiceSvc *practices.Service,
subscriptionsSvc *subscriptions.Service, subscriptionsSvc *subscriptions.Service,
arifpaySvc *arifpay.ArifpayService, arifpaySvc *arifpay.ArifpayService,
logger *slog.Logger, logger *slog.Logger,
@ -93,8 +108,13 @@ func New(
) *Handler { ) *Handler {
return &Handler{ return &Handler{
assessmentSvc: assessmentSvc, assessmentSvc: assessmentSvc,
courseMgmtSvc: courseMgmtSvc,
questionsSvc: questionsSvc, questionsSvc: questionsSvc,
programSvc: programSvc,
courseSvc: courseSvc,
moduleSvc: moduleSvc,
lessonSvc: lessonSvc,
lmsProgressSvc: lmsProgressSvc,
practiceSvc: practiceSvc,
subscriptionsSvc: subscriptionsSvc, subscriptionsSvc: subscriptionsSvc,
arifpaySvc: arifpaySvc, arifpaySvc: arifpaySvc,
logger: logger, logger: logger,

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,308 @@
package handlers
import (
"Yimaru-Backend/internal/domain"
"Yimaru-Backend/internal/services/lessons"
"Yimaru-Backend/internal/services/modules"
"context"
"errors"
"strconv"
"github.com/gofiber/fiber/v2"
)
// CreateLesson godoc
// @Summary Create lesson
// @Tags lessons
// @Accept json
// @Produce json
// @Param moduleId path int true "Module ID"
// @Param body body domain.CreateLessonInput true "Lesson"
// @Router /api/v1/modules/{moduleId}/lessons [post]
func (h *Handler) CreateLesson(c *fiber.Ctx) error {
moduleID, err := strconv.ParseInt(c.Params("moduleId"), 10, 64)
if err != nil {
return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{
Message: "Invalid module id",
Error: err.Error(),
})
}
var req domain.CreateLessonInput
if err := c.BodyParser(&req); err != nil {
return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{
Message: "Invalid request body",
Error: err.Error(),
})
}
if valErrs, ok := h.validator.Validate(c, req); !ok {
return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{
Message: "Validation failed",
Error: firstValidationError(valErrs),
})
}
les, err := h.lessonSvc.Create(c.Context(), moduleID, req)
if err != nil {
if errors.Is(err, modules.ErrModuleNotFound) {
return c.Status(fiber.StatusNotFound).JSON(domain.ErrorResponse{
Message: "Module not found",
Error: err.Error(),
})
}
return c.Status(fiber.StatusInternalServerError).JSON(domain.ErrorResponse{
Message: "Failed to create lesson",
Error: err.Error(),
})
}
actorID := c.Locals("user_id").(int64)
actorRole := string(c.Locals("role").(domain.Role))
ip := c.IP()
ua := c.Get("User-Agent")
rid := les.ID
go h.activityLogSvc.RecordAction(context.Background(), &actorID, &actorRole, domain.ActionLessonCreated, domain.ResourceLesson, &rid, "Created lesson: "+les.Title, nil, &ip, &ua)
return c.Status(fiber.StatusCreated).JSON(domain.Response{
Message: "Lesson created successfully",
Data: les,
Success: true,
StatusCode: fiber.StatusCreated,
})
}
// ListLessonsByModule godoc
// @Tags lessons
// @Param moduleId path int true "Module ID"
// @Param limit query int false "Page size" default(20)
// @Param offset query int false "Offset" default(0)
// @Router /api/v1/modules/{moduleId}/lessons [get]
func (h *Handler) ListLessonsByModule(c *fiber.Ctx) error {
moduleID, err := strconv.ParseInt(c.Params("moduleId"), 10, 64)
if err != nil {
return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{
Message: "Invalid module id",
Error: err.Error(),
})
}
limit, _ := strconv.Atoi(c.Query("limit", "20"))
offset, _ := strconv.Atoi(c.Query("offset", "0"))
items, total, err := h.lessonSvc.ListByModule(c.Context(), moduleID, int32(limit), int32(offset))
if err != nil {
if errors.Is(err, modules.ErrModuleNotFound) {
return c.Status(fiber.StatusNotFound).JSON(domain.ErrorResponse{
Message: "Module not found",
Error: err.Error(),
})
}
return c.Status(fiber.StatusInternalServerError).JSON(domain.ErrorResponse{
Message: "Failed to list lessons",
Error: err.Error(),
})
}
uid := c.Locals("user_id").(int64)
role := c.Locals("role").(domain.Role)
for i := range items {
if err := h.lmsProgressSvc.ApplyAccessLesson(c.Context(), role, uid, &items[i]); err != nil {
return c.Status(fiber.StatusInternalServerError).JSON(domain.ErrorResponse{
Message: "Failed to build lesson list",
Error: err.Error(),
})
}
}
return c.JSON(domain.Response{
Message: "Lessons retrieved successfully",
Data: fiber.Map{
"lessons": items,
"total_count": total,
"limit": limit,
"offset": offset,
},
Success: true,
StatusCode: fiber.StatusOK,
})
}
// GetLesson godoc
// @Tags lessons
// @Param id path int true "Lesson ID"
// @Router /api/v1/lessons/{id} [get]
func (h *Handler) GetLesson(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(),
})
}
les, err := h.lessonSvc.GetByID(c.Context(), id)
if 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 err := h.lmsProgressSvc.ApplyAccessLesson(c.Context(), role, uid, &les); err != nil {
return c.Status(fiber.StatusInternalServerError).JSON(domain.ErrorResponse{
Message: "Failed to evaluate lesson access",
Error: err.Error(),
})
}
if err := lmsBlockIfInaccessible(c, les.Access); err != nil {
return err
}
return c.JSON(domain.Response{
Message: "Lesson retrieved successfully",
Data: les,
Success: true,
StatusCode: fiber.StatusOK,
})
}
// UpdateLesson godoc
// @Tags lessons
// @Param id path int true "Lesson ID"
// @Param body body domain.UpdateLessonInput true "Fields to update"
// @Router /api/v1/lessons/{id} [put]
func (h *Handler) UpdateLesson(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(),
})
}
var req domain.UpdateLessonInput
if err := c.BodyParser(&req); err != nil {
return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{
Message: "Invalid request body",
Error: err.Error(),
})
}
les, err := h.lessonSvc.Update(c.Context(), id, req)
if 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 update lesson",
Error: err.Error(),
})
}
actorID := c.Locals("user_id").(int64)
actorRole := string(c.Locals("role").(domain.Role))
ip := c.IP()
ua := c.Get("User-Agent")
rid := les.ID
go h.activityLogSvc.RecordAction(context.Background(), &actorID, &actorRole, domain.ActionLessonUpdated, domain.ResourceLesson, &rid, "Updated lesson: "+les.Title, nil, &ip, &ua)
return c.JSON(domain.Response{
Message: "Lesson updated successfully",
Data: les,
Success: true,
StatusCode: fiber.StatusOK,
})
}
// DeleteLesson godoc
// @Tags lessons
// @Param id path int true "Lesson ID"
// @Router /api/v1/lessons/{id} [delete]
func (h *Handler) DeleteLesson(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.Delete(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 delete lesson",
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.ActionLessonDeleted, domain.ResourceLesson, &id, "Deleted lesson", nil, &ip, &ua)
return c.JSON(domain.Response{
Message: "Lesson deleted successfully",
Success: true,
StatusCode: fiber.StatusOK,
})
}
// CompleteLesson godoc
// @Summary Mark a lesson as completed
// @Description Records lesson completion; may cascade to module, course, and program progress for the authenticated user. Learners must meet sequential prerequisites; staff bypass checks.
// @Tags lessons
// @Param id path int true "Lesson ID"
// @Success 200 {object} domain.Response
// @Failure 403 {object} domain.ErrorResponse
// @Router /api/v1/lessons/{id}/complete [post]
func (h *Handler) CompleteLesson(c *fiber.Ctx) error {
id, err := strconv.ParseInt(c.Params("id"), 10, 64)
if err != nil {
return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{
Message: "Invalid lesson id",
Error: err.Error(),
})
}
if _, err := h.lessonSvc.GetByID(c.Context(), id); err != nil {
if errors.Is(err, lessons.ErrLessonNotFound) {
return c.Status(fiber.StatusNotFound).JSON(domain.ErrorResponse{
Message: "Lesson not found",
Error: err.Error(),
})
}
return c.Status(fiber.StatusInternalServerError).JSON(domain.ErrorResponse{
Message: "Failed to load lesson",
Error: err.Error(),
})
}
uid := c.Locals("user_id").(int64)
role := c.Locals("role").(domain.Role)
if role == domain.RoleStudent {
ok, reason, err := h.lmsProgressSvc.CanAccessLesson(c.Context(), uid, id)
if err != nil {
return c.Status(fiber.StatusInternalServerError).JSON(domain.ErrorResponse{
Message: "Failed to verify lesson access",
Error: err.Error(),
})
}
if !ok {
return c.Status(fiber.StatusForbidden).JSON(domain.ErrorResponse{
Message: reason,
Error: "LMS_PREREQUISITE_NOT_MET",
})
}
}
if err := h.lmsProgressSvc.CompleteLessonForUser(c.Context(), uid, id); err != nil {
return c.Status(fiber.StatusInternalServerError).JSON(domain.ErrorResponse{
Message: "Failed to record lesson progress",
Error: err.Error(),
})
}
return c.JSON(domain.Response{
Message: "Lesson marked complete",
Success: true,
StatusCode: fiber.StatusOK,
})
}

View File

@ -0,0 +1,18 @@
package handlers
import (
"Yimaru-Backend/internal/domain"
"github.com/gofiber/fiber/v2"
)
// lmsBlockIfInaccessible returns a 403 response when a learner is blocked; otherwise nil.
func lmsBlockIfInaccessible(c *fiber.Ctx, a *domain.LMSEntityAccess) error {
if a == nil || a.IsAccessible {
return nil
}
return c.Status(fiber.StatusForbidden).JSON(domain.ErrorResponse{
Message: a.Reason,
Error: "LMS_PREREQUISITE_NOT_MET",
})
}

View File

@ -0,0 +1,32 @@
package handlers
import (
"Yimaru-Backend/internal/domain"
"github.com/gofiber/fiber/v2"
)
// GetMyLMSProgress godoc
// @Summary Get my LMS completion history
// @Description Returns completed lesson, module, course, and program IDs for the authenticated user (ordered by completion time, then id).
// @Tags lms
// @Produce json
// @Success 200 {object} domain.Response
// @Failure 500 {object} domain.ErrorResponse
// @Router /api/v1/lms/progress [get]
func (h *Handler) GetMyLMSProgress(c *fiber.Ctx) error {
uid := c.Locals("user_id").(int64)
prog, err := h.lmsProgressSvc.GetMyProgress(c.Context(), uid)
if err != nil {
return c.Status(fiber.StatusInternalServerError).JSON(domain.ErrorResponse{
Message: "Failed to load learning progress",
Error: err.Error(),
})
}
return c.JSON(domain.Response{
Message: "LMS progress retrieved successfully",
Data: prog,
Success: true,
StatusCode: fiber.StatusOK,
})
}

View File

@ -0,0 +1,178 @@
package handlers
import (
"Yimaru-Backend/internal/domain"
"Yimaru-Backend/internal/services/courses"
"Yimaru-Backend/internal/services/programs"
"context"
"errors"
"strconv"
"github.com/gofiber/fiber/v2"
)
// ReorderPrograms godoc
// @Summary Reorder all programs
// @Description Sets learning order of programs. Body must list every current program id exactly once, in the desired order (index 0 = first in path).
// @Tags programs
// @Accept json
// @Produce json
// @Param body body domain.ReorderIDsRequest true "New order: ordered_ids is the full set of program ids"
// @Success 200 {object} domain.Response
// @Failure 400 {object} domain.ErrorResponse
// @Router /api/v1/programs/reorder [put]
func (h *Handler) ReorderPrograms(c *fiber.Ctx) error {
var req domain.ReorderIDsRequest
if err := c.BodyParser(&req); err != nil {
return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{
Message: "Invalid request body",
Error: err.Error(),
})
}
if req.OrderedIDs == nil {
return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{
Message: "ordered_ids is required (use an empty array if there are no programs)",
Error: "missing ordered_ids",
})
}
if err := h.programSvc.Reorder(c.Context(), req.OrderedIDs); err != nil {
if errors.Is(err, domain.ErrReorderInvalidIDSet) {
return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{
Message: err.Error(),
Error: "INVALID_REORDER",
})
}
return c.Status(fiber.StatusInternalServerError).JSON(domain.ErrorResponse{
Message: "Failed to reorder programs",
Error: err.Error(),
})
}
actorID := c.Locals("user_id").(int64)
actorRole := string(c.Locals("role").(domain.Role))
ip := c.IP()
ua := c.Get("User-Agent")
go h.activityLogSvc.RecordAction(context.Background(), &actorID, &actorRole, domain.ActionProgramUpdated, domain.ResourceProgram, nil, "Reordered programs", nil, &ip, &ua)
return c.JSON(domain.Response{
Message: "Programs reordered successfully",
Success: true,
StatusCode: fiber.StatusOK,
})
}
// ReorderCoursesInProgram godoc
// @Summary Reorder courses within a program
// @Param id path int true "Program ID"
// @Param body body domain.ReorderIDsRequest true "ordered_ids: every course id in this program, in the new order"
// @Tags courses
// @Router /api/v1/programs/{id}/courses/reorder [put]
func (h *Handler) ReorderCoursesInProgram(c *fiber.Ctx) error {
programID, err := strconv.ParseInt(c.Params("id"), 10, 64)
if err != nil {
return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{
Message: "Invalid program id",
Error: err.Error(),
})
}
var req domain.ReorderIDsRequest
if err := c.BodyParser(&req); err != nil {
return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{
Message: "Invalid request body",
Error: err.Error(),
})
}
if req.OrderedIDs == nil {
return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{
Message: "ordered_ids is required (use an empty array if the program has no courses)",
Error: "missing ordered_ids",
})
}
if err := h.courseSvc.ReorderInProgram(c.Context(), programID, req.OrderedIDs); err != nil {
if errors.Is(err, programs.ErrProgramNotFound) {
return c.Status(fiber.StatusNotFound).JSON(domain.ErrorResponse{
Message: "Program not found",
Error: err.Error(),
})
}
if errors.Is(err, domain.ErrReorderInvalidIDSet) {
return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{
Message: err.Error(),
Error: "INVALID_REORDER",
})
}
return c.Status(fiber.StatusInternalServerError).JSON(domain.ErrorResponse{
Message: "Failed to reorder courses",
Error: err.Error(),
})
}
actorID := c.Locals("user_id").(int64)
actorRole := string(c.Locals("role").(domain.Role))
ip := c.IP()
ua := c.Get("User-Agent")
msg := "Reordered courses in program"
go h.activityLogSvc.RecordAction(context.Background(), &actorID, &actorRole, domain.ActionCourseUpdated, domain.ResourceProgram, &programID, msg, nil, &ip, &ua)
return c.JSON(domain.Response{
Message: "Courses reordered successfully",
Success: true,
StatusCode: fiber.StatusOK,
})
}
// ReorderModulesInCourse godoc
// @Summary Reorder modules within a course
// @Param courseId path int true "Course ID"
// @Param body body domain.ReorderIDsRequest true "ordered_ids: every module id in this course, in the new order"
// @Tags modules
// @Router /api/v1/courses/{courseId}/modules/reorder [put]
func (h *Handler) ReorderModulesInCourse(c *fiber.Ctx) error {
courseID, err := strconv.ParseInt(c.Params("courseId"), 10, 64)
if err != nil {
return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{
Message: "Invalid course id",
Error: err.Error(),
})
}
var req domain.ReorderIDsRequest
if err := c.BodyParser(&req); err != nil {
return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{
Message: "Invalid request body",
Error: err.Error(),
})
}
if req.OrderedIDs == nil {
return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{
Message: "ordered_ids is required (use an empty array if the course has no modules)",
Error: "missing ordered_ids",
})
}
if err := h.moduleSvc.ReorderInCourse(c.Context(), courseID, req.OrderedIDs); err != nil {
if errors.Is(err, courses.ErrCourseNotFound) {
return c.Status(fiber.StatusNotFound).JSON(domain.ErrorResponse{
Message: "Course not found",
Error: err.Error(),
})
}
if errors.Is(err, domain.ErrReorderInvalidIDSet) {
return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{
Message: err.Error(),
Error: "INVALID_REORDER",
})
}
return c.Status(fiber.StatusInternalServerError).JSON(domain.ErrorResponse{
Message: "Failed to reorder modules",
Error: err.Error(),
})
}
actorID := c.Locals("user_id").(int64)
actorRole := string(c.Locals("role").(domain.Role))
ip := c.IP()
ua := c.Get("User-Agent")
go h.activityLogSvc.RecordAction(context.Background(), &actorID, &actorRole, domain.ActionModuleUpdated, domain.ResourceCourse, &courseID, "Reordered modules in course", nil, &ip, &ua)
return c.JSON(domain.Response{
Message: "Modules reordered successfully",
Success: true,
StatusCode: fiber.StatusOK,
})
}

View File

@ -1,277 +0,0 @@
package handlers
import (
"Yimaru-Backend/internal/domain"
"crypto/subtle"
"fmt"
"os"
"path/filepath"
"regexp"
"strings"
"github.com/gofiber/fiber/v2"
)
type resetAndReseedReq struct {
Confirm string `json:"confirm"`
}
type clearCourseManagementReq struct {
Confirm string `json:"confirm"`
}
func extractInsertStatement(sqlContent string, tableName string) (string, bool) {
pattern := fmt.Sprintf(`(?is)INSERT\s+INTO\s+%s\b.*?;`, regexp.QuoteMeta(tableName))
re := regexp.MustCompile(pattern)
statement := strings.TrimSpace(re.FindString(sqlContent))
if statement == "" {
return "", false
}
return statement, true
}
func resolveSeedDir(seedDir string) (string, error) {
cleanSeedDir := strings.TrimSpace(seedDir)
if cleanSeedDir == "" {
cleanSeedDir = "db/data"
}
// If absolute, use directly.
if filepath.IsAbs(cleanSeedDir) {
info, err := os.Stat(cleanSeedDir)
if err != nil {
return "", err
}
if !info.IsDir() {
return "", fmt.Errorf("seed dir is not a directory: %s", cleanSeedDir)
}
return cleanSeedDir, nil
}
candidates := make([]string, 0, 5)
// 1) Relative to current working directory.
candidates = append(candidates, filepath.Clean(cleanSeedDir))
// 2) Relative to executable directory (and parents).
if exePath, err := os.Executable(); err == nil {
exeDir := filepath.Dir(exePath)
candidates = append(candidates,
filepath.Join(exeDir, cleanSeedDir),
filepath.Join(exeDir, "..", cleanSeedDir),
filepath.Join(exeDir, "..", "..", cleanSeedDir),
)
}
for _, candidate := range candidates {
info, err := os.Stat(candidate)
if err != nil {
continue
}
if info.IsDir() {
return candidate, nil
}
}
return "", fmt.Errorf("seed directory not found (tried: %s)", strings.Join(candidates, ", "))
}
// ResetAndReseedDatabase godoc
// @Summary Reset and reseed database
// @Description Truncates course_categories, courses, and sub_courses. If seed SQL contains INSERTs for those tables (e.g. 007_course_management_seed.sql), they are replayed; otherwise tables are left empty after truncate.
// @Tags internal
// @Accept json
// @Produce json
// @Param X-Seed-Reset-Token header string true "Reset token configured in DB_RESET_RESEED_TOKEN"
// @Param body body resetAndReseedReq true "Confirmation payload"
// @Success 200 {object} domain.Response
// @Failure 400 {object} domain.ErrorResponse
// @Failure 403 {object} domain.ErrorResponse
// @Failure 500 {object} domain.ErrorResponse
// @Router /api/v1/internal/db/reset-reseed [post]
func (h *Handler) ResetAndReseedDatabase(c *fiber.Ctx) error {
if h.Cfg == nil || !h.Cfg.DBResetReseedEnabled {
return c.Status(fiber.StatusForbidden).JSON(domain.ErrorResponse{
Message: "Operation is disabled",
Error: "DB_RESET_RESEED_ENABLED must be set to true",
})
}
var req resetAndReseedReq
if err := c.BodyParser(&req); err != nil {
return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{
Message: "Invalid request body",
Error: err.Error(),
})
}
if strings.TrimSpace(req.Confirm) != "RESET_AND_RESEED" {
return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{
Message: "Confirmation required",
Error: `set confirm to "RESET_AND_RESEED"`,
})
}
expectedToken := strings.TrimSpace(h.Cfg.DBResetReseedToken)
if expectedToken != "" {
providedToken := strings.TrimSpace(c.Get("X-Seed-Reset-Token"))
if subtle.ConstantTimeCompare([]byte(providedToken), []byte(expectedToken)) != 1 {
return c.Status(fiber.StatusForbidden).JSON(domain.ErrorResponse{
Message: "Invalid reset token",
Error: "missing or invalid X-Seed-Reset-Token",
})
}
}
seedDir, err := resolveSeedDir(h.Cfg.DBSeedDir)
if err != nil {
return c.Status(fiber.StatusInternalServerError).JSON(domain.ErrorResponse{
Message: "Failed to resolve seed directory",
Error: err.Error(),
})
}
seedCandidates := []string{
filepath.Join(seedDir, "007_course_management_seed.sql"),
filepath.Join(seedDir, "001_initial_seed_data.sql"),
}
tableNames := []string{"course_categories", "courses", "sub_courses"}
statements := map[string]string{}
statementSource := map[string]string{}
for _, file := range seedCandidates {
contentBytes, readErr := os.ReadFile(file)
if readErr != nil {
continue
}
content := string(contentBytes)
for _, tableName := range tableNames {
if _, exists := statements[tableName]; exists {
continue
}
if stmt, ok := extractInsertStatement(content, tableName); ok {
statements[tableName] = stmt
statementSource[tableName] = file
}
}
}
missing := 0
for _, tableName := range tableNames {
if _, ok := statements[tableName]; !ok {
missing++
}
}
if missing != 0 && missing != len(tableNames) {
return c.Status(fiber.StatusInternalServerError).JSON(domain.ErrorResponse{
Message: "Incomplete course seed SQL",
Error: "seed files must define INSERT for all of course_categories, courses, and sub_courses, or none of them (truncate-only)",
})
}
var sqlBuilder strings.Builder
sqlBuilder.WriteString("BEGIN;\n")
sqlBuilder.WriteString("TRUNCATE TABLE sub_courses, courses, course_categories RESTART IDENTITY CASCADE;\n")
if missing == 0 {
for _, tableName := range tableNames {
sqlBuilder.WriteString("\n-- ")
sqlBuilder.WriteString(tableName)
sqlBuilder.WriteString(" from ")
sqlBuilder.WriteString(statementSource[tableName])
sqlBuilder.WriteString("\n")
sqlBuilder.WriteString(statements[tableName])
sqlBuilder.WriteString("\n")
}
}
sqlBuilder.WriteString("COMMIT;")
if _, err := h.analyticsDB.ExecRaw(c.Context(), sqlBuilder.String()); err != nil {
return c.Status(fiber.StatusInternalServerError).JSON(domain.ErrorResponse{
Message: "Failed to reset and reseed database",
Error: err.Error(),
})
}
msg := "Course management hierarchy reset and reseed completed successfully"
if missing == len(tableNames) {
msg = "Course management hierarchy truncated successfully (no INSERT seed configured; tables empty)"
}
return c.JSON(domain.Response{
Message: msg,
Data: map[string]interface{}{
"seed_dir": seedDir,
"tables": tableNames,
"sources": statementSource,
"reseeded": missing == 0,
"truncate_only": missing == len(tableNames),
},
Success: true,
StatusCode: fiber.StatusOK,
})
}
// ClearCourseManagementData godoc
// @Summary Clear course management hierarchy data only
// @Description Truncates course_categories, courses, and sub_courses (same scope as reset-reseed) without re-inserting seed SQL.
// @Tags internal
// @Accept json
// @Produce json
// @Param X-Seed-Reset-Token header string false "Optional token when DB_RESET_RESEED_TOKEN is set"
// @Param body body clearCourseManagementReq true "Confirmation payload"
// @Success 200 {object} domain.Response
// @Failure 400 {object} domain.ErrorResponse
// @Failure 403 {object} domain.ErrorResponse
// @Failure 500 {object} domain.ErrorResponse
// @Router /api/v1/internal/db/clear-course-management [post]
func (h *Handler) ClearCourseManagementData(c *fiber.Ctx) error {
if h.Cfg == nil || !h.Cfg.DBResetReseedEnabled {
return c.Status(fiber.StatusForbidden).JSON(domain.ErrorResponse{
Message: "Operation is disabled",
Error: "internal course management maintenance is disabled",
})
}
var req clearCourseManagementReq
if err := c.BodyParser(&req); err != nil {
return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{
Message: "Invalid request body",
Error: err.Error(),
})
}
if strings.TrimSpace(req.Confirm) != "CLEAR_COURSE_MANAGEMENT" {
return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{
Message: "Confirmation required",
Error: `set confirm to "CLEAR_COURSE_MANAGEMENT"`,
})
}
expectedToken := strings.TrimSpace(h.Cfg.DBResetReseedToken)
if expectedToken != "" {
providedToken := strings.TrimSpace(c.Get("X-Seed-Reset-Token"))
if subtle.ConstantTimeCompare([]byte(providedToken), []byte(expectedToken)) != 1 {
return c.Status(fiber.StatusForbidden).JSON(domain.ErrorResponse{
Message: "Invalid reset token",
Error: "missing or invalid X-Seed-Reset-Token",
})
}
}
tableNames := []string{"course_categories", "courses", "sub_courses"}
sql := `BEGIN;
TRUNCATE TABLE sub_courses, courses, course_categories RESTART IDENTITY CASCADE;
COMMIT;`
if _, err := h.analyticsDB.ExecRaw(c.Context(), sql); err != nil {
return c.Status(fiber.StatusInternalServerError).JSON(domain.ErrorResponse{
Message: "Failed to clear course management data",
Error: err.Error(),
})
}
return c.JSON(domain.Response{
Message: "Course management hierarchy cleared successfully (no re-seed)",
Data: map[string]interface{}{
"tables": tableNames,
},
Success: true,
StatusCode: fiber.StatusOK,
})
}

View File

@ -0,0 +1,257 @@
package handlers
import (
"Yimaru-Backend/internal/domain"
"Yimaru-Backend/internal/services/courses"
"Yimaru-Backend/internal/services/modules"
"context"
"errors"
"strconv"
"github.com/gofiber/fiber/v2"
)
// CreateModule godoc
// @Summary Create module
// @Description Create a module under a course; parent program is taken from the course.
// @Tags modules
// @Accept json
// @Produce json
// @Param courseId path int true "Course ID"
// @Param body body domain.CreateModuleInput true "Module"
// @Success 201 {object} domain.Response
// @Failure 400 {object} domain.ErrorResponse
// @Router /api/v1/courses/{courseId}/modules [post]
func (h *Handler) CreateModule(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.CreateModuleInput
if err := c.BodyParser(&req); err != nil {
return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{
Message: "Invalid request body",
Error: err.Error(),
})
}
if valErrs, ok := h.validator.Validate(c, req); !ok {
return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{
Message: "Validation failed",
Error: firstValidationError(valErrs),
})
}
mod, err := h.moduleSvc.Create(c.Context(), courseID, req)
if err != nil {
if errors.Is(err, courses.ErrCourseNotFound) {
return c.Status(fiber.StatusNotFound).JSON(domain.ErrorResponse{
Message: "Course not found",
Error: err.Error(),
})
}
return c.Status(fiber.StatusInternalServerError).JSON(domain.ErrorResponse{
Message: "Failed to create module",
Error: err.Error(),
})
}
actorID := c.Locals("user_id").(int64)
actorRole := string(c.Locals("role").(domain.Role))
ip := c.IP()
ua := c.Get("User-Agent")
rid := mod.ID
go h.activityLogSvc.RecordAction(context.Background(), &actorID, &actorRole, domain.ActionModuleCreated, domain.ResourceModule, &rid, "Created module: "+mod.Name, nil, &ip, &ua)
return c.Status(fiber.StatusCreated).JSON(domain.Response{
Message: "Module created successfully",
Data: mod,
Success: true,
StatusCode: fiber.StatusCreated,
})
}
// ListModulesByCourse godoc
// @Summary List modules for a course
// @Tags modules
// @Produce json
// @Param courseId path int true "Course ID"
// @Param limit query int false "Page size" default(20)
// @Param offset query int false "Offset" default(0)
// @Success 200 {object} domain.Response
// @Router /api/v1/courses/{courseId}/modules [get]
func (h *Handler) ListModulesByCourse(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(),
})
}
limit, _ := strconv.Atoi(c.Query("limit", "20"))
offset, _ := strconv.Atoi(c.Query("offset", "0"))
items, total, err := h.moduleSvc.ListByCourse(c.Context(), courseID, int32(limit), int32(offset))
if err != nil {
if errors.Is(err, courses.ErrCourseNotFound) {
return c.Status(fiber.StatusNotFound).JSON(domain.ErrorResponse{
Message: "Course not found",
Error: err.Error(),
})
}
return c.Status(fiber.StatusInternalServerError).JSON(domain.ErrorResponse{
Message: "Failed to list modules",
Error: err.Error(),
})
}
uid := c.Locals("user_id").(int64)
role := c.Locals("role").(domain.Role)
for i := range items {
if err := h.lmsProgressSvc.ApplyAccessModule(c.Context(), role, uid, &items[i]); err != nil {
return c.Status(fiber.StatusInternalServerError).JSON(domain.ErrorResponse{
Message: "Failed to build module list",
Error: err.Error(),
})
}
}
return c.JSON(domain.Response{
Message: "Modules retrieved successfully",
Data: fiber.Map{
"modules": items,
"total_count": total,
"limit": limit,
"offset": offset,
},
Success: true,
StatusCode: fiber.StatusOK,
})
}
// GetModule godoc
// @Tags modules
// @Param id path int true "Module ID"
// @Success 200 {object} domain.Response
// @Router /api/v1/modules/{id} [get]
func (h *Handler) GetModule(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 module id",
Error: err.Error(),
})
}
mod, err := h.moduleSvc.GetByID(c.Context(), id)
if err != nil {
if errors.Is(err, modules.ErrModuleNotFound) {
return c.Status(fiber.StatusNotFound).JSON(domain.ErrorResponse{
Message: "Module not found",
Error: err.Error(),
})
}
return c.Status(fiber.StatusInternalServerError).JSON(domain.ErrorResponse{
Message: "Failed to load module",
Error: err.Error(),
})
}
uid := c.Locals("user_id").(int64)
role := c.Locals("role").(domain.Role)
if err := h.lmsProgressSvc.ApplyAccessModule(c.Context(), role, uid, &mod); err != nil {
return c.Status(fiber.StatusInternalServerError).JSON(domain.ErrorResponse{
Message: "Failed to evaluate module access",
Error: err.Error(),
})
}
if err := lmsBlockIfInaccessible(c, mod.Access); err != nil {
return err
}
return c.JSON(domain.Response{
Message: "Module retrieved successfully",
Data: mod,
Success: true,
StatusCode: fiber.StatusOK,
})
}
// UpdateModule godoc
// @Tags modules
// @Param id path int true "Module ID"
// @Param body body domain.UpdateModuleInput true "Fields to update"
// @Router /api/v1/modules/{id} [put]
func (h *Handler) UpdateModule(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 module id",
Error: err.Error(),
})
}
var req domain.UpdateModuleInput
if err := c.BodyParser(&req); err != nil {
return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{
Message: "Invalid request body",
Error: err.Error(),
})
}
mod, err := h.moduleSvc.Update(c.Context(), id, req)
if err != nil {
if errors.Is(err, modules.ErrModuleNotFound) {
return c.Status(fiber.StatusNotFound).JSON(domain.ErrorResponse{
Message: "Module not found",
Error: err.Error(),
})
}
return c.Status(fiber.StatusInternalServerError).JSON(domain.ErrorResponse{
Message: "Failed to update module",
Error: err.Error(),
})
}
actorID := c.Locals("user_id").(int64)
actorRole := string(c.Locals("role").(domain.Role))
ip := c.IP()
ua := c.Get("User-Agent")
rid := mod.ID
go h.activityLogSvc.RecordAction(context.Background(), &actorID, &actorRole, domain.ActionModuleUpdated, domain.ResourceModule, &rid, "Updated module: "+mod.Name, nil, &ip, &ua)
return c.JSON(domain.Response{
Message: "Module updated successfully",
Data: mod,
Success: true,
StatusCode: fiber.StatusOK,
})
}
// DeleteModule godoc
// @Tags modules
// @Param id path int true "Module ID"
// @Router /api/v1/modules/{id} [delete]
func (h *Handler) DeleteModule(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 module id",
Error: err.Error(),
})
}
if err := h.moduleSvc.Delete(c.Context(), id); err != nil {
if errors.Is(err, modules.ErrModuleNotFound) {
return c.Status(fiber.StatusNotFound).JSON(domain.ErrorResponse{
Message: "Module not found",
Error: err.Error(),
})
}
return c.Status(fiber.StatusInternalServerError).JSON(domain.ErrorResponse{
Message: "Failed to delete module",
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.ActionModuleDeleted, domain.ResourceModule, &id, "Deleted module", nil, &ip, &ua)
return c.JSON(domain.Response{
Message: "Module deleted successfully",
Success: true,
StatusCode: fiber.StatusOK,
})
}

Some files were not shown because too many files have changed in this diff Show More