new course management hierarchy

This commit is contained in:
Yared Yemane 2026-04-10 03:06:30 -07:00
parent 7ecfdd9cc8
commit 7613eb583a
48 changed files with 3393 additions and 13198 deletions

View File

@ -0,0 +1,25 @@
UPDATE question_sets qs
SET owner_type = 'SUB_COURSE',
owner_id = sm.legacy_sub_course_id
FROM sub_modules sm
WHERE qs.owner_type = 'SUB_MODULE'
AND qs.owner_id = sm.id
AND qs.set_type = 'PRACTICE'
AND sm.legacy_sub_course_id IS NOT NULL;
DROP TABLE IF EXISTS sub_module_practices CASCADE;
DROP TABLE IF EXISTS sub_module_videos CASCADE;
DROP TABLE IF EXISTS sub_modules CASCADE;
DROP TABLE IF EXISTS modules CASCADE;
DROP TABLE IF EXISTS levels CASCADE;
ALTER TABLE courses DROP COLUMN IF EXISTS sub_category_id;
DROP TABLE IF EXISTS course_sub_categories CASCADE;
-- Best-effort rollback to old expectation.
UPDATE user_practice_progress
SET sub_course_id = 1
WHERE sub_course_id IS NULL;
ALTER TABLE user_practice_progress
ALTER COLUMN sub_course_id SET NOT NULL;

View File

@ -0,0 +1,228 @@
-- Unified hierarchy
-- Course Category -> Course Sub-category -> Course -> Level -> Module -> Sub-Module
-- -> Sub-Module Videos
-- -> Sub-Module Practices (question sets)
CREATE TABLE IF NOT EXISTS course_sub_categories (
id BIGSERIAL PRIMARY KEY,
category_id BIGINT NOT NULL REFERENCES course_categories(id) ON DELETE CASCADE,
name VARCHAR(150) NOT NULL,
description TEXT,
is_active BOOLEAN NOT NULL DEFAULT TRUE,
display_order INT NOT NULL DEFAULT 0,
created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
UNIQUE(category_id, name)
);
ALTER TABLE courses
ADD COLUMN IF NOT EXISTS sub_category_id BIGINT REFERENCES course_sub_categories(id) ON DELETE SET NULL;
CREATE INDEX IF NOT EXISTS idx_courses_sub_category_id ON courses(sub_category_id);
CREATE TABLE IF NOT EXISTS levels (
id BIGSERIAL PRIMARY KEY,
course_id BIGINT NOT NULL REFERENCES courses(id) ON DELETE CASCADE,
cefr_level VARCHAR(2) NOT NULL,
display_order INT NOT NULL DEFAULT 0,
is_active BOOLEAN NOT NULL DEFAULT TRUE,
created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
UNIQUE(course_id, cefr_level),
CHECK (cefr_level IN ('A1', 'A2', 'A3', 'B1', 'B2', 'B3', 'C1', 'C2', 'C3'))
);
CREATE INDEX IF NOT EXISTS idx_levels_course_id ON levels(course_id);
CREATE TABLE IF NOT EXISTS modules (
id BIGSERIAL PRIMARY KEY,
level_id BIGINT NOT NULL REFERENCES levels(id) ON DELETE CASCADE,
title VARCHAR(255) NOT NULL,
description TEXT,
display_order INT NOT NULL DEFAULT 0,
is_active BOOLEAN NOT NULL DEFAULT TRUE,
created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP
);
CREATE INDEX IF NOT EXISTS idx_modules_level_id ON modules(level_id);
CREATE TABLE IF NOT EXISTS sub_modules (
id BIGSERIAL PRIMARY KEY,
module_id BIGINT NOT NULL REFERENCES modules(id) ON DELETE CASCADE,
title VARCHAR(255) NOT NULL,
description TEXT,
display_order INT NOT NULL DEFAULT 0,
is_active BOOLEAN NOT NULL DEFAULT TRUE,
created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
legacy_sub_course_id BIGINT UNIQUE
);
CREATE INDEX IF NOT EXISTS idx_sub_modules_module_id ON sub_modules(module_id);
CREATE TABLE IF NOT EXISTS sub_module_videos (
id BIGSERIAL PRIMARY KEY,
sub_module_id BIGINT NOT NULL REFERENCES sub_modules(id) ON DELETE CASCADE,
title VARCHAR(255) NOT NULL,
description TEXT,
video_url TEXT NOT NULL,
duration INT,
resolution VARCHAR(20),
is_published BOOLEAN NOT NULL DEFAULT FALSE,
publish_date TIMESTAMPTZ,
visibility VARCHAR(50),
instructor_id VARCHAR(100),
thumbnail TEXT,
display_order INT NOT NULL DEFAULT 0,
status VARCHAR(20) NOT NULL DEFAULT 'DRAFT',
vimeo_id TEXT,
vimeo_embed_url TEXT,
vimeo_player_html TEXT,
vimeo_status VARCHAR(50),
video_host_provider VARCHAR(20),
created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP
);
CREATE INDEX IF NOT EXISTS idx_sub_module_videos_sub_module_id ON sub_module_videos(sub_module_id);
CREATE TABLE IF NOT EXISTS sub_module_practices (
id BIGSERIAL PRIMARY KEY,
sub_module_id BIGINT NOT NULL REFERENCES sub_modules(id) ON DELETE CASCADE,
question_set_id BIGINT NOT NULL REFERENCES question_sets(id) ON DELETE CASCADE,
intro_video_url TEXT,
display_order INT NOT NULL DEFAULT 0,
is_active BOOLEAN NOT NULL DEFAULT TRUE,
created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
UNIQUE(question_set_id)
);
CREATE INDEX IF NOT EXISTS idx_sub_module_practices_sub_module_id ON sub_module_practices(sub_module_id);
-- Practice progress now supports sub-module owned practices where no legacy sub_course exists.
ALTER TABLE user_practice_progress
ALTER COLUMN sub_course_id DROP NOT NULL;
-- Backfill from existing structure
INSERT INTO course_sub_categories (category_id, name, description, display_order, is_active)
SELECT cc.id, c.title || ' Group', 'Auto-generated from existing course structure', 0, TRUE
FROM courses c
JOIN course_categories cc ON cc.id = c.category_id
LEFT JOIN course_sub_categories csc
ON csc.category_id = cc.id AND csc.name = c.title || ' Group'
WHERE csc.id IS NULL;
UPDATE courses c
SET sub_category_id = csc.id
FROM course_sub_categories csc
WHERE csc.category_id = c.category_id
AND csc.name = c.title || ' Group'
AND c.sub_category_id IS NULL;
INSERT INTO levels (course_id, cefr_level, display_order, is_active)
SELECT
sc.course_id,
sc.sub_level,
MIN(sc.display_order),
BOOL_AND(sc.is_active)
FROM sub_courses sc
WHERE sc.sub_level IN ('A1', 'A2', 'A3', 'B1', 'B2', 'B3', 'C1', 'C2', 'C3')
GROUP BY sc.course_id, sc.sub_level
ON CONFLICT (course_id, cefr_level) DO NOTHING;
INSERT INTO modules (level_id, title, description, display_order, is_active)
SELECT
l.id,
l.cefr_level || ' Module 1',
'Auto-generated default module for ' || l.cefr_level,
1,
l.is_active
FROM levels l
LEFT JOIN modules m ON m.level_id = l.id AND m.display_order = 1
WHERE m.id IS NULL;
INSERT INTO sub_modules (module_id, title, description, display_order, is_active, legacy_sub_course_id)
SELECT
m.id,
sc.title,
sc.description,
sc.display_order,
sc.is_active,
sc.id
FROM sub_courses sc
JOIN levels l
ON l.course_id = sc.course_id
AND l.cefr_level = sc.sub_level
JOIN modules m
ON m.level_id = l.id
AND m.display_order = 1
LEFT JOIN sub_modules sm ON sm.legacy_sub_course_id = sc.id
WHERE sm.id IS NULL;
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
)
SELECT
sm.id,
scv.title,
scv.description,
scv.video_url,
scv.duration,
scv.resolution,
scv.is_published,
scv.publish_date,
scv.visibility,
scv.instructor_id,
scv.thumbnail,
scv.display_order,
scv.status,
scv.vimeo_id,
scv.vimeo_embed_url,
scv.vimeo_player_html,
scv.vimeo_status,
scv.video_host_provider
FROM sub_course_videos scv
JOIN sub_modules sm ON sm.legacy_sub_course_id = scv.sub_course_id
WHERE NOT EXISTS (
SELECT 1
FROM sub_module_videos smv
WHERE smv.sub_module_id = sm.id
AND smv.title = scv.title
AND COALESCE(smv.video_url, '') = COALESCE(scv.video_url, '')
);
UPDATE question_sets qs
SET owner_type = 'SUB_MODULE',
owner_id = sm.id
FROM sub_modules sm
WHERE qs.owner_type = 'SUB_COURSE'
AND qs.owner_id = sm.legacy_sub_course_id
AND qs.set_type = 'PRACTICE';
INSERT INTO sub_module_practices (sub_module_id, question_set_id, intro_video_url, display_order, is_active)
SELECT
sm.id,
qs.id,
qs.intro_video_url,
COALESCE(qs.display_order, 0),
(qs.status != 'ARCHIVED')
FROM question_sets qs
JOIN sub_modules sm
ON qs.owner_type = 'SUB_MODULE'
AND qs.owner_id = sm.id
WHERE qs.set_type = 'PRACTICE'
ON CONFLICT (question_set_id) DO NOTHING;

View File

@ -0,0 +1,6 @@
ALTER TABLE IF EXISTS sub_module_lessons
RENAME TO sub_module_practices;
ALTER INDEX IF EXISTS idx_sub_module_lessons_sub_module_id
RENAME TO idx_sub_module_practices_sub_module_id;

View File

@ -0,0 +1,6 @@
ALTER TABLE IF EXISTS sub_module_practices
RENAME TO sub_module_lessons;
ALTER INDEX IF EXISTS idx_sub_module_practices_sub_module_id
RENAME TO idx_sub_module_lessons_sub_module_id;

View File

@ -0,0 +1,4 @@
DROP INDEX IF EXISTS idx_sub_module_practices_sub_module_id;
DROP TABLE IF EXISTS sub_module_practices;

View File

@ -0,0 +1,17 @@
CREATE TABLE IF NOT EXISTS sub_module_practices (
id BIGSERIAL PRIMARY KEY,
sub_module_id BIGINT NOT NULL REFERENCES sub_modules(id) ON DELETE CASCADE,
title VARCHAR(255) NOT NULL,
description TEXT,
thumbnail TEXT,
intro_video_url TEXT,
question_set_id BIGINT NOT NULL REFERENCES question_sets(id) ON DELETE CASCADE,
display_order INT NOT NULL DEFAULT 0,
is_active BOOLEAN NOT NULL DEFAULT TRUE,
created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
UNIQUE(question_set_id)
);
CREATE INDEX IF NOT EXISTS idx_sub_module_practices_sub_module_id
ON sub_module_practices(sub_module_id);

195
db/query/hierarchy.sql Normal file
View File

@ -0,0 +1,195 @@
-- 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: GetModulesByLevelID :many
SELECT *
FROM modules
WHERE level_id = $1
AND is_active = TRUE
ORDER BY display_order ASC, id ASC;
-- name: GetSubModulesByModuleID :many
SELECT *
FROM sub_modules
WHERE module_id = $1
AND is_active = TRUE
ORDER BY display_order ASC, id ASC;
-- 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
smp.id,
smp.sub_module_id,
smp.question_set_id,
smp.intro_video_url,
smp.display_order,
smp.is_active,
qs.title,
qs.description,
qs.status,
qs.set_type,
(SELECT COUNT(*) FROM question_set_items qsi WHERE qsi.set_id = qs.id) AS question_count
FROM sub_module_lessons 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: 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,
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: GetFullHierarchyByCourseID :many
SELECT
c.id AS course_id,
c.title AS course_title,
l.id AS level_id,
l.cefr_level,
m.id AS module_id,
m.title AS module_title,
sm.id AS sub_module_id,
sm.title AS sub_module_title
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: CreateLevel :one
INSERT INTO levels (
course_id,
cefr_level,
display_order,
is_active
)
VALUES ($1, $2, COALESCE($3, 0), COALESCE($4, TRUE))
RETURNING *;
-- name: CreateModule :one
INSERT INTO modules (
level_id,
title,
description,
display_order,
is_active
)
VALUES ($1, $2, $3, COALESCE($4, 0), COALESCE($5, TRUE))
RETURNING *;
-- name: CreateSubModule :one
INSERT INTO sub_modules (
module_id,
title,
description,
display_order,
is_active
)
VALUES ($1, $2, $3, COALESCE($4, 0), COALESCE($5, TRUE))
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: AttachQuestionSetLessonToSubModule :one
INSERT INTO sub_module_lessons (
sub_module_id,
question_set_id,
intro_video_url,
display_order,
is_active
)
VALUES ($1, $2, $3, COALESCE($4, 0), COALESCE($5, TRUE))
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
)
VALUES ($1, $2, $3, $4, $5, $6, COALESCE($7, 0), COALESCE($8, TRUE))
RETURNING *;

View File

@ -1,59 +0,0 @@
-- name: GetFullLearningTree :many
SELECT
c.id AS course_id,
c.title AS course_title,
sc.id AS sub_course_id,
sc.title AS sub_course_title,
sc.level AS sub_course_level,
sc.sub_level AS sub_course_sub_level
FROM courses c
LEFT JOIN sub_courses sc ON sc.course_id = c.id AND sc.is_active = true
WHERE c.is_active = true
ORDER BY c.id, sc.display_order, sc.id;
-- name: GetCourseLearningPath :many
SELECT
c.id AS course_id,
c.title AS course_title,
c.description AS course_description,
c.thumbnail AS course_thumbnail,
c.intro_video_url AS course_intro_video_url,
cc.id AS category_id,
cc.name AS category_name,
sc.id AS sub_course_id,
sc.title AS sub_course_title,
sc.description AS sub_course_description,
sc.thumbnail AS sub_course_thumbnail,
sc.display_order AS sub_course_display_order,
sc.level AS sub_course_level,
sc.sub_level AS sub_course_sub_level,
(SELECT COUNT(*) FROM sub_course_prerequisites WHERE sub_course_id = sc.id) AS prerequisite_count,
(SELECT COUNT(*) FROM sub_course_videos WHERE sub_course_id = sc.id AND status = 'PUBLISHED') AS video_count,
(SELECT COUNT(*) FROM question_sets WHERE set_type = 'PRACTICE' AND owner_type = 'SUB_COURSE' AND owner_id = sc.id AND status = 'PUBLISHED') AS practice_count
FROM courses c
JOIN course_categories cc ON cc.id = c.category_id
LEFT JOIN sub_courses sc ON sc.course_id = c.id AND sc.is_active = true
WHERE c.id = $1
ORDER BY sc.display_order, sc.id;
-- name: GetSubCourseVideosForLearningPath :many
SELECT id, title, description, video_url, duration, resolution, display_order,
vimeo_id, vimeo_embed_url, video_host_provider
FROM sub_course_videos
WHERE sub_course_id = $1 AND status = 'PUBLISHED'
ORDER BY display_order, id;
-- name: GetSubCoursePracticesForLearningPath :many
SELECT id, title, description, persona, status, intro_video_url,
(SELECT COUNT(*) FROM question_set_items WHERE set_id = qs.id) AS question_count
FROM question_sets qs
WHERE qs.owner_type = 'SUB_COURSE' AND qs.owner_id = $1
AND qs.set_type = 'PRACTICE' AND qs.status = 'PUBLISHED'
ORDER BY qs.display_order ASC, qs.created_at;
-- name: GetSubCoursePrerequisitesForLearningPath :many
SELECT p.prerequisite_sub_course_id, sc.title, sc.level, sc.sub_level
FROM sub_course_prerequisites p
JOIN sub_courses sc ON sc.id = p.prerequisite_sub_course_id
WHERE p.sub_course_id = $1
ORDER BY sc.display_order;

View File

@ -0,0 +1,55 @@
-- name: GetFirstIncompletePreviousPractice :one
WITH target AS (
SELECT id, owner_type, owner_id, COALESCE(display_order, 0) AS display_order
FROM question_sets
WHERE id = @question_set_id::BIGINT
AND set_type = 'PRACTICE'
AND status = 'PUBLISHED'
),
candidates AS (
SELECT qs.id, qs.title, COALESCE(qs.display_order, 0) AS display_order
FROM question_sets qs
JOIN target t
ON qs.owner_type = t.owner_type
AND qs.owner_id = t.owner_id
WHERE qs.set_type = 'PRACTICE'
AND qs.status = 'PUBLISHED'
AND COALESCE(qs.display_order, 0) < t.display_order
)
SELECT c.id, c.title, c.display_order
FROM candidates c
LEFT JOIN user_practice_progress upp
ON upp.question_set_id = c.id
AND upp.user_id = @user_id::BIGINT
AND upp.completed_at IS NOT NULL
WHERE upp.id IS NULL
ORDER BY c.display_order ASC, c.id ASC
LIMIT 1;
-- name: MarkPracticeCompleted :execrows
INSERT INTO user_practice_progress (
user_id,
sub_course_id,
question_set_id,
completed_at,
updated_at
)
SELECT
@user_id::BIGINT,
CASE
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
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
SET completed_at = EXCLUDED.completed_at,
updated_at = EXCLUDED.updated_at;

View File

@ -1,50 +0,0 @@
-- name: AddSubCoursePrerequisite :one
INSERT INTO sub_course_prerequisites (sub_course_id, prerequisite_sub_course_id)
VALUES ($1, $2)
RETURNING *;
-- name: RemoveSubCoursePrerequisite :exec
DELETE FROM sub_course_prerequisites
WHERE sub_course_id = $1 AND prerequisite_sub_course_id = $2;
-- name: GetSubCoursePrerequisites :many
SELECT
p.id,
p.sub_course_id,
p.prerequisite_sub_course_id,
p.created_at,
sc.title AS prerequisite_title,
sc.level AS prerequisite_level,
sc.display_order AS prerequisite_display_order
FROM sub_course_prerequisites p
JOIN sub_courses sc ON sc.id = p.prerequisite_sub_course_id
WHERE p.sub_course_id = $1
ORDER BY sc.display_order;
-- name: GetSubCourseDependents :many
SELECT
p.id,
p.sub_course_id,
p.prerequisite_sub_course_id,
p.created_at,
sc.title AS dependent_title,
sc.level AS dependent_level
FROM sub_course_prerequisites p
JOIN sub_courses sc ON sc.id = p.sub_course_id
WHERE p.prerequisite_sub_course_id = $1
ORDER BY sc.display_order;
-- name: CountUnmetPrerequisites :one
SELECT COUNT(*)::bigint AS unmet_count
FROM sub_course_prerequisites p
WHERE p.sub_course_id = $1
AND p.prerequisite_sub_course_id NOT IN (
SELECT usp.sub_course_id
FROM user_sub_course_progress usp
WHERE usp.user_id = $2
AND usp.status = 'COMPLETED'
);
-- name: DeleteAllPrerequisitesForSubCourse :exec
DELETE FROM sub_course_prerequisites
WHERE sub_course_id = $1;

View File

@ -1,122 +0,0 @@
-- name: CreateSubCourseVideo :one
INSERT INTO sub_course_videos (
sub_course_id,
title,
description,
video_url,
duration,
resolution,
instructor_id,
thumbnail,
visibility,
display_order,
status,
vimeo_id,
vimeo_embed_url,
vimeo_player_html,
vimeo_status,
video_host_provider
)
VALUES (
$1, $2, $3, $4, $5, $6,
$7, $8, $9,
COALESCE($10, 0),
COALESCE($11, 'DRAFT'),
$12, $13, $14,
COALESCE($15, 'pending'),
COALESCE($16, 'DIRECT')
)
RETURNING *;
-- name: GetSubCourseVideoByID :one
SELECT *
FROM sub_course_videos
WHERE id = $1;
-- name: GetVideosBySubCourse :many
SELECT
COUNT(*) OVER () AS total_count,
id,
sub_course_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
FROM sub_course_videos
WHERE sub_course_id = $1
AND status != 'ARCHIVED'
ORDER BY display_order ASC, id ASC;
-- name: GetPublishedVideosBySubCourse :many
SELECT *
FROM sub_course_videos
WHERE sub_course_id = $1
AND status = 'PUBLISHED'
ORDER BY display_order ASC, publish_date ASC;
-- name: PublishSubCourseVideo :exec
UPDATE sub_course_videos
SET
is_published = true,
publish_date = CURRENT_TIMESTAMP,
status = 'PUBLISHED'
WHERE id = $1;
-- name: UpdateSubCourseVideo :exec
UPDATE sub_course_videos
SET
title = COALESCE($1, title),
description = COALESCE($2, description),
video_url = COALESCE($3, video_url),
duration = COALESCE($4, duration),
resolution = COALESCE($5, resolution),
visibility = COALESCE($6, visibility),
thumbnail = COALESCE($7, thumbnail),
display_order = COALESCE($8, display_order),
status = COALESCE($9, status),
vimeo_id = COALESCE($10, vimeo_id),
vimeo_embed_url = COALESCE($11, vimeo_embed_url),
vimeo_player_html = COALESCE($12, vimeo_player_html),
vimeo_status = COALESCE($13, vimeo_status),
video_host_provider = COALESCE($14, video_host_provider)
WHERE id = $15;
-- name: UpdateVimeoStatus :exec
UPDATE sub_course_videos
SET
vimeo_status = $1
WHERE id = $2;
-- name: GetVideosByVimeoID :one
SELECT * FROM sub_course_videos
WHERE vimeo_id = $1;
-- name: ArchiveSubCourseVideo :exec
UPDATE sub_course_videos
SET status = 'ARCHIVED'
WHERE id = $1;
-- name: DeleteSubCourseVideo :exec
DELETE FROM sub_course_videos
WHERE id = $1;
-- name: ReorderSubCourseVideos :exec
UPDATE sub_course_videos
SET display_order = bulk.position
FROM (
SELECT unnest(@ids::BIGINT[]) AS id, unnest(@positions::INT[]) AS position
) AS bulk
WHERE sub_course_videos.id = bulk.id;

View File

@ -1,95 +0,0 @@
-- name: CreateSubCourse :one
INSERT INTO sub_courses (
course_id,
title,
description,
thumbnail,
display_order,
level,
sub_level,
is_active
)
VALUES ($1, $2, $3, $4, COALESCE($5, 0), $6, $7, COALESCE($8, true))
RETURNING *;
-- name: GetSubCourseByID :one
SELECT *
FROM sub_courses
WHERE id = $1;
-- name: GetSubCoursesByCourse :many
SELECT
COUNT(*) OVER () AS total_count,
id,
course_id,
title,
description,
thumbnail,
display_order,
level,
sub_level,
is_active
FROM sub_courses
WHERE course_id = $1
ORDER BY display_order ASC, id ASC;
-- name: ListSubCoursesByCourse :many
SELECT
id,
course_id,
title,
description,
thumbnail,
display_order,
level,
sub_level,
is_active
FROM sub_courses
WHERE course_id = $1
AND is_active = TRUE
ORDER BY display_order ASC, id ASC;
-- name: ListActiveSubCourses :many
SELECT
id,
course_id,
title,
description,
thumbnail,
display_order,
level,
sub_level,
is_active
FROM sub_courses
WHERE is_active = TRUE
ORDER BY display_order ASC;
-- name: UpdateSubCourse :exec
UPDATE sub_courses
SET
title = COALESCE($1, title),
description = COALESCE($2, description),
thumbnail = COALESCE($3, thumbnail),
display_order = COALESCE($4, display_order),
level = COALESCE($5, level),
sub_level = COALESCE($6, sub_level),
is_active = COALESCE($7, is_active)
WHERE id = $8;
-- name: DeleteSubCourse :one
DELETE FROM sub_courses
WHERE id = $1
RETURNING *;
-- name: DeactivateSubCourse :exec
UPDATE sub_courses
SET is_active = FALSE
WHERE id = $1;
-- name: ReorderSubCourses :exec
UPDATE sub_courses
SET display_order = bulk.position
FROM (
SELECT unnest(@ids::BIGINT[]) AS id, unnest(@positions::INT[]) AS position
) AS bulk
WHERE sub_courses.id = bulk.id;

View File

@ -1,51 +0,0 @@
-- name: MarkPracticeCompleted :one
INSERT INTO user_practice_progress (
user_id,
sub_course_id,
question_set_id,
completed_at,
updated_at
)
SELECT
@user_id::BIGINT,
qs.owner_id::BIGINT,
qs.id,
CURRENT_TIMESTAMP,
CURRENT_TIMESTAMP
FROM question_sets qs
WHERE qs.id = @question_set_id::BIGINT
AND qs.set_type = 'PRACTICE'
AND qs.owner_type = 'SUB_COURSE'
AND qs.status = 'PUBLISHED'
ON CONFLICT (user_id, question_set_id)
DO UPDATE SET
completed_at = CURRENT_TIMESTAMP,
updated_at = CURRENT_TIMESTAMP
RETURNING *;
-- name: GetFirstIncompletePreviousPractice :one
SELECT
p.id,
p.title,
p.display_order
FROM question_sets target
JOIN question_sets p
ON p.owner_type = 'SUB_COURSE'
AND p.owner_id = target.owner_id
AND p.set_type = 'PRACTICE'
AND p.status = 'PUBLISHED'
AND (
p.display_order < target.display_order OR
(p.display_order = target.display_order AND p.id < target.id)
)
LEFT JOIN user_practice_progress upp
ON upp.question_set_id = p.id
AND upp.user_id = @user_id::BIGINT
AND upp.completed_at IS NOT NULL
WHERE target.id = @question_set_id::BIGINT
AND target.set_type = 'PRACTICE'
AND target.owner_type = 'SUB_COURSE'
AND target.status = 'PUBLISHED'
AND upp.question_set_id IS NULL
ORDER BY p.display_order ASC, p.id ASC
LIMIT 1;

View File

@ -1,78 +0,0 @@
-- name: StartSubCourseProgress :one
INSERT INTO user_sub_course_progress (user_id, sub_course_id)
VALUES ($1, $2)
ON CONFLICT (user_id, sub_course_id) DO NOTHING
RETURNING *;
-- name: UpdateSubCourseProgress :exec
UPDATE user_sub_course_progress
SET
progress_percentage = $1,
updated_at = CURRENT_TIMESTAMP
WHERE user_id = $2 AND sub_course_id = $3;
-- name: CompleteSubCourse :exec
UPDATE user_sub_course_progress
SET
status = 'COMPLETED',
progress_percentage = 100,
completed_at = CURRENT_TIMESTAMP,
updated_at = CURRENT_TIMESTAMP
WHERE user_id = $1 AND sub_course_id = $2;
-- name: GetUserSubCourseProgress :one
SELECT * FROM user_sub_course_progress
WHERE user_id = $1 AND sub_course_id = $2;
-- name: GetUserCourseProgress :many
SELECT
usp.id,
usp.user_id,
usp.sub_course_id,
usp.status,
usp.progress_percentage,
usp.started_at,
usp.completed_at,
usp.created_at,
usp.updated_at,
sc.title AS sub_course_title,
sc.level AS sub_course_level,
sc.display_order AS sub_course_display_order
FROM user_sub_course_progress usp
JOIN sub_courses sc ON sc.id = usp.sub_course_id
WHERE usp.user_id = $1 AND sc.course_id = $2
ORDER BY sc.display_order;
-- name: GetSubCoursesWithProgressByCourse :many
SELECT
sc.id AS sub_course_id,
sc.title,
sc.description,
sc.thumbnail,
sc.display_order,
sc.level,
sc.is_active,
COALESCE(usp.status, 'NOT_STARTED') AS progress_status,
COALESCE(usp.progress_percentage, 0)::smallint AS progress_percentage,
usp.started_at,
usp.completed_at,
(SELECT COUNT(*)::bigint
FROM sub_course_prerequisites p
WHERE p.sub_course_id = sc.id
AND p.prerequisite_sub_course_id NOT IN (
SELECT usp2.sub_course_id
FROM user_sub_course_progress usp2
WHERE usp2.user_id = $1
AND usp2.status = 'COMPLETED'
)
) AS unmet_prerequisites_count
FROM sub_courses sc
LEFT JOIN user_sub_course_progress usp
ON usp.sub_course_id = sc.id AND usp.user_id = $1
WHERE sc.course_id = $2
AND sc.is_active = true
ORDER BY sc.display_order;
-- name: DeleteUserSubCourseProgress :exec
DELETE FROM user_sub_course_progress
WHERE user_id = $1 AND sub_course_id = $2;

View File

@ -1,44 +0,0 @@
-- name: MarkVideoCompleted :one
INSERT INTO user_sub_course_video_progress (
user_id,
sub_course_id,
video_id,
completed_at,
updated_at
)
SELECT
@user_id::BIGINT,
v.sub_course_id,
v.id,
CURRENT_TIMESTAMP,
CURRENT_TIMESTAMP
FROM sub_course_videos v
WHERE v.id = @video_id::BIGINT
AND v.status = 'PUBLISHED'
ON CONFLICT (user_id, video_id)
DO UPDATE SET
completed_at = CURRENT_TIMESTAMP,
updated_at = CURRENT_TIMESTAMP
RETURNING *;
-- name: GetFirstIncompletePreviousVideo :one
SELECT
v.id,
v.title,
v.display_order
FROM sub_course_videos target
JOIN sub_course_videos v
ON v.sub_course_id = target.sub_course_id
AND v.status = 'PUBLISHED'
AND (
v.display_order < target.display_order OR
(v.display_order = target.display_order AND v.id < target.id)
)
LEFT JOIN user_sub_course_video_progress p
ON p.video_id = v.id
AND p.user_id = @user_id::BIGINT
AND p.completed_at IS NOT NULL
WHERE target.id = @video_id::BIGINT
AND p.video_id IS NULL
ORDER BY v.display_order ASC, v.id ASC
LIMIT 1;

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

@ -21,7 +21,7 @@ INSERT INTO courses (
is_active is_active
) )
VALUES ($1, $2, $3, $4, $5, COALESCE($6, true)) VALUES ($1, $2, $3, $4, $5, COALESCE($6, true))
RETURNING id, category_id, title, description, is_active, thumbnail, intro_video_url, display_order RETURNING id, category_id, title, description, is_active, thumbnail, intro_video_url, display_order, sub_category_id
` `
type CreateCourseParams struct { type CreateCourseParams struct {
@ -52,6 +52,7 @@ func (q *Queries) CreateCourse(ctx context.Context, arg CreateCourseParams) (Cou
&i.Thumbnail, &i.Thumbnail,
&i.IntroVideoUrl, &i.IntroVideoUrl,
&i.DisplayOrder, &i.DisplayOrder,
&i.SubCategoryID,
) )
return i, err return i, err
} }
@ -67,7 +68,7 @@ func (q *Queries) DeleteCourse(ctx context.Context, id int64) error {
} }
const GetCourseByID = `-- name: GetCourseByID :one const GetCourseByID = `-- name: GetCourseByID :one
SELECT id, category_id, title, description, is_active, thumbnail, intro_video_url, display_order SELECT id, category_id, title, description, is_active, thumbnail, intro_video_url, display_order, sub_category_id
FROM courses FROM courses
WHERE id = $1 WHERE id = $1
` `
@ -84,6 +85,7 @@ func (q *Queries) GetCourseByID(ctx context.Context, id int64) (Course, error) {
&i.Thumbnail, &i.Thumbnail,
&i.IntroVideoUrl, &i.IntroVideoUrl,
&i.DisplayOrder, &i.DisplayOrder,
&i.SubCategoryID,
) )
return i, err return i, err
} }

766
gen/db/hierarchy.sql.go Normal file
View File

@ -0,0 +1,766 @@
// Code generated by sqlc. DO NOT EDIT.
// versions:
// sqlc v1.30.0
// source: hierarchy.sql
package dbgen
import (
"context"
"github.com/jackc/pgx/v5/pgtype"
)
const AttachQuestionSetLessonToSubModule = `-- name: AttachQuestionSetLessonToSubModule :one
INSERT INTO sub_module_lessons (
sub_module_id,
question_set_id,
intro_video_url,
display_order,
is_active
)
VALUES ($1, $2, $3, COALESCE($4, 0), COALESCE($5, TRUE))
RETURNING id, sub_module_id, question_set_id, intro_video_url, display_order, is_active, created_at
`
type AttachQuestionSetLessonToSubModuleParams struct {
SubModuleID int64 `json:"sub_module_id"`
QuestionSetID int64 `json:"question_set_id"`
IntroVideoUrl pgtype.Text `json:"intro_video_url"`
Column4 interface{} `json:"column_4"`
Column5 interface{} `json:"column_5"`
}
func (q *Queries) AttachQuestionSetLessonToSubModule(ctx context.Context, arg AttachQuestionSetLessonToSubModuleParams) (SubModuleLesson, error) {
row := q.db.QueryRow(ctx, AttachQuestionSetLessonToSubModule,
arg.SubModuleID,
arg.QuestionSetID,
arg.IntroVideoUrl,
arg.Column4,
arg.Column5,
)
var i SubModuleLesson
err := row.Scan(
&i.ID,
&i.SubModuleID,
&i.QuestionSetID,
&i.IntroVideoUrl,
&i.DisplayOrder,
&i.IsActive,
&i.CreatedAt,
)
return i, err
}
const CreateCourseSubCategory = `-- 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 id, category_id, name, description, is_active, display_order, created_at
`
type CreateCourseSubCategoryParams struct {
CategoryID int64 `json:"category_id"`
Name string `json:"name"`
Description pgtype.Text `json:"description"`
Column4 interface{} `json:"column_4"`
Column5 interface{} `json:"column_5"`
}
func (q *Queries) CreateCourseSubCategory(ctx context.Context, arg CreateCourseSubCategoryParams) (CourseSubCategory, error) {
row := q.db.QueryRow(ctx, CreateCourseSubCategory,
arg.CategoryID,
arg.Name,
arg.Description,
arg.Column4,
arg.Column5,
)
var i CourseSubCategory
err := row.Scan(
&i.ID,
&i.CategoryID,
&i.Name,
&i.Description,
&i.IsActive,
&i.DisplayOrder,
&i.CreatedAt,
)
return i, err
}
const CreateLevel = `-- name: CreateLevel :one
INSERT INTO levels (
course_id,
cefr_level,
display_order,
is_active
)
VALUES ($1, $2, COALESCE($3, 0), COALESCE($4, TRUE))
RETURNING id, course_id, cefr_level, display_order, is_active, created_at
`
type CreateLevelParams struct {
CourseID int64 `json:"course_id"`
CefrLevel string `json:"cefr_level"`
Column3 interface{} `json:"column_3"`
Column4 interface{} `json:"column_4"`
}
func (q *Queries) CreateLevel(ctx context.Context, arg CreateLevelParams) (Level, error) {
row := q.db.QueryRow(ctx, CreateLevel,
arg.CourseID,
arg.CefrLevel,
arg.Column3,
arg.Column4,
)
var i Level
err := row.Scan(
&i.ID,
&i.CourseID,
&i.CefrLevel,
&i.DisplayOrder,
&i.IsActive,
&i.CreatedAt,
)
return i, err
}
const CreateModule = `-- name: CreateModule :one
INSERT INTO modules (
level_id,
title,
description,
display_order,
is_active
)
VALUES ($1, $2, $3, COALESCE($4, 0), COALESCE($5, TRUE))
RETURNING id, level_id, title, description, display_order, is_active, created_at
`
type CreateModuleParams struct {
LevelID int64 `json:"level_id"`
Title string `json:"title"`
Description pgtype.Text `json:"description"`
Column4 interface{} `json:"column_4"`
Column5 interface{} `json:"column_5"`
}
func (q *Queries) CreateModule(ctx context.Context, arg CreateModuleParams) (Module, error) {
row := q.db.QueryRow(ctx, CreateModule,
arg.LevelID,
arg.Title,
arg.Description,
arg.Column4,
arg.Column5,
)
var i Module
err := row.Scan(
&i.ID,
&i.LevelID,
&i.Title,
&i.Description,
&i.DisplayOrder,
&i.IsActive,
&i.CreatedAt,
)
return i, err
}
const CreateSubModule = `-- name: CreateSubModule :one
INSERT INTO sub_modules (
module_id,
title,
description,
display_order,
is_active
)
VALUES ($1, $2, $3, COALESCE($4, 0), COALESCE($5, TRUE))
RETURNING id, module_id, title, description, display_order, is_active, created_at, legacy_sub_course_id
`
type CreateSubModuleParams struct {
ModuleID int64 `json:"module_id"`
Title string `json:"title"`
Description pgtype.Text `json:"description"`
Column4 interface{} `json:"column_4"`
Column5 interface{} `json:"column_5"`
}
func (q *Queries) CreateSubModule(ctx context.Context, arg CreateSubModuleParams) (SubModule, error) {
row := q.db.QueryRow(ctx, CreateSubModule,
arg.ModuleID,
arg.Title,
arg.Description,
arg.Column4,
arg.Column5,
)
var i SubModule
err := row.Scan(
&i.ID,
&i.ModuleID,
&i.Title,
&i.Description,
&i.DisplayOrder,
&i.IsActive,
&i.CreatedAt,
&i.LegacySubCourseID,
)
return i, err
}
const CreateSubModulePractice = `-- name: CreateSubModulePractice :one
INSERT INTO sub_module_practices (
sub_module_id,
title,
description,
thumbnail,
intro_video_url,
question_set_id,
display_order,
is_active
)
VALUES ($1, $2, $3, $4, $5, $6, COALESCE($7, 0), COALESCE($8, TRUE))
RETURNING id, sub_module_id, title, description, thumbnail, intro_video_url, question_set_id, display_order, is_active, created_at
`
type CreateSubModulePracticeParams struct {
SubModuleID int64 `json:"sub_module_id"`
Title string `json:"title"`
Description pgtype.Text `json:"description"`
Thumbnail pgtype.Text `json:"thumbnail"`
IntroVideoUrl pgtype.Text `json:"intro_video_url"`
QuestionSetID int64 `json:"question_set_id"`
Column7 interface{} `json:"column_7"`
Column8 interface{} `json:"column_8"`
}
func (q *Queries) CreateSubModulePractice(ctx context.Context, arg CreateSubModulePracticeParams) (SubModulePractice, error) {
row := q.db.QueryRow(ctx, CreateSubModulePractice,
arg.SubModuleID,
arg.Title,
arg.Description,
arg.Thumbnail,
arg.IntroVideoUrl,
arg.QuestionSetID,
arg.Column7,
arg.Column8,
)
var i SubModulePractice
err := row.Scan(
&i.ID,
&i.SubModuleID,
&i.Title,
&i.Description,
&i.Thumbnail,
&i.IntroVideoUrl,
&i.QuestionSetID,
&i.DisplayOrder,
&i.IsActive,
&i.CreatedAt,
)
return i, err
}
const CreateSubModuleVideo = `-- 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 id, 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, created_at
`
type CreateSubModuleVideoParams struct {
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"`
Column7 interface{} `json:"column_7"`
PublishDate pgtype.Timestamptz `json:"publish_date"`
Visibility pgtype.Text `json:"visibility"`
InstructorID pgtype.Text `json:"instructor_id"`
Thumbnail pgtype.Text `json:"thumbnail"`
Column12 interface{} `json:"column_12"`
Column13 interface{} `json:"column_13"`
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"`
Column18 interface{} `json:"column_18"`
}
func (q *Queries) CreateSubModuleVideo(ctx context.Context, arg CreateSubModuleVideoParams) (SubModuleVideo, error) {
row := q.db.QueryRow(ctx, CreateSubModuleVideo,
arg.SubModuleID,
arg.Title,
arg.Description,
arg.VideoUrl,
arg.Duration,
arg.Resolution,
arg.Column7,
arg.PublishDate,
arg.Visibility,
arg.InstructorID,
arg.Thumbnail,
arg.Column12,
arg.Column13,
arg.VimeoID,
arg.VimeoEmbedUrl,
arg.VimeoPlayerHtml,
arg.VimeoStatus,
arg.Column18,
)
var i SubModuleVideo
err := row.Scan(
&i.ID,
&i.SubModuleID,
&i.Title,
&i.Description,
&i.VideoUrl,
&i.Duration,
&i.Resolution,
&i.IsPublished,
&i.PublishDate,
&i.Visibility,
&i.InstructorID,
&i.Thumbnail,
&i.DisplayOrder,
&i.Status,
&i.VimeoID,
&i.VimeoEmbedUrl,
&i.VimeoPlayerHtml,
&i.VimeoStatus,
&i.VideoHostProvider,
&i.CreatedAt,
)
return i, err
}
const GetCoursesWithHierarchy = `-- 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
`
type GetCoursesWithHierarchyRow struct {
CategoryID int64 `json:"category_id"`
CategoryName string `json:"category_name"`
SubCategoryID pgtype.Int8 `json:"sub_category_id"`
SubCategoryName pgtype.Text `json:"sub_category_name"`
CourseID pgtype.Int8 `json:"course_id"`
CourseTitle pgtype.Text `json:"course_title"`
}
func (q *Queries) GetCoursesWithHierarchy(ctx context.Context) ([]GetCoursesWithHierarchyRow, error) {
rows, err := q.db.Query(ctx, GetCoursesWithHierarchy)
if err != nil {
return nil, err
}
defer rows.Close()
var items []GetCoursesWithHierarchyRow
for rows.Next() {
var i GetCoursesWithHierarchyRow
if err := rows.Scan(
&i.CategoryID,
&i.CategoryName,
&i.SubCategoryID,
&i.SubCategoryName,
&i.CourseID,
&i.CourseTitle,
); err != nil {
return nil, err
}
items = append(items, i)
}
if err := rows.Err(); err != nil {
return nil, err
}
return items, nil
}
const GetFullHierarchyByCourseID = `-- name: GetFullHierarchyByCourseID :many
SELECT
c.id AS course_id,
c.title AS course_title,
l.id AS level_id,
l.cefr_level,
m.id AS module_id,
m.title AS module_title,
sm.id AS sub_module_id,
sm.title AS sub_module_title
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
`
type GetFullHierarchyByCourseIDRow struct {
CourseID int64 `json:"course_id"`
CourseTitle string `json:"course_title"`
LevelID pgtype.Int8 `json:"level_id"`
CefrLevel pgtype.Text `json:"cefr_level"`
ModuleID pgtype.Int8 `json:"module_id"`
ModuleTitle pgtype.Text `json:"module_title"`
SubModuleID pgtype.Int8 `json:"sub_module_id"`
SubModuleTitle pgtype.Text `json:"sub_module_title"`
}
func (q *Queries) GetFullHierarchyByCourseID(ctx context.Context, id int64) ([]GetFullHierarchyByCourseIDRow, error) {
rows, err := q.db.Query(ctx, GetFullHierarchyByCourseID, id)
if err != nil {
return nil, err
}
defer rows.Close()
var items []GetFullHierarchyByCourseIDRow
for rows.Next() {
var i GetFullHierarchyByCourseIDRow
if err := rows.Scan(
&i.CourseID,
&i.CourseTitle,
&i.LevelID,
&i.CefrLevel,
&i.ModuleID,
&i.ModuleTitle,
&i.SubModuleID,
&i.SubModuleTitle,
); err != nil {
return nil, err
}
items = append(items, i)
}
if err := rows.Err(); err != nil {
return nil, err
}
return items, nil
}
const GetLevelsByCourseID = `-- name: GetLevelsByCourseID :many
SELECT id, course_id, cefr_level, display_order, is_active, created_at
FROM levels
WHERE course_id = $1
AND is_active = TRUE
ORDER BY display_order ASC, id ASC
`
func (q *Queries) GetLevelsByCourseID(ctx context.Context, courseID int64) ([]Level, error) {
rows, err := q.db.Query(ctx, GetLevelsByCourseID, courseID)
if err != nil {
return nil, err
}
defer rows.Close()
var items []Level
for rows.Next() {
var i Level
if err := rows.Scan(
&i.ID,
&i.CourseID,
&i.CefrLevel,
&i.DisplayOrder,
&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 GetModulesByLevelID = `-- name: GetModulesByLevelID :many
SELECT id, level_id, title, description, display_order, is_active, created_at
FROM modules
WHERE level_id = $1
AND is_active = TRUE
ORDER BY display_order ASC, id ASC
`
func (q *Queries) GetModulesByLevelID(ctx context.Context, levelID int64) ([]Module, error) {
rows, err := q.db.Query(ctx, GetModulesByLevelID, levelID)
if err != nil {
return nil, err
}
defer rows.Close()
var items []Module
for rows.Next() {
var i Module
if err := rows.Scan(
&i.ID,
&i.LevelID,
&i.Title,
&i.Description,
&i.DisplayOrder,
&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 GetSubModuleLessons = `-- name: GetSubModuleLessons :many
SELECT
smp.id,
smp.sub_module_id,
smp.question_set_id,
smp.intro_video_url,
smp.display_order,
smp.is_active,
qs.title,
qs.description,
qs.status,
qs.set_type,
(SELECT COUNT(*) FROM question_set_items qsi WHERE qsi.set_id = qs.id) AS question_count
FROM sub_module_lessons 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
`
type GetSubModuleLessonsRow 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"`
Title string `json:"title"`
Description pgtype.Text `json:"description"`
Status string `json:"status"`
SetType string `json:"set_type"`
QuestionCount int64 `json:"question_count"`
}
func (q *Queries) GetSubModuleLessons(ctx context.Context, subModuleID int64) ([]GetSubModuleLessonsRow, error) {
rows, err := q.db.Query(ctx, GetSubModuleLessons, subModuleID)
if err != nil {
return nil, err
}
defer rows.Close()
var items []GetSubModuleLessonsRow
for rows.Next() {
var i GetSubModuleLessonsRow
if err := rows.Scan(
&i.ID,
&i.SubModuleID,
&i.QuestionSetID,
&i.IntroVideoUrl,
&i.DisplayOrder,
&i.IsActive,
&i.Title,
&i.Description,
&i.Status,
&i.SetType,
&i.QuestionCount,
); err != nil {
return nil, err
}
items = append(items, i)
}
if err := rows.Err(); err != nil {
return nil, err
}
return items, nil
}
const GetSubModulePractices = `-- 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,
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
`
type GetSubModulePracticesRow struct {
ID int64 `json:"id"`
SubModuleID int64 `json:"sub_module_id"`
Title string `json:"title"`
Description pgtype.Text `json:"description"`
Thumbnail pgtype.Text `json:"thumbnail"`
IntroVideoUrl pgtype.Text `json:"intro_video_url"`
QuestionSetID int64 `json:"question_set_id"`
DisplayOrder int32 `json:"display_order"`
IsActive bool `json:"is_active"`
Status string `json:"status"`
SetType string `json:"set_type"`
QuestionCount int64 `json:"question_count"`
}
func (q *Queries) GetSubModulePractices(ctx context.Context, subModuleID int64) ([]GetSubModulePracticesRow, error) {
rows, err := q.db.Query(ctx, GetSubModulePractices, subModuleID)
if err != nil {
return nil, err
}
defer rows.Close()
var items []GetSubModulePracticesRow
for rows.Next() {
var i GetSubModulePracticesRow
if err := rows.Scan(
&i.ID,
&i.SubModuleID,
&i.Title,
&i.Description,
&i.Thumbnail,
&i.IntroVideoUrl,
&i.QuestionSetID,
&i.DisplayOrder,
&i.IsActive,
&i.Status,
&i.SetType,
&i.QuestionCount,
); err != nil {
return nil, err
}
items = append(items, i)
}
if err := rows.Err(); err != nil {
return nil, err
}
return items, nil
}
const GetSubModuleVideos = `-- name: GetSubModuleVideos :many
SELECT id, 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, created_at
FROM sub_module_videos
WHERE sub_module_id = $1
AND status != 'ARCHIVED'
ORDER BY display_order ASC, id ASC
`
func (q *Queries) GetSubModuleVideos(ctx context.Context, subModuleID int64) ([]SubModuleVideo, error) {
rows, err := q.db.Query(ctx, GetSubModuleVideos, subModuleID)
if err != nil {
return nil, err
}
defer rows.Close()
var items []SubModuleVideo
for rows.Next() {
var i SubModuleVideo
if err := rows.Scan(
&i.ID,
&i.SubModuleID,
&i.Title,
&i.Description,
&i.VideoUrl,
&i.Duration,
&i.Resolution,
&i.IsPublished,
&i.PublishDate,
&i.Visibility,
&i.InstructorID,
&i.Thumbnail,
&i.DisplayOrder,
&i.Status,
&i.VimeoID,
&i.VimeoEmbedUrl,
&i.VimeoPlayerHtml,
&i.VimeoStatus,
&i.VideoHostProvider,
&i.CreatedAt,
); err != nil {
return nil, err
}
items = append(items, i)
}
if err := rows.Err(); err != nil {
return nil, err
}
return items, nil
}
const GetSubModulesByModuleID = `-- name: GetSubModulesByModuleID :many
SELECT id, module_id, title, description, display_order, is_active, created_at, legacy_sub_course_id
FROM sub_modules
WHERE module_id = $1
AND is_active = TRUE
ORDER BY display_order ASC, id ASC
`
func (q *Queries) GetSubModulesByModuleID(ctx context.Context, moduleID int64) ([]SubModule, error) {
rows, err := q.db.Query(ctx, GetSubModulesByModuleID, moduleID)
if err != nil {
return nil, err
}
defer rows.Close()
var items []SubModule
for rows.Next() {
var i SubModule
if err := rows.Scan(
&i.ID,
&i.ModuleID,
&i.Title,
&i.Description,
&i.DisplayOrder,
&i.IsActive,
&i.CreatedAt,
&i.LegacySubCourseID,
); err != nil {
return nil, err
}
items = append(items, i)
}
if err := rows.Err(); err != nil {
return nil, err
}
return items, nil
}

View File

@ -1,285 +0,0 @@
// Code generated by sqlc. DO NOT EDIT.
// versions:
// sqlc v1.30.0
// source: learning_tree.sql
package dbgen
import (
"context"
"github.com/jackc/pgx/v5/pgtype"
)
const GetCourseLearningPath = `-- name: GetCourseLearningPath :many
SELECT
c.id AS course_id,
c.title AS course_title,
c.description AS course_description,
c.thumbnail AS course_thumbnail,
c.intro_video_url AS course_intro_video_url,
cc.id AS category_id,
cc.name AS category_name,
sc.id AS sub_course_id,
sc.title AS sub_course_title,
sc.description AS sub_course_description,
sc.thumbnail AS sub_course_thumbnail,
sc.display_order AS sub_course_display_order,
sc.level AS sub_course_level,
sc.sub_level AS sub_course_sub_level,
(SELECT COUNT(*) FROM sub_course_prerequisites WHERE sub_course_id = sc.id) AS prerequisite_count,
(SELECT COUNT(*) FROM sub_course_videos WHERE sub_course_id = sc.id AND status = 'PUBLISHED') AS video_count,
(SELECT COUNT(*) FROM question_sets WHERE set_type = 'PRACTICE' AND owner_type = 'SUB_COURSE' AND owner_id = sc.id AND status = 'PUBLISHED') AS practice_count
FROM courses c
JOIN course_categories cc ON cc.id = c.category_id
LEFT JOIN sub_courses sc ON sc.course_id = c.id AND sc.is_active = true
WHERE c.id = $1
ORDER BY sc.display_order, sc.id
`
type GetCourseLearningPathRow struct {
CourseID int64 `json:"course_id"`
CourseTitle string `json:"course_title"`
CourseDescription pgtype.Text `json:"course_description"`
CourseThumbnail pgtype.Text `json:"course_thumbnail"`
CourseIntroVideoUrl pgtype.Text `json:"course_intro_video_url"`
CategoryID int64 `json:"category_id"`
CategoryName string `json:"category_name"`
SubCourseID pgtype.Int8 `json:"sub_course_id"`
SubCourseTitle pgtype.Text `json:"sub_course_title"`
SubCourseDescription pgtype.Text `json:"sub_course_description"`
SubCourseThumbnail pgtype.Text `json:"sub_course_thumbnail"`
SubCourseDisplayOrder pgtype.Int4 `json:"sub_course_display_order"`
SubCourseLevel pgtype.Text `json:"sub_course_level"`
SubCourseSubLevel pgtype.Text `json:"sub_course_sub_level"`
PrerequisiteCount int64 `json:"prerequisite_count"`
VideoCount int64 `json:"video_count"`
PracticeCount int64 `json:"practice_count"`
}
func (q *Queries) GetCourseLearningPath(ctx context.Context, id int64) ([]GetCourseLearningPathRow, error) {
rows, err := q.db.Query(ctx, GetCourseLearningPath, id)
if err != nil {
return nil, err
}
defer rows.Close()
var items []GetCourseLearningPathRow
for rows.Next() {
var i GetCourseLearningPathRow
if err := rows.Scan(
&i.CourseID,
&i.CourseTitle,
&i.CourseDescription,
&i.CourseThumbnail,
&i.CourseIntroVideoUrl,
&i.CategoryID,
&i.CategoryName,
&i.SubCourseID,
&i.SubCourseTitle,
&i.SubCourseDescription,
&i.SubCourseThumbnail,
&i.SubCourseDisplayOrder,
&i.SubCourseLevel,
&i.SubCourseSubLevel,
&i.PrerequisiteCount,
&i.VideoCount,
&i.PracticeCount,
); err != nil {
return nil, err
}
items = append(items, i)
}
if err := rows.Err(); err != nil {
return nil, err
}
return items, nil
}
const GetFullLearningTree = `-- name: GetFullLearningTree :many
SELECT
c.id AS course_id,
c.title AS course_title,
sc.id AS sub_course_id,
sc.title AS sub_course_title,
sc.level AS sub_course_level,
sc.sub_level AS sub_course_sub_level
FROM courses c
LEFT JOIN sub_courses sc ON sc.course_id = c.id AND sc.is_active = true
WHERE c.is_active = true
ORDER BY c.id, sc.display_order, sc.id
`
type GetFullLearningTreeRow struct {
CourseID int64 `json:"course_id"`
CourseTitle string `json:"course_title"`
SubCourseID pgtype.Int8 `json:"sub_course_id"`
SubCourseTitle pgtype.Text `json:"sub_course_title"`
SubCourseLevel pgtype.Text `json:"sub_course_level"`
SubCourseSubLevel pgtype.Text `json:"sub_course_sub_level"`
}
func (q *Queries) GetFullLearningTree(ctx context.Context) ([]GetFullLearningTreeRow, error) {
rows, err := q.db.Query(ctx, GetFullLearningTree)
if err != nil {
return nil, err
}
defer rows.Close()
var items []GetFullLearningTreeRow
for rows.Next() {
var i GetFullLearningTreeRow
if err := rows.Scan(
&i.CourseID,
&i.CourseTitle,
&i.SubCourseID,
&i.SubCourseTitle,
&i.SubCourseLevel,
&i.SubCourseSubLevel,
); err != nil {
return nil, err
}
items = append(items, i)
}
if err := rows.Err(); err != nil {
return nil, err
}
return items, nil
}
const GetSubCoursePracticesForLearningPath = `-- name: GetSubCoursePracticesForLearningPath :many
SELECT id, title, description, persona, status, intro_video_url,
(SELECT COUNT(*) FROM question_set_items WHERE set_id = qs.id) AS question_count
FROM question_sets qs
WHERE qs.owner_type = 'SUB_COURSE' AND qs.owner_id = $1
AND qs.set_type = 'PRACTICE' AND qs.status = 'PUBLISHED'
ORDER BY qs.display_order ASC, qs.created_at
`
type GetSubCoursePracticesForLearningPathRow struct {
ID int64 `json:"id"`
Title string `json:"title"`
Description pgtype.Text `json:"description"`
Persona pgtype.Text `json:"persona"`
Status string `json:"status"`
IntroVideoUrl pgtype.Text `json:"intro_video_url"`
QuestionCount int64 `json:"question_count"`
}
func (q *Queries) GetSubCoursePracticesForLearningPath(ctx context.Context, ownerID pgtype.Int8) ([]GetSubCoursePracticesForLearningPathRow, error) {
rows, err := q.db.Query(ctx, GetSubCoursePracticesForLearningPath, ownerID)
if err != nil {
return nil, err
}
defer rows.Close()
var items []GetSubCoursePracticesForLearningPathRow
for rows.Next() {
var i GetSubCoursePracticesForLearningPathRow
if err := rows.Scan(
&i.ID,
&i.Title,
&i.Description,
&i.Persona,
&i.Status,
&i.IntroVideoUrl,
&i.QuestionCount,
); err != nil {
return nil, err
}
items = append(items, i)
}
if err := rows.Err(); err != nil {
return nil, err
}
return items, nil
}
const GetSubCoursePrerequisitesForLearningPath = `-- name: GetSubCoursePrerequisitesForLearningPath :many
SELECT p.prerequisite_sub_course_id, sc.title, sc.level, sc.sub_level
FROM sub_course_prerequisites p
JOIN sub_courses sc ON sc.id = p.prerequisite_sub_course_id
WHERE p.sub_course_id = $1
ORDER BY sc.display_order
`
type GetSubCoursePrerequisitesForLearningPathRow struct {
PrerequisiteSubCourseID int64 `json:"prerequisite_sub_course_id"`
Title string `json:"title"`
Level string `json:"level"`
SubLevel string `json:"sub_level"`
}
func (q *Queries) GetSubCoursePrerequisitesForLearningPath(ctx context.Context, subCourseID int64) ([]GetSubCoursePrerequisitesForLearningPathRow, error) {
rows, err := q.db.Query(ctx, GetSubCoursePrerequisitesForLearningPath, subCourseID)
if err != nil {
return nil, err
}
defer rows.Close()
var items []GetSubCoursePrerequisitesForLearningPathRow
for rows.Next() {
var i GetSubCoursePrerequisitesForLearningPathRow
if err := rows.Scan(
&i.PrerequisiteSubCourseID,
&i.Title,
&i.Level,
&i.SubLevel,
); err != nil {
return nil, err
}
items = append(items, i)
}
if err := rows.Err(); err != nil {
return nil, err
}
return items, nil
}
const GetSubCourseVideosForLearningPath = `-- name: GetSubCourseVideosForLearningPath :many
SELECT id, title, description, video_url, duration, resolution, display_order,
vimeo_id, vimeo_embed_url, video_host_provider
FROM sub_course_videos
WHERE sub_course_id = $1 AND status = 'PUBLISHED'
ORDER BY display_order, id
`
type GetSubCourseVideosForLearningPathRow struct {
ID int64 `json:"id"`
Title string `json:"title"`
Description pgtype.Text `json:"description"`
VideoUrl string `json:"video_url"`
Duration int32 `json:"duration"`
Resolution pgtype.Text `json:"resolution"`
DisplayOrder int32 `json:"display_order"`
VimeoID pgtype.Text `json:"vimeo_id"`
VimeoEmbedUrl pgtype.Text `json:"vimeo_embed_url"`
VideoHostProvider pgtype.Text `json:"video_host_provider"`
}
func (q *Queries) GetSubCourseVideosForLearningPath(ctx context.Context, subCourseID int64) ([]GetSubCourseVideosForLearningPathRow, error) {
rows, err := q.db.Query(ctx, GetSubCourseVideosForLearningPath, subCourseID)
if err != nil {
return nil, err
}
defer rows.Close()
var items []GetSubCourseVideosForLearningPathRow
for rows.Next() {
var i GetSubCourseVideosForLearningPathRow
if err := rows.Scan(
&i.ID,
&i.Title,
&i.Description,
&i.VideoUrl,
&i.Duration,
&i.Resolution,
&i.DisplayOrder,
&i.VimeoID,
&i.VimeoEmbedUrl,
&i.VideoHostProvider,
); err != nil {
return nil, err
}
items = append(items, i)
}
if err := rows.Err(); err != nil {
return nil, err
}
return items, nil
}

View File

@ -31,6 +31,7 @@ type Course struct {
Thumbnail pgtype.Text `json:"thumbnail"` Thumbnail pgtype.Text `json:"thumbnail"`
IntroVideoUrl pgtype.Text `json:"intro_video_url"` IntroVideoUrl pgtype.Text `json:"intro_video_url"`
DisplayOrder int32 `json:"display_order"` DisplayOrder int32 `json:"display_order"`
SubCategoryID pgtype.Int8 `json:"sub_category_id"`
} }
type CourseCategory struct { type CourseCategory struct {
@ -41,6 +42,16 @@ type CourseCategory struct {
DisplayOrder int32 `json:"display_order"` 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"`
}
type Device struct { type Device struct {
ID int64 `json:"id"` ID int64 `json:"id"`
UserID int64 `json:"user_id"` UserID int64 `json:"user_id"`
@ -58,11 +69,30 @@ type GlobalSetting struct {
UpdatedAt pgtype.Timestamptz `json:"updated_at"` UpdatedAt pgtype.Timestamptz `json:"updated_at"`
} }
type Level struct {
ID int64 `json:"id"`
CourseID int64 `json:"course_id"`
CefrLevel string `json:"cefr_level"`
DisplayOrder int32 `json:"display_order"`
IsActive bool `json:"is_active"`
CreatedAt pgtype.Timestamptz `json:"created_at"`
}
type LevelToSubCourse struct { type LevelToSubCourse struct {
LevelID int64 `json:"level_id"` LevelID int64 `json:"level_id"`
SubCourseID int64 `json:"sub_course_id"` SubCourseID int64 `json:"sub_course_id"`
} }
type Module struct {
ID int64 `json:"id"`
LevelID int64 `json:"level_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"`
}
type ModuleToSubCourse struct { type ModuleToSubCourse struct {
ModuleID int64 `json:"module_id"` ModuleID int64 `json:"module_id"`
SubCourseID int64 `json:"sub_course_id"` SubCourseID int64 `json:"sub_course_id"`
@ -314,6 +344,63 @@ type SubCourseVideo struct {
VideoHostProvider pgtype.Text `json:"video_host_provider"` 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"`
}
type SubModuleLesson 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"`
}
type SubModulePractice struct {
ID int64 `json:"id"`
SubModuleID int64 `json:"sub_module_id"`
Title string `json:"title"`
Description pgtype.Text `json:"description"`
Thumbnail pgtype.Text `json:"thumbnail"`
IntroVideoUrl pgtype.Text `json:"intro_video_url"`
QuestionSetID int64 `json:"question_set_id"`
DisplayOrder int32 `json:"display_order"`
IsActive bool `json:"is_active"`
CreatedAt pgtype.Timestamptz `json:"created_at"`
}
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"`
@ -404,7 +491,7 @@ 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 int64 `json:"sub_course_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"` CompletedAt pgtype.Timestamp `json:"completed_at"`
CreatedAt pgtype.Timestamp `json:"created_at"` CreatedAt pgtype.Timestamp `json:"created_at"`

View File

@ -0,0 +1,98 @@
// Code generated by sqlc. DO NOT EDIT.
// versions:
// sqlc v1.30.0
// source: practice_progress.sql
package dbgen
import (
"context"
)
const GetFirstIncompletePreviousPractice = `-- name: GetFirstIncompletePreviousPractice :one
WITH target AS (
SELECT id, owner_type, owner_id, COALESCE(display_order, 0) AS display_order
FROM question_sets
WHERE id = $2::BIGINT
AND set_type = 'PRACTICE'
AND status = 'PUBLISHED'
),
candidates AS (
SELECT qs.id, qs.title, COALESCE(qs.display_order, 0) AS display_order
FROM question_sets qs
JOIN target t
ON qs.owner_type = t.owner_type
AND qs.owner_id = t.owner_id
WHERE qs.set_type = 'PRACTICE'
AND qs.status = 'PUBLISHED'
AND COALESCE(qs.display_order, 0) < t.display_order
)
SELECT c.id, c.title, c.display_order
FROM candidates c
LEFT JOIN user_practice_progress upp
ON upp.question_set_id = c.id
AND upp.user_id = $1::BIGINT
AND upp.completed_at IS NOT NULL
WHERE upp.id IS NULL
ORDER BY c.display_order ASC, c.id ASC
LIMIT 1
`
type GetFirstIncompletePreviousPracticeParams struct {
UserID int64 `json:"user_id"`
QuestionSetID int64 `json:"question_set_id"`
}
type GetFirstIncompletePreviousPracticeRow struct {
ID int64 `json:"id"`
Title string `json:"title"`
DisplayOrder int32 `json:"display_order"`
}
func (q *Queries) GetFirstIncompletePreviousPractice(ctx context.Context, arg GetFirstIncompletePreviousPracticeParams) (GetFirstIncompletePreviousPracticeRow, error) {
row := q.db.QueryRow(ctx, GetFirstIncompletePreviousPractice, arg.UserID, arg.QuestionSetID)
var i GetFirstIncompletePreviousPracticeRow
err := row.Scan(&i.ID, &i.Title, &i.DisplayOrder)
return i, err
}
const MarkPracticeCompleted = `-- name: MarkPracticeCompleted :execrows
INSERT INTO user_practice_progress (
user_id,
sub_course_id,
question_set_id,
completed_at,
updated_at
)
SELECT
$1::BIGINT,
CASE
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
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
SET completed_at = EXCLUDED.completed_at,
updated_at = EXCLUDED.updated_at
`
type MarkPracticeCompletedParams struct {
UserID int64 `json:"user_id"`
QuestionSetID int64 `json:"question_set_id"`
}
func (q *Queries) MarkPracticeCompleted(ctx context.Context, arg MarkPracticeCompletedParams) (int64, error) {
result, err := q.db.Exec(ctx, MarkPracticeCompleted, arg.UserID, arg.QuestionSetID)
if err != nil {
return 0, err
}
return result.RowsAffected(), nil
}

View File

@ -1,187 +0,0 @@
// Code generated by sqlc. DO NOT EDIT.
// versions:
// sqlc v1.30.0
// source: sub_course_prerequisites.sql
package dbgen
import (
"context"
"github.com/jackc/pgx/v5/pgtype"
)
const AddSubCoursePrerequisite = `-- name: AddSubCoursePrerequisite :one
INSERT INTO sub_course_prerequisites (sub_course_id, prerequisite_sub_course_id)
VALUES ($1, $2)
RETURNING id, sub_course_id, prerequisite_sub_course_id, created_at
`
type AddSubCoursePrerequisiteParams struct {
SubCourseID int64 `json:"sub_course_id"`
PrerequisiteSubCourseID int64 `json:"prerequisite_sub_course_id"`
}
func (q *Queries) AddSubCoursePrerequisite(ctx context.Context, arg AddSubCoursePrerequisiteParams) (SubCoursePrerequisite, error) {
row := q.db.QueryRow(ctx, AddSubCoursePrerequisite, arg.SubCourseID, arg.PrerequisiteSubCourseID)
var i SubCoursePrerequisite
err := row.Scan(
&i.ID,
&i.SubCourseID,
&i.PrerequisiteSubCourseID,
&i.CreatedAt,
)
return i, err
}
const CountUnmetPrerequisites = `-- name: CountUnmetPrerequisites :one
SELECT COUNT(*)::bigint AS unmet_count
FROM sub_course_prerequisites p
WHERE p.sub_course_id = $1
AND p.prerequisite_sub_course_id NOT IN (
SELECT usp.sub_course_id
FROM user_sub_course_progress usp
WHERE usp.user_id = $2
AND usp.status = 'COMPLETED'
)
`
type CountUnmetPrerequisitesParams struct {
SubCourseID int64 `json:"sub_course_id"`
UserID int64 `json:"user_id"`
}
func (q *Queries) CountUnmetPrerequisites(ctx context.Context, arg CountUnmetPrerequisitesParams) (int64, error) {
row := q.db.QueryRow(ctx, CountUnmetPrerequisites, arg.SubCourseID, arg.UserID)
var unmet_count int64
err := row.Scan(&unmet_count)
return unmet_count, err
}
const DeleteAllPrerequisitesForSubCourse = `-- name: DeleteAllPrerequisitesForSubCourse :exec
DELETE FROM sub_course_prerequisites
WHERE sub_course_id = $1
`
func (q *Queries) DeleteAllPrerequisitesForSubCourse(ctx context.Context, subCourseID int64) error {
_, err := q.db.Exec(ctx, DeleteAllPrerequisitesForSubCourse, subCourseID)
return err
}
const GetSubCourseDependents = `-- name: GetSubCourseDependents :many
SELECT
p.id,
p.sub_course_id,
p.prerequisite_sub_course_id,
p.created_at,
sc.title AS dependent_title,
sc.level AS dependent_level
FROM sub_course_prerequisites p
JOIN sub_courses sc ON sc.id = p.sub_course_id
WHERE p.prerequisite_sub_course_id = $1
ORDER BY sc.display_order
`
type GetSubCourseDependentsRow struct {
ID int64 `json:"id"`
SubCourseID int64 `json:"sub_course_id"`
PrerequisiteSubCourseID int64 `json:"prerequisite_sub_course_id"`
CreatedAt pgtype.Timestamptz `json:"created_at"`
DependentTitle string `json:"dependent_title"`
DependentLevel string `json:"dependent_level"`
}
func (q *Queries) GetSubCourseDependents(ctx context.Context, prerequisiteSubCourseID int64) ([]GetSubCourseDependentsRow, error) {
rows, err := q.db.Query(ctx, GetSubCourseDependents, prerequisiteSubCourseID)
if err != nil {
return nil, err
}
defer rows.Close()
var items []GetSubCourseDependentsRow
for rows.Next() {
var i GetSubCourseDependentsRow
if err := rows.Scan(
&i.ID,
&i.SubCourseID,
&i.PrerequisiteSubCourseID,
&i.CreatedAt,
&i.DependentTitle,
&i.DependentLevel,
); err != nil {
return nil, err
}
items = append(items, i)
}
if err := rows.Err(); err != nil {
return nil, err
}
return items, nil
}
const GetSubCoursePrerequisites = `-- name: GetSubCoursePrerequisites :many
SELECT
p.id,
p.sub_course_id,
p.prerequisite_sub_course_id,
p.created_at,
sc.title AS prerequisite_title,
sc.level AS prerequisite_level,
sc.display_order AS prerequisite_display_order
FROM sub_course_prerequisites p
JOIN sub_courses sc ON sc.id = p.prerequisite_sub_course_id
WHERE p.sub_course_id = $1
ORDER BY sc.display_order
`
type GetSubCoursePrerequisitesRow struct {
ID int64 `json:"id"`
SubCourseID int64 `json:"sub_course_id"`
PrerequisiteSubCourseID int64 `json:"prerequisite_sub_course_id"`
CreatedAt pgtype.Timestamptz `json:"created_at"`
PrerequisiteTitle string `json:"prerequisite_title"`
PrerequisiteLevel string `json:"prerequisite_level"`
PrerequisiteDisplayOrder int32 `json:"prerequisite_display_order"`
}
func (q *Queries) GetSubCoursePrerequisites(ctx context.Context, subCourseID int64) ([]GetSubCoursePrerequisitesRow, error) {
rows, err := q.db.Query(ctx, GetSubCoursePrerequisites, subCourseID)
if err != nil {
return nil, err
}
defer rows.Close()
var items []GetSubCoursePrerequisitesRow
for rows.Next() {
var i GetSubCoursePrerequisitesRow
if err := rows.Scan(
&i.ID,
&i.SubCourseID,
&i.PrerequisiteSubCourseID,
&i.CreatedAt,
&i.PrerequisiteTitle,
&i.PrerequisiteLevel,
&i.PrerequisiteDisplayOrder,
); err != nil {
return nil, err
}
items = append(items, i)
}
if err := rows.Err(); err != nil {
return nil, err
}
return items, nil
}
const RemoveSubCoursePrerequisite = `-- name: RemoveSubCoursePrerequisite :exec
DELETE FROM sub_course_prerequisites
WHERE sub_course_id = $1 AND prerequisite_sub_course_id = $2
`
type RemoveSubCoursePrerequisiteParams struct {
SubCourseID int64 `json:"sub_course_id"`
PrerequisiteSubCourseID int64 `json:"prerequisite_sub_course_id"`
}
func (q *Queries) RemoveSubCoursePrerequisite(ctx context.Context, arg RemoveSubCoursePrerequisiteParams) error {
_, err := q.db.Exec(ctx, RemoveSubCoursePrerequisite, arg.SubCourseID, arg.PrerequisiteSubCourseID)
return err
}

View File

@ -1,441 +0,0 @@
// Code generated by sqlc. DO NOT EDIT.
// versions:
// sqlc v1.30.0
// source: sub_course_videos.sql
package dbgen
import (
"context"
"github.com/jackc/pgx/v5/pgtype"
)
const ArchiveSubCourseVideo = `-- name: ArchiveSubCourseVideo :exec
UPDATE sub_course_videos
SET status = 'ARCHIVED'
WHERE id = $1
`
func (q *Queries) ArchiveSubCourseVideo(ctx context.Context, id int64) error {
_, err := q.db.Exec(ctx, ArchiveSubCourseVideo, id)
return err
}
const CreateSubCourseVideo = `-- name: CreateSubCourseVideo :one
INSERT INTO sub_course_videos (
sub_course_id,
title,
description,
video_url,
duration,
resolution,
instructor_id,
thumbnail,
visibility,
display_order,
status,
vimeo_id,
vimeo_embed_url,
vimeo_player_html,
vimeo_status,
video_host_provider
)
VALUES (
$1, $2, $3, $4, $5, $6,
$7, $8, $9,
COALESCE($10, 0),
COALESCE($11, 'DRAFT'),
$12, $13, $14,
COALESCE($15, 'pending'),
COALESCE($16, 'DIRECT')
)
RETURNING id, sub_course_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
`
type CreateSubCourseVideoParams struct {
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"`
InstructorID pgtype.Text `json:"instructor_id"`
Thumbnail pgtype.Text `json:"thumbnail"`
Visibility pgtype.Text `json:"visibility"`
Column10 interface{} `json:"column_10"`
Column11 interface{} `json:"column_11"`
VimeoID pgtype.Text `json:"vimeo_id"`
VimeoEmbedUrl pgtype.Text `json:"vimeo_embed_url"`
VimeoPlayerHtml pgtype.Text `json:"vimeo_player_html"`
Column15 interface{} `json:"column_15"`
Column16 interface{} `json:"column_16"`
}
func (q *Queries) CreateSubCourseVideo(ctx context.Context, arg CreateSubCourseVideoParams) (SubCourseVideo, error) {
row := q.db.QueryRow(ctx, CreateSubCourseVideo,
arg.SubCourseID,
arg.Title,
arg.Description,
arg.VideoUrl,
arg.Duration,
arg.Resolution,
arg.InstructorID,
arg.Thumbnail,
arg.Visibility,
arg.Column10,
arg.Column11,
arg.VimeoID,
arg.VimeoEmbedUrl,
arg.VimeoPlayerHtml,
arg.Column15,
arg.Column16,
)
var i SubCourseVideo
err := row.Scan(
&i.ID,
&i.SubCourseID,
&i.Title,
&i.Description,
&i.VideoUrl,
&i.Duration,
&i.Resolution,
&i.IsPublished,
&i.PublishDate,
&i.Visibility,
&i.InstructorID,
&i.Thumbnail,
&i.DisplayOrder,
&i.Status,
&i.VimeoID,
&i.VimeoEmbedUrl,
&i.VimeoPlayerHtml,
&i.VimeoStatus,
&i.VideoHostProvider,
)
return i, err
}
const DeleteSubCourseVideo = `-- name: DeleteSubCourseVideo :exec
DELETE FROM sub_course_videos
WHERE id = $1
`
func (q *Queries) DeleteSubCourseVideo(ctx context.Context, id int64) error {
_, err := q.db.Exec(ctx, DeleteSubCourseVideo, id)
return err
}
const GetPublishedVideosBySubCourse = `-- name: GetPublishedVideosBySubCourse :many
SELECT id, sub_course_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
FROM sub_course_videos
WHERE sub_course_id = $1
AND status = 'PUBLISHED'
ORDER BY display_order ASC, publish_date ASC
`
func (q *Queries) GetPublishedVideosBySubCourse(ctx context.Context, subCourseID int64) ([]SubCourseVideo, error) {
rows, err := q.db.Query(ctx, GetPublishedVideosBySubCourse, subCourseID)
if err != nil {
return nil, err
}
defer rows.Close()
var items []SubCourseVideo
for rows.Next() {
var i SubCourseVideo
if err := rows.Scan(
&i.ID,
&i.SubCourseID,
&i.Title,
&i.Description,
&i.VideoUrl,
&i.Duration,
&i.Resolution,
&i.IsPublished,
&i.PublishDate,
&i.Visibility,
&i.InstructorID,
&i.Thumbnail,
&i.DisplayOrder,
&i.Status,
&i.VimeoID,
&i.VimeoEmbedUrl,
&i.VimeoPlayerHtml,
&i.VimeoStatus,
&i.VideoHostProvider,
); err != nil {
return nil, err
}
items = append(items, i)
}
if err := rows.Err(); err != nil {
return nil, err
}
return items, nil
}
const GetSubCourseVideoByID = `-- name: GetSubCourseVideoByID :one
SELECT id, sub_course_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
FROM sub_course_videos
WHERE id = $1
`
func (q *Queries) GetSubCourseVideoByID(ctx context.Context, id int64) (SubCourseVideo, error) {
row := q.db.QueryRow(ctx, GetSubCourseVideoByID, id)
var i SubCourseVideo
err := row.Scan(
&i.ID,
&i.SubCourseID,
&i.Title,
&i.Description,
&i.VideoUrl,
&i.Duration,
&i.Resolution,
&i.IsPublished,
&i.PublishDate,
&i.Visibility,
&i.InstructorID,
&i.Thumbnail,
&i.DisplayOrder,
&i.Status,
&i.VimeoID,
&i.VimeoEmbedUrl,
&i.VimeoPlayerHtml,
&i.VimeoStatus,
&i.VideoHostProvider,
)
return i, err
}
const GetVideosBySubCourse = `-- name: GetVideosBySubCourse :many
SELECT
COUNT(*) OVER () AS total_count,
id,
sub_course_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
FROM sub_course_videos
WHERE sub_course_id = $1
AND status != 'ARCHIVED'
ORDER BY display_order ASC, id ASC
`
type GetVideosBySubCourseRow struct {
TotalCount int64 `json:"total_count"`
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"`
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"`
}
func (q *Queries) GetVideosBySubCourse(ctx context.Context, subCourseID int64) ([]GetVideosBySubCourseRow, error) {
rows, err := q.db.Query(ctx, GetVideosBySubCourse, subCourseID)
if err != nil {
return nil, err
}
defer rows.Close()
var items []GetVideosBySubCourseRow
for rows.Next() {
var i GetVideosBySubCourseRow
if err := rows.Scan(
&i.TotalCount,
&i.ID,
&i.SubCourseID,
&i.Title,
&i.Description,
&i.VideoUrl,
&i.Duration,
&i.Resolution,
&i.IsPublished,
&i.PublishDate,
&i.Visibility,
&i.InstructorID,
&i.Thumbnail,
&i.DisplayOrder,
&i.Status,
&i.VimeoID,
&i.VimeoEmbedUrl,
&i.VimeoPlayerHtml,
&i.VimeoStatus,
&i.VideoHostProvider,
); err != nil {
return nil, err
}
items = append(items, i)
}
if err := rows.Err(); err != nil {
return nil, err
}
return items, nil
}
const GetVideosByVimeoID = `-- name: GetVideosByVimeoID :one
SELECT id, sub_course_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 FROM sub_course_videos
WHERE vimeo_id = $1
`
func (q *Queries) GetVideosByVimeoID(ctx context.Context, vimeoID pgtype.Text) (SubCourseVideo, error) {
row := q.db.QueryRow(ctx, GetVideosByVimeoID, vimeoID)
var i SubCourseVideo
err := row.Scan(
&i.ID,
&i.SubCourseID,
&i.Title,
&i.Description,
&i.VideoUrl,
&i.Duration,
&i.Resolution,
&i.IsPublished,
&i.PublishDate,
&i.Visibility,
&i.InstructorID,
&i.Thumbnail,
&i.DisplayOrder,
&i.Status,
&i.VimeoID,
&i.VimeoEmbedUrl,
&i.VimeoPlayerHtml,
&i.VimeoStatus,
&i.VideoHostProvider,
)
return i, err
}
const PublishSubCourseVideo = `-- name: PublishSubCourseVideo :exec
UPDATE sub_course_videos
SET
is_published = true,
publish_date = CURRENT_TIMESTAMP,
status = 'PUBLISHED'
WHERE id = $1
`
func (q *Queries) PublishSubCourseVideo(ctx context.Context, id int64) error {
_, err := q.db.Exec(ctx, PublishSubCourseVideo, id)
return err
}
const ReorderSubCourseVideos = `-- name: ReorderSubCourseVideos :exec
UPDATE sub_course_videos
SET display_order = bulk.position
FROM (
SELECT unnest($1::BIGINT[]) AS id, unnest($2::INT[]) AS position
) AS bulk
WHERE sub_course_videos.id = bulk.id
`
type ReorderSubCourseVideosParams struct {
Ids []int64 `json:"ids"`
Positions []int32 `json:"positions"`
}
func (q *Queries) ReorderSubCourseVideos(ctx context.Context, arg ReorderSubCourseVideosParams) error {
_, err := q.db.Exec(ctx, ReorderSubCourseVideos, arg.Ids, arg.Positions)
return err
}
const UpdateSubCourseVideo = `-- name: UpdateSubCourseVideo :exec
UPDATE sub_course_videos
SET
title = COALESCE($1, title),
description = COALESCE($2, description),
video_url = COALESCE($3, video_url),
duration = COALESCE($4, duration),
resolution = COALESCE($5, resolution),
visibility = COALESCE($6, visibility),
thumbnail = COALESCE($7, thumbnail),
display_order = COALESCE($8, display_order),
status = COALESCE($9, status),
vimeo_id = COALESCE($10, vimeo_id),
vimeo_embed_url = COALESCE($11, vimeo_embed_url),
vimeo_player_html = COALESCE($12, vimeo_player_html),
vimeo_status = COALESCE($13, vimeo_status),
video_host_provider = COALESCE($14, video_host_provider)
WHERE id = $15
`
type UpdateSubCourseVideoParams struct {
Title string `json:"title"`
Description pgtype.Text `json:"description"`
VideoUrl string `json:"video_url"`
Duration int32 `json:"duration"`
Resolution pgtype.Text `json:"resolution"`
Visibility pgtype.Text `json:"visibility"`
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"`
ID int64 `json:"id"`
}
func (q *Queries) UpdateSubCourseVideo(ctx context.Context, arg UpdateSubCourseVideoParams) error {
_, err := q.db.Exec(ctx, UpdateSubCourseVideo,
arg.Title,
arg.Description,
arg.VideoUrl,
arg.Duration,
arg.Resolution,
arg.Visibility,
arg.Thumbnail,
arg.DisplayOrder,
arg.Status,
arg.VimeoID,
arg.VimeoEmbedUrl,
arg.VimeoPlayerHtml,
arg.VimeoStatus,
arg.VideoHostProvider,
arg.ID,
)
return err
}
const UpdateVimeoStatus = `-- name: UpdateVimeoStatus :exec
UPDATE sub_course_videos
SET
vimeo_status = $1
WHERE id = $2
`
type UpdateVimeoStatusParams struct {
VimeoStatus pgtype.Text `json:"vimeo_status"`
ID int64 `json:"id"`
}
func (q *Queries) UpdateVimeoStatus(ctx context.Context, arg UpdateVimeoStatusParams) error {
_, err := q.db.Exec(ctx, UpdateVimeoStatus, arg.VimeoStatus, arg.ID)
return err
}

View File

@ -1,356 +0,0 @@
// Code generated by sqlc. DO NOT EDIT.
// versions:
// sqlc v1.30.0
// source: sub_courses.sql
package dbgen
import (
"context"
"github.com/jackc/pgx/v5/pgtype"
)
const CreateSubCourse = `-- name: CreateSubCourse :one
INSERT INTO sub_courses (
course_id,
title,
description,
thumbnail,
display_order,
level,
sub_level,
is_active
)
VALUES ($1, $2, $3, $4, COALESCE($5, 0), $6, $7, COALESCE($8, true))
RETURNING id, course_id, title, description, thumbnail, display_order, level, is_active, sub_level
`
type CreateSubCourseParams struct {
CourseID int64 `json:"course_id"`
Title string `json:"title"`
Description pgtype.Text `json:"description"`
Thumbnail pgtype.Text `json:"thumbnail"`
Column5 interface{} `json:"column_5"`
Level string `json:"level"`
SubLevel string `json:"sub_level"`
Column8 interface{} `json:"column_8"`
}
func (q *Queries) CreateSubCourse(ctx context.Context, arg CreateSubCourseParams) (SubCourse, error) {
row := q.db.QueryRow(ctx, CreateSubCourse,
arg.CourseID,
arg.Title,
arg.Description,
arg.Thumbnail,
arg.Column5,
arg.Level,
arg.SubLevel,
arg.Column8,
)
var i SubCourse
err := row.Scan(
&i.ID,
&i.CourseID,
&i.Title,
&i.Description,
&i.Thumbnail,
&i.DisplayOrder,
&i.Level,
&i.IsActive,
&i.SubLevel,
)
return i, err
}
const DeactivateSubCourse = `-- name: DeactivateSubCourse :exec
UPDATE sub_courses
SET is_active = FALSE
WHERE id = $1
`
func (q *Queries) DeactivateSubCourse(ctx context.Context, id int64) error {
_, err := q.db.Exec(ctx, DeactivateSubCourse, id)
return err
}
const DeleteSubCourse = `-- name: DeleteSubCourse :one
DELETE FROM sub_courses
WHERE id = $1
RETURNING id, course_id, title, description, thumbnail, display_order, level, is_active, sub_level
`
func (q *Queries) DeleteSubCourse(ctx context.Context, id int64) (SubCourse, error) {
row := q.db.QueryRow(ctx, DeleteSubCourse, id)
var i SubCourse
err := row.Scan(
&i.ID,
&i.CourseID,
&i.Title,
&i.Description,
&i.Thumbnail,
&i.DisplayOrder,
&i.Level,
&i.IsActive,
&i.SubLevel,
)
return i, err
}
const GetSubCourseByID = `-- name: GetSubCourseByID :one
SELECT id, course_id, title, description, thumbnail, display_order, level, is_active, sub_level
FROM sub_courses
WHERE id = $1
`
func (q *Queries) GetSubCourseByID(ctx context.Context, id int64) (SubCourse, error) {
row := q.db.QueryRow(ctx, GetSubCourseByID, id)
var i SubCourse
err := row.Scan(
&i.ID,
&i.CourseID,
&i.Title,
&i.Description,
&i.Thumbnail,
&i.DisplayOrder,
&i.Level,
&i.IsActive,
&i.SubLevel,
)
return i, err
}
const GetSubCoursesByCourse = `-- name: GetSubCoursesByCourse :many
SELECT
COUNT(*) OVER () AS total_count,
id,
course_id,
title,
description,
thumbnail,
display_order,
level,
sub_level,
is_active
FROM sub_courses
WHERE course_id = $1
ORDER BY display_order ASC, id ASC
`
type GetSubCoursesByCourseRow struct {
TotalCount int64 `json:"total_count"`
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"`
SubLevel string `json:"sub_level"`
IsActive bool `json:"is_active"`
}
func (q *Queries) GetSubCoursesByCourse(ctx context.Context, courseID int64) ([]GetSubCoursesByCourseRow, error) {
rows, err := q.db.Query(ctx, GetSubCoursesByCourse, courseID)
if err != nil {
return nil, err
}
defer rows.Close()
var items []GetSubCoursesByCourseRow
for rows.Next() {
var i GetSubCoursesByCourseRow
if err := rows.Scan(
&i.TotalCount,
&i.ID,
&i.CourseID,
&i.Title,
&i.Description,
&i.Thumbnail,
&i.DisplayOrder,
&i.Level,
&i.SubLevel,
&i.IsActive,
); err != nil {
return nil, err
}
items = append(items, i)
}
if err := rows.Err(); err != nil {
return nil, err
}
return items, nil
}
const ListActiveSubCourses = `-- name: ListActiveSubCourses :many
SELECT
id,
course_id,
title,
description,
thumbnail,
display_order,
level,
sub_level,
is_active
FROM sub_courses
WHERE is_active = TRUE
ORDER BY display_order ASC
`
type ListActiveSubCoursesRow 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"`
SubLevel string `json:"sub_level"`
IsActive bool `json:"is_active"`
}
func (q *Queries) ListActiveSubCourses(ctx context.Context) ([]ListActiveSubCoursesRow, error) {
rows, err := q.db.Query(ctx, ListActiveSubCourses)
if err != nil {
return nil, err
}
defer rows.Close()
var items []ListActiveSubCoursesRow
for rows.Next() {
var i ListActiveSubCoursesRow
if err := rows.Scan(
&i.ID,
&i.CourseID,
&i.Title,
&i.Description,
&i.Thumbnail,
&i.DisplayOrder,
&i.Level,
&i.SubLevel,
&i.IsActive,
); err != nil {
return nil, err
}
items = append(items, i)
}
if err := rows.Err(); err != nil {
return nil, err
}
return items, nil
}
const ListSubCoursesByCourse = `-- name: ListSubCoursesByCourse :many
SELECT
id,
course_id,
title,
description,
thumbnail,
display_order,
level,
sub_level,
is_active
FROM sub_courses
WHERE course_id = $1
AND is_active = TRUE
ORDER BY display_order ASC, id ASC
`
type ListSubCoursesByCourseRow 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"`
SubLevel string `json:"sub_level"`
IsActive bool `json:"is_active"`
}
func (q *Queries) ListSubCoursesByCourse(ctx context.Context, courseID int64) ([]ListSubCoursesByCourseRow, error) {
rows, err := q.db.Query(ctx, ListSubCoursesByCourse, courseID)
if err != nil {
return nil, err
}
defer rows.Close()
var items []ListSubCoursesByCourseRow
for rows.Next() {
var i ListSubCoursesByCourseRow
if err := rows.Scan(
&i.ID,
&i.CourseID,
&i.Title,
&i.Description,
&i.Thumbnail,
&i.DisplayOrder,
&i.Level,
&i.SubLevel,
&i.IsActive,
); err != nil {
return nil, err
}
items = append(items, i)
}
if err := rows.Err(); err != nil {
return nil, err
}
return items, nil
}
const ReorderSubCourses = `-- name: ReorderSubCourses :exec
UPDATE sub_courses
SET display_order = bulk.position
FROM (
SELECT unnest($1::BIGINT[]) AS id, unnest($2::INT[]) AS position
) AS bulk
WHERE sub_courses.id = bulk.id
`
type ReorderSubCoursesParams struct {
Ids []int64 `json:"ids"`
Positions []int32 `json:"positions"`
}
func (q *Queries) ReorderSubCourses(ctx context.Context, arg ReorderSubCoursesParams) error {
_, err := q.db.Exec(ctx, ReorderSubCourses, arg.Ids, arg.Positions)
return err
}
const UpdateSubCourse = `-- name: UpdateSubCourse :exec
UPDATE sub_courses
SET
title = COALESCE($1, title),
description = COALESCE($2, description),
thumbnail = COALESCE($3, thumbnail),
display_order = COALESCE($4, display_order),
level = COALESCE($5, level),
sub_level = COALESCE($6, sub_level),
is_active = COALESCE($7, is_active)
WHERE id = $8
`
type UpdateSubCourseParams struct {
Title string `json:"title"`
Description pgtype.Text `json:"description"`
Thumbnail pgtype.Text `json:"thumbnail"`
DisplayOrder int32 `json:"display_order"`
Level string `json:"level"`
SubLevel string `json:"sub_level"`
IsActive bool `json:"is_active"`
ID int64 `json:"id"`
}
func (q *Queries) UpdateSubCourse(ctx context.Context, arg UpdateSubCourseParams) error {
_, err := q.db.Exec(ctx, UpdateSubCourse,
arg.Title,
arg.Description,
arg.Thumbnail,
arg.DisplayOrder,
arg.Level,
arg.SubLevel,
arg.IsActive,
arg.ID,
)
return err
}

View File

@ -1,102 +0,0 @@
// Code generated by sqlc. DO NOT EDIT.
// versions:
// sqlc v1.30.0
// source: user_practice_progress.sql
package dbgen
import (
"context"
)
const GetFirstIncompletePreviousPractice = `-- name: GetFirstIncompletePreviousPractice :one
SELECT
p.id,
p.title,
p.display_order
FROM question_sets target
JOIN question_sets p
ON p.owner_type = 'SUB_COURSE'
AND p.owner_id = target.owner_id
AND p.set_type = 'PRACTICE'
AND p.status = 'PUBLISHED'
AND (
p.display_order < target.display_order OR
(p.display_order = target.display_order AND p.id < target.id)
)
LEFT JOIN user_practice_progress upp
ON upp.question_set_id = p.id
AND upp.user_id = $1::BIGINT
AND upp.completed_at IS NOT NULL
WHERE target.id = $2::BIGINT
AND target.set_type = 'PRACTICE'
AND target.owner_type = 'SUB_COURSE'
AND target.status = 'PUBLISHED'
AND upp.question_set_id IS NULL
ORDER BY p.display_order ASC, p.id ASC
LIMIT 1
`
type GetFirstIncompletePreviousPracticeParams struct {
UserID int64 `json:"user_id"`
QuestionSetID int64 `json:"question_set_id"`
}
type GetFirstIncompletePreviousPracticeRow struct {
ID int64 `json:"id"`
Title string `json:"title"`
DisplayOrder int32 `json:"display_order"`
}
func (q *Queries) GetFirstIncompletePreviousPractice(ctx context.Context, arg GetFirstIncompletePreviousPracticeParams) (GetFirstIncompletePreviousPracticeRow, error) {
row := q.db.QueryRow(ctx, GetFirstIncompletePreviousPractice, arg.UserID, arg.QuestionSetID)
var i GetFirstIncompletePreviousPracticeRow
err := row.Scan(&i.ID, &i.Title, &i.DisplayOrder)
return i, err
}
const MarkPracticeCompleted = `-- name: MarkPracticeCompleted :one
INSERT INTO user_practice_progress (
user_id,
sub_course_id,
question_set_id,
completed_at,
updated_at
)
SELECT
$1::BIGINT,
qs.owner_id::BIGINT,
qs.id,
CURRENT_TIMESTAMP,
CURRENT_TIMESTAMP
FROM question_sets qs
WHERE qs.id = $2::BIGINT
AND qs.set_type = 'PRACTICE'
AND qs.owner_type = 'SUB_COURSE'
AND qs.status = 'PUBLISHED'
ON CONFLICT (user_id, question_set_id)
DO UPDATE SET
completed_at = CURRENT_TIMESTAMP,
updated_at = CURRENT_TIMESTAMP
RETURNING id, user_id, sub_course_id, question_set_id, completed_at, created_at, updated_at
`
type MarkPracticeCompletedParams struct {
UserID int64 `json:"user_id"`
QuestionSetID int64 `json:"question_set_id"`
}
func (q *Queries) MarkPracticeCompleted(ctx context.Context, arg MarkPracticeCompletedParams) (UserPracticeProgress, error) {
row := q.db.QueryRow(ctx, MarkPracticeCompleted, arg.UserID, arg.QuestionSetID)
var i UserPracticeProgress
err := row.Scan(
&i.ID,
&i.UserID,
&i.SubCourseID,
&i.QuestionSetID,
&i.CompletedAt,
&i.CreatedAt,
&i.UpdatedAt,
)
return i, err
}

View File

@ -1,279 +0,0 @@
// Code generated by sqlc. DO NOT EDIT.
// versions:
// sqlc v1.30.0
// source: user_sub_course_progress.sql
package dbgen
import (
"context"
"github.com/jackc/pgx/v5/pgtype"
)
const CompleteSubCourse = `-- name: CompleteSubCourse :exec
UPDATE user_sub_course_progress
SET
status = 'COMPLETED',
progress_percentage = 100,
completed_at = CURRENT_TIMESTAMP,
updated_at = CURRENT_TIMESTAMP
WHERE user_id = $1 AND sub_course_id = $2
`
type CompleteSubCourseParams struct {
UserID int64 `json:"user_id"`
SubCourseID int64 `json:"sub_course_id"`
}
func (q *Queries) CompleteSubCourse(ctx context.Context, arg CompleteSubCourseParams) error {
_, err := q.db.Exec(ctx, CompleteSubCourse, arg.UserID, arg.SubCourseID)
return err
}
const DeleteUserSubCourseProgress = `-- name: DeleteUserSubCourseProgress :exec
DELETE FROM user_sub_course_progress
WHERE user_id = $1 AND sub_course_id = $2
`
type DeleteUserSubCourseProgressParams struct {
UserID int64 `json:"user_id"`
SubCourseID int64 `json:"sub_course_id"`
}
func (q *Queries) DeleteUserSubCourseProgress(ctx context.Context, arg DeleteUserSubCourseProgressParams) error {
_, err := q.db.Exec(ctx, DeleteUserSubCourseProgress, arg.UserID, arg.SubCourseID)
return err
}
const GetSubCoursesWithProgressByCourse = `-- name: GetSubCoursesWithProgressByCourse :many
SELECT
sc.id AS sub_course_id,
sc.title,
sc.description,
sc.thumbnail,
sc.display_order,
sc.level,
sc.is_active,
COALESCE(usp.status, 'NOT_STARTED') AS progress_status,
COALESCE(usp.progress_percentage, 0)::smallint AS progress_percentage,
usp.started_at,
usp.completed_at,
(SELECT COUNT(*)::bigint
FROM sub_course_prerequisites p
WHERE p.sub_course_id = sc.id
AND p.prerequisite_sub_course_id NOT IN (
SELECT usp2.sub_course_id
FROM user_sub_course_progress usp2
WHERE usp2.user_id = $1
AND usp2.status = 'COMPLETED'
)
) AS unmet_prerequisites_count
FROM sub_courses sc
LEFT JOIN user_sub_course_progress usp
ON usp.sub_course_id = sc.id AND usp.user_id = $1
WHERE sc.course_id = $2
AND sc.is_active = true
ORDER BY sc.display_order
`
type GetSubCoursesWithProgressByCourseParams struct {
UserID int64 `json:"user_id"`
CourseID int64 `json:"course_id"`
}
type GetSubCoursesWithProgressByCourseRow struct {
SubCourseID int64 `json:"sub_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"`
ProgressStatus string `json:"progress_status"`
ProgressPercentage int16 `json:"progress_percentage"`
StartedAt pgtype.Timestamptz `json:"started_at"`
CompletedAt pgtype.Timestamptz `json:"completed_at"`
UnmetPrerequisitesCount int64 `json:"unmet_prerequisites_count"`
}
func (q *Queries) GetSubCoursesWithProgressByCourse(ctx context.Context, arg GetSubCoursesWithProgressByCourseParams) ([]GetSubCoursesWithProgressByCourseRow, error) {
rows, err := q.db.Query(ctx, GetSubCoursesWithProgressByCourse, arg.UserID, arg.CourseID)
if err != nil {
return nil, err
}
defer rows.Close()
var items []GetSubCoursesWithProgressByCourseRow
for rows.Next() {
var i GetSubCoursesWithProgressByCourseRow
if err := rows.Scan(
&i.SubCourseID,
&i.Title,
&i.Description,
&i.Thumbnail,
&i.DisplayOrder,
&i.Level,
&i.IsActive,
&i.ProgressStatus,
&i.ProgressPercentage,
&i.StartedAt,
&i.CompletedAt,
&i.UnmetPrerequisitesCount,
); err != nil {
return nil, err
}
items = append(items, i)
}
if err := rows.Err(); err != nil {
return nil, err
}
return items, nil
}
const GetUserCourseProgress = `-- name: GetUserCourseProgress :many
SELECT
usp.id,
usp.user_id,
usp.sub_course_id,
usp.status,
usp.progress_percentage,
usp.started_at,
usp.completed_at,
usp.created_at,
usp.updated_at,
sc.title AS sub_course_title,
sc.level AS sub_course_level,
sc.display_order AS sub_course_display_order
FROM user_sub_course_progress usp
JOIN sub_courses sc ON sc.id = usp.sub_course_id
WHERE usp.user_id = $1 AND sc.course_id = $2
ORDER BY sc.display_order
`
type GetUserCourseProgressParams struct {
UserID int64 `json:"user_id"`
CourseID int64 `json:"course_id"`
}
type GetUserCourseProgressRow 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"`
CreatedAt pgtype.Timestamptz `json:"created_at"`
UpdatedAt pgtype.Timestamptz `json:"updated_at"`
SubCourseTitle string `json:"sub_course_title"`
SubCourseLevel string `json:"sub_course_level"`
SubCourseDisplayOrder int32 `json:"sub_course_display_order"`
}
func (q *Queries) GetUserCourseProgress(ctx context.Context, arg GetUserCourseProgressParams) ([]GetUserCourseProgressRow, error) {
rows, err := q.db.Query(ctx, GetUserCourseProgress, arg.UserID, arg.CourseID)
if err != nil {
return nil, err
}
defer rows.Close()
var items []GetUserCourseProgressRow
for rows.Next() {
var i GetUserCourseProgressRow
if err := rows.Scan(
&i.ID,
&i.UserID,
&i.SubCourseID,
&i.Status,
&i.ProgressPercentage,
&i.StartedAt,
&i.CompletedAt,
&i.CreatedAt,
&i.UpdatedAt,
&i.SubCourseTitle,
&i.SubCourseLevel,
&i.SubCourseDisplayOrder,
); err != nil {
return nil, err
}
items = append(items, i)
}
if err := rows.Err(); err != nil {
return nil, err
}
return items, nil
}
const GetUserSubCourseProgress = `-- name: GetUserSubCourseProgress :one
SELECT id, user_id, sub_course_id, status, progress_percentage, started_at, completed_at, created_at, updated_at FROM user_sub_course_progress
WHERE user_id = $1 AND sub_course_id = $2
`
type GetUserSubCourseProgressParams struct {
UserID int64 `json:"user_id"`
SubCourseID int64 `json:"sub_course_id"`
}
func (q *Queries) GetUserSubCourseProgress(ctx context.Context, arg GetUserSubCourseProgressParams) (UserSubCourseProgress, error) {
row := q.db.QueryRow(ctx, GetUserSubCourseProgress, arg.UserID, arg.SubCourseID)
var i UserSubCourseProgress
err := row.Scan(
&i.ID,
&i.UserID,
&i.SubCourseID,
&i.Status,
&i.ProgressPercentage,
&i.StartedAt,
&i.CompletedAt,
&i.CreatedAt,
&i.UpdatedAt,
)
return i, err
}
const StartSubCourseProgress = `-- name: StartSubCourseProgress :one
INSERT INTO user_sub_course_progress (user_id, sub_course_id)
VALUES ($1, $2)
ON CONFLICT (user_id, sub_course_id) DO NOTHING
RETURNING id, user_id, sub_course_id, status, progress_percentage, started_at, completed_at, created_at, updated_at
`
type StartSubCourseProgressParams struct {
UserID int64 `json:"user_id"`
SubCourseID int64 `json:"sub_course_id"`
}
func (q *Queries) StartSubCourseProgress(ctx context.Context, arg StartSubCourseProgressParams) (UserSubCourseProgress, error) {
row := q.db.QueryRow(ctx, StartSubCourseProgress, arg.UserID, arg.SubCourseID)
var i UserSubCourseProgress
err := row.Scan(
&i.ID,
&i.UserID,
&i.SubCourseID,
&i.Status,
&i.ProgressPercentage,
&i.StartedAt,
&i.CompletedAt,
&i.CreatedAt,
&i.UpdatedAt,
)
return i, err
}
const UpdateSubCourseProgress = `-- name: UpdateSubCourseProgress :exec
UPDATE user_sub_course_progress
SET
progress_percentage = $1,
updated_at = CURRENT_TIMESTAMP
WHERE user_id = $2 AND sub_course_id = $3
`
type UpdateSubCourseProgressParams struct {
ProgressPercentage int16 `json:"progress_percentage"`
UserID int64 `json:"user_id"`
SubCourseID int64 `json:"sub_course_id"`
}
func (q *Queries) UpdateSubCourseProgress(ctx context.Context, arg UpdateSubCourseProgressParams) error {
_, err := q.db.Exec(ctx, UpdateSubCourseProgress, arg.ProgressPercentage, arg.UserID, arg.SubCourseID)
return err
}

View File

@ -1,95 +0,0 @@
// Code generated by sqlc. DO NOT EDIT.
// versions:
// sqlc v1.30.0
// source: user_sub_course_video_progress.sql
package dbgen
import (
"context"
)
const GetFirstIncompletePreviousVideo = `-- name: GetFirstIncompletePreviousVideo :one
SELECT
v.id,
v.title,
v.display_order
FROM sub_course_videos target
JOIN sub_course_videos v
ON v.sub_course_id = target.sub_course_id
AND v.status = 'PUBLISHED'
AND (
v.display_order < target.display_order OR
(v.display_order = target.display_order AND v.id < target.id)
)
LEFT JOIN user_sub_course_video_progress p
ON p.video_id = v.id
AND p.user_id = $1::BIGINT
AND p.completed_at IS NOT NULL
WHERE target.id = $2::BIGINT
AND p.video_id IS NULL
ORDER BY v.display_order ASC, v.id ASC
LIMIT 1
`
type GetFirstIncompletePreviousVideoParams struct {
UserID int64 `json:"user_id"`
VideoID int64 `json:"video_id"`
}
type GetFirstIncompletePreviousVideoRow struct {
ID int64 `json:"id"`
Title string `json:"title"`
DisplayOrder int32 `json:"display_order"`
}
func (q *Queries) GetFirstIncompletePreviousVideo(ctx context.Context, arg GetFirstIncompletePreviousVideoParams) (GetFirstIncompletePreviousVideoRow, error) {
row := q.db.QueryRow(ctx, GetFirstIncompletePreviousVideo, arg.UserID, arg.VideoID)
var i GetFirstIncompletePreviousVideoRow
err := row.Scan(&i.ID, &i.Title, &i.DisplayOrder)
return i, err
}
const MarkVideoCompleted = `-- name: MarkVideoCompleted :one
INSERT INTO user_sub_course_video_progress (
user_id,
sub_course_id,
video_id,
completed_at,
updated_at
)
SELECT
$1::BIGINT,
v.sub_course_id,
v.id,
CURRENT_TIMESTAMP,
CURRENT_TIMESTAMP
FROM sub_course_videos v
WHERE v.id = $2::BIGINT
AND v.status = 'PUBLISHED'
ON CONFLICT (user_id, video_id)
DO UPDATE SET
completed_at = CURRENT_TIMESTAMP,
updated_at = CURRENT_TIMESTAMP
RETURNING id, user_id, sub_course_id, video_id, completed_at, created_at, updated_at
`
type MarkVideoCompletedParams struct {
UserID int64 `json:"user_id"`
VideoID int64 `json:"video_id"`
}
func (q *Queries) MarkVideoCompleted(ctx context.Context, arg MarkVideoCompletedParams) (UserSubCourseVideoProgress, error) {
row := q.db.QueryRow(ctx, MarkVideoCompleted, arg.UserID, arg.VideoID)
var i UserSubCourseVideoProgress
err := row.Scan(
&i.ID,
&i.UserID,
&i.SubCourseID,
&i.VideoID,
&i.CompletedAt,
&i.CreatedAt,
&i.UpdatedAt,
)
return i, err
}

View File

@ -3,13 +3,13 @@ package repository
import ( import (
dbgen "Yimaru-Backend/gen/db" dbgen "Yimaru-Backend/gen/db"
"Yimaru-Backend/internal/domain" "Yimaru-Backend/internal/domain"
"Yimaru-Backend/internal/ports"
"context" "context"
"github.com/jackc/pgx/v5/pgtype" "github.com/jackc/pgx/v5/pgtype"
) )
func NewCourseStore(s *Store) ports.CourseStore { return s } func NewCourseStore(s *Store) *Store { return s }
func NewProgressionStore(s *Store) *Store { return s }
func (s *Store) CreateCourseCategory( func (s *Store) CreateCourseCategory(
ctx context.Context, ctx context.Context,

View File

@ -1,162 +0,0 @@
package repository
import (
"Yimaru-Backend/internal/domain"
"context"
"fmt"
"github.com/jackc/pgx/v5/pgtype"
)
func (s *Store) GetFullLearningTree(ctx context.Context) ([]domain.TreeCourse, error) {
rows, err := s.queries.GetFullLearningTree(ctx)
if err != nil {
return nil, err
}
coursesMap := make(map[int64]*domain.TreeCourse)
for _, row := range rows {
course, ok := coursesMap[row.CourseID]
if !ok {
course = &domain.TreeCourse{
ID: row.CourseID,
Title: row.CourseTitle,
SubCourses: []domain.TreeSubCourse{},
}
coursesMap[row.CourseID] = course
}
if row.SubCourseID.Valid {
subCourse := domain.TreeSubCourse{
ID: row.SubCourseID.Int64,
Title: row.SubCourseTitle.String,
Level: row.SubCourseLevel.String,
SubLevel: row.SubCourseSubLevel.String,
}
course.SubCourses = append(course.SubCourses, subCourse)
}
}
courses := make([]domain.TreeCourse, 0, len(coursesMap))
for _, course := range coursesMap {
courses = append(courses, *course)
}
return courses, nil
}
func (s *Store) GetCourseLearningPath(ctx context.Context, courseID int64) (domain.LearningPath, error) {
rows, err := s.queries.GetCourseLearningPath(ctx, courseID)
if err != nil {
return domain.LearningPath{}, err
}
if len(rows) == 0 {
return domain.LearningPath{}, fmt.Errorf("course not found")
}
first := rows[0]
path := domain.LearningPath{
CourseID: first.CourseID,
CourseTitle: first.CourseTitle,
Description: ptrString(first.CourseDescription),
Thumbnail: ptrString(first.CourseThumbnail),
IntroVideoURL: ptrString(first.CourseIntroVideoUrl),
CategoryID: first.CategoryID,
CategoryName: first.CategoryName,
SubCourses: []domain.LearningPathSubCourse{},
}
for _, row := range rows {
if !row.SubCourseID.Valid {
continue
}
scID := row.SubCourseID.Int64
// Fetch prerequisites, videos, practices for this sub-course
prerequisites, _ := s.getSubCoursePrerequisitesForPath(ctx, scID)
videos, _ := s.getSubCourseVideosForPath(ctx, scID)
practices, _ := s.getSubCoursePracticesForPath(ctx, scID)
sc := domain.LearningPathSubCourse{
ID: scID,
Title: row.SubCourseTitle.String,
Description: ptrString(row.SubCourseDescription),
Thumbnail: ptrString(row.SubCourseThumbnail),
DisplayOrder: row.SubCourseDisplayOrder.Int32,
Level: row.SubCourseLevel.String,
SubLevel: row.SubCourseSubLevel.String,
PrerequisiteCount: row.PrerequisiteCount,
VideoCount: row.VideoCount,
PracticeCount: row.PracticeCount,
Prerequisites: prerequisites,
Videos: videos,
Practices: practices,
}
path.SubCourses = append(path.SubCourses, sc)
}
return path, nil
}
func (s *Store) getSubCoursePrerequisitesForPath(ctx context.Context, subCourseID int64) ([]domain.LearningPathPrerequisite, error) {
rows, err := s.queries.GetSubCoursePrerequisitesForLearningPath(ctx, subCourseID)
if err != nil {
return nil, err
}
result := make([]domain.LearningPathPrerequisite, len(rows))
for i, row := range rows {
result[i] = domain.LearningPathPrerequisite{
SubCourseID: row.PrerequisiteSubCourseID,
Title: row.Title,
Level: row.Level,
SubLevel: row.SubLevel,
}
}
return result, nil
}
func (s *Store) getSubCourseVideosForPath(ctx context.Context, subCourseID int64) ([]domain.LearningPathVideo, error) {
rows, err := s.queries.GetSubCourseVideosForLearningPath(ctx, subCourseID)
if err != nil {
return nil, err
}
result := make([]domain.LearningPathVideo, len(rows))
for i, row := range rows {
result[i] = domain.LearningPathVideo{
ID: row.ID,
Title: row.Title,
Description: ptrString(row.Description),
VideoURL: row.VideoUrl,
Duration: row.Duration,
Resolution: ptrString(row.Resolution),
DisplayOrder: row.DisplayOrder,
VimeoID: ptrString(row.VimeoID),
VimeoEmbedURL: ptrString(row.VimeoEmbedUrl),
VideoHostProvider: ptrString(row.VideoHostProvider),
}
}
return result, nil
}
func (s *Store) getSubCoursePracticesForPath(ctx context.Context, subCourseID int64) ([]domain.LearningPathPractice, error) {
ownerID := pgtype.Int8{Int64: subCourseID, Valid: true}
rows, err := s.queries.GetSubCoursePracticesForLearningPath(ctx, ownerID)
if err != nil {
return nil, err
}
result := make([]domain.LearningPathPractice, len(rows))
for i, row := range rows {
result[i] = domain.LearningPathPractice{
ID: row.ID,
Title: row.Title,
Description: ptrString(row.Description),
Persona: ptrString(row.Persona),
Status: row.Status,
IntroVideoURL: ptrString(row.IntroVideoUrl),
QuestionCount: row.QuestionCount,
}
}
return result, nil
}

View File

@ -1,310 +0,0 @@
package repository
import (
dbgen "Yimaru-Backend/gen/db"
"Yimaru-Backend/internal/domain"
"Yimaru-Backend/internal/ports"
"context"
"errors"
"time"
"github.com/jackc/pgx/v5"
)
func NewProgressionStore(s *Store) ports.ProgressionStore { return s }
func (s *Store) AddSubCoursePrerequisite(ctx context.Context, subCourseID, prerequisiteSubCourseID int64) error {
_, err := s.queries.AddSubCoursePrerequisite(ctx, dbgen.AddSubCoursePrerequisiteParams{
SubCourseID: subCourseID,
PrerequisiteSubCourseID: prerequisiteSubCourseID,
})
return err
}
func (s *Store) RemoveSubCoursePrerequisite(ctx context.Context, subCourseID, prerequisiteSubCourseID int64) error {
return s.queries.RemoveSubCoursePrerequisite(ctx, dbgen.RemoveSubCoursePrerequisiteParams{
SubCourseID: subCourseID,
PrerequisiteSubCourseID: prerequisiteSubCourseID,
})
}
func (s *Store) GetSubCoursePrerequisites(ctx context.Context, subCourseID int64) ([]domain.SubCoursePrerequisite, error) {
rows, err := s.queries.GetSubCoursePrerequisites(ctx, subCourseID)
if err != nil {
return nil, err
}
prereqs := make([]domain.SubCoursePrerequisite, len(rows))
for i, row := range rows {
prereqs[i] = domain.SubCoursePrerequisite{
ID: row.ID,
SubCourseID: row.SubCourseID,
PrerequisiteSubCourseID: row.PrerequisiteSubCourseID,
CreatedAt: row.CreatedAt.Time,
PrerequisiteTitle: row.PrerequisiteTitle,
PrerequisiteLevel: row.PrerequisiteLevel,
PrerequisiteDisplayOrder: row.PrerequisiteDisplayOrder,
}
}
return prereqs, nil
}
func (s *Store) GetSubCourseDependents(ctx context.Context, prerequisiteSubCourseID int64) ([]domain.SubCourseDependent, error) {
rows, err := s.queries.GetSubCourseDependents(ctx, prerequisiteSubCourseID)
if err != nil {
return nil, err
}
deps := make([]domain.SubCourseDependent, len(rows))
for i, row := range rows {
deps[i] = domain.SubCourseDependent{
ID: row.ID,
SubCourseID: row.SubCourseID,
PrerequisiteSubCourseID: row.PrerequisiteSubCourseID,
CreatedAt: row.CreatedAt.Time,
DependentTitle: row.DependentTitle,
DependentLevel: row.DependentLevel,
}
}
return deps, nil
}
func (s *Store) CountUnmetPrerequisites(ctx context.Context, subCourseID, userID int64) (int64, error) {
return s.queries.CountUnmetPrerequisites(ctx, dbgen.CountUnmetPrerequisitesParams{
SubCourseID: subCourseID,
UserID: userID,
})
}
func (s *Store) DeleteAllPrerequisitesForSubCourse(ctx context.Context, subCourseID int64) error {
return s.queries.DeleteAllPrerequisitesForSubCourse(ctx, subCourseID)
}
func (s *Store) StartSubCourseProgress(ctx context.Context, userID, subCourseID int64) (domain.UserSubCourseProgress, error) {
row, err := s.queries.StartSubCourseProgress(ctx, dbgen.StartSubCourseProgressParams{
UserID: userID,
SubCourseID: subCourseID,
})
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return s.GetUserSubCourseProgress(ctx, userID, subCourseID)
}
return domain.UserSubCourseProgress{}, err
}
return mapUserSubCourseProgress(row), nil
}
func (s *Store) UpdateSubCourseProgress(ctx context.Context, userID, subCourseID int64, percentage int16) error {
return s.queries.UpdateSubCourseProgress(ctx, dbgen.UpdateSubCourseProgressParams{
ProgressPercentage: percentage,
UserID: userID,
SubCourseID: subCourseID,
})
}
func (s *Store) CompleteSubCourse(ctx context.Context, userID, subCourseID int64) error {
return s.queries.CompleteSubCourse(ctx, dbgen.CompleteSubCourseParams{
UserID: userID,
SubCourseID: subCourseID,
})
}
func (s *Store) RecalculateSubCourseProgress(ctx context.Context, userID, subCourseID int64) error {
const query = `
WITH totals AS (
SELECT
(SELECT COUNT(*)::INT
FROM sub_course_videos v
WHERE v.sub_course_id = $2
AND v.status = 'PUBLISHED') AS total_videos,
(SELECT COUNT(*)::INT
FROM question_sets qs
WHERE qs.owner_type = 'SUB_COURSE'
AND qs.owner_id = $2
AND qs.set_type = 'PRACTICE'
AND qs.status = 'PUBLISHED') AS total_practices
),
completed AS (
SELECT
(SELECT COUNT(*)::INT
FROM user_sub_course_video_progress uv
JOIN sub_course_videos v ON v.id = uv.video_id
WHERE uv.user_id = $1
AND uv.sub_course_id = $2
AND uv.completed_at IS NOT NULL
AND v.status = 'PUBLISHED') AS completed_videos,
(SELECT COUNT(*)::INT
FROM user_practice_progress up
JOIN question_sets qs ON qs.id = up.question_set_id
WHERE up.user_id = $1
AND up.sub_course_id = $2
AND up.completed_at IS NOT NULL
AND qs.owner_type = 'SUB_COURSE'
AND qs.owner_id = $2
AND qs.set_type = 'PRACTICE'
AND qs.status = 'PUBLISHED') AS completed_practices
),
stats AS (
SELECT
(total_videos + total_practices) AS total_items,
(completed_videos + completed_practices) AS completed_items
FROM totals, completed
)
INSERT INTO user_sub_course_progress (
user_id,
sub_course_id,
status,
progress_percentage,
started_at,
completed_at,
updated_at
)
SELECT
$1,
$2,
CASE
WHEN stats.total_items > 0 AND stats.completed_items >= stats.total_items THEN 'COMPLETED'
WHEN stats.completed_items > 0 THEN 'IN_PROGRESS'
ELSE 'NOT_STARTED'
END,
CASE
WHEN stats.total_items = 0 THEN 0
ELSE ROUND((stats.completed_items::NUMERIC * 100.0) / stats.total_items::NUMERIC)::SMALLINT
END,
CASE
WHEN stats.completed_items > 0 THEN CURRENT_TIMESTAMP
ELSE NULL
END,
CASE
WHEN stats.total_items > 0 AND stats.completed_items >= stats.total_items THEN CURRENT_TIMESTAMP
ELSE NULL
END,
CURRENT_TIMESTAMP
FROM stats
ON CONFLICT (user_id, sub_course_id) DO UPDATE SET
status = EXCLUDED.status,
progress_percentage = EXCLUDED.progress_percentage,
started_at = COALESCE(user_sub_course_progress.started_at, EXCLUDED.started_at),
completed_at = EXCLUDED.completed_at,
updated_at = EXCLUDED.updated_at;
`
_, err := s.conn.Exec(ctx, query, userID, subCourseID)
return err
}
func (s *Store) GetUserSubCourseProgress(ctx context.Context, userID, subCourseID int64) (domain.UserSubCourseProgress, error) {
row, err := s.queries.GetUserSubCourseProgress(ctx, dbgen.GetUserSubCourseProgressParams{
UserID: userID,
SubCourseID: subCourseID,
})
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return domain.UserSubCourseProgress{}, domain.ErrProgressNotFound
}
return domain.UserSubCourseProgress{}, err
}
return mapUserSubCourseProgress(row), nil
}
func (s *Store) GetUserCourseProgress(ctx context.Context, userID, courseID int64) ([]domain.UserCourseProgressItem, error) {
rows, err := s.queries.GetUserCourseProgress(ctx, dbgen.GetUserCourseProgressParams{
UserID: userID,
CourseID: courseID,
})
if err != nil {
return nil, err
}
items := make([]domain.UserCourseProgressItem, len(rows))
for i, row := range rows {
var startedAt, completedAt *time.Time
if row.StartedAt.Valid {
startedAt = &row.StartedAt.Time
}
if row.CompletedAt.Valid {
completedAt = &row.CompletedAt.Time
}
var updatedAt *time.Time
if row.UpdatedAt.Valid {
updatedAt = &row.UpdatedAt.Time
}
items[i] = domain.UserCourseProgressItem{
ID: row.ID,
UserID: row.UserID,
SubCourseID: row.SubCourseID,
Status: domain.ProgressStatus(row.Status),
ProgressPercentage: row.ProgressPercentage,
StartedAt: startedAt,
CompletedAt: completedAt,
CreatedAt: row.CreatedAt.Time,
UpdatedAt: updatedAt,
SubCourseTitle: row.SubCourseTitle,
SubCourseLevel: row.SubCourseLevel,
SubCourseDisplayOrder: row.SubCourseDisplayOrder,
}
}
return items, nil
}
func (s *Store) GetSubCoursesWithProgressByCourse(ctx context.Context, userID, courseID int64) ([]domain.SubCourseWithProgress, error) {
rows, err := s.queries.GetSubCoursesWithProgressByCourse(ctx, dbgen.GetSubCoursesWithProgressByCourseParams{
UserID: userID,
CourseID: courseID,
})
if err != nil {
return nil, err
}
items := make([]domain.SubCourseWithProgress, len(rows))
for i, row := range rows {
var startedAt, completedAt *time.Time
if row.StartedAt.Valid {
startedAt = &row.StartedAt.Time
}
if row.CompletedAt.Valid {
completedAt = &row.CompletedAt.Time
}
items[i] = domain.SubCourseWithProgress{
SubCourseID: row.SubCourseID,
Title: row.Title,
Description: ptrText(row.Description),
Thumbnail: ptrText(row.Thumbnail),
DisplayOrder: row.DisplayOrder,
Level: row.Level,
IsActive: row.IsActive,
ProgressStatus: domain.ProgressStatus(row.ProgressStatus),
ProgressPercentage: row.ProgressPercentage,
StartedAt: startedAt,
CompletedAt: completedAt,
UnmetPrerequisitesCount: row.UnmetPrerequisitesCount,
IsLocked: row.UnmetPrerequisitesCount > 0,
}
}
return items, nil
}
func mapUserSubCourseProgress(row dbgen.UserSubCourseProgress) domain.UserSubCourseProgress {
var startedAt, completedAt *time.Time
if row.StartedAt.Valid {
startedAt = &row.StartedAt.Time
}
if row.CompletedAt.Valid {
completedAt = &row.CompletedAt.Time
}
var updatedAt *time.Time
if row.UpdatedAt.Valid {
updatedAt = &row.UpdatedAt.Time
}
return domain.UserSubCourseProgress{
ID: row.ID,
UserID: row.UserID,
SubCourseID: row.SubCourseID,
Status: domain.ProgressStatus(row.Status),
ProgressPercentage: row.ProgressPercentage,
StartedAt: startedAt,
CompletedAt: completedAt,
CreatedAt: row.CreatedAt.Time,
UpdatedAt: updatedAt,
}
}

View File

@ -1,329 +0,0 @@
package repository
import (
dbgen "Yimaru-Backend/gen/db"
"Yimaru-Backend/internal/domain"
"context"
"errors"
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgtype"
)
func (s *Store) 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) {
var descText, resText, instrText, thumbText, visText, statusText pgtype.Text
var vimeoIDText, vimeoEmbedText, vimeoHTMLText, vimeoStatusText, hostProviderText pgtype.Text
if description != nil {
descText = pgtype.Text{String: *description, Valid: true}
}
if resolution != nil {
resText = pgtype.Text{String: *resolution, Valid: true}
}
if instructorID != nil {
instrText = pgtype.Text{String: *instructorID, Valid: true}
}
if thumbnail != nil {
thumbText = pgtype.Text{String: *thumbnail, Valid: true}
}
if visibility != nil {
visText = pgtype.Text{String: *visibility, Valid: true}
}
if status != nil {
statusText = pgtype.Text{String: *status, Valid: true}
}
if vimeoID != nil {
vimeoIDText = pgtype.Text{String: *vimeoID, Valid: true}
}
if vimeoEmbedURL != nil {
vimeoEmbedText = pgtype.Text{String: *vimeoEmbedURL, Valid: true}
}
if vimeoPlayerHTML != nil {
vimeoHTMLText = pgtype.Text{String: *vimeoPlayerHTML, Valid: true}
}
if vimeoStatus != nil {
vimeoStatusText = pgtype.Text{String: *vimeoStatus, Valid: true}
}
if videoHostProvider != nil {
hostProviderText = pgtype.Text{String: *videoHostProvider, Valid: true}
}
var dispOrder pgtype.Int4
if displayOrder != nil {
dispOrder = pgtype.Int4{Int32: *displayOrder, Valid: true}
}
row, err := s.queries.CreateSubCourseVideo(ctx, dbgen.CreateSubCourseVideoParams{
SubCourseID: subCourseID,
Title: title,
Description: descText,
VideoUrl: videoURL,
Duration: duration,
Resolution: resText,
InstructorID: instrText,
Thumbnail: thumbText,
Visibility: visText,
Column10: dispOrder,
Column11: statusText,
VimeoID: vimeoIDText,
VimeoEmbedUrl: vimeoEmbedText,
VimeoPlayerHtml: vimeoHTMLText,
Column15: vimeoStatusText,
Column16: hostProviderText,
})
if err != nil {
return domain.SubCourseVideo{}, err
}
return mapSubCourseVideoRow(row), nil
}
func (s *Store) GetSubCourseVideoByID(
ctx context.Context,
id int64,
) (domain.SubCourseVideo, error) {
row, err := s.queries.GetSubCourseVideoByID(ctx, id)
if err != nil {
return domain.SubCourseVideo{}, err
}
return mapSubCourseVideoRow(row), nil
}
func (s *Store) GetVideosBySubCourse(
ctx context.Context,
subCourseID int64,
) ([]domain.SubCourseVideo, int64, error) {
rows, err := s.queries.GetVideosBySubCourse(ctx, subCourseID)
if err != nil {
return nil, 0, err
}
var (
videos []domain.SubCourseVideo
totalCount int64
)
for i, row := range rows {
if i == 0 {
totalCount = row.TotalCount
}
videos = append(videos, domain.SubCourseVideo{
ID: row.ID,
SubCourseID: row.SubCourseID,
Title: row.Title,
Description: ptrString(row.Description),
VideoURL: row.VideoUrl,
Duration: row.Duration,
Resolution: ptrString(row.Resolution),
InstructorID: ptrString(row.InstructorID),
Thumbnail: ptrString(row.Thumbnail),
Visibility: ptrString(row.Visibility),
DisplayOrder: row.DisplayOrder,
IsPublished: row.IsPublished,
PublishDate: ptrTimestamptz(row.PublishDate),
Status: row.Status,
VimeoID: ptrString(row.VimeoID),
VimeoEmbedURL: ptrString(row.VimeoEmbedUrl),
VimeoPlayerHTML: ptrString(row.VimeoPlayerHtml),
VimeoStatus: ptrString(row.VimeoStatus),
})
}
return videos, totalCount, nil
}
func (s *Store) GetPublishedVideosBySubCourse(
ctx context.Context,
subCourseID int64,
) ([]domain.SubCourseVideo, error) {
rows, err := s.queries.GetPublishedVideosBySubCourse(ctx, subCourseID)
if err != nil {
return nil, err
}
videos := make([]domain.SubCourseVideo, 0, len(rows))
for _, row := range rows {
videos = append(videos, mapSubCourseVideoRow(row))
}
return videos, nil
}
func (s *Store) GetFirstIncompletePreviousVideo(
ctx context.Context,
userID int64,
videoID int64,
) (*domain.VideoAccessBlock, error) {
row, err := s.queries.GetFirstIncompletePreviousVideo(ctx, dbgen.GetFirstIncompletePreviousVideoParams{
UserID: userID,
VideoID: videoID,
})
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return nil, nil
}
return nil, err
}
return &domain.VideoAccessBlock{
VideoID: row.ID,
Title: row.Title,
DisplayOrder: row.DisplayOrder,
}, nil
}
func (s *Store) MarkVideoCompleted(
ctx context.Context,
userID int64,
videoID int64,
) error {
_, err := s.queries.MarkVideoCompleted(ctx, dbgen.MarkVideoCompletedParams{
UserID: userID,
VideoID: videoID,
})
return err
}
func (s *Store) PublishSubCourseVideo(
ctx context.Context,
videoID int64,
) error {
return s.queries.PublishSubCourseVideo(ctx, videoID)
}
func (s *Store) 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 {
var titleVal, descVal, urlVal, resVal, visVal, thumbVal, statusVal string
var durationVal, dispOrderVal int32
if title != nil {
titleVal = *title
}
if description != nil {
descVal = *description
}
if videoURL != nil {
urlVal = *videoURL
}
if duration != nil {
durationVal = *duration
}
if resolution != nil {
resVal = *resolution
}
if visibility != nil {
visVal = *visibility
}
if thumbnail != nil {
thumbVal = *thumbnail
}
if displayOrder != nil {
dispOrderVal = *displayOrder
}
if status != nil {
statusVal = *status
}
return s.queries.UpdateSubCourseVideo(ctx, dbgen.UpdateSubCourseVideoParams{
Title: titleVal,
Description: pgtype.Text{String: descVal, Valid: description != nil},
VideoUrl: urlVal,
Duration: durationVal,
Resolution: pgtype.Text{String: resVal, Valid: resolution != nil},
Visibility: pgtype.Text{String: visVal, Valid: visibility != nil},
Thumbnail: pgtype.Text{String: thumbVal, Valid: thumbnail != nil},
DisplayOrder: dispOrderVal,
Status: statusVal,
ID: id,
})
}
func (s *Store) ArchiveSubCourseVideo(
ctx context.Context,
id int64,
) error {
return s.queries.ArchiveSubCourseVideo(ctx, id)
}
func (s *Store) DeleteSubCourseVideo(
ctx context.Context,
id int64,
) error {
return s.queries.DeleteSubCourseVideo(ctx, id)
}
func mapSubCourseVideoRow(row dbgen.SubCourseVideo) domain.SubCourseVideo {
return domain.SubCourseVideo{
ID: row.ID,
SubCourseID: row.SubCourseID,
Title: row.Title,
Description: ptrString(row.Description),
VideoURL: row.VideoUrl,
Duration: row.Duration,
Resolution: ptrString(row.Resolution),
InstructorID: ptrString(row.InstructorID),
Thumbnail: ptrString(row.Thumbnail),
Visibility: ptrString(row.Visibility),
DisplayOrder: row.DisplayOrder,
IsPublished: row.IsPublished,
PublishDate: ptrTimestamptz(row.PublishDate),
Status: row.Status,
VimeoID: ptrString(row.VimeoID),
VimeoEmbedURL: ptrString(row.VimeoEmbedUrl),
VimeoPlayerHTML: ptrString(row.VimeoPlayerHtml),
VimeoStatus: ptrString(row.VimeoStatus),
}
}
func (s *Store) UpdateVimeoStatus(ctx context.Context, videoID int64, status string) error {
return s.queries.UpdateVimeoStatus(ctx, dbgen.UpdateVimeoStatusParams{
VimeoStatus: pgtype.Text{String: status, Valid: true},
ID: videoID,
})
}
func (s *Store) GetVideoByVimeoID(ctx context.Context, vimeoID string) (domain.SubCourseVideo, error) {
row, err := s.queries.GetVideosByVimeoID(ctx, pgtype.Text{String: vimeoID, Valid: true})
if err != nil {
return domain.SubCourseVideo{}, err
}
return mapSubCourseVideoRow(row), nil
}
func (s *Store) ReorderSubCourseVideos(ctx context.Context, ids []int64, positions []int32) error {
return s.queries.ReorderSubCourseVideos(ctx, dbgen.ReorderSubCourseVideosParams{
Ids: ids,
Positions: positions,
})
}

View File

@ -1,256 +0,0 @@
package repository
import (
dbgen "Yimaru-Backend/gen/db"
"Yimaru-Backend/internal/domain"
"context"
"github.com/jackc/pgx/v5/pgtype"
)
func (s *Store) CreateSubCourse(
ctx context.Context,
courseID int64,
title string,
description *string,
thumbnail *string,
displayOrder *int32,
level string,
subLevel string,
) (domain.SubCourse, error) {
var descText, thumbText pgtype.Text
if description != nil {
descText = pgtype.Text{String: *description, Valid: true}
}
if thumbnail != nil {
thumbText = pgtype.Text{String: *thumbnail, Valid: true}
}
var dispOrder pgtype.Int4
if displayOrder != nil {
dispOrder = pgtype.Int4{Int32: *displayOrder, Valid: true}
}
row, err := s.queries.CreateSubCourse(ctx, dbgen.CreateSubCourseParams{
CourseID: courseID,
Title: title,
Description: descText,
Thumbnail: thumbText,
Column5: dispOrder,
Level: level,
SubLevel: subLevel,
Column8: pgtype.Bool{Bool: true, Valid: true},
})
if err != nil {
return domain.SubCourse{}, err
}
return domain.SubCourse{
ID: row.ID,
CourseID: row.CourseID,
Title: row.Title,
Description: ptrString(row.Description),
Thumbnail: ptrString(row.Thumbnail),
DisplayOrder: row.DisplayOrder,
Level: row.Level,
SubLevel: row.SubLevel,
IsActive: row.IsActive,
}, nil
}
func (s *Store) GetSubCourseByID(
ctx context.Context,
id int64,
) (domain.SubCourse, error) {
row, err := s.queries.GetSubCourseByID(ctx, id)
if err != nil {
return domain.SubCourse{}, err
}
return domain.SubCourse{
ID: row.ID,
CourseID: row.CourseID,
Title: row.Title,
Description: ptrString(row.Description),
Thumbnail: ptrString(row.Thumbnail),
DisplayOrder: row.DisplayOrder,
Level: row.Level,
SubLevel: row.SubLevel,
IsActive: row.IsActive,
}, nil
}
func (s *Store) GetSubCoursesByCourse(
ctx context.Context,
courseID int64,
) ([]domain.SubCourse, int64, error) {
rows, err := s.queries.GetSubCoursesByCourse(ctx, courseID)
if err != nil {
return nil, 0, err
}
var (
subCourses []domain.SubCourse
totalCount int64
)
for i, row := range rows {
if i == 0 {
totalCount = row.TotalCount
}
subCourses = append(subCourses, domain.SubCourse{
ID: row.ID,
CourseID: row.CourseID,
Title: row.Title,
Description: ptrString(row.Description),
Thumbnail: ptrString(row.Thumbnail),
DisplayOrder: row.DisplayOrder,
Level: row.Level,
SubLevel: row.SubLevel,
IsActive: row.IsActive,
})
}
return subCourses, totalCount, nil
}
func (s *Store) ListSubCoursesByCourse(
ctx context.Context,
courseID int64,
) ([]domain.SubCourse, error) {
rows, err := s.queries.ListSubCoursesByCourse(ctx, courseID)
if err != nil {
return nil, err
}
subCourses := make([]domain.SubCourse, 0, len(rows))
for _, row := range rows {
subCourses = append(subCourses, domain.SubCourse{
ID: row.ID,
CourseID: row.CourseID,
Title: row.Title,
Description: ptrString(row.Description),
Thumbnail: ptrString(row.Thumbnail),
DisplayOrder: row.DisplayOrder,
Level: row.Level,
SubLevel: row.SubLevel,
IsActive: row.IsActive,
})
}
return subCourses, nil
}
func (s *Store) ListActiveSubCourses(
ctx context.Context,
) ([]domain.SubCourse, error) {
rows, err := s.queries.ListActiveSubCourses(ctx)
if err != nil {
return nil, err
}
subCourses := make([]domain.SubCourse, 0, len(rows))
for _, row := range rows {
subCourses = append(subCourses, domain.SubCourse{
ID: row.ID,
CourseID: row.CourseID,
Title: row.Title,
Description: ptrString(row.Description),
Thumbnail: ptrString(row.Thumbnail),
DisplayOrder: row.DisplayOrder,
Level: row.Level,
SubLevel: row.SubLevel,
IsActive: row.IsActive,
})
}
return subCourses, nil
}
func (s *Store) UpdateSubCourse(
ctx context.Context,
id int64,
title *string,
description *string,
thumbnail *string,
displayOrder *int32,
level *string,
subLevel *string,
isActive *bool,
) error {
var titleVal, descVal, thumbVal, levelVal, subLevelVal string
var dispOrderVal int32
var isActiveVal bool
if title != nil {
titleVal = *title
}
if description != nil {
descVal = *description
}
if thumbnail != nil {
thumbVal = *thumbnail
}
if displayOrder != nil {
dispOrderVal = *displayOrder
}
if level != nil {
levelVal = *level
}
if subLevel != nil {
subLevelVal = *subLevel
}
if isActive != nil {
isActiveVal = *isActive
}
return s.queries.UpdateSubCourse(ctx, dbgen.UpdateSubCourseParams{
Title: titleVal,
Description: pgtype.Text{String: descVal, Valid: description != nil},
Thumbnail: pgtype.Text{String: thumbVal, Valid: thumbnail != nil},
DisplayOrder: dispOrderVal,
Level: levelVal,
SubLevel: subLevelVal,
IsActive: isActiveVal,
ID: id,
})
}
func (s *Store) DeactivateSubCourse(
ctx context.Context,
id int64,
) error {
return s.queries.DeactivateSubCourse(ctx, id)
}
func (s *Store) DeleteSubCourse(
ctx context.Context,
id int64,
) (domain.SubCourse, error) {
row, err := s.queries.DeleteSubCourse(ctx, id)
if err != nil {
return domain.SubCourse{}, err
}
return domain.SubCourse{
ID: row.ID,
CourseID: row.CourseID,
Title: row.Title,
Description: ptrString(row.Description),
Thumbnail: ptrString(row.Thumbnail),
DisplayOrder: row.DisplayOrder,
Level: row.Level,
SubLevel: row.SubLevel,
IsActive: row.IsActive,
}, nil
}
func (s *Store) ReorderSubCourses(ctx context.Context, ids []int64, positions []int32) error {
return s.queries.ReorderSubCourses(ctx, dbgen.ReorderSubCoursesParams{
Ids: ids,
Positions: positions,
})
}

View File

@ -1,44 +0,0 @@
package course_management
import (
"Yimaru-Backend/internal/domain"
"context"
)
func (s *Service) CreateCourseCategory(
ctx context.Context,
name string,
) (domain.CourseCategory, error) {
return s.courseStore.CreateCourseCategory(ctx, name)
}
func (s *Service) GetCourseCategoryByID(
ctx context.Context,
id int64,
) (domain.CourseCategory, error) {
return s.courseStore.GetCourseCategoryByID(ctx, id)
}
func (s *Service) GetAllCourseCategories(
ctx context.Context,
limit int32,
offset int32,
) ([]domain.CourseCategory, int64, error) {
return s.courseStore.GetAllCourseCategories(ctx, limit, offset)
}
func (s *Service) UpdateCourseCategory(
ctx context.Context,
id int64,
name *string,
isActive *bool,
) error {
return s.courseStore.UpdateCourseCategory(ctx, id, name, isActive)
}
func (s *Service) DeleteCourseCategory(
ctx context.Context,
id int64,
) error {
return s.courseStore.DeleteCourseCategory(ctx, id)
}

View File

@ -1,52 +0,0 @@
package course_management
import (
"Yimaru-Backend/internal/domain"
"context"
)
func (s *Service) CreateCourse(
ctx context.Context,
categoryID int64,
title string,
description *string,
thumbnail *string,
introVideoURL *string,
) (domain.Course, error) {
return s.courseStore.CreateCourse(ctx, categoryID, title, description, thumbnail, introVideoURL)
}
func (s *Service) GetCourseByID(
ctx context.Context,
id int64,
) (domain.Course, error) {
return s.courseStore.GetCourseByID(ctx, id)
}
func (s *Service) GetCoursesByCategory(
ctx context.Context,
categoryID int64,
limit int32,
offset int32,
) ([]domain.Course, int64, error) {
return s.courseStore.GetCoursesByCategory(ctx, categoryID, limit, offset)
}
func (s *Service) UpdateCourse(
ctx context.Context,
id int64,
title *string,
description *string,
thumbnail *string,
introVideoURL *string,
isActive *bool,
) error {
return s.courseStore.UpdateCourse(ctx, id, title, description, thumbnail, introVideoURL, isActive)
}
func (s *Service) DeleteCourse(
ctx context.Context,
id int64,
) error {
return s.courseStore.DeleteCourse(ctx, id)
}

View File

@ -1,34 +0,0 @@
package course_management
import (
"Yimaru-Backend/internal/domain"
"context"
)
func (s *Service) GetFullLearningTree(ctx context.Context) ([]domain.TreeCourse, error) {
return s.courseStore.GetFullLearningTree(ctx)
}
func (s *Service) GetCourseLearningPath(ctx context.Context, courseID int64) (domain.LearningPath, error) {
return s.courseStore.GetCourseLearningPath(ctx, courseID)
}
func (s *Service) ReorderCourseCategories(ctx context.Context, ids []int64, positions []int32) error {
return s.courseStore.ReorderCourseCategories(ctx, ids, positions)
}
func (s *Service) ReorderCourses(ctx context.Context, ids []int64, positions []int32) error {
return s.courseStore.ReorderCourses(ctx, ids, positions)
}
func (s *Service) ReorderSubCourses(ctx context.Context, ids []int64, positions []int32) error {
return s.courseStore.ReorderSubCourses(ctx, ids, positions)
}
func (s *Service) ReorderSubCourseVideos(ctx context.Context, ids []int64, positions []int32) error {
return s.courseStore.ReorderSubCourseVideos(ctx, ids, positions)
}
func (s *Service) ReorderQuestionSets(ctx context.Context, ids []int64, positions []int32) error {
return s.courseStore.ReorderQuestionSets(ctx, ids, positions)
}

View File

@ -1,73 +0,0 @@
package course_management
import (
"Yimaru-Backend/internal/domain"
"context"
)
// --- Prerequisites (admin) ---
func (s *Service) AddSubCoursePrerequisite(ctx context.Context, subCourseID, prerequisiteSubCourseID int64) error {
if subCourseID == prerequisiteSubCourseID {
return domain.ErrSelfPrerequisite
}
return s.progressionStore.AddSubCoursePrerequisite(ctx, subCourseID, prerequisiteSubCourseID)
}
func (s *Service) RemoveSubCoursePrerequisite(ctx context.Context, subCourseID, prerequisiteSubCourseID int64) error {
return s.progressionStore.RemoveSubCoursePrerequisite(ctx, subCourseID, prerequisiteSubCourseID)
}
func (s *Service) GetSubCoursePrerequisites(ctx context.Context, subCourseID int64) ([]domain.SubCoursePrerequisite, error) {
return s.progressionStore.GetSubCoursePrerequisites(ctx, subCourseID)
}
func (s *Service) GetSubCourseDependents(ctx context.Context, prerequisiteSubCourseID int64) ([]domain.SubCourseDependent, error) {
return s.progressionStore.GetSubCourseDependents(ctx, prerequisiteSubCourseID)
}
// --- User progress ---
func (s *Service) CheckSubCourseAccess(ctx context.Context, userID, subCourseID int64) (bool, error) {
unmet, err := s.progressionStore.CountUnmetPrerequisites(ctx, subCourseID, userID)
if err != nil {
return false, err
}
return unmet == 0, nil
}
func (s *Service) StartSubCourse(ctx context.Context, userID, subCourseID int64) (domain.UserSubCourseProgress, error) {
accessible, err := s.CheckSubCourseAccess(ctx, userID, subCourseID)
if err != nil {
return domain.UserSubCourseProgress{}, err
}
if !accessible {
return domain.UserSubCourseProgress{}, domain.ErrPrerequisiteNotMet
}
return s.progressionStore.StartSubCourseProgress(ctx, userID, subCourseID)
}
func (s *Service) UpdateSubCourseProgress(ctx context.Context, userID, subCourseID int64, percentage int16) error {
return s.progressionStore.UpdateSubCourseProgress(ctx, userID, subCourseID, percentage)
}
func (s *Service) CompleteSubCourse(ctx context.Context, userID, subCourseID int64) error {
return s.progressionStore.CompleteSubCourse(ctx, userID, subCourseID)
}
func (s *Service) RecalculateSubCourseProgress(ctx context.Context, userID, subCourseID int64) error {
return s.progressionStore.RecalculateSubCourseProgress(ctx, userID, subCourseID)
}
func (s *Service) GetUserSubCourseProgress(ctx context.Context, userID, subCourseID int64) (domain.UserSubCourseProgress, error) {
return s.progressionStore.GetUserSubCourseProgress(ctx, userID, subCourseID)
}
func (s *Service) GetUserCourseProgress(ctx context.Context, userID, courseID int64) ([]domain.UserCourseProgressItem, error) {
return s.progressionStore.GetUserCourseProgress(ctx, userID, courseID)
}
func (s *Service) GetSubCoursesWithProgress(ctx context.Context, userID, courseID int64) ([]domain.SubCourseWithProgress, error) {
return s.progressionStore.GetSubCoursesWithProgressByCourse(ctx, userID, courseID)
}

View File

@ -10,8 +10,8 @@ import (
type Service struct { type Service struct {
userStore ports.UserStore userStore ports.UserStore
courseStore ports.CourseStore courseStore interface{}
progressionStore ports.ProgressionStore progressionStore interface{}
notificationSvc *notificationservice.Service notificationSvc *notificationservice.Service
vimeoSvc *vimeoservice.Service vimeoSvc *vimeoservice.Service
cloudConvertSvc *cloudconvertservice.Service cloudConvertSvc *cloudconvertservice.Service
@ -20,8 +20,8 @@ type Service struct {
func NewService( func NewService(
userStore ports.UserStore, userStore ports.UserStore,
courseStore ports.CourseStore, courseStore interface{},
progressionStore ports.ProgressionStore, progressionStore interface{},
notificationSvc *notificationservice.Service, notificationSvc *notificationservice.Service,
cfg *config.Config, cfg *config.Config,
) *Service { ) *Service {

View File

@ -1,299 +0,0 @@
package course_management
import (
"Yimaru-Backend/internal/domain"
"Yimaru-Backend/internal/pkgs/vimeo"
vimeoservice "Yimaru-Backend/internal/services/vimeo"
"context"
"fmt"
"io"
"net/http"
"path"
"time"
)
func (s *Service) 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,
) (domain.SubCourseVideo, error) {
// Default to DIRECT provider when no Vimeo info provided
provider := string(domain.VideoHostProviderDirect)
return s.courseStore.CreateSubCourseVideo(
ctx, subCourseID, title, description, videoURL, duration,
resolution, instructorID, thumbnail, visibility, displayOrder, status,
nil, nil, nil, nil, &provider,
)
}
// CreateSubCourseVideoWithVimeo creates a video and uploads it to Vimeo
func (s *Service) CreateSubCourseVideoWithVimeo(
ctx context.Context,
subCourseID int64,
title string,
description *string,
sourceURL string,
fileSize int64,
duration int32,
resolution *string,
instructorID *string,
thumbnail *string,
visibility *string,
displayOrder *int32,
) (domain.SubCourseVideo, error) {
if s.vimeoSvc == nil {
return domain.SubCourseVideo{}, fmt.Errorf("vimeo service is not configured")
}
descStr := ""
if description != nil {
descStr = *description
}
var uploadResult *vimeoservice.UploadResult
var err error
if s.cloudConvertSvc != nil {
httpClient := &http.Client{Timeout: 30 * time.Minute}
req, reqErr := http.NewRequestWithContext(ctx, http.MethodGet, sourceURL, nil)
if reqErr != nil {
return domain.SubCourseVideo{}, fmt.Errorf("failed to create download request: %w", reqErr)
}
resp, dlErr := httpClient.Do(req)
if dlErr != nil {
return domain.SubCourseVideo{}, fmt.Errorf("failed to download source video: %w", dlErr)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return domain.SubCourseVideo{}, fmt.Errorf("failed to download source video: status %d", resp.StatusCode)
}
dlSize := resp.ContentLength
if dlSize <= 0 {
dlSize = fileSize
}
filename := path.Base(sourceURL)
if filename == "" || filename == "." || filename == "/" {
filename = "video.mp4"
}
result, compErr := s.cloudConvertSvc.CompressVideo(ctx, filename, resp.Body, dlSize)
if compErr != nil {
return domain.SubCourseVideo{}, fmt.Errorf("failed to compress video: %w", compErr)
}
defer result.Data.Close()
uploadResult, err = s.vimeoSvc.UploadVideoFile(ctx, title, descStr, result.Data, result.FileSize)
} else {
uploadResult, err = s.vimeoSvc.CreatePullUpload(ctx, title, descStr, sourceURL, fileSize)
}
if err != nil {
return domain.SubCourseVideo{}, fmt.Errorf("failed to upload to Vimeo: %w", err)
}
embedURL := vimeo.GenerateEmbedURL(uploadResult.VimeoID, &vimeo.EmbedOptions{
Title: true,
Byline: true,
Portrait: true,
})
embedHTML := vimeo.GenerateIframeEmbed(uploadResult.VimeoID, 640, 360, nil)
provider := string(domain.VideoHostProviderVimeo)
vimeoStatus := "uploading"
status := "DRAFT"
return s.courseStore.CreateSubCourseVideo(
ctx, subCourseID, title, description,
uploadResult.Link,
duration, resolution, instructorID, thumbnail, visibility, displayOrder, &status,
&uploadResult.VimeoID, &embedURL, &embedHTML, &vimeoStatus, &provider,
)
}
func (s *Service) CreateSubCourseVideoWithFileUpload(
ctx context.Context,
subCourseID int64,
title string,
description *string,
filename string,
fileData io.Reader,
fileSize int64,
duration int32,
resolution *string,
instructorID *string,
thumbnail *string,
visibility *string,
displayOrder *int32,
) (domain.SubCourseVideo, error) {
if s.vimeoSvc == nil {
return domain.SubCourseVideo{}, fmt.Errorf("vimeo service is not configured")
}
descStr := ""
if description != nil {
descStr = *description
}
videoReader := fileData
videoSize := fileSize
if s.cloudConvertSvc != nil {
result, err := s.cloudConvertSvc.CompressVideo(ctx, filename, fileData, fileSize)
if err != nil {
return domain.SubCourseVideo{}, fmt.Errorf("failed to compress video: %w", err)
}
defer result.Data.Close()
videoReader = result.Data
videoSize = result.FileSize
}
uploadResult, err := s.vimeoSvc.UploadVideoFile(ctx, title, descStr, videoReader, videoSize)
if err != nil {
return domain.SubCourseVideo{}, fmt.Errorf("failed to upload video file to Vimeo: %w", err)
}
embedURL := vimeo.GenerateEmbedURL(uploadResult.VimeoID, &vimeo.EmbedOptions{
Title: true,
Byline: true,
Portrait: true,
})
embedHTML := vimeo.GenerateIframeEmbed(uploadResult.VimeoID, 640, 360, nil)
provider := string(domain.VideoHostProviderVimeo)
vimeoStatus := "uploading"
status := "DRAFT"
return s.courseStore.CreateSubCourseVideo(
ctx, subCourseID, title, description,
uploadResult.Link,
duration, resolution, instructorID, thumbnail, visibility, displayOrder, &status,
&uploadResult.VimeoID, &embedURL, &embedHTML, &vimeoStatus, &provider,
)
}
// CreateSubCourseVideoFromVimeoID creates a video record from an existing Vimeo video
func (s *Service) CreateSubCourseVideoFromVimeoID(
ctx context.Context,
subCourseID int64,
vimeoVideoID string,
title string,
description *string,
displayOrder *int32,
instructorID *string,
) (domain.SubCourseVideo, error) {
if s.vimeoSvc == nil {
return domain.SubCourseVideo{}, fmt.Errorf("vimeo service is not configured")
}
// Fetch video info from Vimeo
info, err := s.vimeoSvc.GetVideoInfo(ctx, vimeoVideoID)
if err != nil {
return domain.SubCourseVideo{}, fmt.Errorf("failed to get Vimeo video info: %w", err)
}
// Use Vimeo data
embedHTML := vimeo.GenerateIframeEmbed(vimeoVideoID, 640, 360, nil)
provider := string(domain.VideoHostProviderVimeo)
vimeoStatus := info.TranscodeStatus
if vimeoStatus == "" {
vimeoStatus = "available"
}
status := "DRAFT"
duration := int32(info.Duration)
resolution := fmt.Sprintf("%dx%d", info.Width, info.Height)
thumbnail := info.ThumbnailURL
return s.courseStore.CreateSubCourseVideo(
ctx, subCourseID, title, description,
info.Link, duration, &resolution, instructorID, &thumbnail, nil, displayOrder, &status,
&vimeoVideoID, &info.EmbedURL, &embedHTML, &vimeoStatus, &provider,
)
}
func (s *Service) GetSubCourseVideoByID(
ctx context.Context,
id int64,
) (domain.SubCourseVideo, error) {
return s.courseStore.GetSubCourseVideoByID(ctx, id)
}
func (s *Service) GetVideosBySubCourse(
ctx context.Context,
subCourseID int64,
) ([]domain.SubCourseVideo, int64, error) {
return s.courseStore.GetVideosBySubCourse(ctx, subCourseID)
}
func (s *Service) GetPublishedVideosBySubCourse(
ctx context.Context,
subCourseID int64,
) ([]domain.SubCourseVideo, error) {
return s.courseStore.GetPublishedVideosBySubCourse(ctx, subCourseID)
}
func (s *Service) GetFirstIncompletePreviousVideo(
ctx context.Context,
userID int64,
videoID int64,
) (*domain.VideoAccessBlock, error) {
return s.courseStore.GetFirstIncompletePreviousVideo(ctx, userID, videoID)
}
func (s *Service) MarkVideoCompleted(
ctx context.Context,
userID int64,
videoID int64,
) error {
return s.courseStore.MarkVideoCompleted(ctx, userID, videoID)
}
func (s *Service) PublishSubCourseVideo(
ctx context.Context,
videoID int64,
) error {
return s.courseStore.PublishSubCourseVideo(ctx, videoID)
}
func (s *Service) 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 {
return s.courseStore.UpdateSubCourseVideo(ctx, id, title, description, videoURL, duration, resolution, visibility, thumbnail, displayOrder, status)
}
func (s *Service) ArchiveSubCourseVideo(
ctx context.Context,
id int64,
) error {
return s.courseStore.ArchiveSubCourseVideo(ctx, id)
}
func (s *Service) DeleteSubCourseVideo(
ctx context.Context,
id int64,
) error {
return s.courseStore.DeleteSubCourseVideo(ctx, id)
}

View File

@ -1,74 +0,0 @@
package course_management
import (
"Yimaru-Backend/internal/domain"
"context"
)
func (s *Service) CreateSubCourse(
ctx context.Context,
courseID int64,
title string,
description *string,
thumbnail *string,
displayOrder *int32,
level string,
subLevel string,
) (domain.SubCourse, error) {
return s.courseStore.CreateSubCourse(ctx, courseID, title, description, thumbnail, displayOrder, level, subLevel)
}
func (s *Service) GetSubCourseByID(
ctx context.Context,
id int64,
) (domain.SubCourse, error) {
return s.courseStore.GetSubCourseByID(ctx, id)
}
func (s *Service) GetSubCoursesByCourse(
ctx context.Context,
courseID int64,
) ([]domain.SubCourse, int64, error) {
return s.courseStore.GetSubCoursesByCourse(ctx, courseID)
}
func (s *Service) ListSubCoursesByCourse(
ctx context.Context,
courseID int64,
) ([]domain.SubCourse, error) {
return s.courseStore.ListSubCoursesByCourse(ctx, courseID)
}
func (s *Service) ListActiveSubCourses(
ctx context.Context,
) ([]domain.SubCourse, error) {
return s.courseStore.ListActiveSubCourses(ctx)
}
func (s *Service) UpdateSubCourse(
ctx context.Context,
id int64,
title *string,
description *string,
thumbnail *string,
displayOrder *int32,
level *string,
subLevel *string,
isActive *bool,
) error {
return s.courseStore.UpdateSubCourse(ctx, id, title, description, thumbnail, displayOrder, level, subLevel, isActive)
}
func (s *Service) DeactivateSubCourse(
ctx context.Context,
id int64,
) error {
return s.courseStore.DeactivateSubCourse(ctx, id)
}
func (s *Service) DeleteSubCourse(
ctx context.Context,
id int64,
) (domain.SubCourse, error) {
return s.courseStore.DeleteSubCourse(ctx, id)
}

File diff suppressed because it is too large Load Diff

View File

@ -313,7 +313,7 @@ func normalizeAndValidateMediaContentType(mediaType, contentType, fileName strin
// @Summary Upload an audio file // @Summary Upload an audio file
// @Tags files // @Tags files
// @Accept multipart/form-data // @Accept multipart/form-data
// @Param file formance file true "Audio file (mp3, wav, ogg, m4a, aac, webm)" // @Param file formData file true "Audio file (mp3, wav, ogg, m4a, aac, webm)"
// @Success 200 {object} domain.Response // @Success 200 {object} domain.Response
// @Router /api/v1/files/audio [post] // @Router /api/v1/files/audio [post]
func (h *Handler) UploadAudio(c *fiber.Ctx) error { func (h *Handler) UploadAudio(c *fiber.Ctx) error {

View File

@ -0,0 +1,382 @@
package handlers
import (
dbgen "Yimaru-Backend/gen/db"
"Yimaru-Backend/internal/domain"
"strconv"
"strings"
"github.com/gofiber/fiber/v2"
"github.com/jackc/pgx/v5/pgtype"
)
type createCourseSubCategoryReq struct {
CategoryID int64 `json:"category_id"`
Name string `json:"name"`
Description *string `json:"description"`
DisplayOrder *int32 `json:"display_order"`
IsActive *bool `json:"is_active"`
}
type createLevelReq struct {
CourseID int64 `json:"course_id"`
CEFRLevel string `json:"cefr_level"`
DisplayOrder *int32 `json:"display_order"`
IsActive *bool `json:"is_active"`
}
type createModuleReq struct {
LevelID int64 `json:"level_id"`
Title string `json:"title"`
Description *string `json:"description"`
DisplayOrder *int32 `json:"display_order"`
IsActive *bool `json:"is_active"`
}
type createSubModuleReq struct {
ModuleID int64 `json:"module_id"`
Title string `json:"title"`
Description *string `json:"description"`
DisplayOrder *int32 `json:"display_order"`
IsActive *bool `json:"is_active"`
}
type createSubModuleVideoReq struct {
SubModuleID int64 `json:"sub_module_id"`
Title string `json:"title"`
Description *string `json:"description"`
VideoURL string `json:"video_url"`
Duration *int32 `json:"duration"`
Resolution *string `json:"resolution"`
Visibility *string `json:"visibility"`
InstructorID *string `json:"instructor_id"`
Thumbnail *string `json:"thumbnail"`
DisplayOrder *int32 `json:"display_order"`
Status *string `json:"status"`
}
type attachSubModuleLessonReq struct {
SubModuleID int64 `json:"sub_module_id"`
QuestionSetID int64 `json:"question_set_id"`
IntroVideoURL *string `json:"intro_video_url"`
DisplayOrder *int32 `json:"display_order"`
IsActive *bool `json:"is_active"`
}
type createSubModulePracticeReq struct {
SubModuleID int64 `json:"sub_module_id"`
Title string `json:"title"`
Description *string `json:"description"`
Thumbnail *string `json:"thumbnail"`
IntroVideoURL *string `json:"intro_video_url"`
QuestionSetID int64 `json:"question_set_id"`
DisplayOrder *int32 `json:"display_order"`
IsActive *bool `json:"is_active"`
}
func toText(v *string) pgtype.Text {
if v == nil {
return pgtype.Text{Valid: false}
}
return pgtype.Text{String: *v, Valid: true}
}
func toInt4(v *int32) pgtype.Int4 {
if v == nil {
return pgtype.Int4{Valid: false}
}
return pgtype.Int4{Int32: *v, Valid: true}
}
func boolOrNil(v *bool) interface{} {
if v == nil {
return nil
}
return *v
}
func intOrNil(v *int32) interface{} {
if v == nil {
return nil
}
return *v
}
// UnifiedHierarchy godoc
// @Summary Get unified course hierarchy
// @Description Returns full hierarchy: category -> sub-category -> course
// @Tags course-management
// @Produce json
// @Success 200 {object} domain.Response
// @Failure 500 {object} domain.ErrorResponse
// @Router /api/v1/course-management/hierarchy [get]
func (h *Handler) UnifiedHierarchy(c *fiber.Ctx) error {
rows, err := h.analyticsDB.GetCoursesWithHierarchy(c.Context())
if err != nil {
return c.Status(fiber.StatusInternalServerError).JSON(domain.ErrorResponse{Message: "Failed to load hierarchy", Error: err.Error()})
}
return c.JSON(domain.Response{Message: "Unified hierarchy retrieved successfully", Data: rows})
}
// UnifiedHierarchyByCourse godoc
// @Summary Get hierarchy for a course
// @Description Returns hierarchy nodes for one course including levels/modules/sub-modules
// @Tags course-management
// @Produce json
// @Param courseId path int true "Course ID"
// @Success 200 {object} domain.Response
// @Failure 400 {object} domain.ErrorResponse
// @Failure 500 {object} domain.ErrorResponse
// @Router /api/v1/course-management/courses/{courseId}/hierarchy [get]
func (h *Handler) UnifiedHierarchyByCourse(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()})
}
rows, err := h.analyticsDB.GetFullHierarchyByCourseID(c.Context(), courseID)
if err != nil {
return c.Status(fiber.StatusInternalServerError).JSON(domain.ErrorResponse{Message: "Failed to load course hierarchy", Error: err.Error()})
}
return c.JSON(domain.Response{Message: "Course hierarchy retrieved successfully", Data: rows})
}
// CreateCourseSubCategory godoc
// @Summary Create course sub-category
// @Description Creates a sub-category under a course category
// @Tags course-management
// @Accept json
// @Produce json
// @Param body body createCourseSubCategoryReq true "Create sub-category payload"
// @Success 201 {object} domain.Response
// @Failure 400 {object} domain.ErrorResponse
// @Failure 500 {object} domain.ErrorResponse
// @Router /api/v1/course-management/sub-categories [post]
func (h *Handler) CreateCourseSubCategory(c *fiber.Ctx) error {
var req createCourseSubCategoryReq
if err := c.BodyParser(&req); err != nil {
return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{Message: "Invalid request body", Error: err.Error()})
}
if req.CategoryID <= 0 || strings.TrimSpace(req.Name) == "" {
return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{Message: "category_id and name are required"})
}
created, err := h.analyticsDB.CreateCourseSubCategory(c.Context(), dbgen.CreateCourseSubCategoryParams{
CategoryID: req.CategoryID,
Name: req.Name,
Description: toText(req.Description),
Column4: intOrNil(req.DisplayOrder),
Column5: boolOrNil(req.IsActive),
})
if err != nil {
return c.Status(fiber.StatusInternalServerError).JSON(domain.ErrorResponse{Message: "Failed to create sub-category", Error: err.Error()})
}
return c.Status(fiber.StatusCreated).JSON(domain.Response{Message: "Course sub-category created", Data: created})
}
// CreateLevel godoc
// @Summary Create level
// @Description Creates a CEFR level under a course
// @Tags course-management
// @Accept json
// @Produce json
// @Param body body createLevelReq true "Create level payload"
// @Success 201 {object} domain.Response
// @Failure 400 {object} domain.ErrorResponse
// @Failure 500 {object} domain.ErrorResponse
// @Router /api/v1/course-management/levels [post]
func (h *Handler) CreateLevel(c *fiber.Ctx) error {
var req createLevelReq
if err := c.BodyParser(&req); err != nil {
return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{Message: "Invalid request body", Error: err.Error()})
}
req.CEFRLevel = strings.ToUpper(strings.TrimSpace(req.CEFRLevel))
validCEFR := map[string]bool{"A1": true, "A2": true, "A3": true, "B1": true, "B2": true, "B3": true, "C1": true, "C2": true, "C3": true}
if req.CourseID <= 0 || !validCEFR[req.CEFRLevel] {
return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{Message: "course_id and valid cefr_level are required"})
}
created, err := h.analyticsDB.CreateLevel(c.Context(), dbgen.CreateLevelParams{
CourseID: req.CourseID,
CefrLevel: req.CEFRLevel,
Column3: intOrNil(req.DisplayOrder),
Column4: boolOrNil(req.IsActive),
})
if err != nil {
return c.Status(fiber.StatusInternalServerError).JSON(domain.ErrorResponse{Message: "Failed to create level", Error: err.Error()})
}
return c.Status(fiber.StatusCreated).JSON(domain.Response{Message: "Level created", Data: created})
}
// CreateModule godoc
// @Summary Create module
// @Description Creates a module under a level
// @Tags course-management
// @Accept json
// @Produce json
// @Param body body createModuleReq true "Create module payload"
// @Success 201 {object} domain.Response
// @Failure 400 {object} domain.ErrorResponse
// @Failure 500 {object} domain.ErrorResponse
// @Router /api/v1/course-management/modules [post]
func (h *Handler) CreateModule(c *fiber.Ctx) error {
var req createModuleReq
if err := c.BodyParser(&req); err != nil {
return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{Message: "Invalid request body", Error: err.Error()})
}
if req.LevelID <= 0 || strings.TrimSpace(req.Title) == "" {
return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{Message: "level_id and title are required"})
}
created, err := h.analyticsDB.CreateModule(c.Context(), dbgen.CreateModuleParams{
LevelID: req.LevelID,
Title: req.Title,
Description: toText(req.Description),
Column4: intOrNil(req.DisplayOrder),
Column5: boolOrNil(req.IsActive),
})
if err != nil {
return c.Status(fiber.StatusInternalServerError).JSON(domain.ErrorResponse{Message: "Failed to create module", Error: err.Error()})
}
return c.Status(fiber.StatusCreated).JSON(domain.Response{Message: "Module created", Data: created})
}
// CreateSubModule godoc
// @Summary Create sub-module
// @Description Creates a sub-module under a module
// @Tags course-management
// @Accept json
// @Produce json
// @Param body body createSubModuleReq true "Create sub-module payload"
// @Success 201 {object} domain.Response
// @Failure 400 {object} domain.ErrorResponse
// @Failure 500 {object} domain.ErrorResponse
// @Router /api/v1/course-management/sub-modules [post]
func (h *Handler) CreateSubModule(c *fiber.Ctx) error {
var req createSubModuleReq
if err := c.BodyParser(&req); err != nil {
return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{Message: "Invalid request body", Error: err.Error()})
}
if req.ModuleID <= 0 || strings.TrimSpace(req.Title) == "" {
return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{Message: "module_id and title are required"})
}
created, err := h.analyticsDB.CreateSubModule(c.Context(), dbgen.CreateSubModuleParams{
ModuleID: req.ModuleID,
Title: req.Title,
Description: toText(req.Description),
Column4: intOrNil(req.DisplayOrder),
Column5: boolOrNil(req.IsActive),
})
if err != nil {
return c.Status(fiber.StatusInternalServerError).JSON(domain.ErrorResponse{Message: "Failed to create sub-module", Error: err.Error()})
}
return c.Status(fiber.StatusCreated).JSON(domain.Response{Message: "Sub-module created", Data: created})
}
// CreateSubModuleVideo godoc
// @Summary Create sub-module video
// @Description Creates a video under a sub-module
// @Tags course-management
// @Accept json
// @Produce json
// @Param body body createSubModuleVideoReq true "Create sub-module video payload"
// @Success 201 {object} domain.Response
// @Failure 400 {object} domain.ErrorResponse
// @Failure 500 {object} domain.ErrorResponse
// @Router /api/v1/course-management/sub-module-videos [post]
func (h *Handler) CreateSubModuleVideo(c *fiber.Ctx) error {
var req createSubModuleVideoReq
if err := c.BodyParser(&req); err != nil {
return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{Message: "Invalid request body", Error: err.Error()})
}
if req.SubModuleID <= 0 || strings.TrimSpace(req.Title) == "" || strings.TrimSpace(req.VideoURL) == "" {
return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{Message: "sub_module_id, title and video_url are required"})
}
created, err := h.analyticsDB.CreateSubModuleVideo(c.Context(), dbgen.CreateSubModuleVideoParams{
SubModuleID: req.SubModuleID,
Title: req.Title,
Description: toText(req.Description),
VideoUrl: req.VideoURL,
Duration: toInt4(req.Duration),
Resolution: toText(req.Resolution),
Column7: nil,
Visibility: toText(req.Visibility),
InstructorID: toText(req.InstructorID),
Thumbnail: toText(req.Thumbnail),
Column12: intOrNil(req.DisplayOrder),
Column13: req.Status,
VimeoID: pgtype.Text{Valid: false},
VimeoEmbedUrl: pgtype.Text{Valid: false},
VimeoPlayerHtml: pgtype.Text{Valid: false},
VimeoStatus: pgtype.Text{Valid: false},
Column18: nil,
})
if err != nil {
return c.Status(fiber.StatusInternalServerError).JSON(domain.ErrorResponse{Message: "Failed to create sub-module video", Error: err.Error()})
}
return c.Status(fiber.StatusCreated).JSON(domain.Response{Message: "Sub-module video created", Data: created})
}
// AttachSubModuleLesson godoc
// @Summary Attach lesson to sub-module
// @Description Links a question set lesson to a sub-module
// @Tags course-management
// @Accept json
// @Produce json
// @Param body body attachSubModuleLessonReq true "Attach lesson payload"
// @Success 201 {object} domain.Response
// @Failure 400 {object} domain.ErrorResponse
// @Failure 500 {object} domain.ErrorResponse
// @Router /api/v1/course-management/sub-module-lessons [post]
func (h *Handler) AttachSubModuleLesson(c *fiber.Ctx) error {
var req attachSubModuleLessonReq
if err := c.BodyParser(&req); err != nil {
return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{Message: "Invalid request body", Error: err.Error()})
}
if req.SubModuleID <= 0 || req.QuestionSetID <= 0 {
return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{Message: "sub_module_id and question_set_id are required"})
}
attached, err := h.analyticsDB.AttachQuestionSetLessonToSubModule(c.Context(), dbgen.AttachQuestionSetLessonToSubModuleParams{
SubModuleID: req.SubModuleID,
QuestionSetID: req.QuestionSetID,
IntroVideoUrl: toText(req.IntroVideoURL),
Column4: intOrNil(req.DisplayOrder),
Column5: boolOrNil(req.IsActive),
})
if err != nil {
return c.Status(fiber.StatusInternalServerError).JSON(domain.ErrorResponse{Message: "Failed to attach lesson", Error: err.Error()})
}
return c.Status(fiber.StatusCreated).JSON(domain.Response{Message: "Lesson attached to sub-module", Data: attached})
}
// CreateSubModulePractice godoc
// @Summary Create practice under sub-module
// @Description Creates a sub-module practice with metadata and linked question set
// @Tags course-management
// @Accept json
// @Produce json
// @Param body body createSubModulePracticeReq true "Create practice payload"
// @Success 201 {object} domain.Response
// @Failure 400 {object} domain.ErrorResponse
// @Failure 500 {object} domain.ErrorResponse
// @Router /api/v1/course-management/sub-module-practices [post]
func (h *Handler) CreateSubModulePractice(c *fiber.Ctx) error {
var req createSubModulePracticeReq
if err := c.BodyParser(&req); err != nil {
return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{Message: "Invalid request body", Error: err.Error()})
}
if req.SubModuleID <= 0 || req.QuestionSetID <= 0 || strings.TrimSpace(req.Title) == "" {
return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{Message: "sub_module_id, title and question_set_id are required"})
}
created, err := h.analyticsDB.CreateSubModulePractice(c.Context(), dbgen.CreateSubModulePracticeParams{
SubModuleID: req.SubModuleID,
Title: req.Title,
Description: toText(req.Description),
Thumbnail: toText(req.Thumbnail),
IntroVideoUrl: toText(req.IntroVideoURL),
QuestionSetID: req.QuestionSetID,
Column7: intOrNil(req.DisplayOrder),
Column8: boolOrNil(req.IsActive),
})
if err != nil {
return c.Status(fiber.StatusInternalServerError).JSON(domain.ErrorResponse{Message: "Failed to create practice", Error: err.Error()})
}
return c.Status(fiber.StatusCreated).JSON(domain.Response{Message: "Practice created", Data: created})
}

View File

@ -1,541 +0,0 @@
package handlers
import (
"Yimaru-Backend/internal/domain"
"errors"
"math"
"strconv"
"time"
"github.com/gofiber/fiber/v2"
)
// --- Request / Response types ---
type addPrerequisiteReq struct {
PrerequisiteSubCourseID int64 `json:"prerequisite_sub_course_id" validate:"required"`
}
type prerequisiteRes struct {
ID int64 `json:"id"`
SubCourseID int64 `json:"sub_course_id"`
PrerequisiteSubCourseID int64 `json:"prerequisite_sub_course_id"`
PrerequisiteTitle string `json:"prerequisite_title"`
PrerequisiteLevel string `json:"prerequisite_level"`
PrerequisiteDisplayOrder int32 `json:"prerequisite_display_order"`
}
type dependentRes struct {
ID int64 `json:"id"`
SubCourseID int64 `json:"sub_course_id"`
PrerequisiteSubCourseID int64 `json:"prerequisite_sub_course_id"`
DependentTitle string `json:"dependent_title"`
DependentLevel string `json:"dependent_level"`
}
type updateProgressReq struct {
ProgressPercentage int16 `json:"progress_percentage" validate:"required,min=0,max=100"`
}
type subCourseProgressRes struct {
SubCourseID int64 `json:"sub_course_id"`
Title string `json:"title"`
Description *string `json:"description,omitempty"`
Thumbnail *string `json:"thumbnail,omitempty"`
DisplayOrder int32 `json:"display_order"`
Level string `json:"level"`
ProgressStatus string `json:"progress_status"`
ProgressPercentage int16 `json:"progress_percentage"`
StartedAt *time.Time `json:"started_at,omitempty"`
CompletedAt *time.Time `json:"completed_at,omitempty"`
IsLocked bool `json:"is_locked"`
}
type userProgressRes struct {
SubCourseID int64 `json:"sub_course_id"`
SubCourseTitle string `json:"sub_course_title"`
SubCourseLevel string `json:"sub_course_level"`
Status string `json:"status"`
ProgressPercentage int16 `json:"progress_percentage"`
StartedAt *time.Time `json:"started_at,omitempty"`
CompletedAt *time.Time `json:"completed_at,omitempty"`
}
type courseProgressSummaryRes struct {
CourseID int64 `json:"course_id"`
LearnerUserID int64 `json:"learner_user_id"`
OverallProgressPercentage int16 `json:"overall_progress_percentage"`
TotalSubCourses int32 `json:"total_sub_courses"`
CompletedSubCourses int32 `json:"completed_sub_courses"`
InProgressSubCourses int32 `json:"in_progress_sub_courses"`
NotStartedSubCourses int32 `json:"not_started_sub_courses"`
LockedSubCourses int32 `json:"locked_sub_courses"`
}
func mapSubCourseProgress(items []domain.SubCourseWithProgress) []subCourseProgressRes {
res := make([]subCourseProgressRes, 0, len(items))
for _, item := range items {
res = append(res, subCourseProgressRes{
SubCourseID: item.SubCourseID,
Title: item.Title,
Description: item.Description,
Thumbnail: item.Thumbnail,
DisplayOrder: item.DisplayOrder,
Level: item.Level,
ProgressStatus: string(item.ProgressStatus),
ProgressPercentage: item.ProgressPercentage,
StartedAt: item.StartedAt,
CompletedAt: item.CompletedAt,
IsLocked: item.IsLocked,
})
}
return res
}
// --- Prerequisite Handlers (admin) ---
// AddSubCoursePrerequisite godoc
// @Summary Add prerequisite to sub-course
// @Description Link a prerequisite sub-course that must be completed before accessing this sub-course
// @Tags progression
// @Accept json
// @Produce json
// @Param id path int true "Sub-course ID"
// @Param body body addPrerequisiteReq true "Prerequisite sub-course ID"
// @Success 201 {object} domain.Response
// @Failure 400 {object} domain.ErrorResponse
// @Failure 500 {object} domain.ErrorResponse
// @Router /api/v1/course-management/sub-courses/{id}/prerequisites [post]
func (h *Handler) AddSubCoursePrerequisite(c *fiber.Ctx) error {
subCourseID, err := strconv.ParseInt(c.Params("id"), 10, 64)
if err != nil {
return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{
Message: "Invalid sub-course ID",
Error: err.Error(),
})
}
var req addPrerequisiteReq
if err := c.BodyParser(&req); err != nil {
return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{
Message: "Invalid request body",
Error: err.Error(),
})
}
if err := h.courseMgmtSvc.AddSubCoursePrerequisite(c.Context(), subCourseID, req.PrerequisiteSubCourseID); err != nil {
if errors.Is(err, domain.ErrSelfPrerequisite) {
return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{
Message: "Invalid prerequisite",
Error: err.Error(),
})
}
return c.Status(fiber.StatusInternalServerError).JSON(domain.ErrorResponse{
Message: "Failed to add prerequisite",
Error: err.Error(),
})
}
return c.Status(fiber.StatusCreated).JSON(domain.Response{
Message: "Prerequisite added successfully",
})
}
// GetSubCoursePrerequisites godoc
// @Summary Get sub-course prerequisites
// @Description Returns all prerequisites for a sub-course
// @Tags progression
// @Produce json
// @Param id path int true "Sub-course ID"
// @Success 200 {object} domain.Response
// @Failure 400 {object} domain.ErrorResponse
// @Failure 500 {object} domain.ErrorResponse
// @Router /api/v1/course-management/sub-courses/{id}/prerequisites [get]
func (h *Handler) GetSubCoursePrerequisites(c *fiber.Ctx) error {
subCourseID, err := strconv.ParseInt(c.Params("id"), 10, 64)
if err != nil {
return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{
Message: "Invalid sub-course ID",
Error: err.Error(),
})
}
prerequisites, err := h.courseMgmtSvc.GetSubCoursePrerequisites(c.Context(), subCourseID)
if err != nil {
return c.Status(fiber.StatusInternalServerError).JSON(domain.ErrorResponse{
Message: "Failed to get prerequisites",
Error: err.Error(),
})
}
var res []prerequisiteRes
for _, p := range prerequisites {
res = append(res, prerequisiteRes{
ID: p.ID,
SubCourseID: p.SubCourseID,
PrerequisiteSubCourseID: p.PrerequisiteSubCourseID,
PrerequisiteTitle: p.PrerequisiteTitle,
PrerequisiteLevel: p.PrerequisiteLevel,
PrerequisiteDisplayOrder: p.PrerequisiteDisplayOrder,
})
}
return c.JSON(domain.Response{
Message: "Prerequisites retrieved successfully",
Data: res,
})
}
// RemoveSubCoursePrerequisite godoc
// @Summary Remove prerequisite from sub-course
// @Description Unlink a prerequisite from a sub-course
// @Tags progression
// @Produce json
// @Param id path int true "Sub-course ID"
// @Param prerequisiteId path int true "Prerequisite sub-course ID"
// @Success 200 {object} domain.Response
// @Failure 400 {object} domain.ErrorResponse
// @Failure 500 {object} domain.ErrorResponse
// @Router /api/v1/course-management/sub-courses/{id}/prerequisites/{prerequisiteId} [delete]
func (h *Handler) RemoveSubCoursePrerequisite(c *fiber.Ctx) error {
subCourseID, err := strconv.ParseInt(c.Params("id"), 10, 64)
if err != nil {
return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{
Message: "Invalid sub-course ID",
Error: err.Error(),
})
}
prerequisiteID, err := strconv.ParseInt(c.Params("prerequisiteId"), 10, 64)
if err != nil {
return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{
Message: "Invalid prerequisite ID",
Error: err.Error(),
})
}
if err := h.courseMgmtSvc.RemoveSubCoursePrerequisite(c.Context(), subCourseID, prerequisiteID); err != nil {
return c.Status(fiber.StatusInternalServerError).JSON(domain.ErrorResponse{
Message: "Failed to remove prerequisite",
Error: err.Error(),
})
}
return c.JSON(domain.Response{
Message: "Prerequisite removed successfully",
})
}
// --- User Progress Handlers ---
// StartSubCourse godoc
// @Summary Start a sub-course
// @Description Mark a sub-course as started for the authenticated user (checks prerequisites)
// @Tags progression
// @Produce json
// @Param id path int true "Sub-course ID"
// @Success 201 {object} domain.Response
// @Failure 400 {object} domain.ErrorResponse
// @Failure 403 {object} domain.ErrorResponse
// @Failure 500 {object} domain.ErrorResponse
// @Router /api/v1/progress/sub-courses/{id}/start [post]
func (h *Handler) StartSubCourse(c *fiber.Ctx) error {
userID := c.Locals("user_id").(int64)
subCourseID, err := strconv.ParseInt(c.Params("id"), 10, 64)
if err != nil {
return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{
Message: "Invalid sub-course ID",
Error: err.Error(),
})
}
progress, err := h.courseMgmtSvc.StartSubCourse(c.Context(), userID, subCourseID)
if err != nil {
if errors.Is(err, domain.ErrPrerequisiteNotMet) {
return c.Status(fiber.StatusForbidden).JSON(domain.ErrorResponse{
Message: "Cannot start sub-course",
Error: "Prerequisites not completed",
})
}
return c.Status(fiber.StatusInternalServerError).JSON(domain.ErrorResponse{
Message: "Failed to start sub-course",
Error: err.Error(),
})
}
return c.Status(fiber.StatusCreated).JSON(domain.Response{
Message: "Sub-course started",
Data: userProgressRes{
SubCourseID: progress.SubCourseID,
Status: string(progress.Status),
ProgressPercentage: progress.ProgressPercentage,
StartedAt: progress.StartedAt,
},
})
}
// UpdateSubCourseProgress godoc
// @Summary Update sub-course progress
// @Description Update the progress percentage for a sub-course
// @Tags progression
// @Accept json
// @Produce json
// @Param id path int true "Sub-course ID"
// @Param body body updateProgressReq true "Progress update"
// @Success 200 {object} domain.Response
// @Failure 400 {object} domain.ErrorResponse
// @Failure 500 {object} domain.ErrorResponse
// @Router /api/v1/progress/sub-courses/{id} [put]
func (h *Handler) UpdateSubCourseProgress(c *fiber.Ctx) error {
userID := c.Locals("user_id").(int64)
subCourseID, err := strconv.ParseInt(c.Params("id"), 10, 64)
if err != nil {
return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{
Message: "Invalid sub-course ID",
Error: err.Error(),
})
}
var req updateProgressReq
if err := c.BodyParser(&req); err != nil {
return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{
Message: "Invalid request body",
Error: err.Error(),
})
}
if err := h.courseMgmtSvc.UpdateSubCourseProgress(c.Context(), userID, subCourseID, req.ProgressPercentage); err != nil {
return c.Status(fiber.StatusInternalServerError).JSON(domain.ErrorResponse{
Message: "Failed to update progress",
Error: err.Error(),
})
}
return c.JSON(domain.Response{
Message: "Progress updated successfully",
})
}
// CompleteSubCourse godoc
// @Summary Complete a sub-course
// @Description Mark a sub-course as completed for the authenticated user
// @Tags progression
// @Produce json
// @Param id path int true "Sub-course ID"
// @Success 200 {object} domain.Response
// @Failure 400 {object} domain.ErrorResponse
// @Failure 500 {object} domain.ErrorResponse
// @Router /api/v1/progress/sub-courses/{id}/complete [post]
func (h *Handler) CompleteSubCourse(c *fiber.Ctx) error {
userID := c.Locals("user_id").(int64)
subCourseID, err := strconv.ParseInt(c.Params("id"), 10, 64)
if err != nil {
return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{
Message: "Invalid sub-course ID",
Error: err.Error(),
})
}
if err := h.courseMgmtSvc.CompleteSubCourse(c.Context(), userID, subCourseID); err != nil {
return c.Status(fiber.StatusInternalServerError).JSON(domain.ErrorResponse{
Message: "Failed to complete sub-course",
Error: err.Error(),
})
}
return c.JSON(domain.Response{
Message: "Sub-course completed",
})
}
// CheckSubCourseAccess godoc
// @Summary Check sub-course access
// @Description Check if the authenticated user has completed all prerequisites for a sub-course
// @Tags progression
// @Produce json
// @Param id path int true "Sub-course ID"
// @Success 200 {object} domain.Response
// @Failure 400 {object} domain.ErrorResponse
// @Failure 500 {object} domain.ErrorResponse
// @Router /api/v1/progress/sub-courses/{id}/access [get]
func (h *Handler) CheckSubCourseAccess(c *fiber.Ctx) error {
userID := c.Locals("user_id").(int64)
subCourseID, err := strconv.ParseInt(c.Params("id"), 10, 64)
if err != nil {
return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{
Message: "Invalid sub-course ID",
Error: err.Error(),
})
}
accessible, err := h.courseMgmtSvc.CheckSubCourseAccess(c.Context(), userID, subCourseID)
if err != nil {
return c.Status(fiber.StatusInternalServerError).JSON(domain.ErrorResponse{
Message: "Failed to check access",
Error: err.Error(),
})
}
return c.JSON(domain.Response{
Message: "Access check completed",
Data: fiber.Map{
"accessible": accessible,
},
})
}
// GetUserCourseProgress godoc
// @Summary Get user's course progress
// @Description Returns the authenticated user's progress for all sub-courses in a course, including lock status
// @Tags progression
// @Produce json
// @Param courseId path int true "Course ID"
// @Success 200 {object} domain.Response
// @Failure 400 {object} domain.ErrorResponse
// @Failure 500 {object} domain.ErrorResponse
// @Router /api/v1/progress/courses/{courseId} [get]
func (h *Handler) GetUserCourseProgress(c *fiber.Ctx) error {
userID := c.Locals("user_id").(int64)
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(),
})
}
items, err := h.courseMgmtSvc.GetSubCoursesWithProgress(c.Context(), userID, courseID)
if err != nil {
return c.Status(fiber.StatusInternalServerError).JSON(domain.ErrorResponse{
Message: "Failed to get course progress",
Error: err.Error(),
})
}
return c.JSON(domain.Response{
Message: "Course progress retrieved successfully",
Data: mapSubCourseProgress(items),
})
}
// GetUserCourseProgressForAdmin godoc
// @Summary Get learner's course progress (admin)
// @Description Returns a target learner's progress for all sub-courses in a course, including lock status
// @Tags progression
// @Produce json
// @Param userId path int true "Learner User ID"
// @Param courseId path int true "Course ID"
// @Success 200 {object} domain.Response
// @Failure 400 {object} domain.ErrorResponse
// @Failure 500 {object} domain.ErrorResponse
// @Router /api/v1/admin/users/{userId}/progress/courses/{courseId} [get]
func (h *Handler) GetUserCourseProgressForAdmin(c *fiber.Ctx) error {
targetUserID, err := strconv.ParseInt(c.Params("userId"), 10, 64)
if err != nil {
return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{
Message: "Invalid user ID",
Error: err.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(),
})
}
items, err := h.courseMgmtSvc.GetSubCoursesWithProgress(c.Context(), targetUserID, courseID)
if err != nil {
return c.Status(fiber.StatusInternalServerError).JSON(domain.ErrorResponse{
Message: "Failed to get learner course progress",
Error: err.Error(),
})
}
return c.JSON(domain.Response{
Message: "Learner course progress retrieved successfully",
Data: mapSubCourseProgress(items),
})
}
// GetUserCourseProgressSummaryForAdmin godoc
// @Summary Get learner's course progress summary (admin)
// @Description Returns course-level aggregated progress metrics for a target learner
// @Tags progression
// @Produce json
// @Param userId path int true "Learner User ID"
// @Param courseId path int true "Course ID"
// @Success 200 {object} domain.Response
// @Failure 400 {object} domain.ErrorResponse
// @Failure 500 {object} domain.ErrorResponse
// @Router /api/v1/admin/users/{userId}/progress/courses/{courseId}/summary [get]
func (h *Handler) GetUserCourseProgressSummaryForAdmin(c *fiber.Ctx) error {
targetUserID, err := strconv.ParseInt(c.Params("userId"), 10, 64)
if err != nil {
return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{
Message: "Invalid user ID",
Error: err.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(),
})
}
items, err := h.courseMgmtSvc.GetSubCoursesWithProgress(c.Context(), targetUserID, courseID)
if err != nil {
return c.Status(fiber.StatusInternalServerError).JSON(domain.ErrorResponse{
Message: "Failed to get learner course progress summary",
Error: err.Error(),
})
}
var (
completedCount int32
inProgressCount int32
notStartedCount int32
lockedCount int32
sumPercentage int64
)
for _, item := range items {
sumPercentage += int64(item.ProgressPercentage)
switch item.ProgressStatus {
case domain.ProgressStatusCompleted:
completedCount++
case domain.ProgressStatusInProgress:
inProgressCount++
default:
notStartedCount++
}
if item.IsLocked {
lockedCount++
}
}
totalSubCourses := int32(len(items))
overall := int16(0)
if totalSubCourses > 0 {
overall = int16(math.Round(float64(sumPercentage) / float64(totalSubCourses)))
}
return c.JSON(domain.Response{
Message: "Learner course progress summary retrieved successfully",
Data: courseProgressSummaryRes{
CourseID: courseID,
LearnerUserID: targetUserID,
OverallProgressPercentage: overall,
TotalSubCourses: totalSubCourses,
CompletedSubCourses: completedCount,
InProgressSubCourses: inProgressCount,
NotStartedSubCourses: notStartedCount,
LockedSubCourses: lockedCount,
},
})
}

View File

@ -1350,19 +1350,6 @@ func (h *Handler) CompletePractice(c *fiber.Ctx) error {
Error: err.Error(), Error: err.Error(),
}) })
} }
if set.OwnerID == nil {
return c.Status(fiber.StatusInternalServerError).JSON(domain.ErrorResponse{
Message: "Failed to update sub-course progress",
Error: "practice owner is missing",
})
}
if err := h.courseMgmtSvc.RecalculateSubCourseProgress(c.Context(), userID, *set.OwnerID); err != nil {
return c.Status(fiber.StatusInternalServerError).JSON(domain.ErrorResponse{
Message: "Failed to update sub-course progress",
Error: err.Error(),
})
}
return c.JSON(domain.Response{ return c.JSON(domain.Response{
Message: "Practice completed", Message: "Practice completed",
}) })

View File

@ -0,0 +1,85 @@
package handlers
import (
"bytes"
"io"
"net/http"
"os"
"path/filepath"
"Yimaru-Backend/internal/domain"
"github.com/gofiber/fiber/v2"
"github.com/google/uuid"
"go.uber.org/zap"
)
func (h *Handler) processAndSaveThumbnail(c *fiber.Ctx, subDir string) (string, error) {
fileHeader, err := c.FormFile("file")
if err != nil {
return "", c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{Message: "Image file is required", Error: err.Error()})
}
if fileHeader.Size > 10*1024*1024 {
return "", c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{Message: "File too large", Error: "Thumbnail image must be <= 10MB"})
}
fh, err := fileHeader.Open()
if err != nil {
return "", c.Status(fiber.StatusInternalServerError).JSON(domain.ErrorResponse{Message: "Failed to read file", Error: err.Error()})
}
defer fh.Close()
head := make([]byte, 512)
n, _ := fh.Read(head)
contentType := http.DetectContentType(head[:n])
if contentType != "image/jpeg" && contentType != "image/png" && contentType != "image/webp" {
return "", c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{Message: "Invalid file type", Error: "Only jpg, png and webp images are allowed"})
}
rest, err := io.ReadAll(fh)
if err != nil {
return "", c.Status(fiber.StatusInternalServerError).JSON(domain.ErrorResponse{Message: "Failed to read file", Error: err.Error()})
}
data := append(head[:n], rest...)
if h.cloudConvertSvc != nil {
optimized, optErr := h.cloudConvertSvc.OptimizeImage(c.Context(), fileHeader.Filename, bytes.NewReader(data), int64(len(data)), 1200, 80)
if optErr != nil {
h.mongoLoggerSvc.Warn("CloudConvert thumbnail optimization failed, using original", zap.Error(optErr))
} else {
optimizedData, readErr := io.ReadAll(optimized.Data)
optimized.Data.Close()
if readErr == nil {
data = optimizedData
contentType = "image/webp"
}
}
}
if h.minioSvc != nil {
result, uploadErr := h.minioSvc.Upload(c.Context(), subDir, fileHeader.Filename, bytes.NewReader(data), int64(len(data)), contentType)
if uploadErr != nil {
return "", c.Status(fiber.StatusInternalServerError).JSON(domain.ErrorResponse{Message: "Failed to upload file to storage", Error: uploadErr.Error()})
}
return "minio://" + result.ObjectKey, nil
}
ext := ".jpg"
if contentType == "image/png" {
ext = ".png"
}
if contentType == "image/webp" {
ext = ".webp"
}
dir := filepath.Join(".", "static", subDir)
if err := os.MkdirAll(dir, 0o755); err != nil {
return "", c.Status(fiber.StatusInternalServerError).JSON(domain.ErrorResponse{Message: "Failed to create storage directory", Error: err.Error()})
}
filename := uuid.New().String() + ext
fullpath := filepath.Join(dir, filename)
if err := os.WriteFile(fullpath, data, 0o644); err != nil {
return "", c.Status(fiber.StatusInternalServerError).JSON(domain.ErrorResponse{Message: "Failed to save file", Error: err.Error()})
}
return "/static/" + subDir + "/" + filename, nil
}

View File

@ -78,60 +78,16 @@ func (a *App) initAppRoutes() {
groupV1.Get("/assessment/questions", h.ListAssessmentQuestions) groupV1.Get("/assessment/questions", h.ListAssessmentQuestions)
groupV1.Get("/assessment/questions/:id", h.GetAssessmentQuestionByID) groupV1.Get("/assessment/questions/:id", h.GetAssessmentQuestionByID)
// Reorder (drag-and-drop support) // Unified Course Management (single hierarchy model)
// Keep static reorder routes before dynamic `/:id` routes to avoid route collisions groupV1.Get("/course-management/hierarchy", a.authMiddleware, a.RequirePermission("learning_tree.get"), h.UnifiedHierarchy)
// (e.g., `/courses/reorder` being parsed as `/courses/:id`). groupV1.Get("/course-management/courses/:courseId/hierarchy", a.authMiddleware, a.RequirePermission("learning_tree.get"), h.UnifiedHierarchyByCourse)
groupV1.Put("/course-management/categories/reorder", a.authMiddleware, a.RequirePermission("course_categories.reorder"), h.ReorderCourseCategories) groupV1.Post("/course-management/sub-categories", a.authMiddleware, a.RequirePermission("course_categories.create"), h.CreateCourseSubCategory)
groupV1.Put("/course-management/courses/reorder", a.authMiddleware, a.RequirePermission("courses.reorder"), h.ReorderCourses) groupV1.Post("/course-management/levels", a.authMiddleware, a.RequirePermission("subcourses.create"), h.CreateLevel)
groupV1.Put("/course-management/sub-courses/reorder", a.authMiddleware, a.RequirePermission("subcourses.reorder"), h.ReorderSubCourses) groupV1.Post("/course-management/modules", a.authMiddleware, a.RequirePermission("subcourses.create"), h.CreateModule)
groupV1.Put("/course-management/videos/reorder", a.authMiddleware, a.RequirePermission("videos.reorder"), h.ReorderSubCourseVideos) groupV1.Post("/course-management/sub-modules", a.authMiddleware, a.RequirePermission("subcourses.create"), h.CreateSubModule)
groupV1.Put("/course-management/practices/reorder", a.authMiddleware, a.RequirePermission("practices.reorder"), h.ReorderPractices) groupV1.Post("/course-management/sub-module-videos", a.authMiddleware, a.RequirePermission("videos.create"), h.CreateSubModuleVideo)
groupV1.Post("/course-management/sub-module-lessons", a.authMiddleware, a.RequirePermission("question_sets.update"), h.AttachSubModuleLesson)
// Course Categories groupV1.Post("/course-management/sub-module-practices", a.authMiddleware, a.RequirePermission("question_sets.update"), h.CreateSubModulePractice)
groupV1.Post("/course-management/categories", a.authMiddleware, a.RequirePermission("course_categories.create"), h.CreateCourseCategory)
groupV1.Get("/course-management/categories", a.authMiddleware, a.RequirePermission("course_categories.list"), h.GetAllCourseCategories)
groupV1.Get("/course-management/categories/:id", a.authMiddleware, a.RequirePermission("course_categories.get"), h.GetCourseCategoryByID)
groupV1.Put("/course-management/categories/:id", a.authMiddleware, a.RequirePermission("course_categories.update"), h.UpdateCourseCategory)
groupV1.Delete("/course-management/categories/:id", a.authMiddleware, a.RequirePermission("course_categories.delete"), h.DeleteCourseCategory)
// Courses
groupV1.Post("/course-management/courses", a.authMiddleware, a.RequirePermission("courses.create"), h.CreateCourse)
groupV1.Get("/course-management/courses/:id", a.authMiddleware, a.RequirePermission("courses.get"), h.GetCourseByID)
groupV1.Get("/course-management/categories/:categoryId/courses", a.authMiddleware, a.RequirePermission("courses.list_by_category"), h.GetCoursesByCategory)
groupV1.Put("/course-management/courses/:id", a.authMiddleware, a.RequirePermission("courses.update"), h.UpdateCourse)
groupV1.Post("/course-management/courses/:id/thumbnail", a.authMiddleware, a.RequirePermission("courses.upload_thumbnail"), h.UploadCourseThumbnail)
groupV1.Delete("/course-management/courses/:id", a.authMiddleware, a.RequirePermission("courses.delete"), h.DeleteCourse)
// Sub-courses
groupV1.Post("/course-management/sub-courses", a.authMiddleware, a.RequirePermission("subcourses.create"), h.CreateSubCourse)
groupV1.Get("/course-management/sub-courses/:id", a.authMiddleware, a.RequirePermission("subcourses.get"), h.GetSubCourseByID)
groupV1.Get("/course-management/courses/:courseId/sub-courses", a.authMiddleware, a.RequirePermission("subcourses.list_by_course"), h.GetSubCoursesByCourse)
groupV1.Get("/course-management/courses/:courseId/sub-courses/list", a.authMiddleware, a.RequirePermission("subcourses.list_by_course_list"), h.ListSubCoursesByCourse)
groupV1.Get("/course-management/sub-courses/active", a.authMiddleware, a.RequirePermission("subcourses.list_active"), h.ListActiveSubCourses)
groupV1.Patch("/course-management/sub-courses/:id", a.authMiddleware, a.RequirePermission("subcourses.update"), h.UpdateSubCourse)
groupV1.Post("/course-management/sub-courses/:id/thumbnail", a.authMiddleware, a.RequirePermission("subcourses.upload_thumbnail"), h.UploadSubCourseThumbnail)
groupV1.Put("/course-management/sub-courses/:id/deactivate", a.authMiddleware, a.RequirePermission("subcourses.deactivate"), h.DeactivateSubCourse)
groupV1.Delete("/course-management/sub-courses/:id", a.authMiddleware, a.RequirePermission("subcourses.delete"), h.DeleteSubCourse)
// Sub-course Videos
groupV1.Post("/course-management/videos", a.authMiddleware, a.RequirePermission("videos.create"), h.CreateSubCourseVideo)
groupV1.Post("/course-management/videos/vimeo", a.authMiddleware, a.RequirePermission("videos.create_vimeo"), h.CreateSubCourseVideoWithVimeo)
groupV1.Post("/course-management/videos/upload", a.authMiddleware, a.RequirePermission("videos.upload"), h.UploadSubCourseVideo)
groupV1.Post("/course-management/videos/vimeo/import", a.authMiddleware, a.RequirePermission("videos.import_vimeo"), h.CreateSubCourseVideoFromVimeoID)
groupV1.Get("/course-management/videos/:id", a.authMiddleware, a.RequirePermission("videos.get"), h.GetSubCourseVideoByID)
groupV1.Get("/course-management/sub-courses/:subCourseId/videos", a.authMiddleware, a.RequirePermission("videos.list_by_subcourse"), h.GetVideosBySubCourse)
groupV1.Get("/course-management/sub-courses/:subCourseId/videos/published", a.authMiddleware, a.RequirePermission("videos.list_published"), h.GetPublishedVideosBySubCourse)
groupV1.Put("/course-management/videos/:id/publish", a.authMiddleware, a.RequirePermission("videos.publish"), h.PublishSubCourseVideo)
groupV1.Put("/course-management/videos/:id", a.authMiddleware, a.RequirePermission("videos.update"), h.UpdateSubCourseVideo)
groupV1.Delete("/course-management/videos/:id", a.authMiddleware, a.RequirePermission("videos.delete"), h.DeleteSubCourseVideo)
// Learning Tree
groupV1.Get("/course-management/learning-tree", a.authMiddleware, a.RequirePermission("learning_tree.get"), h.GetFullLearningTree)
groupV1.Get("/course-management/courses/:courseId/learning-path", a.authMiddleware, a.RequirePermission("learning_tree.get"), h.GetCourseLearningPath)
groupV1.Get("/course-management/human-language/hierarchy", a.authMiddleware, a.RequirePermission("learning_tree.get"), h.GetHumanLanguageHierarchy)
groupV1.Get("/course-management/human-language/courses/:courseId/lessons", a.authMiddleware, a.RequirePermission("learning_tree.get"), h.GetHumanLanguageLessonsByCourse)
groupV1.Post("/course-management/human-language/lessons", a.authMiddleware, a.RequirePermission("subcourses.create"), h.CreateHumanLanguageLesson)
groupV1.Patch("/course-management/human-language/lessons/:id", a.authMiddleware, a.RequirePermission("subcourses.update"), h.UpdateHumanLanguageLesson)
// Questions // Questions
groupV1.Post("/questions", a.authMiddleware, a.RequirePermission("questions.create"), h.CreateQuestion) groupV1.Post("/questions", a.authMiddleware, a.RequirePermission("questions.create"), h.CreateQuestion)
@ -145,7 +101,6 @@ func (a *App) initAppRoutes() {
groupV1.Post("/question-sets", a.authMiddleware, a.RequirePermission("question_sets.create"), h.CreateQuestionSet) groupV1.Post("/question-sets", a.authMiddleware, a.RequirePermission("question_sets.create"), h.CreateQuestionSet)
groupV1.Get("/question-sets", a.authMiddleware, a.RequirePermission("question_sets.list"), h.GetQuestionSetsByType) groupV1.Get("/question-sets", a.authMiddleware, a.RequirePermission("question_sets.list"), h.GetQuestionSetsByType)
groupV1.Get("/question-sets/by-owner", a.authMiddleware, a.RequirePermission("question_sets.list_by_owner"), h.GetQuestionSetsByOwner) groupV1.Get("/question-sets/by-owner", a.authMiddleware, a.RequirePermission("question_sets.list_by_owner"), h.GetQuestionSetsByOwner)
groupV1.Get("/question-sets/sub-courses/:subCourseId/entry-assessment", a.authMiddleware, a.RequirePermission("question_sets.list_by_owner"), h.GetSubCourseEntryAssessmentSet)
groupV1.Get("/question-sets/:id", a.authMiddleware, a.RequirePermission("question_sets.get"), h.GetQuestionSetByID) groupV1.Get("/question-sets/:id", a.authMiddleware, a.RequirePermission("question_sets.get"), h.GetQuestionSetByID)
groupV1.Put("/question-sets/:id", a.authMiddleware, a.RequirePermission("question_sets.update"), h.UpdateQuestionSet) groupV1.Put("/question-sets/:id", a.authMiddleware, a.RequirePermission("question_sets.update"), h.UpdateQuestionSet)
groupV1.Delete("/question-sets/:id", a.authMiddleware, a.RequirePermission("question_sets.delete"), h.DeleteQuestionSet) groupV1.Delete("/question-sets/:id", a.authMiddleware, a.RequirePermission("question_sets.delete"), h.DeleteQuestionSet)
@ -336,21 +291,7 @@ func (a *App) initAppRoutes() {
teamGroup.Delete("/members/:id", a.authMiddleware, a.RequirePermission("team.members.delete"), h.DeleteTeamMember) teamGroup.Delete("/members/:id", a.authMiddleware, a.RequirePermission("team.members.delete"), h.DeleteTeamMember)
teamGroup.Post("/members/:id/change-password", a.authMiddleware, a.RequirePermission("team.members.change_password"), h.ChangeTeamMemberPassword) teamGroup.Post("/members/:id/change-password", a.authMiddleware, a.RequirePermission("team.members.change_password"), h.ChangeTeamMemberPassword)
// Sub-course Prerequisites // Legacy sub-course prerequisite/progression routes removed after hierarchy cutover.
groupV1.Post("/course-management/sub-courses/:id/prerequisites", a.authMiddleware, a.RequirePermission("subcourse_prerequisites.add"), h.AddSubCoursePrerequisite)
groupV1.Get("/course-management/sub-courses/:id/prerequisites", a.authMiddleware, a.RequirePermission("subcourse_prerequisites.list"), h.GetSubCoursePrerequisites)
groupV1.Delete("/course-management/sub-courses/:id/prerequisites/:prerequisiteId", a.authMiddleware, a.RequirePermission("subcourse_prerequisites.remove"), h.RemoveSubCoursePrerequisite)
// User Progression
groupV1.Post("/progress/sub-courses/:id/start", a.authMiddleware, a.RequirePermission("progress.start"), h.StartSubCourse)
groupV1.Put("/progress/sub-courses/:id", a.authMiddleware, a.RequirePermission("progress.update"), h.UpdateSubCourseProgress)
groupV1.Post("/progress/sub-courses/:id/complete", a.authMiddleware, a.RequirePermission("progress.complete"), h.CompleteSubCourse)
groupV1.Post("/progress/videos/:id/complete", a.authMiddleware, a.RequirePermission("progress.update"), h.CompleteSubCourseVideo)
groupV1.Post("/progress/practices/:id/complete", a.authMiddleware, a.RequirePermission("progress.update"), h.CompletePractice)
groupV1.Get("/progress/sub-courses/:id/access", a.authMiddleware, a.RequirePermission("progress.check_access"), h.CheckSubCourseAccess)
groupV1.Get("/progress/courses/:courseId", a.authMiddleware, a.RequirePermission("progress.get_course"), h.GetUserCourseProgress)
groupV1.Get("/admin/users/:userId/progress/courses/:courseId", a.authMiddleware, a.RequirePermission("progress.get_any_user"), h.GetUserCourseProgressForAdmin)
groupV1.Get("/admin/users/:userId/progress/courses/:courseId/summary", a.authMiddleware, a.RequirePermission("progress.get_any_user"), h.GetUserCourseProgressSummaryForAdmin)
// Ratings // Ratings
groupV1.Post("/ratings", a.authMiddleware, a.RequirePermission("ratings.submit"), h.SubmitRating) groupV1.Post("/ratings", a.authMiddleware, a.RequirePermission("ratings.submit"), h.SubmitRating)