diff --git a/db/migrations/000078_hierarchy_publish_status.down.sql b/db/migrations/000078_hierarchy_publish_status.down.sql new file mode 100644 index 0000000..8374eaf --- /dev/null +++ b/db/migrations/000078_hierarchy_publish_status.down.sql @@ -0,0 +1,27 @@ +ALTER TABLE programs DROP CONSTRAINT IF EXISTS chk_programs_publish_status; + +ALTER TABLE programs DROP COLUMN IF EXISTS publish_status; + +ALTER TABLE courses DROP CONSTRAINT IF EXISTS chk_courses_publish_status; + +ALTER TABLE courses DROP COLUMN IF EXISTS publish_status; + +ALTER TABLE modules DROP CONSTRAINT IF EXISTS chk_modules_publish_status; + +ALTER TABLE modules DROP COLUMN IF EXISTS publish_status; + +ALTER TABLE exam_prep.catalog_courses DROP CONSTRAINT IF EXISTS chk_exam_prep_catalog_courses_publish_status; + +ALTER TABLE exam_prep.catalog_courses DROP COLUMN IF EXISTS publish_status; + +ALTER TABLE exam_prep.units DROP CONSTRAINT IF EXISTS chk_exam_prep_units_publish_status; + +ALTER TABLE exam_prep.units DROP COLUMN IF EXISTS publish_status; + +ALTER TABLE exam_prep.unit_modules DROP CONSTRAINT IF EXISTS chk_exam_prep_unit_modules_publish_status; + +ALTER TABLE exam_prep.unit_modules DROP COLUMN IF EXISTS publish_status; + +ALTER TABLE exam_prep.unit_module_lessons DROP CONSTRAINT IF EXISTS chk_exam_prep_unit_module_lessons_publish_status; + +ALTER TABLE exam_prep.unit_module_lessons DROP COLUMN IF EXISTS publish_status; diff --git a/db/migrations/000078_hierarchy_publish_status.up.sql b/db/migrations/000078_hierarchy_publish_status.up.sql new file mode 100644 index 0000000..2dbc330 --- /dev/null +++ b/db/migrations/000078_hierarchy_publish_status.up.sql @@ -0,0 +1,54 @@ +-- Draft vs published visibility for the remaining LMS and exam-prep hierarchy levels +-- (mirrors lessons.publish_status from 000062 and practice publish_status from 000060). +-- Existing rows stay PUBLISHED; new inserts default to DRAFT unless the API sends PUBLISHED. + +-- LMS hierarchy +ALTER TABLE programs + ADD COLUMN publish_status VARCHAR(16) NOT NULL DEFAULT 'PUBLISHED' + CONSTRAINT chk_programs_publish_status CHECK (publish_status IN ('DRAFT', 'PUBLISHED')); + +ALTER TABLE programs + ALTER COLUMN publish_status SET DEFAULT 'DRAFT'; + +ALTER TABLE courses + ADD COLUMN publish_status VARCHAR(16) NOT NULL DEFAULT 'PUBLISHED' + CONSTRAINT chk_courses_publish_status CHECK (publish_status IN ('DRAFT', 'PUBLISHED')); + +ALTER TABLE courses + ALTER COLUMN publish_status SET DEFAULT 'DRAFT'; + +ALTER TABLE modules + ADD COLUMN publish_status VARCHAR(16) NOT NULL DEFAULT 'PUBLISHED' + CONSTRAINT chk_modules_publish_status CHECK (publish_status IN ('DRAFT', 'PUBLISHED')); + +ALTER TABLE modules + ALTER COLUMN publish_status SET DEFAULT 'DRAFT'; + +-- Exam-prep hierarchy +ALTER TABLE exam_prep.catalog_courses + ADD COLUMN publish_status VARCHAR(16) NOT NULL DEFAULT 'PUBLISHED' + CONSTRAINT chk_exam_prep_catalog_courses_publish_status CHECK (publish_status IN ('DRAFT', 'PUBLISHED')); + +ALTER TABLE exam_prep.catalog_courses + ALTER COLUMN publish_status SET DEFAULT 'DRAFT'; + +ALTER TABLE exam_prep.units + ADD COLUMN publish_status VARCHAR(16) NOT NULL DEFAULT 'PUBLISHED' + CONSTRAINT chk_exam_prep_units_publish_status CHECK (publish_status IN ('DRAFT', 'PUBLISHED')); + +ALTER TABLE exam_prep.units + ALTER COLUMN publish_status SET DEFAULT 'DRAFT'; + +ALTER TABLE exam_prep.unit_modules + ADD COLUMN publish_status VARCHAR(16) NOT NULL DEFAULT 'PUBLISHED' + CONSTRAINT chk_exam_prep_unit_modules_publish_status CHECK (publish_status IN ('DRAFT', 'PUBLISHED')); + +ALTER TABLE exam_prep.unit_modules + ALTER COLUMN publish_status SET DEFAULT 'DRAFT'; + +ALTER TABLE exam_prep.unit_module_lessons + ADD COLUMN publish_status VARCHAR(16) NOT NULL DEFAULT 'PUBLISHED' + CONSTRAINT chk_exam_prep_unit_module_lessons_publish_status CHECK (publish_status IN ('DRAFT', 'PUBLISHED')); + +ALTER TABLE exam_prep.unit_module_lessons + ALTER COLUMN publish_status SET DEFAULT 'DRAFT'; diff --git a/db/query/exam_prep_catalog_courses.sql b/db/query/exam_prep_catalog_courses.sql index a55d3f1..cca87fb 100644 --- a/db/query/exam_prep_catalog_courses.sql +++ b/db/query/exam_prep_catalog_courses.sql @@ -1,5 +1,5 @@ -- name: ExamPrepCreateCatalogCourse :one -INSERT INTO exam_prep.catalog_courses (name, description, category, thumbnail, sort_order) +INSERT INTO exam_prep.catalog_courses (name, description, category, thumbnail, sort_order, publish_status) SELECT $1, $2, @@ -8,7 +8,8 @@ SELECT coalesce(( SELECT max(c.sort_order) - FROM exam_prep.catalog_courses AS c), 0) + 1 + FROM exam_prep.catalog_courses AS c), 0) + 1, + $5 RETURNING *; @@ -22,6 +23,10 @@ SELECT INNER JOIN exam_prep.unit_modules m ON m.id = l.unit_module_id INNER JOIN exam_prep.units u ON u.id = m.unit_id WHERE u.catalog_course_id = c.id + AND p.publish_status = 'PUBLISHED' + AND l.publish_status = 'PUBLISHED' + AND m.publish_status = 'PUBLISHED' + AND u.publish_status = 'PUBLISHED' ) AS has_practice FROM exam_prep.catalog_courses c WHERE c.id = $1; @@ -35,7 +40,10 @@ WITH catalog_course_counts AS ( COUNT(DISTINCT l.id)::BIGINT AS lessons_count FROM exam_prep.units u LEFT JOIN exam_prep.unit_modules m ON m.unit_id = u.id + AND m.publish_status = 'PUBLISHED' LEFT JOIN exam_prep.unit_module_lessons l ON l.unit_module_id = m.id + AND l.publish_status = 'PUBLISHED' + WHERE u.publish_status = 'PUBLISHED' GROUP BY u.catalog_course_id ) SELECT @@ -46,6 +54,7 @@ SELECT c.category, c.thumbnail, c.sort_order, + c.publish_status, COALESCE(cc.units_count, 0)::BIGINT AS units_count, COALESCE(cc.modules_count, 0)::BIGINT AS modules_count, COALESCE(cc.lessons_count, 0)::BIGINT AS lessons_count, @@ -56,11 +65,19 @@ SELECT INNER JOIN exam_prep.unit_modules m ON m.id = l.unit_module_id INNER JOIN exam_prep.units u ON u.id = m.unit_id WHERE u.catalog_course_id = c.id + AND p.publish_status = 'PUBLISHED' + AND l.publish_status = 'PUBLISHED' + AND m.publish_status = 'PUBLISHED' + AND u.publish_status = 'PUBLISHED' ) AS has_practice, c.created_at, c.updated_at FROM exam_prep.catalog_courses c LEFT JOIN catalog_course_counts cc ON cc.catalog_course_id = c.id +WHERE ( + sqlc.arg('published_only')::boolean = FALSE + OR c.publish_status = 'PUBLISHED'::TEXT +) ORDER BY c.sort_order ASC, c.id ASC LIMIT $1 OFFSET $2; @@ -78,6 +95,7 @@ SET category = coalesce(sqlc.narg('category')::varchar, category), thumbnail = coalesce(sqlc.narg('thumbnail')::text, thumbnail), sort_order = coalesce(sqlc.narg('sort_order')::int, sort_order), + publish_status = coalesce(sqlc.narg('publish_status')::varchar, publish_status), updated_at = CURRENT_TIMESTAMP WHERE id = sqlc.arg('id') RETURNING diff --git a/db/query/exam_prep_progress.sql b/db/query/exam_prep_progress.sql index e01eda8..ac0f626 100644 --- a/db/query/exam_prep_progress.sql +++ b/db/query/exam_prep_progress.sql @@ -34,6 +34,7 @@ FROM INNER JOIN question_sets qs ON qs.id = p.question_set_id WHERE l.unit_module_id = $1 + AND l.publish_status = 'PUBLISHED' AND qs.set_type = 'PRACTICE' AND qs.status = 'PUBLISHED' AND p.publish_status = 'PUBLISHED'; @@ -48,6 +49,7 @@ FROM INNER JOIN user_practice_progress upp ON upp.question_set_id = p.question_set_id WHERE l.unit_module_id = $1 + AND l.publish_status = 'PUBLISHED' AND upp.user_id = $2 AND upp.completed_at IS NOT NULL AND qs.set_type = 'PRACTICE' @@ -64,6 +66,8 @@ FROM INNER JOIN question_sets qs ON qs.id = p.question_set_id WHERE m.unit_id = $1 + AND l.publish_status = 'PUBLISHED' + AND m.publish_status = 'PUBLISHED' AND qs.set_type = 'PRACTICE' AND qs.status = 'PUBLISHED' AND p.publish_status = 'PUBLISHED'; @@ -79,6 +83,8 @@ FROM INNER JOIN user_practice_progress upp ON upp.question_set_id = p.question_set_id WHERE m.unit_id = $1 + AND l.publish_status = 'PUBLISHED' + AND m.publish_status = 'PUBLISHED' AND upp.user_id = $2 AND upp.completed_at IS NOT NULL AND qs.set_type = 'PRACTICE' @@ -96,6 +102,9 @@ FROM INNER JOIN question_sets qs ON qs.id = p.question_set_id WHERE u.catalog_course_id = $1 + AND l.publish_status = 'PUBLISHED' + AND m.publish_status = 'PUBLISHED' + AND u.publish_status = 'PUBLISHED' AND qs.set_type = 'PRACTICE' AND qs.status = 'PUBLISHED' AND p.publish_status = 'PUBLISHED'; @@ -112,6 +121,9 @@ FROM INNER JOIN user_practice_progress upp ON upp.question_set_id = p.question_set_id WHERE u.catalog_course_id = $1 + AND l.publish_status = 'PUBLISHED' + AND m.publish_status = 'PUBLISHED' + AND u.publish_status = 'PUBLISHED' AND upp.user_id = $2 AND upp.completed_at IS NOT NULL AND qs.set_type = 'PRACTICE' diff --git a/db/query/exam_prep_unit_module_lessons.sql b/db/query/exam_prep_unit_module_lessons.sql index 2e38eec..df9c815 100644 --- a/db/query/exam_prep_unit_module_lessons.sql +++ b/db/query/exam_prep_unit_module_lessons.sql @@ -1,5 +1,5 @@ -- name: ExamPrepCreateUnitModuleLesson :one -INSERT INTO exam_prep.unit_module_lessons (unit_module_id, title, video_url, thumbnail, description, sort_order) +INSERT INTO exam_prep.unit_module_lessons (unit_module_id, title, video_url, thumbnail, description, sort_order, publish_status) SELECT $1, $2, @@ -11,7 +11,8 @@ SELECT max(l.sort_order) FROM exam_prep.unit_module_lessons l WHERE - l.unit_module_id = $1), 0) + 1 + l.unit_module_id = $1), 0) + 1, + $6 RETURNING *; @@ -22,6 +23,7 @@ SELECT SELECT 1 FROM exam_prep.lesson_practices p WHERE p.unit_module_lesson_id = l.id + AND p.publish_status = 'PUBLISHED' ) AS has_practice FROM exam_prep.unit_module_lessons l WHERE l.id = $1; @@ -35,6 +37,17 @@ WHERE ORDER BY l.id; +-- Published lessons only, for learner-facing progress rollups. +-- name: ExamPrepListPublishedUnitModuleLessonIDsByUnitModule :many +SELECT + l.id +FROM exam_prep.unit_module_lessons l +WHERE + l.unit_module_id = $1 + AND l.publish_status = 'PUBLISHED' +ORDER BY + l.id; + -- name: ExamPrepListUnitModuleLessonsByUnitModuleID :many SELECT COUNT(*) OVER () AS total_count, @@ -45,16 +58,22 @@ SELECT l.thumbnail, l.description, l.sort_order, + l.publish_status, EXISTS ( SELECT 1 FROM exam_prep.lesson_practices p WHERE p.unit_module_lesson_id = l.id + AND p.publish_status = 'PUBLISHED' ) AS has_practice, l.created_at, l.updated_at FROM exam_prep.unit_module_lessons l WHERE l.unit_module_id = $1 + AND ( + sqlc.arg('published_only')::boolean = FALSE + OR l.publish_status = 'PUBLISHED'::TEXT + ) ORDER BY l.sort_order ASC, l.id ASC @@ -69,6 +88,7 @@ SET thumbnail = coalesce(sqlc.narg('thumbnail')::text, thumbnail), description = coalesce(sqlc.narg('description')::text, description), sort_order = coalesce(sqlc.narg('sort_order')::int, sort_order), + publish_status = coalesce(sqlc.narg('publish_status')::varchar, publish_status), updated_at = CURRENT_TIMESTAMP WHERE id = sqlc.arg('id') RETURNING diff --git a/db/query/exam_prep_unit_modules.sql b/db/query/exam_prep_unit_modules.sql index 0a5299c..7ce61f7 100644 --- a/db/query/exam_prep_unit_modules.sql +++ b/db/query/exam_prep_unit_modules.sql @@ -1,5 +1,5 @@ -- name: ExamPrepCreateUnitModule :one -INSERT INTO exam_prep.unit_modules (unit_id, name, description, thumbnail, icon, sort_order) +INSERT INTO exam_prep.unit_modules (unit_id, name, description, thumbnail, icon, sort_order, publish_status) SELECT $1, $2, @@ -11,7 +11,8 @@ SELECT max(m.sort_order) FROM exam_prep.unit_modules m WHERE - m.unit_id = $1), 0) + 1 + m.unit_id = $1), 0) + 1, + $6 RETURNING *; @@ -23,6 +24,8 @@ SELECT FROM exam_prep.lesson_practices p INNER JOIN exam_prep.unit_module_lessons l ON l.id = p.unit_module_lesson_id WHERE l.unit_module_id = m.id + AND p.publish_status = 'PUBLISHED' + AND l.publish_status = 'PUBLISHED' ) AS has_practice FROM exam_prep.unit_modules m WHERE m.id = $1; @@ -36,6 +39,17 @@ WHERE ORDER BY m.id; +-- Published modules only, for learner-facing progress rollups. +-- name: ExamPrepListPublishedUnitModuleIDsByUnit :many +SELECT + m.id +FROM exam_prep.unit_modules m +WHERE + m.unit_id = $1 + AND m.publish_status = 'PUBLISHED' +ORDER BY + m.id; + -- name: ExamPrepListUnitModulesByUnit :many WITH module_counts AS ( SELECT @@ -44,7 +58,9 @@ WITH module_counts AS ( COUNT(DISTINCT p.id)::BIGINT AS practices_count FROM exam_prep.unit_modules m LEFT JOIN exam_prep.unit_module_lessons l ON l.unit_module_id = m.id + AND l.publish_status = 'PUBLISHED' LEFT JOIN exam_prep.lesson_practices p ON p.unit_module_lesson_id = l.id + AND p.publish_status = 'PUBLISHED' GROUP BY m.id ) SELECT @@ -56,6 +72,7 @@ SELECT m.thumbnail, m.icon, m.sort_order, + m.publish_status, COALESCE(mc.lessons_count, 0)::BIGINT AS lessons_count, COALESCE(mc.practices_count, 0)::BIGINT AS practices_count, (COALESCE(mc.practices_count, 0)::BIGINT > 0) AS has_practice, @@ -65,6 +82,10 @@ FROM exam_prep.unit_modules m LEFT JOIN module_counts mc ON mc.module_id = m.id WHERE m.unit_id = $1 + AND ( + sqlc.arg('published_only')::boolean = FALSE + OR m.publish_status = 'PUBLISHED'::TEXT + ) ORDER BY m.sort_order ASC, m.id ASC @@ -79,6 +100,7 @@ SET thumbnail = coalesce(sqlc.narg('thumbnail')::text, thumbnail), icon = coalesce(sqlc.narg('icon')::text, icon), sort_order = coalesce(sqlc.narg('sort_order')::int, sort_order), + publish_status = coalesce(sqlc.narg('publish_status')::varchar, publish_status), updated_at = CURRENT_TIMESTAMP WHERE id = sqlc.arg('id') RETURNING diff --git a/db/query/exam_prep_units.sql b/db/query/exam_prep_units.sql index 6ddaac5..2fd4697 100644 --- a/db/query/exam_prep_units.sql +++ b/db/query/exam_prep_units.sql @@ -1,5 +1,5 @@ -- name: ExamPrepCreateUnit :one -INSERT INTO exam_prep.units (catalog_course_id, name, description, thumbnail, sort_order) +INSERT INTO exam_prep.units (catalog_course_id, name, description, thumbnail, sort_order, publish_status) SELECT sqlc.arg('catalog_course_id'), sqlc.arg('name'), @@ -11,7 +11,8 @@ SELECT max(u.sort_order) FROM exam_prep.units u WHERE - u.catalog_course_id = sqlc.arg('catalog_course_id')), 0) + 1) + u.catalog_course_id = sqlc.arg('catalog_course_id')), 0) + 1), + sqlc.arg('publish_status') RETURNING *; @@ -24,6 +25,9 @@ SELECT INNER JOIN exam_prep.unit_module_lessons l ON l.id = p.unit_module_lesson_id INNER JOIN exam_prep.unit_modules m ON m.id = l.unit_module_id WHERE m.unit_id = u.id + AND p.publish_status = 'PUBLISHED' + AND l.publish_status = 'PUBLISHED' + AND m.publish_status = 'PUBLISHED' ) AS has_practice FROM exam_prep.units u WHERE u.id = $1; @@ -37,6 +41,17 @@ WHERE ORDER BY u.id; +-- Published units only, for learner-facing progress rollups. +-- name: ExamPrepListPublishedUnitIDsByCatalogCourse :many +SELECT + u.id +FROM exam_prep.units u +WHERE + u.catalog_course_id = $1 + AND u.publish_status = 'PUBLISHED' +ORDER BY + u.id; + -- name: ExamPrepListUnitsByCatalogCourse :many WITH unit_counts AS ( SELECT @@ -46,8 +61,11 @@ WITH unit_counts AS ( COUNT(DISTINCT p.id)::BIGINT AS practices_count FROM exam_prep.units u LEFT JOIN exam_prep.unit_modules m ON m.unit_id = u.id + AND m.publish_status = 'PUBLISHED' LEFT JOIN exam_prep.unit_module_lessons l ON l.unit_module_id = m.id + AND l.publish_status = 'PUBLISHED' LEFT JOIN exam_prep.lesson_practices p ON p.unit_module_lesson_id = l.id + AND p.publish_status = 'PUBLISHED' GROUP BY u.id ) SELECT @@ -58,6 +76,7 @@ SELECT u.description, u.thumbnail, u.sort_order, + u.publish_status, COALESCE(uc.modules_count, 0)::BIGINT AS modules_count, COALESCE(uc.lessons_count, 0)::BIGINT AS lessons_count, COALESCE(uc.practices_count, 0)::BIGINT AS practices_count, @@ -68,6 +87,10 @@ FROM exam_prep.units u LEFT JOIN unit_counts uc ON uc.unit_id = u.id WHERE u.catalog_course_id = $1 + AND ( + sqlc.arg('published_only')::boolean = FALSE + OR u.publish_status = 'PUBLISHED'::TEXT + ) ORDER BY u.sort_order ASC, u.id ASC @@ -81,6 +104,7 @@ SET description = coalesce(sqlc.narg('description')::text, description), thumbnail = coalesce(sqlc.narg('thumbnail')::text, thumbnail), sort_order = coalesce(sqlc.narg('sort_order')::int, sort_order), + publish_status = coalesce(sqlc.narg('publish_status')::varchar, publish_status), updated_at = CURRENT_TIMESTAMP WHERE id = sqlc.arg('id') RETURNING diff --git a/db/query/lms_courses.sql b/db/query/lms_courses.sql index 8d17a6c..29c6966 100644 --- a/db/query/lms_courses.sql +++ b/db/query/lms_courses.sql @@ -1,5 +1,5 @@ -- name: CreateCourse :one -INSERT INTO courses (program_id, name, description, thumbnail, sort_order) +INSERT INTO courses (program_id, name, description, thumbnail, sort_order, publish_status) SELECT sqlc.arg('program_id'), sqlc.arg('name'), @@ -11,7 +11,8 @@ SELECT max(c.sort_order) FROM courses c WHERE - c.program_id = sqlc.arg('program_id')), 0) + 1) + c.program_id = sqlc.arg('program_id')), 0) + 1), + sqlc.arg('publish_status') RETURNING *; @@ -40,6 +41,18 @@ WHERE ORDER BY c.id; +-- Published courses only, for learner-facing progress rollups. +-- name: ListPublishedCourseIDsByProgram :many +SELECT + c.id +FROM + courses AS c +WHERE + c.program_id = $1 + AND c.publish_status = 'PUBLISHED' +ORDER BY + c.id; + -- name: ListCoursesByProgramID :many SELECT COUNT(*) OVER () AS total_count, @@ -49,6 +62,7 @@ SELECT c.description, c.thumbnail, c.sort_order, + c.publish_status, c.created_at, c.updated_at, ( @@ -57,7 +71,8 @@ SELECT FROM modules m WHERE - m.course_id = c.id) AS module_count, + m.course_id = c.id + AND m.publish_status = 'PUBLISHED') AS module_count, ( SELECT COUNT(*)::bigint @@ -91,6 +106,10 @@ FROM courses c WHERE c.program_id = $1 + AND ( + sqlc.arg('published_only')::boolean = FALSE + OR c.publish_status = 'PUBLISHED'::TEXT + ) ORDER BY c.sort_order ASC, c.id ASC @@ -103,6 +122,7 @@ SET description = COALESCE(sqlc.narg('description')::text, description), thumbnail = COALESCE(sqlc.narg('thumbnail')::text, thumbnail), sort_order = coalesce(sqlc.narg('sort_order')::int, sort_order), + publish_status = COALESCE(sqlc.narg('publish_status')::varchar, publish_status), updated_at = CURRENT_TIMESTAMP WHERE id = sqlc.arg('id') diff --git a/db/query/lms_modules.sql b/db/query/lms_modules.sql index 917f014..ea245ee 100644 --- a/db/query/lms_modules.sql +++ b/db/query/lms_modules.sql @@ -1,5 +1,5 @@ -- name: CreateModule :one -INSERT INTO modules (program_id, course_id, name, description, icon, sort_order) +INSERT INTO modules (program_id, course_id, name, description, icon, sort_order, publish_status) SELECT sqlc.arg('program_id'), sqlc.arg('course_id'), @@ -12,7 +12,8 @@ SELECT max(m.sort_order) FROM modules m WHERE - m.course_id = sqlc.arg('course_id')), 0) + 1) + m.course_id = sqlc.arg('course_id')), 0) + 1), + sqlc.arg('publish_status') RETURNING *; @@ -40,6 +41,18 @@ WHERE ORDER BY m.id; +-- Published modules only, for learner-facing progress rollups. +-- name: ListPublishedModuleIDsByCourse :many +SELECT + m.id +FROM + modules AS m +WHERE + m.course_id = $1 + AND m.publish_status = 'PUBLISHED' +ORDER BY + m.id; + -- name: ListModulesByProgramAndCourse :many SELECT COUNT(*) OVER () AS total_count, @@ -50,6 +63,7 @@ SELECT m.description, m.icon, m.sort_order, + m.publish_status, m.created_at, m.updated_at, EXISTS ( @@ -64,6 +78,10 @@ FROM WHERE m.program_id = $1 AND m.course_id = $2 + AND ( + sqlc.arg('published_only')::boolean = FALSE + OR m.publish_status = 'PUBLISHED'::TEXT + ) ORDER BY m.sort_order ASC, m.id ASC @@ -77,6 +95,7 @@ SET description = COALESCE(sqlc.narg('description')::text, description), icon = COALESCE(sqlc.narg('icon')::text, icon), sort_order = coalesce(sqlc.narg('sort_order')::int, sort_order), + publish_status = COALESCE(sqlc.narg('publish_status')::varchar, publish_status), updated_at = CURRENT_TIMESTAMP WHERE id = sqlc.arg('id') diff --git a/db/query/lms_progress.sql b/db/query/lms_progress.sql index 1b5aa9c..6895f15 100644 --- a/db/query/lms_progress.sql +++ b/db/query/lms_progress.sql @@ -5,6 +5,7 @@ SELECT FROM programs AS p1 INNER JOIN programs AS p2 ON p2.category = p1.category + AND p2.publish_status = 'PUBLISHED' AND ( p2.sort_order < p1.sort_order OR ( @@ -25,6 +26,7 @@ SELECT FROM courses AS c1 INNER JOIN courses AS c2 ON c2.program_id = c1.program_id + AND c2.publish_status = 'PUBLISHED' AND ( c2.sort_order < c1.sort_order OR ( @@ -45,6 +47,7 @@ SELECT FROM modules AS m1 INNER JOIN modules AS m2 ON m2.course_id = m1.course_id + AND m2.publish_status = 'PUBLISHED' AND ( m2.sort_order < m1.sort_order OR ( @@ -226,7 +229,8 @@ SELECT FROM modules WHERE - course_id = $1; + course_id = $1 + AND publish_status = 'PUBLISHED'; -- name: CountUserCompletedModulesInCourse :one SELECT @@ -236,7 +240,8 @@ FROM INNER JOIN modules m ON m.id = ump.module_id WHERE m.course_id = $1 - AND ump.user_id = $2; + AND ump.user_id = $2 + AND m.publish_status = 'PUBLISHED'; -- name: CountCoursesInProgram :one SELECT @@ -244,7 +249,8 @@ SELECT FROM courses WHERE - program_id = $1; + program_id = $1 + AND publish_status = 'PUBLISHED'; -- name: CountUserCompletedCoursesInProgram :one SELECT @@ -254,7 +260,8 @@ FROM INNER JOIN courses c ON c.id = ucp.course_id WHERE c.program_id = $1 - AND ucp.user_id = $2; + AND ucp.user_id = $2 + AND c.publish_status = 'PUBLISHED'; -- name: ListLMSCompletedLessonIDsByUser :many SELECT @@ -448,7 +455,7 @@ WHERE AND ulp.user_id = $2 AND l.publish_status = 'PUBLISHED'; --- Published practices in a module (direct module practices and practices on lessons in the module). +-- Published practices in a module (direct module practices and practices on published lessons in the module). -- name: CountPublishedPracticesInModule :one SELECT count(*)::int AS n @@ -464,7 +471,8 @@ WHERE FROM lessons WHERE - module_id = $1)) + module_id = $1 + AND publish_status = 'PUBLISHED')) AND qs.set_type = 'PRACTICE' AND qs.status = 'PUBLISHED' AND lp.publish_status = 'PUBLISHED'; @@ -485,7 +493,8 @@ WHERE FROM lessons WHERE - module_id = $1)) + module_id = $1 + AND publish_status = 'PUBLISHED')) AND upp.user_id = $2 AND upp.completed_at IS NOT NULL AND qs.set_type = 'PRACTICE' @@ -507,7 +516,8 @@ WHERE FROM modules WHERE - course_id = $1) + course_id = $1 + AND publish_status = 'PUBLISHED') OR lp.lesson_id IN ( SELECT l.id @@ -515,7 +525,9 @@ WHERE lessons l INNER JOIN modules m ON m.id = l.module_id WHERE - m.course_id = $1)) + m.course_id = $1 + AND l.publish_status = 'PUBLISHED' + AND m.publish_status = 'PUBLISHED')) AND qs.set_type = 'PRACTICE' AND qs.status = 'PUBLISHED' AND lp.publish_status = 'PUBLISHED'; @@ -536,7 +548,8 @@ WHERE FROM modules WHERE - course_id = $1) + course_id = $1 + AND publish_status = 'PUBLISHED') OR lp.lesson_id IN ( SELECT l.id @@ -544,7 +557,9 @@ WHERE lessons l INNER JOIN modules m ON m.id = l.module_id WHERE - m.course_id = $1)) + m.course_id = $1 + AND l.publish_status = 'PUBLISHED' + AND m.publish_status = 'PUBLISHED')) AND upp.user_id = $2 AND upp.completed_at IS NOT NULL AND qs.set_type = 'PRACTICE' @@ -565,7 +580,8 @@ WHERE FROM courses c WHERE - c.program_id = $1) + c.program_id = $1 + AND c.publish_status = 'PUBLISHED') OR lp.module_id IN ( SELECT m.id @@ -573,7 +589,9 @@ WHERE modules m INNER JOIN courses c ON c.id = m.course_id WHERE - c.program_id = $1) + c.program_id = $1 + AND m.publish_status = 'PUBLISHED' + AND c.publish_status = 'PUBLISHED') OR lp.lesson_id IN ( SELECT l.id @@ -582,7 +600,10 @@ WHERE INNER JOIN modules m ON m.id = l.module_id INNER JOIN courses c ON c.id = m.course_id WHERE - c.program_id = $1)) + c.program_id = $1 + AND l.publish_status = 'PUBLISHED' + AND m.publish_status = 'PUBLISHED' + AND c.publish_status = 'PUBLISHED')) AND qs.set_type = 'PRACTICE' AND qs.status = 'PUBLISHED' AND lp.publish_status = 'PUBLISHED'; @@ -602,7 +623,8 @@ WHERE FROM courses c WHERE - c.program_id = $1) + c.program_id = $1 + AND c.publish_status = 'PUBLISHED') OR lp.module_id IN ( SELECT m.id @@ -610,7 +632,9 @@ WHERE modules m INNER JOIN courses c ON c.id = m.course_id WHERE - c.program_id = $1) + c.program_id = $1 + AND m.publish_status = 'PUBLISHED' + AND c.publish_status = 'PUBLISHED') OR lp.lesson_id IN ( SELECT l.id @@ -619,7 +643,10 @@ WHERE INNER JOIN modules m ON m.id = l.module_id INNER JOIN courses c ON c.id = m.course_id WHERE - c.program_id = $1)) + c.program_id = $1 + AND l.publish_status = 'PUBLISHED' + AND m.publish_status = 'PUBLISHED' + AND c.publish_status = 'PUBLISHED')) AND upp.user_id = $2 AND upp.completed_at IS NOT NULL AND qs.set_type = 'PRACTICE' diff --git a/db/query/programs.sql b/db/query/programs.sql index 8c5ce07..fed6fff 100644 --- a/db/query/programs.sql +++ b/db/query/programs.sql @@ -1,5 +1,5 @@ -- name: CreateProgram :one -INSERT INTO programs (name, description, category, thumbnail, sort_order) +INSERT INTO programs (name, description, category, thumbnail, sort_order, publish_status) SELECT sqlc.arg('name'), sqlc.arg('description'), @@ -8,7 +8,8 @@ SELECT COALESCE(sqlc.narg('sort_order')::int, COALESCE(( SELECT max(p.sort_order) - FROM programs AS p), 0) + 1) + FROM programs AS p), 0) + 1), + sqlc.arg('publish_status') RETURNING *; @@ -34,9 +35,14 @@ SELECT p.category, p.thumbnail, p.sort_order, + p.publish_status, p.created_at, p.updated_at FROM programs p +WHERE ( + sqlc.arg('published_only')::boolean = FALSE + OR p.publish_status = 'PUBLISHED'::TEXT +) ORDER BY p.sort_order ASC, p.id ASC LIMIT $1 OFFSET $2; @@ -48,6 +54,7 @@ SET category = COALESCE(sqlc.narg('category')::varchar, category), thumbnail = COALESCE(sqlc.narg('thumbnail')::text, thumbnail), sort_order = coalesce(sqlc.narg('sort_order')::int, sort_order), + publish_status = COALESCE(sqlc.narg('publish_status')::varchar, publish_status), updated_at = CURRENT_TIMESTAMP WHERE id = sqlc.arg('id') diff --git a/gen/db/exam_prep_catalog_courses.sql.go b/gen/db/exam_prep_catalog_courses.sql.go index 355c7e5..5b686e7 100644 --- a/gen/db/exam_prep_catalog_courses.sql.go +++ b/gen/db/exam_prep_catalog_courses.sql.go @@ -12,7 +12,7 @@ import ( ) const ExamPrepCreateCatalogCourse = `-- name: ExamPrepCreateCatalogCourse :one -INSERT INTO exam_prep.catalog_courses (name, description, category, thumbnail, sort_order) +INSERT INTO exam_prep.catalog_courses (name, description, category, thumbnail, sort_order, publish_status) SELECT $1, $2, @@ -21,16 +21,18 @@ SELECT coalesce(( SELECT max(c.sort_order) - FROM exam_prep.catalog_courses AS c), 0) + 1 + FROM exam_prep.catalog_courses AS c), 0) + 1, + $5 RETURNING - id, name, description, thumbnail, sort_order, created_at, updated_at, category + id, name, description, thumbnail, sort_order, created_at, updated_at, category, publish_status ` type ExamPrepCreateCatalogCourseParams struct { - Name string `json:"name"` - Description pgtype.Text `json:"description"` - Category string `json:"category"` - Thumbnail pgtype.Text `json:"thumbnail"` + Name string `json:"name"` + Description pgtype.Text `json:"description"` + Category string `json:"category"` + Thumbnail pgtype.Text `json:"thumbnail"` + PublishStatus string `json:"publish_status"` } func (q *Queries) ExamPrepCreateCatalogCourse(ctx context.Context, arg ExamPrepCreateCatalogCourseParams) (ExamPrepCatalogCourse, error) { @@ -39,6 +41,7 @@ func (q *Queries) ExamPrepCreateCatalogCourse(ctx context.Context, arg ExamPrepC arg.Description, arg.Category, arg.Thumbnail, + arg.PublishStatus, ) var i ExamPrepCatalogCourse err := row.Scan( @@ -50,6 +53,7 @@ func (q *Queries) ExamPrepCreateCatalogCourse(ctx context.Context, arg ExamPrepC &i.CreatedAt, &i.UpdatedAt, &i.Category, + &i.PublishStatus, ) return i, err } @@ -66,7 +70,7 @@ func (q *Queries) ExamPrepDeleteCatalogCourse(ctx context.Context, id int64) err const ExamPrepGetCatalogCourseByID = `-- name: ExamPrepGetCatalogCourseByID :one SELECT - c.id, c.name, c.description, c.thumbnail, c.sort_order, c.created_at, c.updated_at, c.category, + c.id, c.name, c.description, c.thumbnail, c.sort_order, c.created_at, c.updated_at, c.category, c.publish_status, EXISTS ( SELECT 1 FROM exam_prep.lesson_practices p @@ -74,21 +78,26 @@ SELECT INNER JOIN exam_prep.unit_modules m ON m.id = l.unit_module_id INNER JOIN exam_prep.units u ON u.id = m.unit_id WHERE u.catalog_course_id = c.id + AND p.publish_status = 'PUBLISHED' + AND l.publish_status = 'PUBLISHED' + AND m.publish_status = 'PUBLISHED' + AND u.publish_status = 'PUBLISHED' ) AS has_practice FROM exam_prep.catalog_courses c WHERE c.id = $1 ` type ExamPrepGetCatalogCourseByIDRow struct { - ID int64 `json:"id"` - Name string `json:"name"` - Description pgtype.Text `json:"description"` - Thumbnail pgtype.Text `json:"thumbnail"` - SortOrder int32 `json:"sort_order"` - CreatedAt pgtype.Timestamptz `json:"created_at"` - UpdatedAt pgtype.Timestamptz `json:"updated_at"` - Category string `json:"category"` - HasPractice bool `json:"has_practice"` + ID int64 `json:"id"` + Name string `json:"name"` + Description pgtype.Text `json:"description"` + Thumbnail pgtype.Text `json:"thumbnail"` + SortOrder int32 `json:"sort_order"` + CreatedAt pgtype.Timestamptz `json:"created_at"` + UpdatedAt pgtype.Timestamptz `json:"updated_at"` + Category string `json:"category"` + PublishStatus string `json:"publish_status"` + HasPractice bool `json:"has_practice"` } func (q *Queries) ExamPrepGetCatalogCourseByID(ctx context.Context, id int64) (ExamPrepGetCatalogCourseByIDRow, error) { @@ -103,6 +112,7 @@ func (q *Queries) ExamPrepGetCatalogCourseByID(ctx context.Context, id int64) (E &i.CreatedAt, &i.UpdatedAt, &i.Category, + &i.PublishStatus, &i.HasPractice, ) return i, err @@ -144,7 +154,10 @@ WITH catalog_course_counts AS ( COUNT(DISTINCT l.id)::BIGINT AS lessons_count FROM exam_prep.units u LEFT JOIN exam_prep.unit_modules m ON m.unit_id = u.id + AND m.publish_status = 'PUBLISHED' LEFT JOIN exam_prep.unit_module_lessons l ON l.unit_module_id = m.id + AND l.publish_status = 'PUBLISHED' + WHERE u.publish_status = 'PUBLISHED' GROUP BY u.catalog_course_id ) SELECT @@ -155,6 +168,7 @@ SELECT c.category, c.thumbnail, c.sort_order, + c.publish_status, COALESCE(cc.units_count, 0)::BIGINT AS units_count, COALESCE(cc.modules_count, 0)::BIGINT AS modules_count, COALESCE(cc.lessons_count, 0)::BIGINT AS lessons_count, @@ -165,38 +179,48 @@ SELECT INNER JOIN exam_prep.unit_modules m ON m.id = l.unit_module_id INNER JOIN exam_prep.units u ON u.id = m.unit_id WHERE u.catalog_course_id = c.id + AND p.publish_status = 'PUBLISHED' + AND l.publish_status = 'PUBLISHED' + AND m.publish_status = 'PUBLISHED' + AND u.publish_status = 'PUBLISHED' ) AS has_practice, c.created_at, c.updated_at FROM exam_prep.catalog_courses c LEFT JOIN catalog_course_counts cc ON cc.catalog_course_id = c.id +WHERE ( + $3::boolean = FALSE + OR c.publish_status = 'PUBLISHED'::TEXT +) ORDER BY c.sort_order ASC, c.id ASC LIMIT $1 OFFSET $2 ` type ExamPrepListCatalogCoursesParams struct { - Limit int32 `json:"limit"` - Offset int32 `json:"offset"` + Limit int32 `json:"limit"` + Offset int32 `json:"offset"` + PublishedOnly bool `json:"published_only"` } type ExamPrepListCatalogCoursesRow struct { - TotalCount int64 `json:"total_count"` - ID int64 `json:"id"` - Name string `json:"name"` - Description pgtype.Text `json:"description"` - Category string `json:"category"` - Thumbnail pgtype.Text `json:"thumbnail"` - SortOrder int32 `json:"sort_order"` - UnitsCount int64 `json:"units_count"` - ModulesCount int64 `json:"modules_count"` - LessonsCount int64 `json:"lessons_count"` - HasPractice bool `json:"has_practice"` - CreatedAt pgtype.Timestamptz `json:"created_at"` - UpdatedAt pgtype.Timestamptz `json:"updated_at"` + TotalCount int64 `json:"total_count"` + ID int64 `json:"id"` + Name string `json:"name"` + Description pgtype.Text `json:"description"` + Category string `json:"category"` + Thumbnail pgtype.Text `json:"thumbnail"` + SortOrder int32 `json:"sort_order"` + PublishStatus string `json:"publish_status"` + UnitsCount int64 `json:"units_count"` + ModulesCount int64 `json:"modules_count"` + LessonsCount int64 `json:"lessons_count"` + HasPractice bool `json:"has_practice"` + CreatedAt pgtype.Timestamptz `json:"created_at"` + UpdatedAt pgtype.Timestamptz `json:"updated_at"` } func (q *Queries) ExamPrepListCatalogCourses(ctx context.Context, arg ExamPrepListCatalogCoursesParams) ([]ExamPrepListCatalogCoursesRow, error) { - rows, err := q.db.Query(ctx, ExamPrepListCatalogCourses, arg.Limit, arg.Offset) + rows, err := q.db.Query(ctx, ExamPrepListCatalogCourses, arg.Limit, arg.Offset, arg.PublishedOnly) if err != nil { return nil, err } @@ -212,6 +236,7 @@ func (q *Queries) ExamPrepListCatalogCourses(ctx context.Context, arg ExamPrepLi &i.Category, &i.Thumbnail, &i.SortOrder, + &i.PublishStatus, &i.UnitsCount, &i.ModulesCount, &i.LessonsCount, @@ -237,19 +262,21 @@ SET category = coalesce($3::varchar, category), thumbnail = coalesce($4::text, thumbnail), sort_order = coalesce($5::int, sort_order), + publish_status = coalesce($6::varchar, publish_status), updated_at = CURRENT_TIMESTAMP -WHERE id = $6 +WHERE id = $7 RETURNING - id, name, description, thumbnail, sort_order, created_at, updated_at, category + id, name, description, thumbnail, sort_order, created_at, updated_at, category, publish_status ` type ExamPrepUpdateCatalogCourseParams struct { - Name pgtype.Text `json:"name"` - Description pgtype.Text `json:"description"` - Category pgtype.Text `json:"category"` - Thumbnail pgtype.Text `json:"thumbnail"` - SortOrder pgtype.Int4 `json:"sort_order"` - ID int64 `json:"id"` + Name pgtype.Text `json:"name"` + Description pgtype.Text `json:"description"` + Category pgtype.Text `json:"category"` + Thumbnail pgtype.Text `json:"thumbnail"` + SortOrder pgtype.Int4 `json:"sort_order"` + PublishStatus pgtype.Text `json:"publish_status"` + ID int64 `json:"id"` } func (q *Queries) ExamPrepUpdateCatalogCourse(ctx context.Context, arg ExamPrepUpdateCatalogCourseParams) (ExamPrepCatalogCourse, error) { @@ -259,6 +286,7 @@ func (q *Queries) ExamPrepUpdateCatalogCourse(ctx context.Context, arg ExamPrepU arg.Category, arg.Thumbnail, arg.SortOrder, + arg.PublishStatus, arg.ID, ) var i ExamPrepCatalogCourse @@ -271,6 +299,7 @@ func (q *Queries) ExamPrepUpdateCatalogCourse(ctx context.Context, arg ExamPrepU &i.CreatedAt, &i.UpdatedAt, &i.Category, + &i.PublishStatus, ) return i, err } diff --git a/gen/db/exam_prep_progress.sql.go b/gen/db/exam_prep_progress.sql.go index a2957e0..b338594 100644 --- a/gen/db/exam_prep_progress.sql.go +++ b/gen/db/exam_prep_progress.sql.go @@ -20,6 +20,9 @@ FROM INNER JOIN question_sets qs ON qs.id = p.question_set_id WHERE u.catalog_course_id = $1 + AND l.publish_status = 'PUBLISHED' + AND m.publish_status = 'PUBLISHED' + AND u.publish_status = 'PUBLISHED' AND qs.set_type = 'PRACTICE' AND qs.status = 'PUBLISHED' AND p.publish_status = 'PUBLISHED' @@ -61,6 +64,7 @@ FROM INNER JOIN question_sets qs ON qs.id = p.question_set_id WHERE l.unit_module_id = $1 + AND l.publish_status = 'PUBLISHED' AND qs.set_type = 'PRACTICE' AND qs.status = 'PUBLISHED' AND p.publish_status = 'PUBLISHED' @@ -83,6 +87,8 @@ FROM INNER JOIN question_sets qs ON qs.id = p.question_set_id WHERE m.unit_id = $1 + AND l.publish_status = 'PUBLISHED' + AND m.publish_status = 'PUBLISHED' AND qs.set_type = 'PRACTICE' AND qs.status = 'PUBLISHED' AND p.publish_status = 'PUBLISHED' @@ -107,6 +113,9 @@ FROM INNER JOIN user_practice_progress upp ON upp.question_set_id = p.question_set_id WHERE u.catalog_course_id = $1 + AND l.publish_status = 'PUBLISHED' + AND m.publish_status = 'PUBLISHED' + AND u.publish_status = 'PUBLISHED' AND upp.user_id = $2 AND upp.completed_at IS NOT NULL AND qs.set_type = 'PRACTICE' @@ -164,6 +173,7 @@ FROM INNER JOIN user_practice_progress upp ON upp.question_set_id = p.question_set_id WHERE l.unit_module_id = $1 + AND l.publish_status = 'PUBLISHED' AND upp.user_id = $2 AND upp.completed_at IS NOT NULL AND qs.set_type = 'PRACTICE' @@ -194,6 +204,8 @@ FROM INNER JOIN user_practice_progress upp ON upp.question_set_id = p.question_set_id WHERE m.unit_id = $1 + AND l.publish_status = 'PUBLISHED' + AND m.publish_status = 'PUBLISHED' AND upp.user_id = $2 AND upp.completed_at IS NOT NULL AND qs.set_type = 'PRACTICE' diff --git a/gen/db/exam_prep_unit_module_lessons.sql.go b/gen/db/exam_prep_unit_module_lessons.sql.go index 62a81ea..03df8f1 100644 --- a/gen/db/exam_prep_unit_module_lessons.sql.go +++ b/gen/db/exam_prep_unit_module_lessons.sql.go @@ -12,7 +12,7 @@ import ( ) const ExamPrepCreateUnitModuleLesson = `-- name: ExamPrepCreateUnitModuleLesson :one -INSERT INTO exam_prep.unit_module_lessons (unit_module_id, title, video_url, thumbnail, description, sort_order) +INSERT INTO exam_prep.unit_module_lessons (unit_module_id, title, video_url, thumbnail, description, sort_order, publish_status) SELECT $1, $2, @@ -24,17 +24,19 @@ SELECT max(l.sort_order) FROM exam_prep.unit_module_lessons l WHERE - l.unit_module_id = $1), 0) + 1 + l.unit_module_id = $1), 0) + 1, + $6 RETURNING - id, unit_module_id, title, video_url, thumbnail, description, sort_order, created_at, updated_at + id, unit_module_id, title, video_url, thumbnail, description, sort_order, created_at, updated_at, publish_status ` type ExamPrepCreateUnitModuleLessonParams struct { - UnitModuleID int64 `json:"unit_module_id"` - Title string `json:"title"` - VideoUrl pgtype.Text `json:"video_url"` - Thumbnail pgtype.Text `json:"thumbnail"` - Description pgtype.Text `json:"description"` + UnitModuleID int64 `json:"unit_module_id"` + Title string `json:"title"` + VideoUrl pgtype.Text `json:"video_url"` + Thumbnail pgtype.Text `json:"thumbnail"` + Description pgtype.Text `json:"description"` + PublishStatus string `json:"publish_status"` } func (q *Queries) ExamPrepCreateUnitModuleLesson(ctx context.Context, arg ExamPrepCreateUnitModuleLessonParams) (ExamPrepUnitModuleLesson, error) { @@ -44,6 +46,7 @@ func (q *Queries) ExamPrepCreateUnitModuleLesson(ctx context.Context, arg ExamPr arg.VideoUrl, arg.Thumbnail, arg.Description, + arg.PublishStatus, ) var i ExamPrepUnitModuleLesson err := row.Scan( @@ -56,6 +59,7 @@ func (q *Queries) ExamPrepCreateUnitModuleLesson(ctx context.Context, arg ExamPr &i.SortOrder, &i.CreatedAt, &i.UpdatedAt, + &i.PublishStatus, ) return i, err } @@ -72,27 +76,29 @@ func (q *Queries) ExamPrepDeleteUnitModuleLesson(ctx context.Context, id int64) const ExamPrepGetUnitModuleLessonByID = `-- name: ExamPrepGetUnitModuleLessonByID :one SELECT - l.id, l.unit_module_id, l.title, l.video_url, l.thumbnail, l.description, l.sort_order, l.created_at, l.updated_at, + l.id, l.unit_module_id, l.title, l.video_url, l.thumbnail, l.description, l.sort_order, l.created_at, l.updated_at, l.publish_status, EXISTS ( SELECT 1 FROM exam_prep.lesson_practices p WHERE p.unit_module_lesson_id = l.id + AND p.publish_status = 'PUBLISHED' ) AS has_practice FROM exam_prep.unit_module_lessons l WHERE l.id = $1 ` type ExamPrepGetUnitModuleLessonByIDRow struct { - ID int64 `json:"id"` - UnitModuleID int64 `json:"unit_module_id"` - Title string `json:"title"` - VideoUrl pgtype.Text `json:"video_url"` - Thumbnail pgtype.Text `json:"thumbnail"` - Description pgtype.Text `json:"description"` - SortOrder int32 `json:"sort_order"` - CreatedAt pgtype.Timestamptz `json:"created_at"` - UpdatedAt pgtype.Timestamptz `json:"updated_at"` - HasPractice bool `json:"has_practice"` + ID int64 `json:"id"` + UnitModuleID int64 `json:"unit_module_id"` + Title string `json:"title"` + VideoUrl pgtype.Text `json:"video_url"` + Thumbnail pgtype.Text `json:"thumbnail"` + Description pgtype.Text `json:"description"` + SortOrder int32 `json:"sort_order"` + CreatedAt pgtype.Timestamptz `json:"created_at"` + UpdatedAt pgtype.Timestamptz `json:"updated_at"` + PublishStatus string `json:"publish_status"` + HasPractice bool `json:"has_practice"` } func (q *Queries) ExamPrepGetUnitModuleLessonByID(ctx context.Context, id int64) (ExamPrepGetUnitModuleLessonByIDRow, error) { @@ -108,11 +114,44 @@ func (q *Queries) ExamPrepGetUnitModuleLessonByID(ctx context.Context, id int64) &i.SortOrder, &i.CreatedAt, &i.UpdatedAt, + &i.PublishStatus, &i.HasPractice, ) return i, err } +const ExamPrepListPublishedUnitModuleLessonIDsByUnitModule = `-- name: ExamPrepListPublishedUnitModuleLessonIDsByUnitModule :many +SELECT + l.id +FROM exam_prep.unit_module_lessons l +WHERE + l.unit_module_id = $1 + AND l.publish_status = 'PUBLISHED' +ORDER BY + l.id +` + +// Published lessons only, for learner-facing progress rollups. +func (q *Queries) ExamPrepListPublishedUnitModuleLessonIDsByUnitModule(ctx context.Context, unitModuleID int64) ([]int64, error) { + rows, err := q.db.Query(ctx, ExamPrepListPublishedUnitModuleLessonIDsByUnitModule, unitModuleID) + if err != nil { + return nil, err + } + defer rows.Close() + var items []int64 + for rows.Next() { + var id int64 + if err := rows.Scan(&id); err != nil { + return nil, err + } + items = append(items, id) + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + const ExamPrepListUnitModuleLessonIDsByUnitModule = `-- name: ExamPrepListUnitModuleLessonIDsByUnitModule :many SELECT l.id @@ -153,16 +192,22 @@ SELECT l.thumbnail, l.description, l.sort_order, + l.publish_status, EXISTS ( SELECT 1 FROM exam_prep.lesson_practices p WHERE p.unit_module_lesson_id = l.id + AND p.publish_status = 'PUBLISHED' ) AS has_practice, l.created_at, l.updated_at FROM exam_prep.unit_module_lessons l WHERE l.unit_module_id = $1 + AND ( + $4::boolean = FALSE + OR l.publish_status = 'PUBLISHED'::TEXT + ) ORDER BY l.sort_order ASC, l.id ASC @@ -171,27 +216,34 @@ OFFSET $3 ` type ExamPrepListUnitModuleLessonsByUnitModuleIDParams struct { - UnitModuleID int64 `json:"unit_module_id"` - Limit int32 `json:"limit"` - Offset int32 `json:"offset"` + UnitModuleID int64 `json:"unit_module_id"` + Limit int32 `json:"limit"` + Offset int32 `json:"offset"` + PublishedOnly bool `json:"published_only"` } type ExamPrepListUnitModuleLessonsByUnitModuleIDRow struct { - TotalCount int64 `json:"total_count"` - ID int64 `json:"id"` - UnitModuleID int64 `json:"unit_module_id"` - Title string `json:"title"` - VideoUrl pgtype.Text `json:"video_url"` - Thumbnail pgtype.Text `json:"thumbnail"` - Description pgtype.Text `json:"description"` - SortOrder int32 `json:"sort_order"` - HasPractice bool `json:"has_practice"` - CreatedAt pgtype.Timestamptz `json:"created_at"` - UpdatedAt pgtype.Timestamptz `json:"updated_at"` + TotalCount int64 `json:"total_count"` + ID int64 `json:"id"` + UnitModuleID int64 `json:"unit_module_id"` + Title string `json:"title"` + VideoUrl pgtype.Text `json:"video_url"` + Thumbnail pgtype.Text `json:"thumbnail"` + Description pgtype.Text `json:"description"` + SortOrder int32 `json:"sort_order"` + PublishStatus string `json:"publish_status"` + HasPractice bool `json:"has_practice"` + CreatedAt pgtype.Timestamptz `json:"created_at"` + UpdatedAt pgtype.Timestamptz `json:"updated_at"` } func (q *Queries) ExamPrepListUnitModuleLessonsByUnitModuleID(ctx context.Context, arg ExamPrepListUnitModuleLessonsByUnitModuleIDParams) ([]ExamPrepListUnitModuleLessonsByUnitModuleIDRow, error) { - rows, err := q.db.Query(ctx, ExamPrepListUnitModuleLessonsByUnitModuleID, arg.UnitModuleID, arg.Limit, arg.Offset) + rows, err := q.db.Query(ctx, ExamPrepListUnitModuleLessonsByUnitModuleID, + arg.UnitModuleID, + arg.Limit, + arg.Offset, + arg.PublishedOnly, + ) if err != nil { return nil, err } @@ -208,6 +260,7 @@ func (q *Queries) ExamPrepListUnitModuleLessonsByUnitModuleID(ctx context.Contex &i.Thumbnail, &i.Description, &i.SortOrder, + &i.PublishStatus, &i.HasPractice, &i.CreatedAt, &i.UpdatedAt, @@ -230,19 +283,21 @@ SET thumbnail = coalesce($3::text, thumbnail), description = coalesce($4::text, description), sort_order = coalesce($5::int, sort_order), + publish_status = coalesce($6::varchar, publish_status), updated_at = CURRENT_TIMESTAMP -WHERE id = $6 +WHERE id = $7 RETURNING - id, unit_module_id, title, video_url, thumbnail, description, sort_order, created_at, updated_at + id, unit_module_id, title, video_url, thumbnail, description, sort_order, created_at, updated_at, publish_status ` type ExamPrepUpdateUnitModuleLessonParams struct { - Title pgtype.Text `json:"title"` - VideoUrl pgtype.Text `json:"video_url"` - Thumbnail pgtype.Text `json:"thumbnail"` - Description pgtype.Text `json:"description"` - SortOrder pgtype.Int4 `json:"sort_order"` - ID int64 `json:"id"` + Title pgtype.Text `json:"title"` + VideoUrl pgtype.Text `json:"video_url"` + Thumbnail pgtype.Text `json:"thumbnail"` + Description pgtype.Text `json:"description"` + SortOrder pgtype.Int4 `json:"sort_order"` + PublishStatus pgtype.Text `json:"publish_status"` + ID int64 `json:"id"` } func (q *Queries) ExamPrepUpdateUnitModuleLesson(ctx context.Context, arg ExamPrepUpdateUnitModuleLessonParams) (ExamPrepUnitModuleLesson, error) { @@ -252,6 +307,7 @@ func (q *Queries) ExamPrepUpdateUnitModuleLesson(ctx context.Context, arg ExamPr arg.Thumbnail, arg.Description, arg.SortOrder, + arg.PublishStatus, arg.ID, ) var i ExamPrepUnitModuleLesson @@ -265,6 +321,7 @@ func (q *Queries) ExamPrepUpdateUnitModuleLesson(ctx context.Context, arg ExamPr &i.SortOrder, &i.CreatedAt, &i.UpdatedAt, + &i.PublishStatus, ) return i, err } diff --git a/gen/db/exam_prep_unit_modules.sql.go b/gen/db/exam_prep_unit_modules.sql.go index 8a767b2..2f5553c 100644 --- a/gen/db/exam_prep_unit_modules.sql.go +++ b/gen/db/exam_prep_unit_modules.sql.go @@ -12,7 +12,7 @@ import ( ) const ExamPrepCreateUnitModule = `-- name: ExamPrepCreateUnitModule :one -INSERT INTO exam_prep.unit_modules (unit_id, name, description, thumbnail, icon, sort_order) +INSERT INTO exam_prep.unit_modules (unit_id, name, description, thumbnail, icon, sort_order, publish_status) SELECT $1, $2, @@ -24,17 +24,19 @@ SELECT max(m.sort_order) FROM exam_prep.unit_modules m WHERE - m.unit_id = $1), 0) + 1 + m.unit_id = $1), 0) + 1, + $6 RETURNING - id, unit_id, name, description, thumbnail, icon, sort_order, created_at, updated_at + id, unit_id, name, description, thumbnail, icon, sort_order, created_at, updated_at, publish_status ` type ExamPrepCreateUnitModuleParams struct { - UnitID int64 `json:"unit_id"` - Name string `json:"name"` - Description pgtype.Text `json:"description"` - Thumbnail pgtype.Text `json:"thumbnail"` - Icon pgtype.Text `json:"icon"` + UnitID int64 `json:"unit_id"` + Name string `json:"name"` + Description pgtype.Text `json:"description"` + Thumbnail pgtype.Text `json:"thumbnail"` + Icon pgtype.Text `json:"icon"` + PublishStatus string `json:"publish_status"` } func (q *Queries) ExamPrepCreateUnitModule(ctx context.Context, arg ExamPrepCreateUnitModuleParams) (ExamPrepUnitModule, error) { @@ -44,6 +46,7 @@ func (q *Queries) ExamPrepCreateUnitModule(ctx context.Context, arg ExamPrepCrea arg.Description, arg.Thumbnail, arg.Icon, + arg.PublishStatus, ) var i ExamPrepUnitModule err := row.Scan( @@ -56,6 +59,7 @@ func (q *Queries) ExamPrepCreateUnitModule(ctx context.Context, arg ExamPrepCrea &i.SortOrder, &i.CreatedAt, &i.UpdatedAt, + &i.PublishStatus, ) return i, err } @@ -72,28 +76,31 @@ func (q *Queries) ExamPrepDeleteUnitModule(ctx context.Context, id int64) error const ExamPrepGetUnitModuleByID = `-- name: ExamPrepGetUnitModuleByID :one SELECT - m.id, m.unit_id, m.name, m.description, m.thumbnail, m.icon, m.sort_order, m.created_at, m.updated_at, + m.id, m.unit_id, m.name, m.description, m.thumbnail, m.icon, m.sort_order, m.created_at, m.updated_at, m.publish_status, EXISTS ( SELECT 1 FROM exam_prep.lesson_practices p INNER JOIN exam_prep.unit_module_lessons l ON l.id = p.unit_module_lesson_id WHERE l.unit_module_id = m.id + AND p.publish_status = 'PUBLISHED' + AND l.publish_status = 'PUBLISHED' ) AS has_practice FROM exam_prep.unit_modules m WHERE m.id = $1 ` type ExamPrepGetUnitModuleByIDRow struct { - ID int64 `json:"id"` - UnitID int64 `json:"unit_id"` - Name string `json:"name"` - Description pgtype.Text `json:"description"` - Thumbnail pgtype.Text `json:"thumbnail"` - Icon pgtype.Text `json:"icon"` - SortOrder int32 `json:"sort_order"` - CreatedAt pgtype.Timestamptz `json:"created_at"` - UpdatedAt pgtype.Timestamptz `json:"updated_at"` - HasPractice bool `json:"has_practice"` + ID int64 `json:"id"` + UnitID int64 `json:"unit_id"` + Name string `json:"name"` + Description pgtype.Text `json:"description"` + Thumbnail pgtype.Text `json:"thumbnail"` + Icon pgtype.Text `json:"icon"` + SortOrder int32 `json:"sort_order"` + CreatedAt pgtype.Timestamptz `json:"created_at"` + UpdatedAt pgtype.Timestamptz `json:"updated_at"` + PublishStatus string `json:"publish_status"` + HasPractice bool `json:"has_practice"` } func (q *Queries) ExamPrepGetUnitModuleByID(ctx context.Context, id int64) (ExamPrepGetUnitModuleByIDRow, error) { @@ -109,11 +116,44 @@ func (q *Queries) ExamPrepGetUnitModuleByID(ctx context.Context, id int64) (Exam &i.SortOrder, &i.CreatedAt, &i.UpdatedAt, + &i.PublishStatus, &i.HasPractice, ) return i, err } +const ExamPrepListPublishedUnitModuleIDsByUnit = `-- name: ExamPrepListPublishedUnitModuleIDsByUnit :many +SELECT + m.id +FROM exam_prep.unit_modules m +WHERE + m.unit_id = $1 + AND m.publish_status = 'PUBLISHED' +ORDER BY + m.id +` + +// Published modules only, for learner-facing progress rollups. +func (q *Queries) ExamPrepListPublishedUnitModuleIDsByUnit(ctx context.Context, unitID int64) ([]int64, error) { + rows, err := q.db.Query(ctx, ExamPrepListPublishedUnitModuleIDsByUnit, unitID) + if err != nil { + return nil, err + } + defer rows.Close() + var items []int64 + for rows.Next() { + var id int64 + if err := rows.Scan(&id); err != nil { + return nil, err + } + items = append(items, id) + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + const ExamPrepListUnitModuleIDsByUnit = `-- name: ExamPrepListUnitModuleIDsByUnit :many SELECT m.id @@ -152,7 +192,9 @@ WITH module_counts AS ( COUNT(DISTINCT p.id)::BIGINT AS practices_count FROM exam_prep.unit_modules m LEFT JOIN exam_prep.unit_module_lessons l ON l.unit_module_id = m.id + AND l.publish_status = 'PUBLISHED' LEFT JOIN exam_prep.lesson_practices p ON p.unit_module_lesson_id = l.id + AND p.publish_status = 'PUBLISHED' GROUP BY m.id ) SELECT @@ -164,6 +206,7 @@ SELECT m.thumbnail, m.icon, m.sort_order, + m.publish_status, COALESCE(mc.lessons_count, 0)::BIGINT AS lessons_count, COALESCE(mc.practices_count, 0)::BIGINT AS practices_count, (COALESCE(mc.practices_count, 0)::BIGINT > 0) AS has_practice, @@ -173,6 +216,10 @@ FROM exam_prep.unit_modules m LEFT JOIN module_counts mc ON mc.module_id = m.id WHERE m.unit_id = $1 + AND ( + $4::boolean = FALSE + OR m.publish_status = 'PUBLISHED'::TEXT + ) ORDER BY m.sort_order ASC, m.id ASC @@ -181,9 +228,10 @@ OFFSET $3 ` type ExamPrepListUnitModulesByUnitParams struct { - UnitID int64 `json:"unit_id"` - Limit int32 `json:"limit"` - Offset int32 `json:"offset"` + UnitID int64 `json:"unit_id"` + Limit int32 `json:"limit"` + Offset int32 `json:"offset"` + PublishedOnly bool `json:"published_only"` } type ExamPrepListUnitModulesByUnitRow struct { @@ -195,6 +243,7 @@ type ExamPrepListUnitModulesByUnitRow struct { Thumbnail pgtype.Text `json:"thumbnail"` Icon pgtype.Text `json:"icon"` SortOrder int32 `json:"sort_order"` + PublishStatus string `json:"publish_status"` LessonsCount int64 `json:"lessons_count"` PracticesCount int64 `json:"practices_count"` HasPractice bool `json:"has_practice"` @@ -203,7 +252,12 @@ type ExamPrepListUnitModulesByUnitRow struct { } func (q *Queries) ExamPrepListUnitModulesByUnit(ctx context.Context, arg ExamPrepListUnitModulesByUnitParams) ([]ExamPrepListUnitModulesByUnitRow, error) { - rows, err := q.db.Query(ctx, ExamPrepListUnitModulesByUnit, arg.UnitID, arg.Limit, arg.Offset) + rows, err := q.db.Query(ctx, ExamPrepListUnitModulesByUnit, + arg.UnitID, + arg.Limit, + arg.Offset, + arg.PublishedOnly, + ) if err != nil { return nil, err } @@ -220,6 +274,7 @@ func (q *Queries) ExamPrepListUnitModulesByUnit(ctx context.Context, arg ExamPre &i.Thumbnail, &i.Icon, &i.SortOrder, + &i.PublishStatus, &i.LessonsCount, &i.PracticesCount, &i.HasPractice, @@ -244,19 +299,21 @@ SET thumbnail = coalesce($3::text, thumbnail), icon = coalesce($4::text, icon), sort_order = coalesce($5::int, sort_order), + publish_status = coalesce($6::varchar, publish_status), updated_at = CURRENT_TIMESTAMP -WHERE id = $6 +WHERE id = $7 RETURNING - id, unit_id, name, description, thumbnail, icon, sort_order, created_at, updated_at + id, unit_id, name, description, thumbnail, icon, sort_order, created_at, updated_at, publish_status ` type ExamPrepUpdateUnitModuleParams struct { - Name pgtype.Text `json:"name"` - Description pgtype.Text `json:"description"` - Thumbnail pgtype.Text `json:"thumbnail"` - Icon pgtype.Text `json:"icon"` - SortOrder pgtype.Int4 `json:"sort_order"` - ID int64 `json:"id"` + Name pgtype.Text `json:"name"` + Description pgtype.Text `json:"description"` + Thumbnail pgtype.Text `json:"thumbnail"` + Icon pgtype.Text `json:"icon"` + SortOrder pgtype.Int4 `json:"sort_order"` + PublishStatus pgtype.Text `json:"publish_status"` + ID int64 `json:"id"` } func (q *Queries) ExamPrepUpdateUnitModule(ctx context.Context, arg ExamPrepUpdateUnitModuleParams) (ExamPrepUnitModule, error) { @@ -266,6 +323,7 @@ func (q *Queries) ExamPrepUpdateUnitModule(ctx context.Context, arg ExamPrepUpda arg.Thumbnail, arg.Icon, arg.SortOrder, + arg.PublishStatus, arg.ID, ) var i ExamPrepUnitModule @@ -279,6 +337,7 @@ func (q *Queries) ExamPrepUpdateUnitModule(ctx context.Context, arg ExamPrepUpda &i.SortOrder, &i.CreatedAt, &i.UpdatedAt, + &i.PublishStatus, ) return i, err } diff --git a/gen/db/exam_prep_units.sql.go b/gen/db/exam_prep_units.sql.go index 5ebe664..8b8d013 100644 --- a/gen/db/exam_prep_units.sql.go +++ b/gen/db/exam_prep_units.sql.go @@ -12,7 +12,7 @@ import ( ) const ExamPrepCreateUnit = `-- name: ExamPrepCreateUnit :one -INSERT INTO exam_prep.units (catalog_course_id, name, description, thumbnail, sort_order) +INSERT INTO exam_prep.units (catalog_course_id, name, description, thumbnail, sort_order, publish_status) SELECT $1, $2, @@ -24,9 +24,10 @@ SELECT max(u.sort_order) FROM exam_prep.units u WHERE - u.catalog_course_id = $1), 0) + 1) + u.catalog_course_id = $1), 0) + 1), + $6 RETURNING - id, catalog_course_id, name, description, thumbnail, sort_order, created_at, updated_at + id, catalog_course_id, name, description, thumbnail, sort_order, created_at, updated_at, publish_status ` type ExamPrepCreateUnitParams struct { @@ -35,6 +36,7 @@ type ExamPrepCreateUnitParams struct { Description pgtype.Text `json:"description"` Thumbnail pgtype.Text `json:"thumbnail"` SortOrder pgtype.Int4 `json:"sort_order"` + PublishStatus string `json:"publish_status"` } func (q *Queries) ExamPrepCreateUnit(ctx context.Context, arg ExamPrepCreateUnitParams) (ExamPrepUnit, error) { @@ -44,6 +46,7 @@ func (q *Queries) ExamPrepCreateUnit(ctx context.Context, arg ExamPrepCreateUnit arg.Description, arg.Thumbnail, arg.SortOrder, + arg.PublishStatus, ) var i ExamPrepUnit err := row.Scan( @@ -55,6 +58,7 @@ func (q *Queries) ExamPrepCreateUnit(ctx context.Context, arg ExamPrepCreateUnit &i.SortOrder, &i.CreatedAt, &i.UpdatedAt, + &i.PublishStatus, ) return i, err } @@ -71,13 +75,16 @@ func (q *Queries) ExamPrepDeleteUnit(ctx context.Context, id int64) error { const ExamPrepGetUnitByID = `-- name: ExamPrepGetUnitByID :one SELECT - u.id, u.catalog_course_id, u.name, u.description, u.thumbnail, u.sort_order, u.created_at, u.updated_at, + u.id, u.catalog_course_id, u.name, u.description, u.thumbnail, u.sort_order, u.created_at, u.updated_at, u.publish_status, EXISTS ( SELECT 1 FROM exam_prep.lesson_practices p INNER JOIN exam_prep.unit_module_lessons l ON l.id = p.unit_module_lesson_id INNER JOIN exam_prep.unit_modules m ON m.id = l.unit_module_id WHERE m.unit_id = u.id + AND p.publish_status = 'PUBLISHED' + AND l.publish_status = 'PUBLISHED' + AND m.publish_status = 'PUBLISHED' ) AS has_practice FROM exam_prep.units u WHERE u.id = $1 @@ -92,6 +99,7 @@ type ExamPrepGetUnitByIDRow struct { SortOrder int32 `json:"sort_order"` CreatedAt pgtype.Timestamptz `json:"created_at"` UpdatedAt pgtype.Timestamptz `json:"updated_at"` + PublishStatus string `json:"publish_status"` HasPractice bool `json:"has_practice"` } @@ -107,11 +115,44 @@ func (q *Queries) ExamPrepGetUnitByID(ctx context.Context, id int64) (ExamPrepGe &i.SortOrder, &i.CreatedAt, &i.UpdatedAt, + &i.PublishStatus, &i.HasPractice, ) return i, err } +const ExamPrepListPublishedUnitIDsByCatalogCourse = `-- name: ExamPrepListPublishedUnitIDsByCatalogCourse :many +SELECT + u.id +FROM exam_prep.units u +WHERE + u.catalog_course_id = $1 + AND u.publish_status = 'PUBLISHED' +ORDER BY + u.id +` + +// Published units only, for learner-facing progress rollups. +func (q *Queries) ExamPrepListPublishedUnitIDsByCatalogCourse(ctx context.Context, catalogCourseID int64) ([]int64, error) { + rows, err := q.db.Query(ctx, ExamPrepListPublishedUnitIDsByCatalogCourse, catalogCourseID) + if err != nil { + return nil, err + } + defer rows.Close() + var items []int64 + for rows.Next() { + var id int64 + if err := rows.Scan(&id); err != nil { + return nil, err + } + items = append(items, id) + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + const ExamPrepListUnitIDsByCatalogCourse = `-- name: ExamPrepListUnitIDsByCatalogCourse :many SELECT u.id @@ -151,8 +192,11 @@ WITH unit_counts AS ( COUNT(DISTINCT p.id)::BIGINT AS practices_count FROM exam_prep.units u LEFT JOIN exam_prep.unit_modules m ON m.unit_id = u.id + AND m.publish_status = 'PUBLISHED' LEFT JOIN exam_prep.unit_module_lessons l ON l.unit_module_id = m.id + AND l.publish_status = 'PUBLISHED' LEFT JOIN exam_prep.lesson_practices p ON p.unit_module_lesson_id = l.id + AND p.publish_status = 'PUBLISHED' GROUP BY u.id ) SELECT @@ -163,6 +207,7 @@ SELECT u.description, u.thumbnail, u.sort_order, + u.publish_status, COALESCE(uc.modules_count, 0)::BIGINT AS modules_count, COALESCE(uc.lessons_count, 0)::BIGINT AS lessons_count, COALESCE(uc.practices_count, 0)::BIGINT AS practices_count, @@ -173,6 +218,10 @@ FROM exam_prep.units u LEFT JOIN unit_counts uc ON uc.unit_id = u.id WHERE u.catalog_course_id = $1 + AND ( + $4::boolean = FALSE + OR u.publish_status = 'PUBLISHED'::TEXT + ) ORDER BY u.sort_order ASC, u.id ASC @@ -184,6 +233,7 @@ type ExamPrepListUnitsByCatalogCourseParams struct { CatalogCourseID int64 `json:"catalog_course_id"` Limit int32 `json:"limit"` Offset int32 `json:"offset"` + PublishedOnly bool `json:"published_only"` } type ExamPrepListUnitsByCatalogCourseRow struct { @@ -194,6 +244,7 @@ type ExamPrepListUnitsByCatalogCourseRow struct { Description pgtype.Text `json:"description"` Thumbnail pgtype.Text `json:"thumbnail"` SortOrder int32 `json:"sort_order"` + PublishStatus string `json:"publish_status"` ModulesCount int64 `json:"modules_count"` LessonsCount int64 `json:"lessons_count"` PracticesCount int64 `json:"practices_count"` @@ -203,7 +254,12 @@ type ExamPrepListUnitsByCatalogCourseRow struct { } func (q *Queries) ExamPrepListUnitsByCatalogCourse(ctx context.Context, arg ExamPrepListUnitsByCatalogCourseParams) ([]ExamPrepListUnitsByCatalogCourseRow, error) { - rows, err := q.db.Query(ctx, ExamPrepListUnitsByCatalogCourse, arg.CatalogCourseID, arg.Limit, arg.Offset) + rows, err := q.db.Query(ctx, ExamPrepListUnitsByCatalogCourse, + arg.CatalogCourseID, + arg.Limit, + arg.Offset, + arg.PublishedOnly, + ) if err != nil { return nil, err } @@ -219,6 +275,7 @@ func (q *Queries) ExamPrepListUnitsByCatalogCourse(ctx context.Context, arg Exam &i.Description, &i.Thumbnail, &i.SortOrder, + &i.PublishStatus, &i.ModulesCount, &i.LessonsCount, &i.PracticesCount, @@ -243,18 +300,20 @@ SET description = coalesce($2::text, description), thumbnail = coalesce($3::text, thumbnail), sort_order = coalesce($4::int, sort_order), + publish_status = coalesce($5::varchar, publish_status), updated_at = CURRENT_TIMESTAMP -WHERE id = $5 +WHERE id = $6 RETURNING - id, catalog_course_id, name, description, thumbnail, sort_order, created_at, updated_at + id, catalog_course_id, name, description, thumbnail, sort_order, created_at, updated_at, publish_status ` type ExamPrepUpdateUnitParams struct { - Name pgtype.Text `json:"name"` - Description pgtype.Text `json:"description"` - Thumbnail pgtype.Text `json:"thumbnail"` - SortOrder pgtype.Int4 `json:"sort_order"` - ID int64 `json:"id"` + Name pgtype.Text `json:"name"` + Description pgtype.Text `json:"description"` + Thumbnail pgtype.Text `json:"thumbnail"` + SortOrder pgtype.Int4 `json:"sort_order"` + PublishStatus pgtype.Text `json:"publish_status"` + ID int64 `json:"id"` } func (q *Queries) ExamPrepUpdateUnit(ctx context.Context, arg ExamPrepUpdateUnitParams) (ExamPrepUnit, error) { @@ -263,6 +322,7 @@ func (q *Queries) ExamPrepUpdateUnit(ctx context.Context, arg ExamPrepUpdateUnit arg.Description, arg.Thumbnail, arg.SortOrder, + arg.PublishStatus, arg.ID, ) var i ExamPrepUnit @@ -275,6 +335,7 @@ func (q *Queries) ExamPrepUpdateUnit(ctx context.Context, arg ExamPrepUpdateUnit &i.SortOrder, &i.CreatedAt, &i.UpdatedAt, + &i.PublishStatus, ) return i, err } diff --git a/gen/db/lms_courses.sql.go b/gen/db/lms_courses.sql.go index 4065d55..0afe015 100644 --- a/gen/db/lms_courses.sql.go +++ b/gen/db/lms_courses.sql.go @@ -12,7 +12,7 @@ import ( ) const CreateCourse = `-- name: CreateCourse :one -INSERT INTO courses (program_id, name, description, thumbnail, sort_order) +INSERT INTO courses (program_id, name, description, thumbnail, sort_order, publish_status) SELECT $1, $2, @@ -24,17 +24,19 @@ SELECT max(c.sort_order) FROM courses c WHERE - c.program_id = $1), 0) + 1) + c.program_id = $1), 0) + 1), + $6 RETURNING - id, program_id, name, description, thumbnail, created_at, updated_at, sort_order + id, program_id, name, description, thumbnail, created_at, updated_at, sort_order, publish_status ` type CreateCourseParams struct { - ProgramID int64 `json:"program_id"` - Name string `json:"name"` - Description pgtype.Text `json:"description"` - Thumbnail pgtype.Text `json:"thumbnail"` - SortOrder pgtype.Int4 `json:"sort_order"` + ProgramID int64 `json:"program_id"` + Name string `json:"name"` + Description pgtype.Text `json:"description"` + Thumbnail pgtype.Text `json:"thumbnail"` + SortOrder pgtype.Int4 `json:"sort_order"` + PublishStatus string `json:"publish_status"` } func (q *Queries) CreateCourse(ctx context.Context, arg CreateCourseParams) (Course, error) { @@ -44,6 +46,7 @@ func (q *Queries) CreateCourse(ctx context.Context, arg CreateCourseParams) (Cou arg.Description, arg.Thumbnail, arg.SortOrder, + arg.PublishStatus, ) var i Course err := row.Scan( @@ -55,6 +58,7 @@ func (q *Queries) CreateCourse(ctx context.Context, arg CreateCourseParams) (Cou &i.CreatedAt, &i.UpdatedAt, &i.SortOrder, + &i.PublishStatus, ) return i, err } @@ -71,7 +75,7 @@ func (q *Queries) DeleteCourse(ctx context.Context, id int64) error { const GetCourseByID = `-- name: GetCourseByID :one SELECT - c.id, c.program_id, c.name, c.description, c.thumbnail, c.created_at, c.updated_at, c.sort_order, + c.id, c.program_id, c.name, c.description, c.thumbnail, c.created_at, c.updated_at, c.sort_order, c.publish_status, EXISTS ( SELECT 1 FROM lms_practices p @@ -86,15 +90,16 @@ WHERE c.id = $1 ` type GetCourseByIDRow struct { - ID int64 `json:"id"` - ProgramID int64 `json:"program_id"` - Name string `json:"name"` - Description pgtype.Text `json:"description"` - Thumbnail pgtype.Text `json:"thumbnail"` - CreatedAt pgtype.Timestamptz `json:"created_at"` - UpdatedAt pgtype.Timestamptz `json:"updated_at"` - SortOrder int32 `json:"sort_order"` - HasPractice bool `json:"has_practice"` + ID int64 `json:"id"` + ProgramID int64 `json:"program_id"` + Name string `json:"name"` + Description pgtype.Text `json:"description"` + Thumbnail pgtype.Text `json:"thumbnail"` + CreatedAt pgtype.Timestamptz `json:"created_at"` + UpdatedAt pgtype.Timestamptz `json:"updated_at"` + SortOrder int32 `json:"sort_order"` + PublishStatus string `json:"publish_status"` + HasPractice bool `json:"has_practice"` } func (q *Queries) GetCourseByID(ctx context.Context, id int64) (GetCourseByIDRow, error) { @@ -109,6 +114,7 @@ func (q *Queries) GetCourseByID(ctx context.Context, id int64) (GetCourseByIDRow &i.CreatedAt, &i.UpdatedAt, &i.SortOrder, + &i.PublishStatus, &i.HasPractice, ) return i, err @@ -154,6 +160,7 @@ SELECT c.description, c.thumbnail, c.sort_order, + c.publish_status, c.created_at, c.updated_at, ( @@ -162,7 +169,8 @@ SELECT FROM modules m WHERE - m.course_id = c.id) AS module_count, + m.course_id = c.id + AND m.publish_status = 'PUBLISHED') AS module_count, ( SELECT COUNT(*)::bigint @@ -196,6 +204,10 @@ FROM courses c WHERE c.program_id = $1 + AND ( + $4::boolean = FALSE + OR c.publish_status = 'PUBLISHED'::TEXT + ) ORDER BY c.sort_order ASC, c.id ASC @@ -203,9 +215,10 @@ LIMIT $2 OFFSET $3 ` type ListCoursesByProgramIDParams struct { - ProgramID int64 `json:"program_id"` - Limit int32 `json:"limit"` - Offset int32 `json:"offset"` + ProgramID int64 `json:"program_id"` + Limit int32 `json:"limit"` + Offset int32 `json:"offset"` + PublishedOnly bool `json:"published_only"` } type ListCoursesByProgramIDRow struct { @@ -216,6 +229,7 @@ type ListCoursesByProgramIDRow struct { Description pgtype.Text `json:"description"` Thumbnail pgtype.Text `json:"thumbnail"` SortOrder int32 `json:"sort_order"` + PublishStatus string `json:"publish_status"` CreatedAt pgtype.Timestamptz `json:"created_at"` UpdatedAt pgtype.Timestamptz `json:"updated_at"` ModuleCount int64 `json:"module_count"` @@ -225,7 +239,12 @@ type ListCoursesByProgramIDRow struct { } func (q *Queries) ListCoursesByProgramID(ctx context.Context, arg ListCoursesByProgramIDParams) ([]ListCoursesByProgramIDRow, error) { - rows, err := q.db.Query(ctx, ListCoursesByProgramID, arg.ProgramID, arg.Limit, arg.Offset) + rows, err := q.db.Query(ctx, ListCoursesByProgramID, + arg.ProgramID, + arg.Limit, + arg.Offset, + arg.PublishedOnly, + ) if err != nil { return nil, err } @@ -241,6 +260,7 @@ func (q *Queries) ListCoursesByProgramID(ctx context.Context, arg ListCoursesByP &i.Description, &i.Thumbnail, &i.SortOrder, + &i.PublishStatus, &i.CreatedAt, &i.UpdatedAt, &i.ModuleCount, @@ -258,6 +278,39 @@ func (q *Queries) ListCoursesByProgramID(ctx context.Context, arg ListCoursesByP return items, nil } +const ListPublishedCourseIDsByProgram = `-- name: ListPublishedCourseIDsByProgram :many +SELECT + c.id +FROM + courses AS c +WHERE + c.program_id = $1 + AND c.publish_status = 'PUBLISHED' +ORDER BY + c.id +` + +// Published courses only, for learner-facing progress rollups. +func (q *Queries) ListPublishedCourseIDsByProgram(ctx context.Context, programID int64) ([]int64, error) { + rows, err := q.db.Query(ctx, ListPublishedCourseIDsByProgram, programID) + if err != nil { + return nil, err + } + defer rows.Close() + var items []int64 + for rows.Next() { + var id int64 + if err := rows.Scan(&id); err != nil { + return nil, err + } + items = append(items, id) + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + const UpdateCourse = `-- name: UpdateCourse :one UPDATE courses SET @@ -265,19 +318,21 @@ SET description = COALESCE($2::text, description), thumbnail = COALESCE($3::text, thumbnail), sort_order = coalesce($4::int, sort_order), + publish_status = COALESCE($5::varchar, publish_status), updated_at = CURRENT_TIMESTAMP WHERE - id = $5 + id = $6 RETURNING - id, program_id, name, description, thumbnail, created_at, updated_at, sort_order + id, program_id, name, description, thumbnail, created_at, updated_at, sort_order, publish_status ` type UpdateCourseParams struct { - Name pgtype.Text `json:"name"` - Description pgtype.Text `json:"description"` - Thumbnail pgtype.Text `json:"thumbnail"` - SortOrder pgtype.Int4 `json:"sort_order"` - ID int64 `json:"id"` + Name pgtype.Text `json:"name"` + Description pgtype.Text `json:"description"` + Thumbnail pgtype.Text `json:"thumbnail"` + SortOrder pgtype.Int4 `json:"sort_order"` + PublishStatus pgtype.Text `json:"publish_status"` + ID int64 `json:"id"` } func (q *Queries) UpdateCourse(ctx context.Context, arg UpdateCourseParams) (Course, error) { @@ -286,6 +341,7 @@ func (q *Queries) UpdateCourse(ctx context.Context, arg UpdateCourseParams) (Cou arg.Description, arg.Thumbnail, arg.SortOrder, + arg.PublishStatus, arg.ID, ) var i Course @@ -298,6 +354,7 @@ func (q *Queries) UpdateCourse(ctx context.Context, arg UpdateCourseParams) (Cou &i.CreatedAt, &i.UpdatedAt, &i.SortOrder, + &i.PublishStatus, ) return i, err } diff --git a/gen/db/lms_modules.sql.go b/gen/db/lms_modules.sql.go index b0d80fb..db53765 100644 --- a/gen/db/lms_modules.sql.go +++ b/gen/db/lms_modules.sql.go @@ -12,7 +12,7 @@ import ( ) const CreateModule = `-- name: CreateModule :one -INSERT INTO modules (program_id, course_id, name, description, icon, sort_order) +INSERT INTO modules (program_id, course_id, name, description, icon, sort_order, publish_status) SELECT $1, $2, @@ -25,18 +25,20 @@ SELECT max(m.sort_order) FROM modules m WHERE - m.course_id = $2), 0) + 1) + m.course_id = $2), 0) + 1), + $7 RETURNING - id, program_id, course_id, name, description, icon, created_at, updated_at, sort_order + id, program_id, course_id, name, description, icon, created_at, updated_at, sort_order, publish_status ` type CreateModuleParams struct { - ProgramID int64 `json:"program_id"` - CourseID int64 `json:"course_id"` - Name string `json:"name"` - Description pgtype.Text `json:"description"` - Icon pgtype.Text `json:"icon"` - SortOrder pgtype.Int4 `json:"sort_order"` + ProgramID int64 `json:"program_id"` + CourseID int64 `json:"course_id"` + Name string `json:"name"` + Description pgtype.Text `json:"description"` + Icon pgtype.Text `json:"icon"` + SortOrder pgtype.Int4 `json:"sort_order"` + PublishStatus string `json:"publish_status"` } func (q *Queries) CreateModule(ctx context.Context, arg CreateModuleParams) (Module, error) { @@ -47,6 +49,7 @@ func (q *Queries) CreateModule(ctx context.Context, arg CreateModuleParams) (Mod arg.Description, arg.Icon, arg.SortOrder, + arg.PublishStatus, ) var i Module err := row.Scan( @@ -59,6 +62,7 @@ func (q *Queries) CreateModule(ctx context.Context, arg CreateModuleParams) (Mod &i.CreatedAt, &i.UpdatedAt, &i.SortOrder, + &i.PublishStatus, ) return i, err } @@ -75,7 +79,7 @@ func (q *Queries) DeleteModule(ctx context.Context, id int64) error { const GetModuleByID = `-- name: GetModuleByID :one SELECT - m.id, m.program_id, m.course_id, m.name, m.description, m.icon, m.created_at, m.updated_at, m.sort_order, + m.id, m.program_id, m.course_id, m.name, m.description, m.icon, m.created_at, m.updated_at, m.sort_order, m.publish_status, EXISTS ( SELECT 1 FROM lms_practices p @@ -89,16 +93,17 @@ WHERE m.id = $1 ` type GetModuleByIDRow struct { - ID int64 `json:"id"` - ProgramID int64 `json:"program_id"` - CourseID int64 `json:"course_id"` - Name string `json:"name"` - Description pgtype.Text `json:"description"` - Icon pgtype.Text `json:"icon"` - CreatedAt pgtype.Timestamptz `json:"created_at"` - UpdatedAt pgtype.Timestamptz `json:"updated_at"` - SortOrder int32 `json:"sort_order"` - HasPractice bool `json:"has_practice"` + ID int64 `json:"id"` + ProgramID int64 `json:"program_id"` + CourseID int64 `json:"course_id"` + Name string `json:"name"` + Description pgtype.Text `json:"description"` + Icon pgtype.Text `json:"icon"` + CreatedAt pgtype.Timestamptz `json:"created_at"` + UpdatedAt pgtype.Timestamptz `json:"updated_at"` + SortOrder int32 `json:"sort_order"` + PublishStatus string `json:"publish_status"` + HasPractice bool `json:"has_practice"` } func (q *Queries) GetModuleByID(ctx context.Context, id int64) (GetModuleByIDRow, error) { @@ -114,6 +119,7 @@ func (q *Queries) GetModuleByID(ctx context.Context, id int64) (GetModuleByIDRow &i.CreatedAt, &i.UpdatedAt, &i.SortOrder, + &i.PublishStatus, &i.HasPractice, ) return i, err @@ -160,6 +166,7 @@ SELECT m.description, m.icon, m.sort_order, + m.publish_status, m.created_at, m.updated_at, EXISTS ( @@ -174,6 +181,10 @@ FROM WHERE m.program_id = $1 AND m.course_id = $2 + AND ( + $5::boolean = FALSE + OR m.publish_status = 'PUBLISHED'::TEXT + ) ORDER BY m.sort_order ASC, m.id ASC @@ -182,24 +193,26 @@ OFFSET $4 ` type ListModulesByProgramAndCourseParams struct { - ProgramID int64 `json:"program_id"` - CourseID int64 `json:"course_id"` - Limit int32 `json:"limit"` - Offset int32 `json:"offset"` + ProgramID int64 `json:"program_id"` + CourseID int64 `json:"course_id"` + Limit int32 `json:"limit"` + Offset int32 `json:"offset"` + PublishedOnly bool `json:"published_only"` } type ListModulesByProgramAndCourseRow struct { - TotalCount int64 `json:"total_count"` - ID int64 `json:"id"` - ProgramID int64 `json:"program_id"` - CourseID int64 `json:"course_id"` - Name string `json:"name"` - Description pgtype.Text `json:"description"` - Icon pgtype.Text `json:"icon"` - SortOrder int32 `json:"sort_order"` - CreatedAt pgtype.Timestamptz `json:"created_at"` - UpdatedAt pgtype.Timestamptz `json:"updated_at"` - HasPractice bool `json:"has_practice"` + TotalCount int64 `json:"total_count"` + ID int64 `json:"id"` + ProgramID int64 `json:"program_id"` + CourseID int64 `json:"course_id"` + Name string `json:"name"` + Description pgtype.Text `json:"description"` + Icon pgtype.Text `json:"icon"` + SortOrder int32 `json:"sort_order"` + PublishStatus string `json:"publish_status"` + CreatedAt pgtype.Timestamptz `json:"created_at"` + UpdatedAt pgtype.Timestamptz `json:"updated_at"` + HasPractice bool `json:"has_practice"` } func (q *Queries) ListModulesByProgramAndCourse(ctx context.Context, arg ListModulesByProgramAndCourseParams) ([]ListModulesByProgramAndCourseRow, error) { @@ -208,6 +221,7 @@ func (q *Queries) ListModulesByProgramAndCourse(ctx context.Context, arg ListMod arg.CourseID, arg.Limit, arg.Offset, + arg.PublishedOnly, ) if err != nil { return nil, err @@ -225,6 +239,7 @@ func (q *Queries) ListModulesByProgramAndCourse(ctx context.Context, arg ListMod &i.Description, &i.Icon, &i.SortOrder, + &i.PublishStatus, &i.CreatedAt, &i.UpdatedAt, &i.HasPractice, @@ -239,6 +254,39 @@ func (q *Queries) ListModulesByProgramAndCourse(ctx context.Context, arg ListMod return items, nil } +const ListPublishedModuleIDsByCourse = `-- name: ListPublishedModuleIDsByCourse :many +SELECT + m.id +FROM + modules AS m +WHERE + m.course_id = $1 + AND m.publish_status = 'PUBLISHED' +ORDER BY + m.id +` + +// Published modules only, for learner-facing progress rollups. +func (q *Queries) ListPublishedModuleIDsByCourse(ctx context.Context, courseID int64) ([]int64, error) { + rows, err := q.db.Query(ctx, ListPublishedModuleIDsByCourse, courseID) + if err != nil { + return nil, err + } + defer rows.Close() + var items []int64 + for rows.Next() { + var id int64 + if err := rows.Scan(&id); err != nil { + return nil, err + } + items = append(items, id) + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + const UpdateModule = `-- name: UpdateModule :one UPDATE modules SET @@ -246,19 +294,21 @@ SET description = COALESCE($2::text, description), icon = COALESCE($3::text, icon), sort_order = coalesce($4::int, sort_order), + publish_status = COALESCE($5::varchar, publish_status), updated_at = CURRENT_TIMESTAMP WHERE - id = $5 + id = $6 RETURNING - id, program_id, course_id, name, description, icon, created_at, updated_at, sort_order + id, program_id, course_id, name, description, icon, created_at, updated_at, sort_order, publish_status ` type UpdateModuleParams struct { - Name pgtype.Text `json:"name"` - Description pgtype.Text `json:"description"` - Icon pgtype.Text `json:"icon"` - SortOrder pgtype.Int4 `json:"sort_order"` - ID int64 `json:"id"` + Name pgtype.Text `json:"name"` + Description pgtype.Text `json:"description"` + Icon pgtype.Text `json:"icon"` + SortOrder pgtype.Int4 `json:"sort_order"` + PublishStatus pgtype.Text `json:"publish_status"` + ID int64 `json:"id"` } func (q *Queries) UpdateModule(ctx context.Context, arg UpdateModuleParams) (Module, error) { @@ -267,6 +317,7 @@ func (q *Queries) UpdateModule(ctx context.Context, arg UpdateModuleParams) (Mod arg.Description, arg.Icon, arg.SortOrder, + arg.PublishStatus, arg.ID, ) var i Module @@ -280,6 +331,7 @@ func (q *Queries) UpdateModule(ctx context.Context, arg UpdateModuleParams) (Mod &i.CreatedAt, &i.UpdatedAt, &i.SortOrder, + &i.PublishStatus, ) return i, err } diff --git a/gen/db/lms_progress.sql.go b/gen/db/lms_progress.sql.go index 4987f82..0f8c4b9 100644 --- a/gen/db/lms_progress.sql.go +++ b/gen/db/lms_progress.sql.go @@ -18,6 +18,7 @@ FROM courses WHERE program_id = $1 + AND publish_status = 'PUBLISHED' ` func (q *Queries) CountCoursesInProgram(ctx context.Context, programID int64) (int32, error) { @@ -90,6 +91,7 @@ FROM modules WHERE course_id = $1 + AND publish_status = 'PUBLISHED' ` func (q *Queries) CountModulesInCourse(ctx context.Context, courseID int64) (int32, error) { @@ -156,7 +158,8 @@ WHERE FROM modules WHERE - course_id = $1) + course_id = $1 + AND publish_status = 'PUBLISHED') OR lp.lesson_id IN ( SELECT l.id @@ -164,7 +167,9 @@ WHERE lessons l INNER JOIN modules m ON m.id = l.module_id WHERE - m.course_id = $1)) + m.course_id = $1 + AND l.publish_status = 'PUBLISHED' + AND m.publish_status = 'PUBLISHED')) AND qs.set_type = 'PRACTICE' AND qs.status = 'PUBLISHED' AND lp.publish_status = 'PUBLISHED' @@ -212,13 +217,14 @@ WHERE FROM lessons WHERE - module_id = $1)) + module_id = $1 + AND publish_status = 'PUBLISHED')) AND qs.set_type = 'PRACTICE' AND qs.status = 'PUBLISHED' AND lp.publish_status = 'PUBLISHED' ` -// Published practices in a module (direct module practices and practices on lessons in the module). +// Published practices in a module (direct module practices and practices on published lessons in the module). func (q *Queries) CountPublishedPracticesInModule(ctx context.Context, moduleID pgtype.Int8) (int32, error) { row := q.db.QueryRow(ctx, CountPublishedPracticesInModule, moduleID) var n int32 @@ -240,7 +246,8 @@ WHERE FROM courses c WHERE - c.program_id = $1) + c.program_id = $1 + AND c.publish_status = 'PUBLISHED') OR lp.module_id IN ( SELECT m.id @@ -248,7 +255,9 @@ WHERE modules m INNER JOIN courses c ON c.id = m.course_id WHERE - c.program_id = $1) + c.program_id = $1 + AND m.publish_status = 'PUBLISHED' + AND c.publish_status = 'PUBLISHED') OR lp.lesson_id IN ( SELECT l.id @@ -257,7 +266,10 @@ WHERE INNER JOIN modules m ON m.id = l.module_id INNER JOIN courses c ON c.id = m.course_id WHERE - c.program_id = $1)) + c.program_id = $1 + AND l.publish_status = 'PUBLISHED' + AND m.publish_status = 'PUBLISHED' + AND c.publish_status = 'PUBLISHED')) AND qs.set_type = 'PRACTICE' AND qs.status = 'PUBLISHED' AND lp.publish_status = 'PUBLISHED' @@ -279,6 +291,7 @@ FROM WHERE c.program_id = $1 AND ucp.user_id = $2 + AND c.publish_status = 'PUBLISHED' ` type CountUserCompletedCoursesInProgramParams struct { @@ -377,6 +390,7 @@ FROM WHERE m.course_id = $1 AND ump.user_id = $2 + AND m.publish_status = 'PUBLISHED' ` type CountUserCompletedModulesInCourseParams struct { @@ -463,7 +477,8 @@ WHERE FROM modules WHERE - course_id = $1) + course_id = $1 + AND publish_status = 'PUBLISHED') OR lp.lesson_id IN ( SELECT l.id @@ -471,7 +486,9 @@ WHERE lessons l INNER JOIN modules m ON m.id = l.module_id WHERE - m.course_id = $1)) + m.course_id = $1 + AND l.publish_status = 'PUBLISHED' + AND m.publish_status = 'PUBLISHED')) AND upp.user_id = $2 AND upp.completed_at IS NOT NULL AND qs.set_type = 'PRACTICE' @@ -535,7 +552,8 @@ WHERE FROM lessons WHERE - module_id = $1)) + module_id = $1 + AND publish_status = 'PUBLISHED')) AND upp.user_id = $2 AND upp.completed_at IS NOT NULL AND qs.set_type = 'PRACTICE' @@ -570,7 +588,8 @@ WHERE FROM courses c WHERE - c.program_id = $1) + c.program_id = $1 + AND c.publish_status = 'PUBLISHED') OR lp.module_id IN ( SELECT m.id @@ -578,7 +597,9 @@ WHERE modules m INNER JOIN courses c ON c.id = m.course_id WHERE - c.program_id = $1) + c.program_id = $1 + AND m.publish_status = 'PUBLISHED' + AND c.publish_status = 'PUBLISHED') OR lp.lesson_id IN ( SELECT l.id @@ -587,7 +608,10 @@ WHERE INNER JOIN modules m ON m.id = l.module_id INNER JOIN courses c ON c.id = m.course_id WHERE - c.program_id = $1)) + c.program_id = $1 + AND l.publish_status = 'PUBLISHED' + AND m.publish_status = 'PUBLISHED' + AND c.publish_status = 'PUBLISHED')) AND upp.user_id = $2 AND upp.completed_at IS NOT NULL AND qs.set_type = 'PRACTICE' @@ -643,10 +667,11 @@ func (q *Queries) GetPracticeScopeByQuestionSetID(ctx context.Context, questionS const GetPreviousCourseInProgram = `-- name: GetPreviousCourseInProgram :one SELECT - c2.id, c2.program_id, c2.name, c2.description, c2.thumbnail, c2.created_at, c2.updated_at, c2.sort_order + c2.id, c2.program_id, c2.name, c2.description, c2.thumbnail, c2.created_at, c2.updated_at, c2.sort_order, c2.publish_status FROM courses AS c1 INNER JOIN courses AS c2 ON c2.program_id = c1.program_id + AND c2.publish_status = 'PUBLISHED' AND ( c2.sort_order < c1.sort_order OR ( @@ -674,6 +699,7 @@ func (q *Queries) GetPreviousCourseInProgram(ctx context.Context, id int64) (Cou &i.CreatedAt, &i.UpdatedAt, &i.SortOrder, + &i.PublishStatus, ) return i, err } @@ -721,10 +747,11 @@ func (q *Queries) GetPreviousLessonInModule(ctx context.Context, id int64) (Less const GetPreviousModuleInCourse = `-- name: GetPreviousModuleInCourse :one SELECT - m2.id, m2.program_id, m2.course_id, m2.name, m2.description, m2.icon, m2.created_at, m2.updated_at, m2.sort_order + m2.id, m2.program_id, m2.course_id, m2.name, m2.description, m2.icon, m2.created_at, m2.updated_at, m2.sort_order, m2.publish_status FROM modules AS m1 INNER JOIN modules AS m2 ON m2.course_id = m1.course_id + AND m2.publish_status = 'PUBLISHED' AND ( m2.sort_order < m1.sort_order OR ( @@ -753,16 +780,18 @@ func (q *Queries) GetPreviousModuleInCourse(ctx context.Context, id int64) (Modu &i.CreatedAt, &i.UpdatedAt, &i.SortOrder, + &i.PublishStatus, ) return i, err } const GetPreviousProgram = `-- name: GetPreviousProgram :one SELECT - p2.id, p2.name, p2.description, p2.thumbnail, p2.created_at, p2.updated_at, p2.sort_order, p2.category + p2.id, p2.name, p2.description, p2.thumbnail, p2.created_at, p2.updated_at, p2.sort_order, p2.category, p2.publish_status FROM programs AS p1 INNER JOIN programs AS p2 ON p2.category = p1.category + AND p2.publish_status = 'PUBLISHED' AND ( p2.sort_order < p1.sort_order OR ( @@ -791,6 +820,7 @@ func (q *Queries) GetPreviousProgram(ctx context.Context, id int64) (Program, er &i.UpdatedAt, &i.SortOrder, &i.Category, + &i.PublishStatus, ) return i, err } diff --git a/gen/db/models.go b/gen/db/models.go index f4fc234..0f90706 100644 --- a/gen/db/models.go +++ b/gen/db/models.go @@ -23,14 +23,15 @@ type ActivityLog struct { } type Course struct { - ID int64 `json:"id"` - ProgramID int64 `json:"program_id"` - Name string `json:"name"` - Description pgtype.Text `json:"description"` - Thumbnail pgtype.Text `json:"thumbnail"` - CreatedAt pgtype.Timestamptz `json:"created_at"` - UpdatedAt pgtype.Timestamptz `json:"updated_at"` - SortOrder int32 `json:"sort_order"` + ID int64 `json:"id"` + ProgramID int64 `json:"program_id"` + Name string `json:"name"` + Description pgtype.Text `json:"description"` + Thumbnail pgtype.Text `json:"thumbnail"` + CreatedAt pgtype.Timestamptz `json:"created_at"` + UpdatedAt pgtype.Timestamptz `json:"updated_at"` + SortOrder int32 `json:"sort_order"` + PublishStatus string `json:"publish_status"` } type Device struct { @@ -58,14 +59,15 @@ type EmailTemplate struct { } type ExamPrepCatalogCourse struct { - ID int64 `json:"id"` - Name string `json:"name"` - Description pgtype.Text `json:"description"` - Thumbnail pgtype.Text `json:"thumbnail"` - SortOrder int32 `json:"sort_order"` - CreatedAt pgtype.Timestamptz `json:"created_at"` - UpdatedAt pgtype.Timestamptz `json:"updated_at"` - Category string `json:"category"` + ID int64 `json:"id"` + Name string `json:"name"` + Description pgtype.Text `json:"description"` + Thumbnail pgtype.Text `json:"thumbnail"` + SortOrder int32 `json:"sort_order"` + CreatedAt pgtype.Timestamptz `json:"created_at"` + UpdatedAt pgtype.Timestamptz `json:"updated_at"` + Category string `json:"category"` + PublishStatus string `json:"publish_status"` } type ExamPrepLessonPractice struct { @@ -91,30 +93,33 @@ type ExamPrepUnit struct { SortOrder int32 `json:"sort_order"` CreatedAt pgtype.Timestamptz `json:"created_at"` UpdatedAt pgtype.Timestamptz `json:"updated_at"` + PublishStatus string `json:"publish_status"` } type ExamPrepUnitModule struct { - ID int64 `json:"id"` - UnitID int64 `json:"unit_id"` - Name string `json:"name"` - Description pgtype.Text `json:"description"` - Thumbnail pgtype.Text `json:"thumbnail"` - Icon pgtype.Text `json:"icon"` - SortOrder int32 `json:"sort_order"` - CreatedAt pgtype.Timestamptz `json:"created_at"` - UpdatedAt pgtype.Timestamptz `json:"updated_at"` + ID int64 `json:"id"` + UnitID int64 `json:"unit_id"` + Name string `json:"name"` + Description pgtype.Text `json:"description"` + Thumbnail pgtype.Text `json:"thumbnail"` + Icon pgtype.Text `json:"icon"` + SortOrder int32 `json:"sort_order"` + CreatedAt pgtype.Timestamptz `json:"created_at"` + UpdatedAt pgtype.Timestamptz `json:"updated_at"` + PublishStatus string `json:"publish_status"` } type ExamPrepUnitModuleLesson struct { - ID int64 `json:"id"` - UnitModuleID int64 `json:"unit_module_id"` - Title string `json:"title"` - VideoUrl pgtype.Text `json:"video_url"` - Thumbnail pgtype.Text `json:"thumbnail"` - Description pgtype.Text `json:"description"` - SortOrder int32 `json:"sort_order"` - CreatedAt pgtype.Timestamptz `json:"created_at"` - UpdatedAt pgtype.Timestamptz `json:"updated_at"` + ID int64 `json:"id"` + UnitModuleID int64 `json:"unit_module_id"` + Title string `json:"title"` + VideoUrl pgtype.Text `json:"video_url"` + Thumbnail pgtype.Text `json:"thumbnail"` + Description pgtype.Text `json:"description"` + SortOrder int32 `json:"sort_order"` + CreatedAt pgtype.Timestamptz `json:"created_at"` + UpdatedAt pgtype.Timestamptz `json:"updated_at"` + PublishStatus string `json:"publish_status"` } type Faq struct { @@ -230,15 +235,16 @@ type MobileAppVersion struct { } type Module struct { - ID int64 `json:"id"` - ProgramID int64 `json:"program_id"` - CourseID int64 `json:"course_id"` - Name string `json:"name"` - Description pgtype.Text `json:"description"` - Icon pgtype.Text `json:"icon"` - CreatedAt pgtype.Timestamptz `json:"created_at"` - UpdatedAt pgtype.Timestamptz `json:"updated_at"` - SortOrder int32 `json:"sort_order"` + ID int64 `json:"id"` + ProgramID int64 `json:"program_id"` + CourseID int64 `json:"course_id"` + Name string `json:"name"` + Description pgtype.Text `json:"description"` + Icon pgtype.Text `json:"icon"` + CreatedAt pgtype.Timestamptz `json:"created_at"` + UpdatedAt pgtype.Timestamptz `json:"updated_at"` + SortOrder int32 `json:"sort_order"` + PublishStatus string `json:"publish_status"` } type ModuleToSubCourse struct { @@ -303,14 +309,15 @@ type Permission struct { } type Program struct { - ID int64 `json:"id"` - Name string `json:"name"` - Description pgtype.Text `json:"description"` - Thumbnail pgtype.Text `json:"thumbnail"` - CreatedAt pgtype.Timestamptz `json:"created_at"` - UpdatedAt pgtype.Timestamptz `json:"updated_at"` - SortOrder int32 `json:"sort_order"` - Category string `json:"category"` + ID int64 `json:"id"` + Name string `json:"name"` + Description pgtype.Text `json:"description"` + Thumbnail pgtype.Text `json:"thumbnail"` + CreatedAt pgtype.Timestamptz `json:"created_at"` + UpdatedAt pgtype.Timestamptz `json:"updated_at"` + SortOrder int32 `json:"sort_order"` + Category string `json:"category"` + PublishStatus string `json:"publish_status"` } type Question struct { diff --git a/gen/db/programs.sql.go b/gen/db/programs.sql.go index 885d515..7057681 100644 --- a/gen/db/programs.sql.go +++ b/gen/db/programs.sql.go @@ -12,7 +12,7 @@ import ( ) const CreateProgram = `-- name: CreateProgram :one -INSERT INTO programs (name, description, category, thumbnail, sort_order) +INSERT INTO programs (name, description, category, thumbnail, sort_order, publish_status) SELECT $1, $2, @@ -21,17 +21,19 @@ SELECT COALESCE($5::int, COALESCE(( SELECT max(p.sort_order) - FROM programs AS p), 0) + 1) + FROM programs AS p), 0) + 1), + $6 RETURNING - id, name, description, thumbnail, created_at, updated_at, sort_order, category + id, name, description, thumbnail, created_at, updated_at, sort_order, category, publish_status ` type CreateProgramParams struct { - Name string `json:"name"` - Description pgtype.Text `json:"description"` - Category string `json:"category"` - Thumbnail pgtype.Text `json:"thumbnail"` - SortOrder pgtype.Int4 `json:"sort_order"` + Name string `json:"name"` + Description pgtype.Text `json:"description"` + Category string `json:"category"` + Thumbnail pgtype.Text `json:"thumbnail"` + SortOrder pgtype.Int4 `json:"sort_order"` + PublishStatus string `json:"publish_status"` } func (q *Queries) CreateProgram(ctx context.Context, arg CreateProgramParams) (Program, error) { @@ -41,6 +43,7 @@ func (q *Queries) CreateProgram(ctx context.Context, arg CreateProgramParams) (P arg.Category, arg.Thumbnail, arg.SortOrder, + arg.PublishStatus, ) var i Program err := row.Scan( @@ -52,6 +55,7 @@ func (q *Queries) CreateProgram(ctx context.Context, arg CreateProgramParams) (P &i.UpdatedAt, &i.SortOrder, &i.Category, + &i.PublishStatus, ) return i, err } @@ -67,7 +71,7 @@ func (q *Queries) DeleteProgram(ctx context.Context, id int64) error { } const GetProgramByID = `-- name: GetProgramByID :one -SELECT id, name, description, thumbnail, created_at, updated_at, sort_order, category +SELECT id, name, description, thumbnail, created_at, updated_at, sort_order, category, publish_status FROM programs WHERE id = $1 ` @@ -84,6 +88,7 @@ func (q *Queries) GetProgramByID(ctx context.Context, id int64) (Program, error) &i.UpdatedAt, &i.SortOrder, &i.Category, + &i.PublishStatus, ) return i, err } @@ -126,32 +131,39 @@ SELECT p.category, p.thumbnail, p.sort_order, + p.publish_status, p.created_at, p.updated_at FROM programs p +WHERE ( + $3::boolean = FALSE + OR p.publish_status = 'PUBLISHED'::TEXT +) ORDER BY p.sort_order ASC, p.id ASC LIMIT $1 OFFSET $2 ` type ListProgramsParams struct { - Limit int32 `json:"limit"` - Offset int32 `json:"offset"` + Limit int32 `json:"limit"` + Offset int32 `json:"offset"` + PublishedOnly bool `json:"published_only"` } type ListProgramsRow struct { - TotalCount int64 `json:"total_count"` - ID int64 `json:"id"` - Name string `json:"name"` - Description pgtype.Text `json:"description"` - Category string `json:"category"` - Thumbnail pgtype.Text `json:"thumbnail"` - SortOrder int32 `json:"sort_order"` - CreatedAt pgtype.Timestamptz `json:"created_at"` - UpdatedAt pgtype.Timestamptz `json:"updated_at"` + TotalCount int64 `json:"total_count"` + ID int64 `json:"id"` + Name string `json:"name"` + Description pgtype.Text `json:"description"` + Category string `json:"category"` + Thumbnail pgtype.Text `json:"thumbnail"` + SortOrder int32 `json:"sort_order"` + PublishStatus string `json:"publish_status"` + CreatedAt pgtype.Timestamptz `json:"created_at"` + UpdatedAt pgtype.Timestamptz `json:"updated_at"` } func (q *Queries) ListPrograms(ctx context.Context, arg ListProgramsParams) ([]ListProgramsRow, error) { - rows, err := q.db.Query(ctx, ListPrograms, arg.Limit, arg.Offset) + rows, err := q.db.Query(ctx, ListPrograms, arg.Limit, arg.Offset, arg.PublishedOnly) if err != nil { return nil, err } @@ -167,6 +179,7 @@ func (q *Queries) ListPrograms(ctx context.Context, arg ListProgramsParams) ([]L &i.Category, &i.Thumbnail, &i.SortOrder, + &i.PublishStatus, &i.CreatedAt, &i.UpdatedAt, ); err != nil { @@ -188,20 +201,22 @@ SET category = COALESCE($3::varchar, category), thumbnail = COALESCE($4::text, thumbnail), sort_order = coalesce($5::int, sort_order), + publish_status = COALESCE($6::varchar, publish_status), updated_at = CURRENT_TIMESTAMP WHERE - id = $6 + id = $7 RETURNING - id, name, description, thumbnail, created_at, updated_at, sort_order, category + id, name, description, thumbnail, created_at, updated_at, sort_order, category, publish_status ` type UpdateProgramParams struct { - Name pgtype.Text `json:"name"` - Description pgtype.Text `json:"description"` - Category pgtype.Text `json:"category"` - Thumbnail pgtype.Text `json:"thumbnail"` - SortOrder pgtype.Int4 `json:"sort_order"` - ID int64 `json:"id"` + Name pgtype.Text `json:"name"` + Description pgtype.Text `json:"description"` + Category pgtype.Text `json:"category"` + Thumbnail pgtype.Text `json:"thumbnail"` + SortOrder pgtype.Int4 `json:"sort_order"` + PublishStatus pgtype.Text `json:"publish_status"` + ID int64 `json:"id"` } func (q *Queries) UpdateProgram(ctx context.Context, arg UpdateProgramParams) (Program, error) { @@ -211,6 +226,7 @@ func (q *Queries) UpdateProgram(ctx context.Context, arg UpdateProgramParams) (P arg.Category, arg.Thumbnail, arg.SortOrder, + arg.PublishStatus, arg.ID, ) var i Program @@ -223,6 +239,7 @@ func (q *Queries) UpdateProgram(ctx context.Context, arg UpdateProgramParams) (P &i.UpdatedAt, &i.SortOrder, &i.Category, + &i.PublishStatus, ) return i, err } diff --git a/internal/domain/content_publish_status.go b/internal/domain/content_publish_status.go new file mode 100644 index 0000000..8ea38da --- /dev/null +++ b/internal/domain/content_publish_status.go @@ -0,0 +1,31 @@ +package domain + +import "strings" + +// ContentPublishStatus controls learner visibility for LMS hierarchy entities (programs, +// courses, modules) and exam-prep hierarchy entities (catalog courses, units, unit modules, +// unit module lessons). Mirrors LessonPublishStatus / PracticePublishStatus. +type ContentPublishStatus string + +const ( + ContentPublishDraft ContentPublishStatus = "DRAFT" + ContentPublishPublished ContentPublishStatus = "PUBLISHED" +) + +// ContentPublishStatusFromDB normalizes persisted values. +func ContentPublishStatusFromDB(raw string) ContentPublishStatus { + switch strings.TrimSpace(strings.ToUpper(raw)) { + case string(ContentPublishPublished): + return ContentPublishPublished + default: + return ContentPublishDraft + } +} + +// ContentPublishStatusFromCreateInput resolves create body: omit → draft; explicit value validated separately. +func ContentPublishStatusFromCreateInput(raw *string) ContentPublishStatus { + if raw == nil || strings.TrimSpace(*raw) == "" { + return ContentPublishDraft + } + return ContentPublishStatusFromDB(*raw) +} diff --git a/internal/domain/course.go b/internal/domain/course.go index 768bed6..9d0669e 100644 --- a/internal/domain/course.go +++ b/internal/domain/course.go @@ -15,14 +15,15 @@ var DefaultCEFRCourseNames = []string{"A1", "A2", "B1", "B2", "C1", "C2"} // Course belongs to a Program. type Course struct { - ID int64 `json:"id"` - ProgramID int64 `json:"program_id"` - Name string `json:"name"` - Description *string `json:"description,omitempty"` - Thumbnail *string `json:"thumbnail,omitempty"` - SortOrder int `json:"sort_order"` - CreatedAt time.Time `json:"created_at"` - UpdatedAt *time.Time `json:"updated_at,omitempty"` + ID int64 `json:"id"` + ProgramID int64 `json:"program_id"` + Name string `json:"name"` + Description *string `json:"description,omitempty"` + Thumbnail *string `json:"thumbnail,omitempty"` + SortOrder int `json:"sort_order"` + PublishStatus ContentPublishStatus `json:"publish_status"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt *time.Time `json:"updated_at,omitempty"` // Populated on list-by-program. Practice count: lms_practices rows with course_id = course only // (not practices attached to a module or lesson under this course). ModuleCount int `json:"module_count"` @@ -32,17 +33,25 @@ type Course struct { Access *LMSEntityAccess `json:"access,omitempty"` } +// VisibleToLearners is true when the course appears in subscriber/catalog LMS APIs. +func (c Course) VisibleToLearners() bool { + return c.PublishStatus == ContentPublishPublished +} + type CreateCourseInput struct { Name string `json:"name" validate:"required"` Description *string `json:"description,omitempty"` Thumbnail *string `json:"thumbnail,omitempty"` // SortOrder within the program when set; omit to append after current max within program_id (uniqueness is per-program). SortOrder *int `json:"sort_order,omitempty" validate:"omitempty,min=0"` + // Omit or empty defaults to DRAFT; set PUBLISHED to make visible to learners immediately. + PublishStatus *string `json:"publish_status,omitempty" validate:"omitempty,oneof=DRAFT draft PUBLISHED published"` } type UpdateCourseInput struct { - Name *string `json:"name,omitempty"` - Description *string `json:"description,omitempty"` - Thumbnail *string `json:"thumbnail,omitempty"` - SortOrder *int `json:"sort_order,omitempty"` + Name *string `json:"name,omitempty"` + Description *string `json:"description,omitempty"` + Thumbnail *string `json:"thumbnail,omitempty"` + SortOrder *int `json:"sort_order,omitempty"` + PublishStatus *string `json:"publish_status,omitempty" validate:"omitempty,oneof=DRAFT draft PUBLISHED published"` } diff --git a/internal/domain/exam_prep_catalog_course.go b/internal/domain/exam_prep_catalog_course.go index 99f93a2..bc6f6f7 100644 --- a/internal/domain/exam_prep_catalog_course.go +++ b/internal/domain/exam_prep_catalog_course.go @@ -4,19 +4,25 @@ import "time" // ExamPrepCatalogCourse is a top-level exam-prep track (e.g. DET, IELTS) in schema exam_prep — separate from LMS Learn English courses. type ExamPrepCatalogCourse struct { - ID int64 `json:"id"` - Name string `json:"name"` - Description *string `json:"description,omitempty"` - Category string `json:"category"` - Thumbnail *string `json:"thumbnail,omitempty"` - SortOrder int `json:"sort_order"` - UnitsCount *int64 `json:"units_count,omitempty"` - ModulesCount *int64 `json:"modules_count,omitempty"` - LessonsCount *int64 `json:"lessons_count,omitempty"` - HasPractice bool `json:"has_practice"` - Access *LMSEntityAccess `json:"access,omitempty"` - CreatedAt time.Time `json:"created_at"` - UpdatedAt *time.Time `json:"updated_at,omitempty"` + ID int64 `json:"id"` + Name string `json:"name"` + Description *string `json:"description,omitempty"` + Category string `json:"category"` + Thumbnail *string `json:"thumbnail,omitempty"` + SortOrder int `json:"sort_order"` + PublishStatus ContentPublishStatus `json:"publish_status"` + UnitsCount *int64 `json:"units_count,omitempty"` + ModulesCount *int64 `json:"modules_count,omitempty"` + LessonsCount *int64 `json:"lessons_count,omitempty"` + HasPractice bool `json:"has_practice"` + Access *LMSEntityAccess `json:"access,omitempty"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt *time.Time `json:"updated_at,omitempty"` +} + +// VisibleToLearners is true when the catalog course appears in student/learner exam-prep APIs. +func (c ExamPrepCatalogCourse) VisibleToLearners() bool { + return c.PublishStatus == ContentPublishPublished } type CreateExamPrepCatalogCourseInput struct { @@ -24,12 +30,15 @@ type CreateExamPrepCatalogCourseInput struct { Description *string `json:"description,omitempty"` Category string `json:"category" validate:"required,oneof=IELTS DUOLINGO"` Thumbnail *string `json:"thumbnail,omitempty"` + // Omit or empty defaults to DRAFT; set PUBLISHED to make visible to learners immediately. + PublishStatus *string `json:"publish_status,omitempty" validate:"omitempty,oneof=DRAFT draft PUBLISHED published"` } type UpdateExamPrepCatalogCourseInput struct { - Name *string `json:"name,omitempty"` - Description *string `json:"description,omitempty"` - Category *string `json:"category,omitempty" validate:"omitempty,oneof=IELTS DUOLINGO"` - Thumbnail *string `json:"thumbnail,omitempty"` - SortOrder *int `json:"sort_order,omitempty"` + Name *string `json:"name,omitempty"` + Description *string `json:"description,omitempty"` + Category *string `json:"category,omitempty" validate:"omitempty,oneof=IELTS DUOLINGO"` + Thumbnail *string `json:"thumbnail,omitempty"` + SortOrder *int `json:"sort_order,omitempty"` + PublishStatus *string `json:"publish_status,omitempty" validate:"omitempty,oneof=DRAFT draft PUBLISHED published"` } diff --git a/internal/domain/exam_prep_lesson.go b/internal/domain/exam_prep_lesson.go index 30f0a11..8857e0e 100644 --- a/internal/domain/exam_prep_lesson.go +++ b/internal/domain/exam_prep_lesson.go @@ -4,17 +4,23 @@ import "time" // ExamPrepLesson is a video lesson under an exam-prep unit module (exam_prep.unit_module_lessons). type ExamPrepLesson struct { - ID int64 `json:"id"` - UnitModuleID int64 `json:"unit_module_id"` - Title string `json:"title"` - VideoURL *string `json:"video_url,omitempty"` - Thumbnail *string `json:"thumbnail,omitempty"` - Description *string `json:"description,omitempty"` - SortOrder int `json:"sort_order"` - HasPractice bool `json:"has_practice"` - Access *LMSEntityAccess `json:"access,omitempty"` - CreatedAt time.Time `json:"created_at"` - UpdatedAt *time.Time `json:"updated_at,omitempty"` + ID int64 `json:"id"` + UnitModuleID int64 `json:"unit_module_id"` + Title string `json:"title"` + VideoURL *string `json:"video_url,omitempty"` + Thumbnail *string `json:"thumbnail,omitempty"` + Description *string `json:"description,omitempty"` + SortOrder int `json:"sort_order"` + PublishStatus ContentPublishStatus `json:"publish_status"` + HasPractice bool `json:"has_practice"` + Access *LMSEntityAccess `json:"access,omitempty"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt *time.Time `json:"updated_at,omitempty"` +} + +// VisibleToLearners is true when the lesson appears in student/learner exam-prep APIs. +func (l ExamPrepLesson) VisibleToLearners() bool { + return l.PublishStatus == ContentPublishPublished } type CreateExamPrepLessonInput struct { @@ -22,12 +28,15 @@ type CreateExamPrepLessonInput struct { VideoURL *string `json:"video_url,omitempty"` Thumbnail *string `json:"thumbnail,omitempty"` Description *string `json:"description,omitempty"` + // Omit or empty defaults to DRAFT; set PUBLISHED to make visible to learners immediately. + PublishStatus *string `json:"publish_status,omitempty" validate:"omitempty,oneof=DRAFT draft PUBLISHED published"` } type UpdateExamPrepLessonInput struct { - Title *string `json:"title,omitempty"` - VideoURL *string `json:"video_url,omitempty"` - Thumbnail *string `json:"thumbnail,omitempty"` - Description *string `json:"description,omitempty"` - SortOrder *int `json:"sort_order,omitempty"` + Title *string `json:"title,omitempty"` + VideoURL *string `json:"video_url,omitempty"` + Thumbnail *string `json:"thumbnail,omitempty"` + Description *string `json:"description,omitempty"` + SortOrder *int `json:"sort_order,omitempty"` + PublishStatus *string `json:"publish_status,omitempty" validate:"omitempty,oneof=DRAFT draft PUBLISHED published"` } diff --git a/internal/domain/exam_prep_module.go b/internal/domain/exam_prep_module.go index 81bf1ab..a495003 100644 --- a/internal/domain/exam_prep_module.go +++ b/internal/domain/exam_prep_module.go @@ -4,19 +4,25 @@ import "time" // ExamPrepModule is a module under an exam-prep unit (stored in exam_prep.unit_modules). type ExamPrepModule struct { - ID int64 `json:"id"` - UnitID int64 `json:"unit_id"` - Name string `json:"name"` - Description *string `json:"description,omitempty"` - Thumbnail *string `json:"thumbnail,omitempty"` - Icon *string `json:"icon,omitempty"` - SortOrder int `json:"sort_order"` - LessonsCount *int64 `json:"lessons_count,omitempty"` - PracticesCount *int64 `json:"practices_count,omitempty"` - HasPractice bool `json:"has_practice"` - Access *LMSEntityAccess `json:"access,omitempty"` - CreatedAt time.Time `json:"created_at"` - UpdatedAt *time.Time `json:"updated_at,omitempty"` + ID int64 `json:"id"` + UnitID int64 `json:"unit_id"` + Name string `json:"name"` + Description *string `json:"description,omitempty"` + Thumbnail *string `json:"thumbnail,omitempty"` + Icon *string `json:"icon,omitempty"` + SortOrder int `json:"sort_order"` + PublishStatus ContentPublishStatus `json:"publish_status"` + LessonsCount *int64 `json:"lessons_count,omitempty"` + PracticesCount *int64 `json:"practices_count,omitempty"` + HasPractice bool `json:"has_practice"` + Access *LMSEntityAccess `json:"access,omitempty"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt *time.Time `json:"updated_at,omitempty"` +} + +// VisibleToLearners is true when the module appears in student/learner exam-prep APIs. +func (m ExamPrepModule) VisibleToLearners() bool { + return m.PublishStatus == ContentPublishPublished } type CreateExamPrepModuleInput struct { @@ -24,12 +30,15 @@ type CreateExamPrepModuleInput struct { Description *string `json:"description,omitempty"` Thumbnail *string `json:"thumbnail,omitempty"` Icon *string `json:"icon,omitempty"` + // Omit or empty defaults to DRAFT; set PUBLISHED to make visible to learners immediately. + PublishStatus *string `json:"publish_status,omitempty" validate:"omitempty,oneof=DRAFT draft PUBLISHED published"` } type UpdateExamPrepModuleInput struct { - Name *string `json:"name,omitempty"` - Description *string `json:"description,omitempty"` - Thumbnail *string `json:"thumbnail,omitempty"` - Icon *string `json:"icon,omitempty"` - SortOrder *int `json:"sort_order,omitempty"` + Name *string `json:"name,omitempty"` + Description *string `json:"description,omitempty"` + Thumbnail *string `json:"thumbnail,omitempty"` + Icon *string `json:"icon,omitempty"` + SortOrder *int `json:"sort_order,omitempty"` + PublishStatus *string `json:"publish_status,omitempty" validate:"omitempty,oneof=DRAFT draft PUBLISHED published"` } diff --git a/internal/domain/exam_prep_unit.go b/internal/domain/exam_prep_unit.go index 31180c5..a8780f2 100644 --- a/internal/domain/exam_prep_unit.go +++ b/internal/domain/exam_prep_unit.go @@ -4,19 +4,25 @@ import "time" // ExamPrepUnit is a chapter-like grouping under an exam-prep catalog course (schema exam_prep.units). type ExamPrepUnit struct { - ID int64 `json:"id"` - CatalogCourseID int64 `json:"catalog_course_id"` - Name string `json:"name"` - Description *string `json:"description,omitempty"` - Thumbnail *string `json:"thumbnail,omitempty"` - SortOrder int `json:"sort_order"` - ModulesCount *int64 `json:"modules_count,omitempty"` - LessonsCount *int64 `json:"lessons_count,omitempty"` - PracticesCount *int64 `json:"practices_count,omitempty"` - HasPractice bool `json:"has_practice"` - Access *LMSEntityAccess `json:"access,omitempty"` - CreatedAt time.Time `json:"created_at"` - UpdatedAt *time.Time `json:"updated_at,omitempty"` + ID int64 `json:"id"` + CatalogCourseID int64 `json:"catalog_course_id"` + Name string `json:"name"` + Description *string `json:"description,omitempty"` + Thumbnail *string `json:"thumbnail,omitempty"` + SortOrder int `json:"sort_order"` + PublishStatus ContentPublishStatus `json:"publish_status"` + ModulesCount *int64 `json:"modules_count,omitempty"` + LessonsCount *int64 `json:"lessons_count,omitempty"` + PracticesCount *int64 `json:"practices_count,omitempty"` + HasPractice bool `json:"has_practice"` + Access *LMSEntityAccess `json:"access,omitempty"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt *time.Time `json:"updated_at,omitempty"` +} + +// VisibleToLearners is true when the unit appears in student/learner exam-prep APIs. +func (u ExamPrepUnit) VisibleToLearners() bool { + return u.PublishStatus == ContentPublishPublished } type CreateExamPrepUnitInput struct { @@ -25,11 +31,14 @@ type CreateExamPrepUnitInput struct { Thumbnail *string `json:"thumbnail,omitempty"` // SortOrder within the catalog course when set; omit to append after current max sort_order within catalog_course_id. SortOrder *int `json:"sort_order,omitempty" validate:"omitempty,min=0"` + // Omit or empty defaults to DRAFT; set PUBLISHED to make visible to learners immediately. + PublishStatus *string `json:"publish_status,omitempty" validate:"omitempty,oneof=DRAFT draft PUBLISHED published"` } type UpdateExamPrepUnitInput struct { - Name *string `json:"name,omitempty"` - Description *string `json:"description,omitempty"` - Thumbnail *string `json:"thumbnail,omitempty"` - SortOrder *int `json:"sort_order,omitempty"` + Name *string `json:"name,omitempty"` + Description *string `json:"description,omitempty"` + Thumbnail *string `json:"thumbnail,omitempty"` + SortOrder *int `json:"sort_order,omitempty"` + PublishStatus *string `json:"publish_status,omitempty" validate:"omitempty,oneof=DRAFT draft PUBLISHED published"` } diff --git a/internal/domain/module.go b/internal/domain/module.go index 1874600..8cfc8c0 100644 --- a/internal/domain/module.go +++ b/internal/domain/module.go @@ -4,17 +4,23 @@ import "time" // Module belongs to a Course. program_id is the course’s program (stored for querying; not required from the client on create). type Module struct { - ID int64 `json:"id"` - ProgramID int64 `json:"program_id"` - CourseID int64 `json:"course_id"` - Name string `json:"name"` - Description *string `json:"description,omitempty"` - Icon *string `json:"icon,omitempty"` - SortOrder int `json:"sort_order"` - HasPractice bool `json:"has_practice"` - CreatedAt time.Time `json:"created_at"` - UpdatedAt *time.Time `json:"updated_at,omitempty"` - Access *LMSEntityAccess `json:"access,omitempty"` + ID int64 `json:"id"` + ProgramID int64 `json:"program_id"` + CourseID int64 `json:"course_id"` + Name string `json:"name"` + Description *string `json:"description,omitempty"` + Icon *string `json:"icon,omitempty"` + SortOrder int `json:"sort_order"` + PublishStatus ContentPublishStatus `json:"publish_status"` + HasPractice bool `json:"has_practice"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt *time.Time `json:"updated_at,omitempty"` + Access *LMSEntityAccess `json:"access,omitempty"` +} + +// VisibleToLearners is true when the module appears in subscriber/catalog LMS APIs. +func (m Module) VisibleToLearners() bool { + return m.PublishStatus == ContentPublishPublished } type CreateModuleInput struct { @@ -23,11 +29,14 @@ type CreateModuleInput struct { Icon *string `json:"icon,omitempty"` // SortOrder within the course when set; omit to append after current max within course_id (uniqueness is per-course). SortOrder *int `json:"sort_order,omitempty" validate:"omitempty,min=0"` + // Omit or empty defaults to DRAFT; set PUBLISHED to make visible to learners immediately. + PublishStatus *string `json:"publish_status,omitempty" validate:"omitempty,oneof=DRAFT draft PUBLISHED published"` } type UpdateModuleInput struct { - Name *string `json:"name,omitempty"` - Description *string `json:"description,omitempty"` - Icon *string `json:"icon,omitempty"` - SortOrder *int `json:"sort_order,omitempty"` + Name *string `json:"name,omitempty"` + Description *string `json:"description,omitempty"` + Icon *string `json:"icon,omitempty"` + SortOrder *int `json:"sort_order,omitempty"` + PublishStatus *string `json:"publish_status,omitempty" validate:"omitempty,oneof=DRAFT draft PUBLISHED published"` } diff --git a/internal/domain/program.go b/internal/domain/program.go index b0944ab..17f20a2 100644 --- a/internal/domain/program.go +++ b/internal/domain/program.go @@ -4,15 +4,21 @@ import "time" // Program is the top-level container in the LMS hierarchy (e.g. tracks like Beginner / Intermediate / Advanced). type Program struct { - ID int64 `json:"id"` - Name string `json:"name"` - Description *string `json:"description,omitempty"` - Category string `json:"category"` - Thumbnail *string `json:"thumbnail,omitempty"` - SortOrder int `json:"sort_order"` - CreatedAt time.Time `json:"created_at"` - UpdatedAt *time.Time `json:"updated_at,omitempty"` - Access *LMSEntityAccess `json:"access,omitempty"` + ID int64 `json:"id"` + Name string `json:"name"` + Description *string `json:"description,omitempty"` + Category string `json:"category"` + Thumbnail *string `json:"thumbnail,omitempty"` + SortOrder int `json:"sort_order"` + PublishStatus ContentPublishStatus `json:"publish_status"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt *time.Time `json:"updated_at,omitempty"` + Access *LMSEntityAccess `json:"access,omitempty"` +} + +// VisibleToLearners is true when the program appears in subscriber/catalog LMS APIs. +func (p Program) VisibleToLearners() bool { + return p.PublishStatus == ContentPublishPublished } type CreateProgramInput struct { @@ -22,12 +28,15 @@ type CreateProgramInput struct { Thumbnail *string `json:"thumbnail,omitempty"` // SortOrder inserts at this global program order when set; omit to append after current max (sort_order uniqueness is enforced). SortOrder *int `json:"sort_order,omitempty" validate:"omitempty,min=0"` + // Omit or empty defaults to DRAFT; set PUBLISHED to make visible to learners immediately. + PublishStatus *string `json:"publish_status,omitempty" validate:"omitempty,oneof=DRAFT draft PUBLISHED published"` } type UpdateProgramInput struct { - Name *string `json:"name,omitempty"` - Description *string `json:"description,omitempty"` - Category *string `json:"category,omitempty" validate:"omitempty,oneof=LEARN_ENGLISH IELTS DUOLINGO"` - Thumbnail *string `json:"thumbnail,omitempty"` - SortOrder *int `json:"sort_order,omitempty"` + Name *string `json:"name,omitempty"` + Description *string `json:"description,omitempty"` + Category *string `json:"category,omitempty" validate:"omitempty,oneof=LEARN_ENGLISH IELTS DUOLINGO"` + Thumbnail *string `json:"thumbnail,omitempty"` + SortOrder *int `json:"sort_order,omitempty"` + PublishStatus *string `json:"publish_status,omitempty" validate:"omitempty,oneof=DRAFT draft PUBLISHED published"` } diff --git a/internal/domain/question_type_builder.go b/internal/domain/question_type_builder.go index 1be461e..6e7d092 100644 --- a/internal/domain/question_type_builder.go +++ b/internal/domain/question_type_builder.go @@ -284,6 +284,63 @@ func resolveDefinitionForRuntimeAUDIO(catalog []QuestionTypeDefinition) Question return candidates[0] } +// ResolveEffectiveQuestionTypeDefinitionID returns the definition ID clients should use when +// rendering a question. Stored FKs are preferred; legacy runtime types and unlinked DYNAMIC +// payloads are resolved against the active catalog when possible. +func ResolveEffectiveQuestionTypeDefinitionID( + storedID *int64, + runtimeQuestionType string, + catalog []QuestionTypeDefinition, + payload *DynamicQuestionPayload, +) *int64 { + if storedID != nil && *storedID > 0 { + return storedID + } + + def := ResolveQuestionTypeDefinitionForQuestion(nil, runtimeQuestionType, catalog) + if def.ID > 0 { + id := def.ID + return &id + } + + if payload != nil && UsesDynamicQuestionPayload(runtimeQuestionType) { + if matched := resolveDefinitionForDynamicPayload(*payload, catalog); matched.ID > 0 { + id := matched.ID + return &id + } + } + + return nil +} + +func resolveDefinitionForDynamicPayload(payload DynamicQuestionPayload, catalog []QuestionTypeDefinition) QuestionTypeDefinition { + var matches []QuestionTypeDefinition + for _, d := range catalog { + if status := strings.ToUpper(strings.TrimSpace(d.Status)); status != "" && status != "ACTIVE" { + continue + } + if err := ValidateDynamicPayloadAgainstDefinition(payload, d); err != nil { + continue + } + matches = append(matches, d) + } + if len(matches) == 0 { + return QuestionTypeDefinition{} + } + sort.Slice(matches, func(i, j int) bool { + if matches[i].IsSystem != matches[j].IsSystem { + return !matches[i].IsSystem + } + si := len(matches[i].StimulusSchema) + len(matches[i].ResponseSchema) + sj := len(matches[j].StimulusSchema) + len(matches[j].ResponseSchema) + if si != sj { + return si > sj + } + return matches[i].ID < matches[j].ID + }) + return matches[0] +} + func humanizeDefinitionKey(key string) string { parts := strings.Split(strings.ReplaceAll(key, "-", "_"), "_") for i, p := range parts { diff --git a/internal/domain/question_type_builder_test.go b/internal/domain/question_type_builder_test.go index bb3ac6c..b623975 100644 --- a/internal/domain/question_type_builder_test.go +++ b/internal/domain/question_type_builder_test.go @@ -201,6 +201,52 @@ func TestResolveQuestionTypeDefinitionForQuestion_linkedDefinition(t *testing.T) } } +func TestResolveEffectiveQuestionTypeDefinitionID_legacyAUDIO(t *testing.T) { + got := ResolveEffectiveQuestionTypeDefinitionID(nil, "AUDIO", testQuestionTypeCatalog(), nil) + if got == nil || *got != 10 { + t.Fatalf("expected audio_conversation_type id 10, got %v", got) + } +} + +func TestResolveEffectiveQuestionTypeDefinitionID_storedIDPreferred(t *testing.T) { + stored := int64(2) + got := ResolveEffectiveQuestionTypeDefinitionID(&stored, "AUDIO", testQuestionTypeCatalog(), nil) + if got == nil || *got != 2 { + t.Fatalf("expected stored id 2, got %v", got) + } +} + +func TestResolveEffectiveQuestionTypeDefinitionID_dynamicPayloadMatch(t *testing.T) { + catalog := append(testQuestionTypeCatalog(), QuestionTypeDefinition{ + ID: 42, + Key: "audio_with_timer", + DisplayName: "Audio With Timer", + StimulusComponentKinds: []string{"AUDIO_PROMPT"}, + ResponseComponentKinds: []string{"ANSWER_TIMER", "AUDIO_RESPONSE"}, + StimulusSchema: []DynamicElementDefinition{ + {ID: "audio_prompt_1", Kind: "AUDIO_PROMPT", Required: true}, + }, + ResponseSchema: []DynamicElementDefinition{ + {ID: "answer_timer_1", Kind: "ANSWER_TIMER", Required: true}, + {ID: "audio_response_1", Kind: "AUDIO_RESPONSE", Required: true}, + }, + Status: "ACTIVE", + }) + payload := &DynamicQuestionPayload{ + Stimulus: []DynamicElementInstance{ + {ID: "audio_prompt_1", Kind: "AUDIO_PROMPT", Value: "https://example.com/prompt.mp3"}, + }, + Response: []DynamicElementInstance{ + {ID: "answer_timer_1", Kind: "ANSWER_TIMER", Value: map[string]interface{}{"seconds": 20}}, + {ID: "audio_response_1", Kind: "AUDIO_RESPONSE", Value: "https://example.com/answer.mp3"}, + }, + } + got := ResolveEffectiveQuestionTypeDefinitionID(nil, "DYNAMIC", catalog, payload) + if got == nil || *got != 42 { + t.Fatalf("expected matched definition id 42, got %v", got) + } +} + func TestValidateQuestionTextNotAllowedForDynamic(t *testing.T) { if err := ValidateQuestionTextNotAllowedForDynamic("DYNAMIC", "nope"); err == nil { t.Fatal("expected error when question_text sent for DYNAMIC") diff --git a/internal/ports/exam_prep_catalog_course.go b/internal/ports/exam_prep_catalog_course.go index bc400d0..c5db5a6 100644 --- a/internal/ports/exam_prep_catalog_course.go +++ b/internal/ports/exam_prep_catalog_course.go @@ -9,7 +9,7 @@ import ( type ExamPrepCatalogCourseStore interface { CreateExamPrepCatalogCourse(ctx context.Context, input domain.CreateExamPrepCatalogCourseInput) (domain.ExamPrepCatalogCourse, error) GetExamPrepCatalogCourseByID(ctx context.Context, id int64) (domain.ExamPrepCatalogCourse, error) - ListExamPrepCatalogCourses(ctx context.Context, limit, offset int32) ([]domain.ExamPrepCatalogCourse, int64, error) + ListExamPrepCatalogCourses(ctx context.Context, publishedOnly bool, limit, offset int32) ([]domain.ExamPrepCatalogCourse, int64, error) ListAllExamPrepCatalogCourseIDs(ctx context.Context) ([]int64, error) UpdateExamPrepCatalogCourse(ctx context.Context, id int64, input domain.UpdateExamPrepCatalogCourseInput) (domain.ExamPrepCatalogCourse, error) DeleteExamPrepCatalogCourse(ctx context.Context, id int64) error diff --git a/internal/ports/exam_prep_lesson.go b/internal/ports/exam_prep_lesson.go index b6d432b..039385e 100644 --- a/internal/ports/exam_prep_lesson.go +++ b/internal/ports/exam_prep_lesson.go @@ -9,8 +9,9 @@ import ( type ExamPrepLessonStore interface { CreateExamPrepUnitModuleLesson(ctx context.Context, unitModuleID int64, input domain.CreateExamPrepLessonInput) (domain.ExamPrepLesson, error) GetExamPrepUnitModuleLessonByID(ctx context.Context, id int64) (domain.ExamPrepLesson, error) - ListExamPrepUnitModuleLessonsByUnitModuleID(ctx context.Context, unitModuleID int64, limit, offset int32) ([]domain.ExamPrepLesson, int64, error) + ListExamPrepUnitModuleLessonsByUnitModuleID(ctx context.Context, unitModuleID int64, publishedOnly bool, limit, offset int32) ([]domain.ExamPrepLesson, int64, error) ListExamPrepUnitModuleLessonIDsByUnitModule(ctx context.Context, unitModuleID int64) ([]int64, error) + ListPublishedExamPrepUnitModuleLessonIDsByUnitModule(ctx context.Context, unitModuleID int64) ([]int64, error) UpdateExamPrepUnitModuleLesson(ctx context.Context, id int64, input domain.UpdateExamPrepLessonInput) (domain.ExamPrepLesson, error) DeleteExamPrepUnitModuleLesson(ctx context.Context, id int64) error ReorderExamPrepUnitModuleLessonsInUnitModule(ctx context.Context, unitModuleID int64, orderedIDs []int64) error diff --git a/internal/ports/exam_prep_module.go b/internal/ports/exam_prep_module.go index f73fe22..556a161 100644 --- a/internal/ports/exam_prep_module.go +++ b/internal/ports/exam_prep_module.go @@ -9,8 +9,9 @@ import ( type ExamPrepModuleStore interface { CreateExamPrepUnitModule(ctx context.Context, unitID int64, input domain.CreateExamPrepModuleInput) (domain.ExamPrepModule, error) GetExamPrepUnitModuleByID(ctx context.Context, id int64) (domain.ExamPrepModule, error) - ListExamPrepUnitModulesByUnit(ctx context.Context, unitID int64, limit, offset int32) ([]domain.ExamPrepModule, int64, error) + ListExamPrepUnitModulesByUnit(ctx context.Context, unitID int64, publishedOnly bool, limit, offset int32) ([]domain.ExamPrepModule, int64, error) ListExamPrepUnitModuleIDsByUnit(ctx context.Context, unitID int64) ([]int64, error) + ListPublishedExamPrepUnitModuleIDsByUnit(ctx context.Context, unitID int64) ([]int64, error) UpdateExamPrepUnitModule(ctx context.Context, id int64, input domain.UpdateExamPrepModuleInput) (domain.ExamPrepModule, error) DeleteExamPrepUnitModule(ctx context.Context, id int64) error ReorderExamPrepUnitModulesInUnit(ctx context.Context, unitID int64, orderedIDs []int64) error diff --git a/internal/ports/exam_prep_unit.go b/internal/ports/exam_prep_unit.go index 67d3125..39c860d 100644 --- a/internal/ports/exam_prep_unit.go +++ b/internal/ports/exam_prep_unit.go @@ -9,8 +9,9 @@ import ( type ExamPrepUnitStore interface { CreateExamPrepUnit(ctx context.Context, catalogCourseID int64, input domain.CreateExamPrepUnitInput) (domain.ExamPrepUnit, error) GetExamPrepUnitByID(ctx context.Context, id int64) (domain.ExamPrepUnit, error) - ListExamPrepUnitsByCatalogCourse(ctx context.Context, catalogCourseID int64, limit, offset int32) ([]domain.ExamPrepUnit, int64, error) + ListExamPrepUnitsByCatalogCourse(ctx context.Context, catalogCourseID int64, publishedOnly bool, limit, offset int32) ([]domain.ExamPrepUnit, int64, error) ListExamPrepUnitIDsByCatalogCourse(ctx context.Context, catalogCourseID int64) ([]int64, error) + ListPublishedExamPrepUnitIDsByCatalogCourse(ctx context.Context, catalogCourseID int64) ([]int64, error) UpdateExamPrepUnit(ctx context.Context, id int64, input domain.UpdateExamPrepUnitInput) (domain.ExamPrepUnit, error) DeleteExamPrepUnit(ctx context.Context, id int64) error ReorderExamPrepUnitsInCatalogCourse(ctx context.Context, catalogCourseID int64, orderedIDs []int64) error diff --git a/internal/ports/lms_course.go b/internal/ports/lms_course.go index ac0137a..567a2f6 100644 --- a/internal/ports/lms_course.go +++ b/internal/ports/lms_course.go @@ -8,8 +8,9 @@ import ( type CourseStore interface { CreateCourse(ctx context.Context, programID int64, input domain.CreateCourseInput) (domain.Course, error) GetCourseByID(ctx context.Context, id int64) (domain.Course, error) - ListCoursesByProgramID(ctx context.Context, programID int64, limit, offset int32) ([]domain.Course, int64, error) + ListCoursesByProgramID(ctx context.Context, programID int64, publishedOnly bool, limit, offset int32) ([]domain.Course, int64, error) ListCourseIDsByProgram(ctx context.Context, programID int64) ([]int64, error) + ListPublishedCourseIDsByProgram(ctx context.Context, programID int64) ([]int64, error) ReorderCoursesInProgram(ctx context.Context, programID int64, orderedIDs []int64) error UpdateCourse(ctx context.Context, id int64, input domain.UpdateCourseInput) (domain.Course, error) DeleteCourse(ctx context.Context, id int64) error diff --git a/internal/ports/lms_module.go b/internal/ports/lms_module.go index f574a9e..3ea25b2 100644 --- a/internal/ports/lms_module.go +++ b/internal/ports/lms_module.go @@ -8,8 +8,9 @@ import ( type ModuleStore interface { CreateModule(ctx context.Context, programID, courseID int64, input domain.CreateModuleInput) (domain.Module, error) GetModuleByID(ctx context.Context, id int64) (domain.Module, error) - ListModulesByProgramAndCourse(ctx context.Context, programID, courseID int64, limit, offset int32) ([]domain.Module, int64, error) + ListModulesByProgramAndCourse(ctx context.Context, programID, courseID int64, publishedOnly bool, limit, offset int32) ([]domain.Module, int64, error) ListModuleIDsByCourse(ctx context.Context, courseID int64) ([]int64, error) + ListPublishedModuleIDsByCourse(ctx context.Context, courseID int64) ([]int64, error) ReorderModulesInCourse(ctx context.Context, courseID int64, orderedIDs []int64) error UpdateModule(ctx context.Context, id int64, input domain.UpdateModuleInput) (domain.Module, error) DeleteModule(ctx context.Context, id int64) error diff --git a/internal/ports/program.go b/internal/ports/program.go index 1157072..9567bde 100644 --- a/internal/ports/program.go +++ b/internal/ports/program.go @@ -8,7 +8,7 @@ import ( type ProgramStore interface { CreateProgram(ctx context.Context, input domain.CreateProgramInput) (domain.Program, error) GetProgramByID(ctx context.Context, id int64) (domain.Program, error) - ListPrograms(ctx context.Context, limit, offset int32) ([]domain.Program, int64, error) + ListPrograms(ctx context.Context, publishedOnly bool, limit, offset int32) ([]domain.Program, int64, error) ListAllProgramIDs(ctx context.Context) ([]int64, error) ReorderPrograms(ctx context.Context, orderedIDs []int64) error UpdateProgram(ctx context.Context, id int64, input domain.UpdateProgramInput) (domain.Program, error) diff --git a/internal/repository/exam_prep_catalog_courses.go b/internal/repository/exam_prep_catalog_courses.go index 035a836..7d62959 100644 --- a/internal/repository/exam_prep_catalog_courses.go +++ b/internal/repository/exam_prep_catalog_courses.go @@ -13,10 +13,11 @@ import ( func examPrepCatalogCourseToDomain(c dbgen.ExamPrepCatalogCourse) domain.ExamPrepCatalogCourse { out := domain.ExamPrepCatalogCourse{ - ID: c.ID, - Name: c.Name, - Category: c.Category, - SortOrder: int(c.SortOrder), + ID: c.ID, + Name: c.Name, + Category: c.Category, + SortOrder: int(c.SortOrder), + PublishStatus: domain.ContentPublishStatusFromDB(c.PublishStatus), } out.Description = fromPgText(c.Description) out.Thumbnail = fromPgText(c.Thumbnail) @@ -30,10 +31,11 @@ func examPrepCatalogCourseToDomain(c dbgen.ExamPrepCatalogCourse) domain.ExamPre func (s *Store) CreateExamPrepCatalogCourse(ctx context.Context, input domain.CreateExamPrepCatalogCourseInput) (domain.ExamPrepCatalogCourse, error) { c, err := s.queries.ExamPrepCreateCatalogCourse(ctx, dbgen.ExamPrepCreateCatalogCourseParams{ - Name: input.Name, - Description: toPgText(input.Description), - Category: input.Category, - Thumbnail: toPgText(input.Thumbnail), + Name: input.Name, + Description: toPgText(input.Description), + Category: input.Category, + Thumbnail: toPgText(input.Thumbnail), + PublishStatus: string(domain.ContentPublishStatusFromCreateInput(input.PublishStatus)), }) if err != nil { return domain.ExamPrepCatalogCourse{}, err @@ -50,23 +52,25 @@ func (s *Store) GetExamPrepCatalogCourseByID(ctx context.Context, id int64) (dom return domain.ExamPrepCatalogCourse{}, err } out := examPrepCatalogCourseToDomain(dbgen.ExamPrepCatalogCourse{ - ID: c.ID, - Name: c.Name, - Description: c.Description, - Category: c.Category, - Thumbnail: c.Thumbnail, - SortOrder: c.SortOrder, - CreatedAt: c.CreatedAt, - UpdatedAt: c.UpdatedAt, + ID: c.ID, + Name: c.Name, + Description: c.Description, + Category: c.Category, + Thumbnail: c.Thumbnail, + SortOrder: c.SortOrder, + CreatedAt: c.CreatedAt, + UpdatedAt: c.UpdatedAt, + PublishStatus: c.PublishStatus, }) out.HasPractice = c.HasPractice return out, nil } -func (s *Store) ListExamPrepCatalogCourses(ctx context.Context, limit, offset int32) ([]domain.ExamPrepCatalogCourse, int64, error) { +func (s *Store) ListExamPrepCatalogCourses(ctx context.Context, publishedOnly bool, limit, offset int32) ([]domain.ExamPrepCatalogCourse, int64, error) { rows, err := s.queries.ExamPrepListCatalogCourses(ctx, dbgen.ExamPrepListCatalogCoursesParams{ - Limit: limit, - Offset: offset, + Limit: limit, + Offset: offset, + PublishedOnly: publishedOnly, }) if err != nil { return nil, 0, err @@ -81,14 +85,15 @@ func (s *Store) ListExamPrepCatalogCourses(ctx context.Context, limit, offset in total = r.TotalCount } item := examPrepCatalogCourseToDomain(dbgen.ExamPrepCatalogCourse{ - ID: r.ID, - Name: r.Name, - Description: r.Description, - Category: r.Category, - Thumbnail: r.Thumbnail, - SortOrder: r.SortOrder, - CreatedAt: r.CreatedAt, - UpdatedAt: r.UpdatedAt, + ID: r.ID, + Name: r.Name, + Description: r.Description, + Category: r.Category, + Thumbnail: r.Thumbnail, + SortOrder: r.SortOrder, + CreatedAt: r.CreatedAt, + UpdatedAt: r.UpdatedAt, + PublishStatus: r.PublishStatus, }) item.UnitsCount = &r.UnitsCount item.ModulesCount = &r.ModulesCount @@ -111,12 +116,13 @@ func (s *Store) UpdateExamPrepCatalogCourse(ctx context.Context, id int64, input nameText = pgtype.Text{Valid: false} } c, err := s.queries.ExamPrepUpdateCatalogCourse(ctx, dbgen.ExamPrepUpdateCatalogCourseParams{ - ID: id, - Name: nameText, - Description: optionalTextUpdate(input.Description), - Category: optionalTextUpdate(input.Category), - Thumbnail: optionalTextUpdate(input.Thumbnail), - SortOrder: optionalInt4Update(input.SortOrder), + ID: id, + Name: nameText, + Description: optionalTextUpdate(input.Description), + Category: optionalTextUpdate(input.Category), + Thumbnail: optionalTextUpdate(input.Thumbnail), + SortOrder: optionalInt4Update(input.SortOrder), + PublishStatus: optionalPublishStatusUpdate(input.PublishStatus), }) if err != nil { if errors.Is(err, pgx.ErrNoRows) { diff --git a/internal/repository/exam_prep_unit_module_lessons.go b/internal/repository/exam_prep_unit_module_lessons.go index facdbb6..9baad81 100644 --- a/internal/repository/exam_prep_unit_module_lessons.go +++ b/internal/repository/exam_prep_unit_module_lessons.go @@ -13,10 +13,11 @@ import ( func examPrepLessonToDomain(l dbgen.ExamPrepUnitModuleLesson) domain.ExamPrepLesson { out := domain.ExamPrepLesson{ - ID: l.ID, - UnitModuleID: l.UnitModuleID, - Title: l.Title, - SortOrder: int(l.SortOrder), + ID: l.ID, + UnitModuleID: l.UnitModuleID, + Title: l.Title, + SortOrder: int(l.SortOrder), + PublishStatus: domain.ContentPublishStatusFromDB(l.PublishStatus), } out.VideoURL = fromPgText(l.VideoUrl) out.Thumbnail = fromPgText(l.Thumbnail) @@ -31,11 +32,12 @@ func examPrepLessonToDomain(l dbgen.ExamPrepUnitModuleLesson) domain.ExamPrepLes func (s *Store) CreateExamPrepUnitModuleLesson(ctx context.Context, unitModuleID int64, input domain.CreateExamPrepLessonInput) (domain.ExamPrepLesson, error) { l, err := s.queries.ExamPrepCreateUnitModuleLesson(ctx, dbgen.ExamPrepCreateUnitModuleLessonParams{ - UnitModuleID: unitModuleID, - Title: input.Title, - VideoUrl: toPgText(input.VideoURL), - Thumbnail: toPgText(input.Thumbnail), - Description: toPgText(input.Description), + UnitModuleID: unitModuleID, + Title: input.Title, + VideoUrl: toPgText(input.VideoURL), + Thumbnail: toPgText(input.Thumbnail), + Description: toPgText(input.Description), + PublishStatus: string(domain.ContentPublishStatusFromCreateInput(input.PublishStatus)), }) if err != nil { return domain.ExamPrepLesson{}, err @@ -52,25 +54,27 @@ func (s *Store) GetExamPrepUnitModuleLessonByID(ctx context.Context, id int64) ( return domain.ExamPrepLesson{}, err } out := examPrepLessonToDomain(dbgen.ExamPrepUnitModuleLesson{ - ID: l.ID, - UnitModuleID: l.UnitModuleID, - Title: l.Title, - VideoUrl: l.VideoUrl, - Thumbnail: l.Thumbnail, - Description: l.Description, - SortOrder: l.SortOrder, - CreatedAt: l.CreatedAt, - UpdatedAt: l.UpdatedAt, + ID: l.ID, + UnitModuleID: l.UnitModuleID, + Title: l.Title, + VideoUrl: l.VideoUrl, + Thumbnail: l.Thumbnail, + Description: l.Description, + SortOrder: l.SortOrder, + CreatedAt: l.CreatedAt, + UpdatedAt: l.UpdatedAt, + PublishStatus: l.PublishStatus, }) out.HasPractice = l.HasPractice return out, nil } -func (s *Store) ListExamPrepUnitModuleLessonsByUnitModuleID(ctx context.Context, unitModuleID int64, limit, offset int32) ([]domain.ExamPrepLesson, int64, error) { +func (s *Store) ListExamPrepUnitModuleLessonsByUnitModuleID(ctx context.Context, unitModuleID int64, publishedOnly bool, limit, offset int32) ([]domain.ExamPrepLesson, int64, error) { rows, err := s.queries.ExamPrepListUnitModuleLessonsByUnitModuleID(ctx, dbgen.ExamPrepListUnitModuleLessonsByUnitModuleIDParams{ - UnitModuleID: unitModuleID, - Limit: limit, - Offset: offset, + UnitModuleID: unitModuleID, + Limit: limit, + Offset: offset, + PublishedOnly: publishedOnly, }) if err != nil { return nil, 0, err @@ -85,15 +89,16 @@ func (s *Store) ListExamPrepUnitModuleLessonsByUnitModuleID(ctx context.Context, total = r.TotalCount } item := examPrepLessonToDomain(dbgen.ExamPrepUnitModuleLesson{ - ID: r.ID, - UnitModuleID: r.UnitModuleID, - Title: r.Title, - VideoUrl: r.VideoUrl, - Thumbnail: r.Thumbnail, - Description: r.Description, - SortOrder: r.SortOrder, - CreatedAt: r.CreatedAt, - UpdatedAt: r.UpdatedAt, + ID: r.ID, + UnitModuleID: r.UnitModuleID, + Title: r.Title, + VideoUrl: r.VideoUrl, + Thumbnail: r.Thumbnail, + Description: r.Description, + SortOrder: r.SortOrder, + CreatedAt: r.CreatedAt, + UpdatedAt: r.UpdatedAt, + PublishStatus: r.PublishStatus, }) item.HasPractice = r.HasPractice out = append(out, item) @@ -105,6 +110,10 @@ func (s *Store) ListExamPrepUnitModuleLessonIDsByUnitModule(ctx context.Context, return s.queries.ExamPrepListUnitModuleLessonIDsByUnitModule(ctx, unitModuleID) } +func (s *Store) ListPublishedExamPrepUnitModuleLessonIDsByUnitModule(ctx context.Context, unitModuleID int64) ([]int64, error) { + return s.queries.ExamPrepListPublishedUnitModuleLessonIDsByUnitModule(ctx, unitModuleID) +} + func (s *Store) UpdateExamPrepUnitModuleLesson(ctx context.Context, id int64, input domain.UpdateExamPrepLessonInput) (domain.ExamPrepLesson, error) { var titleText pgtype.Text if input.Title != nil { @@ -113,12 +122,13 @@ func (s *Store) UpdateExamPrepUnitModuleLesson(ctx context.Context, id int64, in titleText = pgtype.Text{Valid: false} } l, err := s.queries.ExamPrepUpdateUnitModuleLesson(ctx, dbgen.ExamPrepUpdateUnitModuleLessonParams{ - ID: id, - Title: titleText, - VideoUrl: optionalTextUpdate(input.VideoURL), - Thumbnail: optionalTextUpdate(input.Thumbnail), - Description: optionalTextUpdate(input.Description), - SortOrder: optionalInt4Update(input.SortOrder), + ID: id, + Title: titleText, + VideoUrl: optionalTextUpdate(input.VideoURL), + Thumbnail: optionalTextUpdate(input.Thumbnail), + Description: optionalTextUpdate(input.Description), + SortOrder: optionalInt4Update(input.SortOrder), + PublishStatus: optionalPublishStatusUpdate(input.PublishStatus), }) if err != nil { if errors.Is(err, pgx.ErrNoRows) { diff --git a/internal/repository/exam_prep_unit_modules.go b/internal/repository/exam_prep_unit_modules.go index 93af321..105472c 100644 --- a/internal/repository/exam_prep_unit_modules.go +++ b/internal/repository/exam_prep_unit_modules.go @@ -13,10 +13,11 @@ import ( func examPrepModuleToDomain(m dbgen.ExamPrepUnitModule) domain.ExamPrepModule { out := domain.ExamPrepModule{ - ID: m.ID, - UnitID: m.UnitID, - Name: m.Name, - SortOrder: int(m.SortOrder), + ID: m.ID, + UnitID: m.UnitID, + Name: m.Name, + SortOrder: int(m.SortOrder), + PublishStatus: domain.ContentPublishStatusFromDB(m.PublishStatus), } out.Description = fromPgText(m.Description) out.Thumbnail = fromPgText(m.Thumbnail) @@ -31,11 +32,12 @@ func examPrepModuleToDomain(m dbgen.ExamPrepUnitModule) domain.ExamPrepModule { func (s *Store) CreateExamPrepUnitModule(ctx context.Context, unitID int64, input domain.CreateExamPrepModuleInput) (domain.ExamPrepModule, error) { m, err := s.queries.ExamPrepCreateUnitModule(ctx, dbgen.ExamPrepCreateUnitModuleParams{ - UnitID: unitID, - Name: input.Name, - Description: toPgText(input.Description), - Thumbnail: toPgText(input.Thumbnail), - Icon: toPgText(input.Icon), + UnitID: unitID, + Name: input.Name, + Description: toPgText(input.Description), + Thumbnail: toPgText(input.Thumbnail), + Icon: toPgText(input.Icon), + PublishStatus: string(domain.ContentPublishStatusFromCreateInput(input.PublishStatus)), }) if err != nil { return domain.ExamPrepModule{}, err @@ -52,25 +54,27 @@ func (s *Store) GetExamPrepUnitModuleByID(ctx context.Context, id int64) (domain return domain.ExamPrepModule{}, err } out := examPrepModuleToDomain(dbgen.ExamPrepUnitModule{ - ID: m.ID, - UnitID: m.UnitID, - Name: m.Name, - Description: m.Description, - Thumbnail: m.Thumbnail, - Icon: m.Icon, - SortOrder: m.SortOrder, - CreatedAt: m.CreatedAt, - UpdatedAt: m.UpdatedAt, + ID: m.ID, + UnitID: m.UnitID, + Name: m.Name, + Description: m.Description, + Thumbnail: m.Thumbnail, + Icon: m.Icon, + SortOrder: m.SortOrder, + CreatedAt: m.CreatedAt, + UpdatedAt: m.UpdatedAt, + PublishStatus: m.PublishStatus, }) out.HasPractice = m.HasPractice return out, nil } -func (s *Store) ListExamPrepUnitModulesByUnit(ctx context.Context, unitID int64, limit, offset int32) ([]domain.ExamPrepModule, int64, error) { +func (s *Store) ListExamPrepUnitModulesByUnit(ctx context.Context, unitID int64, publishedOnly bool, limit, offset int32) ([]domain.ExamPrepModule, int64, error) { rows, err := s.queries.ExamPrepListUnitModulesByUnit(ctx, dbgen.ExamPrepListUnitModulesByUnitParams{ - UnitID: unitID, - Limit: limit, - Offset: offset, + UnitID: unitID, + Limit: limit, + Offset: offset, + PublishedOnly: publishedOnly, }) if err != nil { return nil, 0, err @@ -85,15 +89,16 @@ func (s *Store) ListExamPrepUnitModulesByUnit(ctx context.Context, unitID int64, total = r.TotalCount } item := examPrepModuleToDomain(dbgen.ExamPrepUnitModule{ - ID: r.ID, - UnitID: r.UnitID, - Name: r.Name, - Description: r.Description, - Thumbnail: r.Thumbnail, - Icon: r.Icon, - SortOrder: r.SortOrder, - CreatedAt: r.CreatedAt, - UpdatedAt: r.UpdatedAt, + ID: r.ID, + UnitID: r.UnitID, + Name: r.Name, + Description: r.Description, + Thumbnail: r.Thumbnail, + Icon: r.Icon, + SortOrder: r.SortOrder, + CreatedAt: r.CreatedAt, + UpdatedAt: r.UpdatedAt, + PublishStatus: r.PublishStatus, }) item.LessonsCount = &r.LessonsCount item.PracticesCount = &r.PracticesCount @@ -107,6 +112,10 @@ func (s *Store) ListExamPrepUnitModuleIDsByUnit(ctx context.Context, unitID int6 return s.queries.ExamPrepListUnitModuleIDsByUnit(ctx, unitID) } +func (s *Store) ListPublishedExamPrepUnitModuleIDsByUnit(ctx context.Context, unitID int64) ([]int64, error) { + return s.queries.ExamPrepListPublishedUnitModuleIDsByUnit(ctx, unitID) +} + func (s *Store) UpdateExamPrepUnitModule(ctx context.Context, id int64, input domain.UpdateExamPrepModuleInput) (domain.ExamPrepModule, error) { var nameText pgtype.Text if input.Name != nil { @@ -115,12 +124,13 @@ func (s *Store) UpdateExamPrepUnitModule(ctx context.Context, id int64, input do nameText = pgtype.Text{Valid: false} } m, err := s.queries.ExamPrepUpdateUnitModule(ctx, dbgen.ExamPrepUpdateUnitModuleParams{ - ID: id, - Name: nameText, - Description: optionalTextUpdate(input.Description), - Thumbnail: optionalTextUpdate(input.Thumbnail), - Icon: optionalTextUpdate(input.Icon), - SortOrder: optionalInt4Update(input.SortOrder), + ID: id, + Name: nameText, + Description: optionalTextUpdate(input.Description), + Thumbnail: optionalTextUpdate(input.Thumbnail), + Icon: optionalTextUpdate(input.Icon), + SortOrder: optionalInt4Update(input.SortOrder), + PublishStatus: optionalPublishStatusUpdate(input.PublishStatus), }) if err != nil { if errors.Is(err, pgx.ErrNoRows) { diff --git a/internal/repository/exam_prep_units.go b/internal/repository/exam_prep_units.go index 3a7d5c4..0c016be 100644 --- a/internal/repository/exam_prep_units.go +++ b/internal/repository/exam_prep_units.go @@ -17,6 +17,7 @@ func examPrepUnitToDomain(u dbgen.ExamPrepUnit) domain.ExamPrepUnit { CatalogCourseID: u.CatalogCourseID, Name: u.Name, SortOrder: int(u.SortOrder), + PublishStatus: domain.ContentPublishStatusFromDB(u.PublishStatus), } out.Description = fromPgText(u.Description) out.Thumbnail = fromPgText(u.Thumbnail) @@ -29,6 +30,7 @@ func examPrepUnitToDomain(u dbgen.ExamPrepUnit) domain.ExamPrepUnit { } func (s *Store) CreateExamPrepUnit(ctx context.Context, catalogCourseID int64, input domain.CreateExamPrepUnitInput) (domain.ExamPrepUnit, error) { + pub := string(domain.ContentPublishStatusFromCreateInput(input.PublishStatus)) if input.SortOrder != nil { q, tx, err := s.BeginTx(ctx) if err != nil { @@ -48,6 +50,7 @@ func (s *Store) CreateExamPrepUnit(ctx context.Context, catalogCourseID int64, i Description: toPgText(input.Description), Thumbnail: toPgText(input.Thumbnail), SortOrder: pgtype.Int4{Int32: target, Valid: true}, + PublishStatus: pub, }) if err != nil { return domain.ExamPrepUnit{}, err @@ -64,6 +67,7 @@ func (s *Store) CreateExamPrepUnit(ctx context.Context, catalogCourseID int64, i Description: toPgText(input.Description), Thumbnail: toPgText(input.Thumbnail), SortOrder: pgtype.Int4{Valid: false}, + PublishStatus: pub, }) if err != nil { return domain.ExamPrepUnit{}, err @@ -88,16 +92,18 @@ func (s *Store) GetExamPrepUnitByID(ctx context.Context, id int64) (domain.ExamP SortOrder: u.SortOrder, CreatedAt: u.CreatedAt, UpdatedAt: u.UpdatedAt, + PublishStatus: u.PublishStatus, }) out.HasPractice = u.HasPractice return out, nil } -func (s *Store) ListExamPrepUnitsByCatalogCourse(ctx context.Context, catalogCourseID int64, limit, offset int32) ([]domain.ExamPrepUnit, int64, error) { +func (s *Store) ListExamPrepUnitsByCatalogCourse(ctx context.Context, catalogCourseID int64, publishedOnly bool, limit, offset int32) ([]domain.ExamPrepUnit, int64, error) { rows, err := s.queries.ExamPrepListUnitsByCatalogCourse(ctx, dbgen.ExamPrepListUnitsByCatalogCourseParams{ CatalogCourseID: catalogCourseID, Limit: limit, Offset: offset, + PublishedOnly: publishedOnly, }) if err != nil { return nil, 0, err @@ -120,6 +126,7 @@ func (s *Store) ListExamPrepUnitsByCatalogCourse(ctx context.Context, catalogCou SortOrder: r.SortOrder, CreatedAt: r.CreatedAt, UpdatedAt: r.UpdatedAt, + PublishStatus: r.PublishStatus, }) item.ModulesCount = &r.ModulesCount item.LessonsCount = &r.LessonsCount @@ -134,6 +141,10 @@ func (s *Store) ListExamPrepUnitIDsByCatalogCourse(ctx context.Context, catalogC return s.queries.ExamPrepListUnitIDsByCatalogCourse(ctx, catalogCourseID) } +func (s *Store) ListPublishedExamPrepUnitIDsByCatalogCourse(ctx context.Context, catalogCourseID int64) ([]int64, error) { + return s.queries.ExamPrepListPublishedUnitIDsByCatalogCourse(ctx, catalogCourseID) +} + func (s *Store) UpdateExamPrepUnit(ctx context.Context, id int64, input domain.UpdateExamPrepUnitInput) (domain.ExamPrepUnit, error) { var nameText pgtype.Text if input.Name != nil { @@ -142,11 +153,12 @@ func (s *Store) UpdateExamPrepUnit(ctx context.Context, id int64, input domain.U nameText = pgtype.Text{Valid: false} } u, err := s.queries.ExamPrepUpdateUnit(ctx, dbgen.ExamPrepUpdateUnitParams{ - ID: id, - Name: nameText, - Description: optionalTextUpdate(input.Description), - Thumbnail: optionalTextUpdate(input.Thumbnail), - SortOrder: optionalInt4Update(input.SortOrder), + ID: id, + Name: nameText, + Description: optionalTextUpdate(input.Description), + Thumbnail: optionalTextUpdate(input.Thumbnail), + SortOrder: optionalInt4Update(input.SortOrder), + PublishStatus: optionalPublishStatusUpdate(input.PublishStatus), }) if err != nil { if errors.Is(err, pgx.ErrNoRows) { diff --git a/internal/repository/lms_courses.go b/internal/repository/lms_courses.go index 73ef759..01c4373 100644 --- a/internal/repository/lms_courses.go +++ b/internal/repository/lms_courses.go @@ -13,9 +13,10 @@ import ( func courseToDomain(c dbgen.Course) domain.Course { out := domain.Course{ - ID: c.ID, - ProgramID: c.ProgramID, - Name: c.Name, + ID: c.ID, + ProgramID: c.ProgramID, + Name: c.Name, + PublishStatus: domain.ContentPublishStatusFromDB(c.PublishStatus), } out.Description = fromPgText(c.Description) out.Thumbnail = fromPgText(c.Thumbnail) @@ -29,6 +30,7 @@ func courseToDomain(c dbgen.Course) domain.Course { } func (s *Store) CreateCourse(ctx context.Context, programID int64, input domain.CreateCourseInput) (domain.Course, error) { + pub := string(domain.ContentPublishStatusFromCreateInput(input.PublishStatus)) if input.SortOrder != nil { q, tx, err := s.BeginTx(ctx) if err != nil { @@ -43,11 +45,12 @@ func (s *Store) CreateCourse(ctx context.Context, programID int64, input domain. return domain.Course{}, err } c, err := q.CreateCourse(ctx, dbgen.CreateCourseParams{ - ProgramID: programID, - Name: input.Name, - Description: toPgText(input.Description), - Thumbnail: toPgText(input.Thumbnail), - SortOrder: pgtype.Int4{Int32: target, Valid: true}, + ProgramID: programID, + Name: input.Name, + Description: toPgText(input.Description), + Thumbnail: toPgText(input.Thumbnail), + SortOrder: pgtype.Int4{Int32: target, Valid: true}, + PublishStatus: pub, }) if err != nil { return domain.Course{}, err @@ -59,11 +62,12 @@ func (s *Store) CreateCourse(ctx context.Context, programID int64, input domain. } c, err := s.queries.CreateCourse(ctx, dbgen.CreateCourseParams{ - ProgramID: programID, - Name: input.Name, - Description: toPgText(input.Description), - Thumbnail: toPgText(input.Thumbnail), - SortOrder: pgtype.Int4{Valid: false}, + ProgramID: programID, + Name: input.Name, + Description: toPgText(input.Description), + Thumbnail: toPgText(input.Thumbnail), + SortOrder: pgtype.Int4{Valid: false}, + PublishStatus: pub, }) if err != nil { return domain.Course{}, err @@ -75,6 +79,10 @@ func (s *Store) ListCourseIDsByProgram(ctx context.Context, programID int64) ([] return s.queries.ListCourseIDsByProgram(ctx, programID) } +func (s *Store) ListPublishedCourseIDsByProgram(ctx context.Context, programID int64) ([]int64, error) { + return s.queries.ListPublishedCourseIDsByProgram(ctx, programID) +} + func (s *Store) GetCourseByID(ctx context.Context, id int64) (domain.Course, error) { c, err := s.queries.GetCourseByID(ctx, id) if err != nil { @@ -84,24 +92,26 @@ func (s *Store) GetCourseByID(ctx context.Context, id int64) (domain.Course, err return domain.Course{}, err } out := courseToDomain(dbgen.Course{ - ID: c.ID, - ProgramID: c.ProgramID, - Name: c.Name, - Description: c.Description, - Thumbnail: c.Thumbnail, - SortOrder: c.SortOrder, - CreatedAt: c.CreatedAt, - UpdatedAt: c.UpdatedAt, + ID: c.ID, + ProgramID: c.ProgramID, + Name: c.Name, + Description: c.Description, + Thumbnail: c.Thumbnail, + SortOrder: c.SortOrder, + CreatedAt: c.CreatedAt, + UpdatedAt: c.UpdatedAt, + PublishStatus: c.PublishStatus, }) out.HasPractice = c.HasPractice return out, nil } -func (s *Store) ListCoursesByProgramID(ctx context.Context, programID int64, limit, offset int32) ([]domain.Course, int64, error) { +func (s *Store) ListCoursesByProgramID(ctx context.Context, programID int64, publishedOnly bool, limit, offset int32) ([]domain.Course, int64, error) { rows, err := s.queries.ListCoursesByProgramID(ctx, dbgen.ListCoursesByProgramIDParams{ - ProgramID: programID, - Limit: limit, - Offset: offset, + ProgramID: programID, + Limit: limit, + Offset: offset, + PublishedOnly: publishedOnly, }) if err != nil { return nil, 0, err @@ -116,14 +126,15 @@ func (s *Store) ListCoursesByProgramID(ctx context.Context, programID int64, lim total = r.TotalCount } co := courseToDomain(dbgen.Course{ - ID: r.ID, - ProgramID: r.ProgramID, - Name: r.Name, - Description: r.Description, - Thumbnail: r.Thumbnail, - CreatedAt: r.CreatedAt, - UpdatedAt: r.UpdatedAt, - SortOrder: r.SortOrder, + ID: r.ID, + ProgramID: r.ProgramID, + Name: r.Name, + Description: r.Description, + Thumbnail: r.Thumbnail, + CreatedAt: r.CreatedAt, + UpdatedAt: r.UpdatedAt, + SortOrder: r.SortOrder, + PublishStatus: r.PublishStatus, }) co.ModuleCount = int(r.ModuleCount) co.LessonCount = int(r.LessonCount) @@ -160,11 +171,12 @@ func (s *Store) UpdateCourse(ctx context.Context, id int64, input domain.UpdateC return domain.Course{}, err } c, err := q.UpdateCourse(ctx, dbgen.UpdateCourseParams{ - ID: id, - Name: nameText, - Description: optionalTextUpdate(input.Description), - Thumbnail: optionalTextUpdate(input.Thumbnail), - SortOrder: pgtype.Int4{Valid: false}, + ID: id, + Name: nameText, + Description: optionalTextUpdate(input.Description), + Thumbnail: optionalTextUpdate(input.Thumbnail), + SortOrder: pgtype.Int4{Valid: false}, + PublishStatus: optionalPublishStatusUpdate(input.PublishStatus), }) if err != nil { return domain.Course{}, err @@ -180,11 +192,12 @@ func (s *Store) UpdateCourse(ctx context.Context, id int64, input domain.UpdateC } c, err := s.queries.UpdateCourse(ctx, dbgen.UpdateCourseParams{ - ID: id, - Name: nameText, - Description: optionalTextUpdate(input.Description), - Thumbnail: optionalTextUpdate(input.Thumbnail), - SortOrder: sortParam, + ID: id, + Name: nameText, + Description: optionalTextUpdate(input.Description), + Thumbnail: optionalTextUpdate(input.Thumbnail), + SortOrder: sortParam, + PublishStatus: optionalPublishStatusUpdate(input.PublishStatus), }) if err != nil { if errors.Is(err, pgx.ErrNoRows) { diff --git a/internal/repository/lms_modules.go b/internal/repository/lms_modules.go index 09d0f19..6b98ebd 100644 --- a/internal/repository/lms_modules.go +++ b/internal/repository/lms_modules.go @@ -13,10 +13,11 @@ import ( func moduleToDomain(m dbgen.Module) domain.Module { out := domain.Module{ - ID: m.ID, - ProgramID: m.ProgramID, - CourseID: m.CourseID, - Name: m.Name, + ID: m.ID, + ProgramID: m.ProgramID, + CourseID: m.CourseID, + Name: m.Name, + PublishStatus: domain.ContentPublishStatusFromDB(m.PublishStatus), } out.Description = fromPgText(m.Description) out.Icon = fromPgText(m.Icon) @@ -30,6 +31,7 @@ func moduleToDomain(m dbgen.Module) domain.Module { } func (s *Store) CreateModule(ctx context.Context, programID, courseID int64, input domain.CreateModuleInput) (domain.Module, error) { + pub := string(domain.ContentPublishStatusFromCreateInput(input.PublishStatus)) if input.SortOrder != nil { q, tx, err := s.BeginTx(ctx) if err != nil { @@ -44,12 +46,13 @@ func (s *Store) CreateModule(ctx context.Context, programID, courseID int64, inp return domain.Module{}, err } m, err := q.CreateModule(ctx, dbgen.CreateModuleParams{ - ProgramID: programID, - CourseID: courseID, - Name: input.Name, - Description: toPgText(input.Description), - Icon: toPgText(input.Icon), - SortOrder: pgtype.Int4{Int32: target, Valid: true}, + ProgramID: programID, + CourseID: courseID, + Name: input.Name, + Description: toPgText(input.Description), + Icon: toPgText(input.Icon), + SortOrder: pgtype.Int4{Int32: target, Valid: true}, + PublishStatus: pub, }) if err != nil { return domain.Module{}, err @@ -61,12 +64,13 @@ func (s *Store) CreateModule(ctx context.Context, programID, courseID int64, inp } m, err := s.queries.CreateModule(ctx, dbgen.CreateModuleParams{ - ProgramID: programID, - CourseID: courseID, - Name: input.Name, - Description: toPgText(input.Description), - Icon: toPgText(input.Icon), - SortOrder: pgtype.Int4{Valid: false}, + ProgramID: programID, + CourseID: courseID, + Name: input.Name, + Description: toPgText(input.Description), + Icon: toPgText(input.Icon), + SortOrder: pgtype.Int4{Valid: false}, + PublishStatus: pub, }) if err != nil { return domain.Module{}, err @@ -78,6 +82,10 @@ func (s *Store) ListModuleIDsByCourse(ctx context.Context, courseID int64) ([]in return s.queries.ListModuleIDsByCourse(ctx, courseID) } +func (s *Store) ListPublishedModuleIDsByCourse(ctx context.Context, courseID int64) ([]int64, error) { + return s.queries.ListPublishedModuleIDsByCourse(ctx, courseID) +} + func (s *Store) GetModuleByID(ctx context.Context, id int64) (domain.Module, error) { m, err := s.queries.GetModuleByID(ctx, id) if err != nil { @@ -87,26 +95,28 @@ func (s *Store) GetModuleByID(ctx context.Context, id int64) (domain.Module, err return domain.Module{}, err } out := moduleToDomain(dbgen.Module{ - ID: m.ID, - ProgramID: m.ProgramID, - CourseID: m.CourseID, - Name: m.Name, - Description: m.Description, - Icon: m.Icon, - SortOrder: m.SortOrder, - CreatedAt: m.CreatedAt, - UpdatedAt: m.UpdatedAt, + ID: m.ID, + ProgramID: m.ProgramID, + CourseID: m.CourseID, + Name: m.Name, + Description: m.Description, + Icon: m.Icon, + SortOrder: m.SortOrder, + CreatedAt: m.CreatedAt, + UpdatedAt: m.UpdatedAt, + PublishStatus: m.PublishStatus, }) out.HasPractice = m.HasPractice return out, nil } -func (s *Store) ListModulesByProgramAndCourse(ctx context.Context, programID, courseID int64, limit, offset int32) ([]domain.Module, int64, error) { +func (s *Store) ListModulesByProgramAndCourse(ctx context.Context, programID, courseID int64, publishedOnly bool, limit, offset int32) ([]domain.Module, int64, error) { rows, err := s.queries.ListModulesByProgramAndCourse(ctx, dbgen.ListModulesByProgramAndCourseParams{ - ProgramID: programID, - CourseID: courseID, - Limit: limit, - Offset: offset, + ProgramID: programID, + CourseID: courseID, + Limit: limit, + Offset: offset, + PublishedOnly: publishedOnly, }) if err != nil { return nil, 0, err @@ -121,15 +131,16 @@ func (s *Store) ListModulesByProgramAndCourse(ctx context.Context, programID, co total = r.TotalCount } mod := moduleToDomain(dbgen.Module{ - ID: r.ID, - ProgramID: r.ProgramID, - CourseID: r.CourseID, - Name: r.Name, - Description: r.Description, - Icon: r.Icon, - CreatedAt: r.CreatedAt, - UpdatedAt: r.UpdatedAt, - SortOrder: r.SortOrder, + ID: r.ID, + ProgramID: r.ProgramID, + CourseID: r.CourseID, + Name: r.Name, + Description: r.Description, + Icon: r.Icon, + CreatedAt: r.CreatedAt, + UpdatedAt: r.UpdatedAt, + SortOrder: r.SortOrder, + PublishStatus: r.PublishStatus, }) mod.HasPractice = r.HasPractice out = append(out, mod) @@ -168,11 +179,12 @@ func (s *Store) UpdateModule(ctx context.Context, id int64, input domain.UpdateM nameText = pgtype.Text{Valid: false} } m, err := q.UpdateModule(ctx, dbgen.UpdateModuleParams{ - ID: id, - Name: nameText, - Description: optionalTextUpdate(input.Description), - Icon: optionalTextUpdate(input.Icon), - SortOrder: sortParam, + ID: id, + Name: nameText, + Description: optionalTextUpdate(input.Description), + Icon: optionalTextUpdate(input.Icon), + SortOrder: sortParam, + PublishStatus: optionalPublishStatusUpdate(input.PublishStatus), }) if err != nil { if errors.Is(err, pgx.ErrNoRows) { diff --git a/internal/repository/programs.go b/internal/repository/programs.go index 7b51cc0..b1049ce 100644 --- a/internal/repository/programs.go +++ b/internal/repository/programs.go @@ -14,9 +14,10 @@ import ( func programToDomain(p dbgen.Program) domain.Program { out := domain.Program{ - ID: p.ID, - Name: p.Name, - Category: p.Category, + ID: p.ID, + Name: p.Name, + Category: p.Category, + PublishStatus: domain.ContentPublishStatusFromDB(p.PublishStatus), } out.Description = fromPgText(p.Description) out.Thumbnail = fromPgText(p.Thumbnail) @@ -30,6 +31,7 @@ func programToDomain(p dbgen.Program) domain.Program { } func (s *Store) CreateProgram(ctx context.Context, input domain.CreateProgramInput) (domain.Program, error) { + pub := string(domain.ContentPublishStatusFromCreateInput(input.PublishStatus)) if input.SortOrder != nil { q, tx, err := s.BeginTx(ctx) if err != nil { @@ -41,11 +43,12 @@ func (s *Store) CreateProgram(ctx context.Context, input domain.CreateProgramInp return domain.Program{}, err } p, err := q.CreateProgram(ctx, dbgen.CreateProgramParams{ - Name: input.Name, - Description: toPgText(input.Description), - Category: input.Category, - Thumbnail: toPgText(input.Thumbnail), - SortOrder: pgtype.Int4{Int32: target, Valid: true}, + Name: input.Name, + Description: toPgText(input.Description), + Category: input.Category, + Thumbnail: toPgText(input.Thumbnail), + SortOrder: pgtype.Int4{Int32: target, Valid: true}, + PublishStatus: pub, }) if err != nil { return domain.Program{}, err @@ -57,11 +60,12 @@ func (s *Store) CreateProgram(ctx context.Context, input domain.CreateProgramInp } p, err := s.queries.CreateProgram(ctx, dbgen.CreateProgramParams{ - Name: input.Name, - Description: toPgText(input.Description), - Category: input.Category, - Thumbnail: toPgText(input.Thumbnail), - SortOrder: pgtype.Int4{Valid: false}, + Name: input.Name, + Description: toPgText(input.Description), + Category: input.Category, + Thumbnail: toPgText(input.Thumbnail), + SortOrder: pgtype.Int4{Valid: false}, + PublishStatus: pub, }) if err != nil { return domain.Program{}, err @@ -84,10 +88,11 @@ func (s *Store) GetProgramByID(ctx context.Context, id int64) (domain.Program, e return programToDomain(p), nil } -func (s *Store) ListPrograms(ctx context.Context, limit, offset int32) ([]domain.Program, int64, error) { +func (s *Store) ListPrograms(ctx context.Context, publishedOnly bool, limit, offset int32) ([]domain.Program, int64, error) { rows, err := s.queries.ListPrograms(ctx, dbgen.ListProgramsParams{ - Limit: limit, - Offset: offset, + Limit: limit, + Offset: offset, + PublishedOnly: publishedOnly, }) if err != nil { return nil, 0, err @@ -102,14 +107,15 @@ func (s *Store) ListPrograms(ctx context.Context, limit, offset int32) ([]domain total = r.TotalCount } out = append(out, programToDomain(dbgen.Program{ - ID: r.ID, - Name: r.Name, - Description: r.Description, - Category: r.Category, - Thumbnail: r.Thumbnail, - CreatedAt: r.CreatedAt, - UpdatedAt: r.UpdatedAt, - SortOrder: r.SortOrder, + ID: r.ID, + Name: r.Name, + Description: r.Description, + Category: r.Category, + Thumbnail: r.Thumbnail, + CreatedAt: r.CreatedAt, + UpdatedAt: r.UpdatedAt, + SortOrder: r.SortOrder, + PublishStatus: r.PublishStatus, })) } return out, total, nil @@ -167,12 +173,13 @@ func (s *Store) UpdateProgram(ctx context.Context, id int64, input domain.Update nameText = pgtype.Text{Valid: false} } p, err := q.UpdateProgram(ctx, dbgen.UpdateProgramParams{ - ID: id, - Name: nameText, - Description: optionalTextUpdate(input.Description), - Category: optionalTextUpdate(input.Category), - Thumbnail: optionalTextUpdate(input.Thumbnail), - SortOrder: pgtype.Int4{Valid: false}, + ID: id, + Name: nameText, + Description: optionalTextUpdate(input.Description), + Category: optionalTextUpdate(input.Category), + Thumbnail: optionalTextUpdate(input.Thumbnail), + SortOrder: pgtype.Int4{Valid: false}, + PublishStatus: optionalPublishStatusUpdate(input.PublishStatus), }) if err != nil { return domain.Program{}, err @@ -192,12 +199,13 @@ func (s *Store) UpdateProgram(ctx context.Context, id int64, input domain.Update nameText = pgtype.Text{Valid: false} } p, err := s.queries.UpdateProgram(ctx, dbgen.UpdateProgramParams{ - ID: id, - Name: nameText, - Description: optionalTextUpdate(input.Description), - Category: optionalTextUpdate(input.Category), - Thumbnail: optionalTextUpdate(input.Thumbnail), - SortOrder: sortParam, + ID: id, + Name: nameText, + Description: optionalTextUpdate(input.Description), + Category: optionalTextUpdate(input.Category), + Thumbnail: optionalTextUpdate(input.Thumbnail), + SortOrder: sortParam, + PublishStatus: optionalPublishStatusUpdate(input.PublishStatus), }) if err != nil { if errors.Is(err, pgx.ErrNoRows) { diff --git a/internal/services/courses/service.go b/internal/services/courses/service.go index 498741a..403581d 100644 --- a/internal/services/courses/service.go +++ b/internal/services/courses/service.go @@ -42,7 +42,7 @@ func (s *Service) GetByID(ctx context.Context, id int64) (domain.Course, error) return c, nil } -func (s *Service) ListByProgram(ctx context.Context, programID int64, limit, offset int32) ([]domain.Course, int64, error) { +func (s *Service) ListByProgram(ctx context.Context, programID int64, publishedOnly bool, limit, offset int32) ([]domain.Course, int64, error) { if _, err := s.programs.GetProgramByID(ctx, programID); err != nil { if errors.Is(err, pgx.ErrNoRows) { return nil, 0, programs.ErrProgramNotFound @@ -58,7 +58,7 @@ func (s *Service) ListByProgram(ctx context.Context, programID int64, limit, off if offset < 0 { offset = 0 } - return s.courses.ListCoursesByProgramID(ctx, programID, limit, offset) + return s.courses.ListCoursesByProgramID(ctx, programID, publishedOnly, limit, offset) } func (s *Service) Update(ctx context.Context, id int64, input domain.UpdateCourseInput) (domain.Course, error) { diff --git a/internal/services/examprep/service.go b/internal/services/examprep/service.go index 0ac6ed1..7ffe801 100644 --- a/internal/services/examprep/service.go +++ b/internal/services/examprep/service.go @@ -59,7 +59,7 @@ func (s *Service) GetCatalogCourseByID(ctx context.Context, id int64) (domain.Ex return c, nil } -func (s *Service) ListCatalogCourses(ctx context.Context, limit, offset int32) ([]domain.ExamPrepCatalogCourse, int64, error) { +func (s *Service) ListCatalogCourses(ctx context.Context, publishedOnly bool, limit, offset int32) ([]domain.ExamPrepCatalogCourse, int64, error) { if limit <= 0 { limit = 20 } @@ -69,7 +69,7 @@ func (s *Service) ListCatalogCourses(ctx context.Context, limit, offset int32) ( if offset < 0 { offset = 0 } - return s.store.ListExamPrepCatalogCourses(ctx, limit, offset) + return s.store.ListExamPrepCatalogCourses(ctx, publishedOnly, limit, offset) } func (s *Service) UpdateCatalogCourse(ctx context.Context, id int64, input domain.UpdateExamPrepCatalogCourseInput) (domain.ExamPrepCatalogCourse, error) { @@ -124,7 +124,7 @@ func (s *Service) CreateUnit(ctx context.Context, catalogCourseID int64, input d return s.store.CreateExamPrepUnit(ctx, catalogCourseID, input) } -func (s *Service) ListUnitsByCatalogCourse(ctx context.Context, catalogCourseID int64, limit, offset int32) ([]domain.ExamPrepUnit, int64, error) { +func (s *Service) ListUnitsByCatalogCourse(ctx context.Context, catalogCourseID int64, publishedOnly bool, limit, offset int32) ([]domain.ExamPrepUnit, int64, error) { if err := s.ensureCatalogCourse(ctx, catalogCourseID); err != nil { return nil, 0, err } @@ -137,7 +137,7 @@ func (s *Service) ListUnitsByCatalogCourse(ctx context.Context, catalogCourseID if offset < 0 { offset = 0 } - return s.store.ListExamPrepUnitsByCatalogCourse(ctx, catalogCourseID, limit, offset) + return s.store.ListExamPrepUnitsByCatalogCourse(ctx, catalogCourseID, publishedOnly, limit, offset) } func (s *Service) GetUnitByID(ctx context.Context, id int64) (domain.ExamPrepUnit, error) { @@ -206,7 +206,7 @@ func (s *Service) CreateModule(ctx context.Context, unitID int64, input domain.C return s.store.CreateExamPrepUnitModule(ctx, unitID, input) } -func (s *Service) ListModulesByUnit(ctx context.Context, unitID int64, limit, offset int32) ([]domain.ExamPrepModule, int64, error) { +func (s *Service) ListModulesByUnit(ctx context.Context, unitID int64, publishedOnly bool, limit, offset int32) ([]domain.ExamPrepModule, int64, error) { if err := s.ensureUnit(ctx, unitID); err != nil { return nil, 0, err } @@ -219,7 +219,7 @@ func (s *Service) ListModulesByUnit(ctx context.Context, unitID int64, limit, of if offset < 0 { offset = 0 } - return s.store.ListExamPrepUnitModulesByUnit(ctx, unitID, limit, offset) + return s.store.ListExamPrepUnitModulesByUnit(ctx, unitID, publishedOnly, limit, offset) } func (s *Service) GetModuleByID(ctx context.Context, id int64) (domain.ExamPrepModule, error) { @@ -288,7 +288,7 @@ func (s *Service) CreateLesson(ctx context.Context, unitModuleID int64, input do return s.store.CreateExamPrepUnitModuleLesson(ctx, unitModuleID, input) } -func (s *Service) ListLessonsByUnitModule(ctx context.Context, unitModuleID int64, limit, offset int32) ([]domain.ExamPrepLesson, int64, error) { +func (s *Service) ListLessonsByUnitModule(ctx context.Context, unitModuleID int64, publishedOnly bool, limit, offset int32) ([]domain.ExamPrepLesson, int64, error) { if err := s.ensureModule(ctx, unitModuleID); err != nil { return nil, 0, err } @@ -301,7 +301,7 @@ func (s *Service) ListLessonsByUnitModule(ctx context.Context, unitModuleID int6 if offset < 0 { offset = 0 } - return s.store.ListExamPrepUnitModuleLessonsByUnitModuleID(ctx, unitModuleID, limit, offset) + return s.store.ListExamPrepUnitModuleLessonsByUnitModuleID(ctx, unitModuleID, publishedOnly, limit, offset) } func (s *Service) GetLessonByID(ctx context.Context, id int64) (domain.ExamPrepLesson, error) { diff --git a/internal/services/lmsprogress/service.go b/internal/services/lmsprogress/service.go index 9c27b3a..7e19456 100644 --- a/internal/services/lmsprogress/service.go +++ b/internal/services/lmsprogress/service.go @@ -345,7 +345,7 @@ func (s *Service) lmsCourseProgress(ctx context.Context, userID, courseID int64) return 1, true, 1, 1, nil } - moduleIDs, err := s.store.ListModuleIDsByCourse(ctx, courseID) + moduleIDs, err := s.store.ListPublishedModuleIDsByCourse(ctx, courseID) if err != nil { return 0, false, 0, 0, err } @@ -373,7 +373,7 @@ func (s *Service) lmsCourseProgress(ctx context.Context, userID, courseID int64) } func (s *Service) lmsProgramProgress(ctx context.Context, userID, programID int64) (fraction float64, done bool, completed, total int32, err error) { - courseIDs, err := s.store.ListCourseIDsByProgram(ctx, programID) + courseIDs, err := s.store.ListPublishedCourseIDsByProgram(ctx, programID) if err != nil { return 0, false, 0, 0, err } @@ -428,7 +428,7 @@ func (s *Service) examPrepLessonProgress(ctx context.Context, userID, lessonID i } func (s *Service) examPrepModuleProgress(ctx context.Context, userID, moduleID int64) (fraction float64, done bool, completed, total int32, err error) { - lessonIDs, err := s.store.ListExamPrepUnitModuleLessonIDsByUnitModule(ctx, moduleID) + lessonIDs, err := s.store.ListPublishedExamPrepUnitModuleLessonIDsByUnitModule(ctx, moduleID) if err != nil { return 0, false, 0, 0, err } @@ -456,7 +456,7 @@ func (s *Service) examPrepModuleProgress(ctx context.Context, userID, moduleID i } func (s *Service) examPrepUnitProgress(ctx context.Context, userID, unitID int64) (fraction float64, done bool, completed, total int32, err error) { - moduleIDs, err := s.store.ListExamPrepUnitModuleIDsByUnit(ctx, unitID) + moduleIDs, err := s.store.ListPublishedExamPrepUnitModuleIDsByUnit(ctx, unitID) if err != nil { return 0, false, 0, 0, err } @@ -484,7 +484,7 @@ func (s *Service) examPrepUnitProgress(ctx context.Context, userID, unitID int64 } func (s *Service) examPrepCatalogCourseProgress(ctx context.Context, userID, catalogCourseID int64) (fraction float64, done bool, completed, total int32, err error) { - unitIDs, err := s.store.ListExamPrepUnitIDsByCatalogCourse(ctx, catalogCourseID) + unitIDs, err := s.store.ListPublishedExamPrepUnitIDsByCatalogCourse(ctx, catalogCourseID) if err != nil { return 0, false, 0, 0, err } diff --git a/internal/services/modules/service.go b/internal/services/modules/service.go index 3ef95a4..9fd6998 100644 --- a/internal/services/modules/service.go +++ b/internal/services/modules/service.go @@ -53,7 +53,7 @@ func (s *Service) GetByID(ctx context.Context, id int64) (domain.Module, error) } // ListByCourse loads the course and lists modules for its program_id and course_id. -func (s *Service) ListByCourse(ctx context.Context, courseID int64, limit, offset int32) ([]domain.Module, int64, error) { +func (s *Service) ListByCourse(ctx context.Context, courseID int64, publishedOnly bool, limit, offset int32) ([]domain.Module, int64, error) { c, err := s.getCourseOrErr(ctx, courseID) if err != nil { return nil, 0, err @@ -67,7 +67,7 @@ func (s *Service) ListByCourse(ctx context.Context, courseID int64, limit, offse if offset < 0 { offset = 0 } - return s.modules.ListModulesByProgramAndCourse(ctx, c.ProgramID, courseID, limit, offset) + return s.modules.ListModulesByProgramAndCourse(ctx, c.ProgramID, courseID, publishedOnly, limit, offset) } func (s *Service) Update(ctx context.Context, id int64, input domain.UpdateModuleInput) (domain.Module, error) { diff --git a/internal/services/programs/service.go b/internal/services/programs/service.go index e99920a..dfb7ac4 100644 --- a/internal/services/programs/service.go +++ b/internal/services/programs/service.go @@ -34,7 +34,7 @@ func (s *Service) GetByID(ctx context.Context, id int64) (domain.Program, error) return p, nil } -func (s *Service) List(ctx context.Context, limit, offset int32) ([]domain.Program, int64, error) { +func (s *Service) List(ctx context.Context, publishedOnly bool, limit, offset int32) ([]domain.Program, int64, error) { if limit <= 0 { limit = 20 } @@ -44,7 +44,7 @@ func (s *Service) List(ctx context.Context, limit, offset int32) ([]domain.Progr if offset < 0 { offset = 0 } - return s.store.ListPrograms(ctx, limit, offset) + return s.store.ListPrograms(ctx, publishedOnly, limit, offset) } func (s *Service) Update(ctx context.Context, id int64, input domain.UpdateProgramInput) (domain.Program, error) { diff --git a/internal/services/questions/service.go b/internal/services/questions/service.go index e5ec71c..d61e82e 100644 --- a/internal/services/questions/service.go +++ b/internal/services/questions/service.go @@ -44,6 +44,12 @@ func (s *Service) ListQuestionTypeDefinitions(ctx context.Context, status *strin return s.questionStore.ListQuestionTypeDefinitions(ctx, status, includeSystem, limit, offset) } +func (s *Service) ActiveQuestionTypeDefinitionCatalog(ctx context.Context) ([]domain.QuestionTypeDefinition, error) { + active := "ACTIVE" + catalog, _, err := s.questionStore.ListQuestionTypeDefinitions(ctx, &active, true, 0, 0) + return catalog, err +} + func (s *Service) UpdateQuestionTypeDefinition(ctx context.Context, id int64, input domain.UpdateQuestionTypeDefinitionInput) error { return s.questionStore.UpdateQuestionTypeDefinition(ctx, id, input) } diff --git a/internal/web_server/handlers/content_publish_gate.go b/internal/web_server/handlers/content_publish_gate.go new file mode 100644 index 0000000..d4a8415 --- /dev/null +++ b/internal/web_server/handlers/content_publish_gate.go @@ -0,0 +1,45 @@ +package handlers + +import ( + "Yimaru-Backend/internal/domain" + + "github.com/gofiber/fiber/v2" +) + +// Manage helpers for hierarchy publish gating: staff who can create or update an entity see +// drafts; everyone else only sees PUBLISHED rows (mirrors canManageLessons / practices). + +func (h *Handler) canManagePrograms(c *fiber.Ctx) bool { + rn := string(c.Locals("role").(domain.Role)) + return h.rbacSvc.HasPermission(rn, "programs.create") || h.rbacSvc.HasPermission(rn, "programs.update") +} + +func (h *Handler) canManageLMSCourses(c *fiber.Ctx) bool { + rn := string(c.Locals("role").(domain.Role)) + return h.rbacSvc.HasPermission(rn, "courses.create") || h.rbacSvc.HasPermission(rn, "courses.update") +} + +func (h *Handler) canManageLMSModules(c *fiber.Ctx) bool { + rn := string(c.Locals("role").(domain.Role)) + return h.rbacSvc.HasPermission(rn, "modules.create") || h.rbacSvc.HasPermission(rn, "modules.update") +} + +func (h *Handler) canManageExamPrepCatalogCourses(c *fiber.Ctx) bool { + rn := string(c.Locals("role").(domain.Role)) + return h.rbacSvc.HasPermission(rn, "exam_prep.catalog_courses.create") || h.rbacSvc.HasPermission(rn, "exam_prep.catalog_courses.update") +} + +func (h *Handler) canManageExamPrepUnits(c *fiber.Ctx) bool { + rn := string(c.Locals("role").(domain.Role)) + return h.rbacSvc.HasPermission(rn, "exam_prep.units.create") || h.rbacSvc.HasPermission(rn, "exam_prep.units.update") +} + +func (h *Handler) canManageExamPrepModules(c *fiber.Ctx) bool { + rn := string(c.Locals("role").(domain.Role)) + return h.rbacSvc.HasPermission(rn, "exam_prep.modules.create") || h.rbacSvc.HasPermission(rn, "exam_prep.modules.update") +} + +func (h *Handler) canManageExamPrepLessons(c *fiber.Ctx) bool { + rn := string(c.Locals("role").(domain.Role)) + return h.rbacSvc.HasPermission(rn, "exam_prep.lessons.create") || h.rbacSvc.HasPermission(rn, "exam_prep.lessons.update") +} diff --git a/internal/web_server/handlers/course_handler.go b/internal/web_server/handlers/course_handler.go index 1d9f8c9..0c450c6 100644 --- a/internal/web_server/handlers/course_handler.go +++ b/internal/web_server/handlers/course_handler.go @@ -92,7 +92,18 @@ func (h *Handler) ListCoursesByProgram(c *fiber.Ctx) error { } limit, _ := strconv.Atoi(c.Query("limit", "20")) offset, _ := strconv.Atoi(c.Query("offset", "0")) - items, total, err := h.courseSvc.ListByProgram(c.Context(), programID, int32(limit), int32(offset)) + publishedOnly := !h.canManageLMSCourses(c) + if publishedOnly { + // Draft programs hide their courses from non-managers. + p, err := h.programSvc.GetByID(c.Context(), programID) + if err == nil && !p.VisibleToLearners() { + return c.Status(fiber.StatusNotFound).JSON(domain.ErrorResponse{ + Message: "Program not found", + Error: programs.ErrProgramNotFound.Error(), + }) + } + } + items, total, err := h.courseSvc.ListByProgram(c.Context(), programID, publishedOnly, int32(limit), int32(offset)) if err != nil { if errors.Is(err, programs.ErrProgramNotFound) { return c.Status(fiber.StatusNotFound).JSON(domain.ErrorResponse{ @@ -157,6 +168,12 @@ func (h *Handler) GetCourse(c *fiber.Ctx) error { Error: err.Error(), }) } + if !course.VisibleToLearners() && !h.canManageLMSCourses(c) { + return c.Status(fiber.StatusNotFound).JSON(domain.ErrorResponse{ + Message: "Course not found", + Error: courses.ErrCourseNotFound.Error(), + }) + } uid := c.Locals("user_id").(int64) role := c.Locals("role").(domain.Role) if err := h.lmsProgressSvc.ApplyAccessCourse(c.Context(), role, uid, &course); err != nil { diff --git a/internal/web_server/handlers/exam_prep_catalog_course_handler.go b/internal/web_server/handlers/exam_prep_catalog_course_handler.go index 93b9c54..d970d45 100644 --- a/internal/web_server/handlers/exam_prep_catalog_course_handler.go +++ b/internal/web_server/handlers/exam_prep_catalog_course_handler.go @@ -59,6 +59,7 @@ func (h *Handler) CreateExamPrepCatalogCourse(c *fiber.Ctx) error { func (h *Handler) ListExamPrepCatalogCourses(c *fiber.Ctx) error { limit, _ := strconv.Atoi(c.Query("limit", "20")) offset, _ := strconv.Atoi(c.Query("offset", "0")) + publishedOnly := !h.canManageExamPrepCatalogCourses(c) role, _ := c.Locals("role").(domain.Role) if role.IsCustomerLearnerRole() && !domain.CategorySubscriptionGateDisabled { @@ -84,7 +85,7 @@ func (h *Handler) ListExamPrepCatalogCourses(c *fiber.Ctx) error { }) } - allItems, _, err := h.examPrepSvc.ListCatalogCourses(c.Context(), 200, 0) + allItems, _, err := h.examPrepSvc.ListCatalogCourses(c.Context(), publishedOnly, 200, 0) if err != nil { return c.Status(fiber.StatusInternalServerError).JSON(domain.ErrorResponse{ Message: "Failed to list catalog courses", @@ -137,7 +138,7 @@ func (h *Handler) ListExamPrepCatalogCourses(c *fiber.Ctx) error { }) } - items, total, err := h.examPrepSvc.ListCatalogCourses(c.Context(), int32(limit), int32(offset)) + items, total, err := h.examPrepSvc.ListCatalogCourses(c.Context(), publishedOnly, int32(limit), int32(offset)) if err != nil { return c.Status(fiber.StatusInternalServerError).JSON(domain.ErrorResponse{ Message: "Failed to list catalog courses", @@ -232,6 +233,12 @@ func (h *Handler) GetExamPrepCatalogCourseByID(c *fiber.Ctx) error { Error: err.Error(), }) } + if !out.VisibleToLearners() && !h.canManageExamPrepCatalogCourses(c) { + return c.Status(fiber.StatusNotFound).JSON(domain.ErrorResponse{ + Message: "Catalog course not found", + Error: examprep.ErrCatalogCourseNotFound.Error(), + }) + } if err := h.applyExamPrepAccessCatalogCourse(c.Context(), c, &out); err != nil { return c.Status(fiber.StatusInternalServerError).JSON(domain.ErrorResponse{ Message: "Failed to build catalog course", diff --git a/internal/web_server/handlers/exam_prep_lesson_handler.go b/internal/web_server/handlers/exam_prep_lesson_handler.go index 41540d1..141dfe4 100644 --- a/internal/web_server/handlers/exam_prep_lesson_handler.go +++ b/internal/web_server/handlers/exam_prep_lesson_handler.go @@ -74,7 +74,18 @@ func (h *Handler) ListExamPrepLessonsByUnitModule(c *fiber.Ctx) error { } limit, _ := strconv.Atoi(c.Query("limit", "20")) offset, _ := strconv.Atoi(c.Query("offset", "0")) - items, total, err := h.examPrepSvc.ListLessonsByUnitModule(c.Context(), moduleID, int32(limit), int32(offset)) + publishedOnly := !h.canManageExamPrepLessons(c) + if publishedOnly { + // Draft modules hide their lessons from non-managers. + m, err := h.examPrepSvc.GetModuleByID(c.Context(), moduleID) + if err == nil && !m.VisibleToLearners() { + return c.Status(fiber.StatusNotFound).JSON(domain.ErrorResponse{ + Message: "Module not found", + Error: examprep.ErrModuleNotFound.Error(), + }) + } + } + items, total, err := h.examPrepSvc.ListLessonsByUnitModule(c.Context(), moduleID, publishedOnly, int32(limit), int32(offset)) if err != nil { if errors.Is(err, examprep.ErrModuleNotFound) { return c.Status(fiber.StatusNotFound).JSON(domain.ErrorResponse{ @@ -181,6 +192,12 @@ func (h *Handler) GetExamPrepLessonByID(c *fiber.Ctx) error { Error: err.Error(), }) } + if !les.VisibleToLearners() && !h.canManageExamPrepLessons(c) { + return c.Status(fiber.StatusNotFound).JSON(domain.ErrorResponse{ + Message: "Lesson not found", + Error: examprep.ErrLessonNotFound.Error(), + }) + } if err := h.applyExamPrepAccessLesson(c.Context(), c, &les); err != nil { return c.Status(fiber.StatusInternalServerError).JSON(domain.ErrorResponse{ Message: "Failed to build lesson", diff --git a/internal/web_server/handlers/exam_prep_module_handler.go b/internal/web_server/handlers/exam_prep_module_handler.go index cb49a4e..f82583c 100644 --- a/internal/web_server/handlers/exam_prep_module_handler.go +++ b/internal/web_server/handlers/exam_prep_module_handler.go @@ -72,7 +72,18 @@ func (h *Handler) ListExamPrepModulesByUnit(c *fiber.Ctx) error { } limit, _ := strconv.Atoi(c.Query("limit", "20")) offset, _ := strconv.Atoi(c.Query("offset", "0")) - items, total, err := h.examPrepSvc.ListModulesByUnit(c.Context(), unitID, int32(limit), int32(offset)) + publishedOnly := !h.canManageExamPrepModules(c) + if publishedOnly { + // Draft units hide their modules from non-managers. + u, err := h.examPrepSvc.GetUnitByID(c.Context(), unitID) + if err == nil && !u.VisibleToLearners() { + return c.Status(fiber.StatusNotFound).JSON(domain.ErrorResponse{ + Message: "Unit not found", + Error: examprep.ErrUnitNotFound.Error(), + }) + } + } + items, total, err := h.examPrepSvc.ListModulesByUnit(c.Context(), unitID, publishedOnly, int32(limit), int32(offset)) if err != nil { if errors.Is(err, examprep.ErrUnitNotFound) { return c.Status(fiber.StatusNotFound).JSON(domain.ErrorResponse{ @@ -181,6 +192,12 @@ func (h *Handler) GetExamPrepModuleByID(c *fiber.Ctx) error { Error: err.Error(), }) } + if !out.VisibleToLearners() && !h.canManageExamPrepModules(c) { + return c.Status(fiber.StatusNotFound).JSON(domain.ErrorResponse{ + Message: "Module not found", + Error: examprep.ErrModuleNotFound.Error(), + }) + } if err := h.applyExamPrepAccessModule(c.Context(), c, &out); err != nil { return c.Status(fiber.StatusInternalServerError).JSON(domain.ErrorResponse{ Message: "Failed to build module", diff --git a/internal/web_server/handlers/exam_prep_unit_handler.go b/internal/web_server/handlers/exam_prep_unit_handler.go index 053ef70..a6590ff 100644 --- a/internal/web_server/handlers/exam_prep_unit_handler.go +++ b/internal/web_server/handlers/exam_prep_unit_handler.go @@ -79,7 +79,18 @@ func (h *Handler) ListExamPrepUnitsByCatalogCourse(c *fiber.Ctx) error { } limit, _ := strconv.Atoi(c.Query("limit", "20")) offset, _ := strconv.Atoi(c.Query("offset", "0")) - items, total, err := h.examPrepSvc.ListUnitsByCatalogCourse(c.Context(), catalogCourseID, int32(limit), int32(offset)) + publishedOnly := !h.canManageExamPrepUnits(c) + if publishedOnly { + // Draft catalog courses hide their units from non-managers. + cc, err := h.examPrepSvc.GetCatalogCourseByID(c.Context(), catalogCourseID) + if err == nil && !cc.VisibleToLearners() { + return c.Status(fiber.StatusNotFound).JSON(domain.ErrorResponse{ + Message: "Catalog course not found", + Error: examprep.ErrCatalogCourseNotFound.Error(), + }) + } + } + items, total, err := h.examPrepSvc.ListUnitsByCatalogCourse(c.Context(), catalogCourseID, publishedOnly, int32(limit), int32(offset)) if err != nil { if errors.Is(err, examprep.ErrCatalogCourseNotFound) { return c.Status(fiber.StatusNotFound).JSON(domain.ErrorResponse{ @@ -189,6 +200,12 @@ func (h *Handler) GetExamPrepUnitByID(c *fiber.Ctx) error { Error: err.Error(), }) } + if !out.VisibleToLearners() && !h.canManageExamPrepUnits(c) { + return c.Status(fiber.StatusNotFound).JSON(domain.ErrorResponse{ + Message: "Unit not found", + Error: examprep.ErrUnitNotFound.Error(), + }) + } if err := h.applyExamPrepAccessUnit(c.Context(), c, &out); err != nil { return c.Status(fiber.StatusInternalServerError).JSON(domain.ErrorResponse{ Message: "Failed to build unit", diff --git a/internal/web_server/handlers/lms_progress_handler.go b/internal/web_server/handlers/lms_progress_handler.go index 904dd62..ff77abf 100644 --- a/internal/web_server/handlers/lms_progress_handler.go +++ b/internal/web_server/handlers/lms_progress_handler.go @@ -171,7 +171,7 @@ func (h *Handler) AdminGetUserRecentActivity(c *fiber.Ctx) error { } func (h *Handler) buildLMSProgressSummary(ctx context.Context, role domain.Role, userID int64, publishedOnly bool) (domain.LMSProgressSummary, error) { - programs, err := h.listAllPrograms(ctx) + programs, err := h.listAllPrograms(ctx, publishedOnly) if err != nil { return domain.LMSProgressSummary{}, err } @@ -183,7 +183,7 @@ func (h *Handler) buildLMSProgressSummary(ctx context.Context, role domain.Role, if err := h.lmsProgressSvc.ApplyAccessProgram(ctx, role, userID, &programs[i]); err != nil { return domain.LMSProgressSummary{}, err } - courses, err := h.listAllCoursesByProgram(ctx, programs[i].ID) + courses, err := h.listAllCoursesByProgram(ctx, programs[i].ID, publishedOnly) if err != nil { return domain.LMSProgressSummary{}, err } @@ -198,7 +198,7 @@ func (h *Handler) buildLMSProgressSummary(ctx context.Context, role domain.Role, if err := h.lmsProgressSvc.ApplyAccessCourse(ctx, role, userID, &courses[j]); err != nil { return domain.LMSProgressSummary{}, err } - modules, err := h.listAllModulesByCourse(ctx, courses[j].ID) + modules, err := h.listAllModulesByCourse(ctx, courses[j].ID, publishedOnly) if err != nil { return domain.LMSProgressSummary{}, err } @@ -251,13 +251,13 @@ func (h *Handler) buildLMSProgressSummary(ctx context.Context, role domain.Role, return summary, nil } -func (h *Handler) listAllPrograms(ctx context.Context) ([]domain.Program, error) { +func (h *Handler) listAllPrograms(ctx context.Context, publishedOnly bool) ([]domain.Program, error) { var ( all []domain.Program offset int32 ) for { - items, total, err := h.programSvc.List(ctx, lmsProgressSummaryPageSize, offset) + items, total, err := h.programSvc.List(ctx, publishedOnly, lmsProgressSummaryPageSize, offset) if err != nil { return nil, err } @@ -272,13 +272,13 @@ func (h *Handler) listAllPrograms(ctx context.Context) ([]domain.Program, error) } } -func (h *Handler) listAllCoursesByProgram(ctx context.Context, programID int64) ([]domain.Course, error) { +func (h *Handler) listAllCoursesByProgram(ctx context.Context, programID int64, publishedOnly bool) ([]domain.Course, error) { var ( all []domain.Course offset int32 ) for { - items, total, err := h.courseSvc.ListByProgram(ctx, programID, lmsProgressSummaryPageSize, offset) + items, total, err := h.courseSvc.ListByProgram(ctx, programID, publishedOnly, lmsProgressSummaryPageSize, offset) if err != nil { return nil, err } @@ -293,13 +293,13 @@ func (h *Handler) listAllCoursesByProgram(ctx context.Context, programID int64) } } -func (h *Handler) listAllModulesByCourse(ctx context.Context, courseID int64) ([]domain.Module, error) { +func (h *Handler) listAllModulesByCourse(ctx context.Context, courseID int64, publishedOnly bool) ([]domain.Module, error) { var ( all []domain.Module offset int32 ) for { - items, total, err := h.moduleSvc.ListByCourse(ctx, courseID, lmsProgressSummaryPageSize, offset) + items, total, err := h.moduleSvc.ListByCourse(ctx, courseID, publishedOnly, lmsProgressSummaryPageSize, offset) if err != nil { return nil, err } diff --git a/internal/web_server/handlers/module_handler.go b/internal/web_server/handlers/module_handler.go index 06c8228..b84569e 100644 --- a/internal/web_server/handlers/module_handler.go +++ b/internal/web_server/handlers/module_handler.go @@ -90,7 +90,18 @@ func (h *Handler) ListModulesByCourse(c *fiber.Ctx) error { } limit, _ := strconv.Atoi(c.Query("limit", "20")) offset, _ := strconv.Atoi(c.Query("offset", "0")) - items, total, err := h.moduleSvc.ListByCourse(c.Context(), courseID, int32(limit), int32(offset)) + publishedOnly := !h.canManageLMSModules(c) + if publishedOnly { + // Draft courses hide their modules from non-managers. + course, err := h.courseSvc.GetByID(c.Context(), courseID) + if err == nil && !course.VisibleToLearners() { + return c.Status(fiber.StatusNotFound).JSON(domain.ErrorResponse{ + Message: "Course not found", + Error: courses.ErrCourseNotFound.Error(), + }) + } + } + items, total, err := h.moduleSvc.ListByCourse(c.Context(), courseID, publishedOnly, int32(limit), int32(offset)) if err != nil { if errors.Is(err, courses.ErrCourseNotFound) { return c.Status(fiber.StatusNotFound).JSON(domain.ErrorResponse{ @@ -152,6 +163,12 @@ func (h *Handler) GetModule(c *fiber.Ctx) error { Error: err.Error(), }) } + if !mod.VisibleToLearners() && !h.canManageLMSModules(c) { + return c.Status(fiber.StatusNotFound).JSON(domain.ErrorResponse{ + Message: "Module not found", + Error: modules.ErrModuleNotFound.Error(), + }) + } uid := c.Locals("user_id").(int64) role := c.Locals("role").(domain.Role) if err := h.lmsProgressSvc.ApplyAccessModule(c.Context(), role, uid, &mod); err != nil { diff --git a/internal/web_server/handlers/practice_full_update_handler.go b/internal/web_server/handlers/practice_full_update_handler.go index 0416a7f..1a85830 100644 --- a/internal/web_server/handlers/practice_full_update_handler.go +++ b/internal/web_server/handlers/practice_full_update_handler.go @@ -9,61 +9,19 @@ import ( "github.com/gofiber/fiber/v2" ) -func fullUpdatePracticeQuestionResponses(questions []domain.QuestionWithDetails) []questionRes { +func fullUpdatePracticeQuestionResponses(questions []domain.QuestionWithDetails, catalog []domain.QuestionTypeDefinition) []questionRes { out := make([]questionRes, 0, len(questions)) for _, question := range questions { - options := make([]optionRes, 0, len(question.Options)) - for _, opt := range question.Options { - options = append(options, optionRes{ - ID: opt.ID, - OptionText: opt.OptionText, - OptionOrder: opt.OptionOrder, - IsCorrect: opt.IsCorrect, - }) - } - - shortAnswers := make([]shortAnswerRes, 0, len(question.ShortAnswers)) - for _, sa := range question.ShortAnswers { - shortAnswers = append(shortAnswers, shortAnswerRes{ - ID: sa.ID, - AcceptableAnswer: sa.AcceptableAnswer, - MatchType: sa.MatchType, - }) - } - - var audioCorrectAnswerText *string - if question.AudioAnswer != nil { - audioCorrectAnswerText = &question.AudioAnswer.CorrectAnswerText - } - - out = append(out, questionRes{ - ID: question.ID, - QuestionText: questionTextField(question.QuestionType, question.QuestionText), - QuestionType: question.QuestionType, - QuestionTypeDefinitionID: question.QuestionTypeDefinitionID, - DynamicPayload: question.DynamicPayload, - DifficultyLevel: question.DifficultyLevel, - Points: question.Points, - Explanation: question.Explanation, - Tips: question.Tips, - VoicePrompt: question.VoicePrompt, - SampleAnswerVoicePrompt: question.SampleAnswerVoicePrompt, - ImageURL: question.ImageURL, - Status: question.Status, - CreatedAt: question.CreatedAt.String(), - Options: options, - ShortAnswers: shortAnswers, - AudioCorrectAnswerText: audioCorrectAnswerText, - }) + out = append(out, buildQuestionRes(question, catalog)) } return out } -func fullUpdatePracticeResponse(result domain.FullUpdatePracticeResult) fiber.Map { +func fullUpdatePracticeResponse(result domain.FullUpdatePracticeResult, catalog []domain.QuestionTypeDefinition) fiber.Map { return fiber.Map{ "practice": result.Practice, "question_set": questionSetResFromDomain(result.QuestionSet), - "questions": fullUpdatePracticeQuestionResponses(result.Questions), + "questions": fullUpdatePracticeQuestionResponses(result.Questions, catalog), } } @@ -141,9 +99,17 @@ func (h *Handler) UpdateLmsPracticeFull(c *fiber.Ctx) error { }) } + catalog, err := h.activeQuestionTypeDefinitionCatalog(c.Context()) + if err != nil { + return c.Status(fiber.StatusInternalServerError).JSON(domain.ErrorResponse{ + Message: "Failed to load question type catalog", + Error: err.Error(), + }) + } + return c.JSON(domain.Response{ Message: "Practice updated successfully", - Data: fullUpdatePracticeResponse(result), + Data: fullUpdatePracticeResponse(result, catalog), Success: true, StatusCode: fiber.StatusOK, }) @@ -187,9 +153,17 @@ func (h *Handler) UpdateExamPrepPracticeFull(c *fiber.Ctx) error { }) } + catalog, err := h.activeQuestionTypeDefinitionCatalog(c.Context()) + if err != nil { + return c.Status(fiber.StatusInternalServerError).JSON(domain.ErrorResponse{ + Message: "Failed to load question type catalog", + Error: err.Error(), + }) + } + return c.JSON(domain.Response{ Message: "Practice updated successfully", - Data: fullUpdatePracticeResponse(result), + Data: fullUpdatePracticeResponse(result, catalog), Success: true, StatusCode: fiber.StatusOK, }) diff --git a/internal/web_server/handlers/program_handler.go b/internal/web_server/handlers/program_handler.go index cd34ae4..f78a6a4 100644 --- a/internal/web_server/handlers/program_handler.go +++ b/internal/web_server/handlers/program_handler.go @@ -69,7 +69,8 @@ func (h *Handler) CreateProgram(c *fiber.Ctx) error { func (h *Handler) ListPrograms(c *fiber.Ctx) error { limit, _ := strconv.Atoi(c.Query("limit", "20")) offset, _ := strconv.Atoi(c.Query("offset", "0")) - items, total, err := h.programSvc.List(c.Context(), int32(limit), int32(offset)) + publishedOnly := !h.canManagePrograms(c) + items, total, err := h.programSvc.List(c.Context(), publishedOnly, int32(limit), int32(offset)) if err != nil { return c.Status(fiber.StatusInternalServerError).JSON(domain.ErrorResponse{ Message: "Failed to list programs", @@ -128,6 +129,12 @@ func (h *Handler) GetProgram(c *fiber.Ctx) error { Error: err.Error(), }) } + if !p.VisibleToLearners() && !h.canManagePrograms(c) { + return c.Status(fiber.StatusNotFound).JSON(domain.ErrorResponse{ + Message: "Program not found", + Error: programs.ErrProgramNotFound.Error(), + }) + } uid := c.Locals("user_id").(int64) role := c.Locals("role").(domain.Role) if err := h.lmsProgressSvc.ApplyAccessProgram(c.Context(), role, uid, &p); err != nil { diff --git a/internal/web_server/handlers/questions.go b/internal/web_server/handlers/questions.go index 593a9b5..257f280 100644 --- a/internal/web_server/handlers/questions.go +++ b/internal/web_server/handlers/questions.go @@ -81,6 +81,65 @@ type listQuestionsRes struct { TotalCount int64 `json:"total_count"` } +func (h *Handler) activeQuestionTypeDefinitionCatalog(ctx context.Context) ([]domain.QuestionTypeDefinition, error) { + return h.questionsSvc.ActiveQuestionTypeDefinitionCatalog(ctx) +} + +func buildQuestionRes(q domain.QuestionWithDetails, catalog []domain.QuestionTypeDefinition) questionRes { + options := make([]optionRes, 0, len(q.Options)) + for _, opt := range q.Options { + options = append(options, optionRes{ + ID: opt.ID, + OptionText: opt.OptionText, + OptionOrder: opt.OptionOrder, + IsCorrect: opt.IsCorrect, + }) + } + + shortAnswers := make([]shortAnswerRes, 0, len(q.ShortAnswers)) + for _, sa := range q.ShortAnswers { + shortAnswers = append(shortAnswers, shortAnswerRes{ + ID: sa.ID, + AcceptableAnswer: sa.AcceptableAnswer, + MatchType: sa.MatchType, + }) + } + + var audioCorrectAnswerText *string + if q.AudioAnswer != nil { + audioCorrectAnswerText = &q.AudioAnswer.CorrectAnswerText + } + + return questionRes{ + ID: q.ID, + QuestionText: questionTextField(q.QuestionType, q.QuestionText), + QuestionType: q.QuestionType, + QuestionTypeDefinitionID: domain.ResolveEffectiveQuestionTypeDefinitionID( + q.QuestionTypeDefinitionID, + q.QuestionType, + catalog, + q.DynamicPayload, + ), + DynamicPayload: q.DynamicPayload, + DifficultyLevel: q.DifficultyLevel, + Points: q.Points, + Explanation: q.Explanation, + Tips: q.Tips, + VoicePrompt: q.VoicePrompt, + SampleAnswerVoicePrompt: q.SampleAnswerVoicePrompt, + ImageURL: q.ImageURL, + Status: q.Status, + CreatedAt: q.CreatedAt.String(), + Options: options, + ShortAnswers: shortAnswers, + AudioCorrectAnswerText: audioCorrectAnswerText, + } +} + +func buildQuestionResFromQuestion(q domain.Question, catalog []domain.QuestionTypeDefinition) questionRes { + return buildQuestionRes(domain.QuestionWithDetails{Question: q}, catalog) +} + func resolveQuestionTypeFromDefinition(def domain.QuestionTypeDefinition) string { return domain.ResolveRuntimeQuestionTypeFromDefinition(def.Key, def.ResponseComponentKinds) } @@ -264,24 +323,17 @@ func (h *Handler) CreateQuestion(c *fiber.Ctx) error { }) go h.activityLogSvc.RecordAction(context.Background(), &actorID, &actorRole, domain.ActionQuestionCreated, domain.ResourceQuestion, &question.ID, "Created question: "+activityLogQuestionSummary(question.QuestionType, question.QuestionText, question.DynamicPayload), meta, &ip, &ua) + catalog, err := h.activeQuestionTypeDefinitionCatalog(c.Context()) + if err != nil { + return c.Status(fiber.StatusInternalServerError).JSON(domain.ErrorResponse{ + Message: "Failed to load question type catalog", + Error: err.Error(), + }) + } + return c.Status(fiber.StatusCreated).JSON(domain.Response{ Message: "Question created successfully", - Data: questionRes{ - ID: question.ID, - QuestionText: questionTextField(question.QuestionType, question.QuestionText), - QuestionType: question.QuestionType, - QuestionTypeDefinitionID: question.QuestionTypeDefinitionID, - DynamicPayload: question.DynamicPayload, - DifficultyLevel: question.DifficultyLevel, - Points: question.Points, - Explanation: question.Explanation, - Tips: question.Tips, - VoicePrompt: question.VoicePrompt, - SampleAnswerVoicePrompt: question.SampleAnswerVoicePrompt, - ImageURL: question.ImageURL, - Status: question.Status, - CreatedAt: question.CreatedAt.String(), - }, + Data: buildQuestionResFromQuestion(question, catalog), }) } @@ -313,51 +365,17 @@ func (h *Handler) GetQuestionByID(c *fiber.Ctx) error { }) } - var options []optionRes - for _, opt := range question.Options { - options = append(options, optionRes{ - ID: opt.ID, - OptionText: opt.OptionText, - OptionOrder: opt.OptionOrder, - IsCorrect: opt.IsCorrect, + catalog, err := h.activeQuestionTypeDefinitionCatalog(c.Context()) + if err != nil { + return c.Status(fiber.StatusInternalServerError).JSON(domain.ErrorResponse{ + Message: "Failed to load question type catalog", + Error: err.Error(), }) } - var shortAnswers []shortAnswerRes - for _, sa := range question.ShortAnswers { - shortAnswers = append(shortAnswers, shortAnswerRes{ - ID: sa.ID, - AcceptableAnswer: sa.AcceptableAnswer, - MatchType: sa.MatchType, - }) - } - - var audioCorrectAnswerText *string - if question.AudioAnswer != nil { - audioCorrectAnswerText = &question.AudioAnswer.CorrectAnswerText - } - return c.JSON(domain.Response{ Message: "Question retrieved successfully", - Data: questionRes{ - ID: question.ID, - QuestionText: questionTextField(question.QuestionType, question.QuestionText), - QuestionType: question.QuestionType, - QuestionTypeDefinitionID: question.QuestionTypeDefinitionID, - DynamicPayload: question.DynamicPayload, - DifficultyLevel: question.DifficultyLevel, - Points: question.Points, - Explanation: question.Explanation, - Tips: question.Tips, - VoicePrompt: question.VoicePrompt, - SampleAnswerVoicePrompt: question.SampleAnswerVoicePrompt, - ImageURL: question.ImageURL, - Status: question.Status, - CreatedAt: question.CreatedAt.String(), - Options: options, - ShortAnswers: shortAnswers, - AudioCorrectAnswerText: audioCorrectAnswerText, - }, + Data: buildQuestionRes(question, catalog), }) } @@ -404,24 +422,19 @@ func (h *Handler) ListQuestions(c *fiber.Ctx) error { }) } - var questionResponses []questionRes - for _, q := range questions { - questionResponses = append(questionResponses, questionRes{ - ID: q.ID, - QuestionText: questionTextField(q.QuestionType, q.QuestionText), - QuestionType: q.QuestionType, - QuestionTypeDefinitionID: q.QuestionTypeDefinitionID, - DynamicPayload: q.DynamicPayload, - DifficultyLevel: q.DifficultyLevel, - Points: q.Points, - Explanation: q.Explanation, - Tips: q.Tips, - VoicePrompt: q.VoicePrompt, - Status: q.Status, - CreatedAt: q.CreatedAt.String(), + catalog, err := h.activeQuestionTypeDefinitionCatalog(c.Context()) + if err != nil { + return c.Status(fiber.StatusInternalServerError).JSON(domain.ErrorResponse{ + Message: "Failed to load question type catalog", + Error: err.Error(), }) } + questionResponses := make([]questionRes, 0, len(questions)) + for _, q := range questions { + questionResponses = append(questionResponses, buildQuestionResFromQuestion(q, catalog)) + } + return c.JSON(domain.Response{ Message: "Questions retrieved successfully", Data: listQuestionsRes{ @@ -464,21 +477,19 @@ func (h *Handler) SearchQuestions(c *fiber.Ctx) error { }) } - var questionResponses []questionRes - for _, q := range questions { - questionResponses = append(questionResponses, questionRes{ - ID: q.ID, - QuestionText: questionTextField(q.QuestionType, q.QuestionText), - QuestionType: q.QuestionType, - QuestionTypeDefinitionID: q.QuestionTypeDefinitionID, - DynamicPayload: q.DynamicPayload, - DifficultyLevel: q.DifficultyLevel, - Points: q.Points, - Status: q.Status, - CreatedAt: q.CreatedAt.String(), + catalog, err := h.activeQuestionTypeDefinitionCatalog(c.Context()) + if err != nil { + return c.Status(fiber.StatusInternalServerError).JSON(domain.ErrorResponse{ + Message: "Failed to load question type catalog", + Error: err.Error(), }) } + questionResponses := make([]questionRes, 0, len(questions)) + for _, q := range questions { + questionResponses = append(questionResponses, buildQuestionResFromQuestion(q, catalog)) + } + return c.JSON(domain.Response{ Message: "Questions retrieved successfully", Data: listQuestionsRes{ @@ -1458,6 +1469,14 @@ func (h *Handler) GetQuestionsInSet(c *fiber.Ctx) error { }) } + catalog, err := h.activeQuestionTypeDefinitionCatalog(c.Context()) + if err != nil { + return c.Status(fiber.StatusInternalServerError).JSON(domain.ErrorResponse{ + Message: "Failed to load question type catalog", + Error: err.Error(), + }) + } + questionResponses := make([]questionRes, 0, len(items)) for _, item := range items { question, err := h.questionsSvc.GetQuestionWithDetails(c.Context(), item.QuestionID) @@ -1468,49 +1487,7 @@ func (h *Handler) GetQuestionsInSet(c *fiber.Ctx) error { }) } - options := make([]optionRes, 0, len(question.Options)) - for _, opt := range question.Options { - options = append(options, optionRes{ - ID: opt.ID, - OptionText: opt.OptionText, - OptionOrder: opt.OptionOrder, - IsCorrect: opt.IsCorrect, - }) - } - - shortAnswers := make([]shortAnswerRes, 0, len(question.ShortAnswers)) - for _, sa := range question.ShortAnswers { - shortAnswers = append(shortAnswers, shortAnswerRes{ - ID: sa.ID, - AcceptableAnswer: sa.AcceptableAnswer, - MatchType: sa.MatchType, - }) - } - - var audioCorrectAnswerText *string - if question.AudioAnswer != nil { - audioCorrectAnswerText = &question.AudioAnswer.CorrectAnswerText - } - - questionResponses = append(questionResponses, questionRes{ - ID: question.ID, - QuestionText: questionTextField(question.QuestionType, question.QuestionText), - QuestionType: question.QuestionType, - QuestionTypeDefinitionID: question.QuestionTypeDefinitionID, - DynamicPayload: question.DynamicPayload, - DifficultyLevel: question.DifficultyLevel, - Points: question.Points, - Explanation: question.Explanation, - Tips: question.Tips, - VoicePrompt: question.VoicePrompt, - SampleAnswerVoicePrompt: question.SampleAnswerVoicePrompt, - ImageURL: question.ImageURL, - Status: question.Status, - CreatedAt: question.CreatedAt.String(), - Options: options, - ShortAnswers: shortAnswers, - AudioCorrectAnswerText: audioCorrectAnswerText, - }) + questionResponses = append(questionResponses, buildQuestionRes(question, catalog)) } return c.JSON(domain.Response{