From 12ad59c4094850a2efb3b11944b934d15791b56d Mon Sep 17 00:00:00 2001 From: Yared Yemane Date: Tue, 19 May 2026 03:57:43 -0700 Subject: [PATCH] Add draft vs published status for LMS and exam-prep practices. Expose publish_status on create/update, filter learner-facing lists and gates, and add migration 000060. Co-authored-by: Cursor --- .../000060_practice_publish_status.down.sql | 5 + .../000060_practice_publish_status.up.sql | 8 ++ db/query/exam_prep_lesson_practices.sql | 18 ++- db/query/lms_admin_activity.sql | 3 + db/query/lms_courses.sql | 5 +- db/query/lms_lessons.sql | 2 + db/query/lms_modules.sql | 2 + db/query/lms_practices.sql | 27 ++++- db/query/lms_progress.sql | 15 ++- db/query/user_recent_activity.sql | 3 + docs/PRACTICE_CREATION_API_GUIDE.md | 5 +- gen/db/exam_prep_lesson_practices.sql.go | 63 ++++++++-- gen/db/lms_admin_activity.sql.go | 3 + gen/db/lms_courses.sql.go | 5 +- gen/db/lms_lessons.sql.go | 2 + gen/db/lms_modules.sql.go | 2 + gen/db/lms_practices.sql.go | 112 +++++++++++++++--- gen/db/lms_progress.sql.go | 5 + gen/db/models.go | 2 + gen/db/user_recent_activity.sql.go | 3 + internal/domain/exam_prep_practice.go | 28 +++-- internal/domain/practice.go | 61 ++++++++-- internal/ports/exam_prep_practice.go | 3 +- internal/ports/lms_practice.go | 8 +- .../repository/exam_prep_lesson_practices.go | 20 +++- internal/repository/lms_practices.go | 68 +++++++---- internal/repository/programs.go | 14 +++ internal/services/examprep/service.go | 8 +- internal/services/practices/service.go | 16 ++- .../handlers/exam_prep_practice_handler.go | 6 +- .../web_server/handlers/practice_handler.go | 12 +- .../handlers/practice_publish_gate.go | 51 ++++++++ internal/web_server/handlers/questions.go | 35 ++++++ 33 files changed, 521 insertions(+), 99 deletions(-) create mode 100644 db/migrations/000060_practice_publish_status.down.sql create mode 100644 db/migrations/000060_practice_publish_status.up.sql create mode 100644 internal/web_server/handlers/practice_publish_gate.go diff --git a/db/migrations/000060_practice_publish_status.down.sql b/db/migrations/000060_practice_publish_status.down.sql new file mode 100644 index 0000000..0ed47ef --- /dev/null +++ b/db/migrations/000060_practice_publish_status.down.sql @@ -0,0 +1,5 @@ +ALTER TABLE lms_practices DROP CONSTRAINT chk_lms_practices_publish_status; +ALTER TABLE lms_practices DROP COLUMN publish_status; + +ALTER TABLE exam_prep.lesson_practices DROP CONSTRAINT chk_exam_prep_lesson_practices_publish_status; +ALTER TABLE exam_prep.lesson_practices DROP COLUMN publish_status; diff --git a/db/migrations/000060_practice_publish_status.up.sql b/db/migrations/000060_practice_publish_status.up.sql new file mode 100644 index 0000000..88dd296 --- /dev/null +++ b/db/migrations/000060_practice_publish_status.up.sql @@ -0,0 +1,8 @@ +-- Draft vs published visibility for LMS and exam-prep practices. +ALTER TABLE lms_practices + ADD COLUMN publish_status VARCHAR(16) NOT NULL DEFAULT 'PUBLISHED' + CONSTRAINT chk_lms_practices_publish_status CHECK (publish_status IN ('DRAFT', 'PUBLISHED')); + +ALTER TABLE exam_prep.lesson_practices + ADD COLUMN publish_status VARCHAR(16) NOT NULL DEFAULT 'PUBLISHED' + CONSTRAINT chk_exam_prep_lesson_practices_publish_status CHECK (publish_status IN ('DRAFT', 'PUBLISHED')); diff --git a/db/query/exam_prep_lesson_practices.sql b/db/query/exam_prep_lesson_practices.sql index 5b58cc0..c27f45d 100644 --- a/db/query/exam_prep_lesson_practices.sql +++ b/db/query/exam_prep_lesson_practices.sql @@ -6,8 +6,9 @@ INSERT INTO exam_prep.lesson_practices ( story_image, persona_id, question_set_id, - quick_tips -) VALUES ($1, $2, $3, $4, $5, $6, $7) + quick_tips, + publish_status +) VALUES ($1, $2, $3, $4, $5, $6, $7, $8) RETURNING *; -- name: ExamPrepGetLessonPracticeByID :one @@ -15,6 +16,13 @@ SELECT * FROM exam_prep.lesson_practices WHERE id = $1; +-- name: ExamPrepGetLessonPracticeByQuestionSetID :one +SELECT * +FROM exam_prep.lesson_practices +WHERE question_set_id = $1 +ORDER BY id DESC +LIMIT 1; + -- name: ExamPrepListLessonPracticesByLessonID :many SELECT COUNT(*) OVER () AS total_count, @@ -26,10 +34,15 @@ SELECT p.persona_id, p.question_set_id, p.quick_tips, + p.publish_status, p.created_at, p.updated_at FROM exam_prep.lesson_practices p WHERE p.unit_module_lesson_id = $1 + AND ( + sqlc.arg('published_only')::boolean = FALSE + OR p.publish_status = 'PUBLISHED'::TEXT + ) ORDER BY p.created_at DESC LIMIT $2 OFFSET $3; @@ -43,6 +56,7 @@ SET persona_id = coalesce(sqlc.narg('persona_id')::bigint, persona_id), question_set_id = coalesce(sqlc.narg('question_set_id')::bigint, question_set_id), quick_tips = coalesce(sqlc.narg('quick_tips')::text, quick_tips), + 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_admin_activity.sql b/db/query/lms_admin_activity.sql index 2bbed1f..31fc450 100644 --- a/db/query/lms_admin_activity.sql +++ b/db/query/lms_admin_activity.sql @@ -86,6 +86,7 @@ UNION ALL user_practice_progress upp INNER JOIN lms_practices lp ON lp.question_set_id = upp.question_set_id AND lp.lesson_id IS NOT NULL + AND lp.publish_status = 'PUBLISHED' INNER JOIN question_sets qs ON qs.id = upp.question_set_id AND qs.set_type = 'PRACTICE' AND qs.status = 'PUBLISHED' @@ -132,6 +133,7 @@ UNION ALL INNER JOIN lms_practices lp ON lp.question_set_id = upp.question_set_id AND lp.module_id IS NOT NULL AND lp.lesson_id IS NULL + AND lp.publish_status = 'PUBLISHED' INNER JOIN question_sets qs ON qs.id = upp.question_set_id AND qs.set_type = 'PRACTICE' AND qs.status = 'PUBLISHED' @@ -176,6 +178,7 @@ UNION ALL AND lp.course_id IS NOT NULL AND lp.module_id IS NULL AND lp.lesson_id IS NULL + AND lp.publish_status = 'PUBLISHED' INNER JOIN question_sets qs ON qs.id = upp.question_set_id AND qs.set_type = 'PRACTICE' AND qs.status = 'PUBLISHED' diff --git a/db/query/lms_courses.sql b/db/query/lms_courses.sql index ed86aeb..a3cbf1c 100644 --- a/db/query/lms_courses.sql +++ b/db/query/lms_courses.sql @@ -24,6 +24,7 @@ SELECT WHERE p.course_id = c.id AND p.module_id IS NULL AND p.lesson_id IS NULL + AND p.publish_status = 'PUBLISHED' ) AS has_practice FROM courses c @@ -75,13 +76,15 @@ SELECT WHERE p.course_id = c.id AND p.module_id IS NULL - AND p.lesson_id IS NULL) AS practice_count, + AND p.lesson_id IS NULL + AND p.publish_status = 'PUBLISHED') AS practice_count, EXISTS ( SELECT 1 FROM lms_practices p WHERE p.course_id = c.id AND p.module_id IS NULL AND p.lesson_id IS NULL + AND p.publish_status = 'PUBLISHED' ) AS has_practice FROM courses c diff --git a/db/query/lms_lessons.sql b/db/query/lms_lessons.sql index 011adbd..e2c7983 100644 --- a/db/query/lms_lessons.sql +++ b/db/query/lms_lessons.sql @@ -22,6 +22,7 @@ SELECT SELECT 1 FROM lms_practices p WHERE p.lesson_id = l.id + AND p.publish_status = 'PUBLISHED' ) AS has_practice FROM lessons l @@ -43,6 +44,7 @@ SELECT SELECT 1 FROM lms_practices p WHERE p.lesson_id = l.id + AND p.publish_status = 'PUBLISHED' ) AS has_practice FROM lessons l diff --git a/db/query/lms_modules.sql b/db/query/lms_modules.sql index a149913..ae5d0b9 100644 --- a/db/query/lms_modules.sql +++ b/db/query/lms_modules.sql @@ -23,6 +23,7 @@ SELECT FROM lms_practices p WHERE p.module_id = m.id AND p.lesson_id IS NULL + AND p.publish_status = 'PUBLISHED' ) AS has_practice FROM modules m @@ -55,6 +56,7 @@ SELECT FROM lms_practices p WHERE p.module_id = m.id AND p.lesson_id IS NULL + AND p.publish_status = 'PUBLISHED' ) AS has_practice FROM modules m diff --git a/db/query/lms_practices.sql b/db/query/lms_practices.sql index 650e1a4..f3e48de 100644 --- a/db/query/lms_practices.sql +++ b/db/query/lms_practices.sql @@ -1,8 +1,8 @@ -- name: CreateLmsPractice :one INSERT INTO lms_practices ( course_id, module_id, lesson_id, - title, story_description, story_image, persona_id, question_set_id, quick_tips -) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9) + title, story_description, story_image, persona_id, question_set_id, quick_tips, publish_status +) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10) RETURNING *; -- name: GetLmsPracticeByID :one @@ -10,6 +10,13 @@ SELECT * FROM lms_practices WHERE id = $1; +-- name: GetLmsPracticeByQuestionSetID :one +SELECT * +FROM lms_practices +WHERE question_set_id = $1 +ORDER BY id DESC +LIMIT 1; + -- name: ListLmsPracticesByCourseID :many SELECT COUNT(*) OVER () AS total_count, @@ -23,10 +30,15 @@ SELECT p.persona_id, p.question_set_id, p.quick_tips, + p.publish_status, p.created_at, p.updated_at FROM lms_practices p WHERE p.course_id = $1 + AND ( + sqlc.arg('published_only')::boolean = FALSE + OR p.publish_status = 'PUBLISHED'::TEXT + ) ORDER BY p.created_at DESC LIMIT $2 OFFSET $3; @@ -43,10 +55,15 @@ SELECT p.persona_id, p.question_set_id, p.quick_tips, + p.publish_status, p.created_at, p.updated_at FROM lms_practices p WHERE p.module_id = $1 + AND ( + sqlc.arg('published_only')::boolean = FALSE + OR p.publish_status = 'PUBLISHED'::TEXT + ) ORDER BY p.created_at DESC LIMIT $2 OFFSET $3; @@ -63,10 +80,15 @@ SELECT p.persona_id, p.question_set_id, p.quick_tips, + p.publish_status, p.created_at, p.updated_at FROM lms_practices p WHERE p.lesson_id = $1 + AND ( + sqlc.arg('published_only')::boolean = FALSE + OR p.publish_status = 'PUBLISHED'::TEXT + ) ORDER BY p.created_at DESC LIMIT $2 OFFSET $3; @@ -79,6 +101,7 @@ SET persona_id = COALESCE(sqlc.narg('persona_id')::bigint, persona_id), question_set_id = COALESCE(sqlc.narg('question_set_id')::bigint, question_set_id), quick_tips = COALESCE(sqlc.narg('quick_tips')::text, quick_tips), + 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_progress.sql b/db/query/lms_progress.sql index 5c6a606..6ef393f 100644 --- a/db/query/lms_progress.sql +++ b/db/query/lms_progress.sql @@ -257,7 +257,8 @@ FROM WHERE lp.module_id = $1 AND qs.set_type = 'PRACTICE' - AND qs.status = 'PUBLISHED'; + AND qs.status = 'PUBLISHED' + AND lp.publish_status = 'PUBLISHED'; -- name: CountUserCompletedPublishedPracticesInModule :one SELECT @@ -271,7 +272,8 @@ WHERE AND upp.user_id = $2 AND upp.completed_at IS NOT NULL AND qs.set_type = 'PRACTICE' - AND qs.status = 'PUBLISHED'; + AND qs.status = 'PUBLISHED' + AND lp.publish_status = 'PUBLISHED'; -- name: CountPublishedPracticesInCourse :one SELECT @@ -282,7 +284,8 @@ FROM WHERE lp.course_id = $1 AND qs.set_type = 'PRACTICE' - AND qs.status = 'PUBLISHED'; + AND qs.status = 'PUBLISHED' + AND lp.publish_status = 'PUBLISHED'; -- name: CountUserCompletedPublishedPracticesInCourse :one SELECT @@ -308,7 +311,8 @@ FROM WHERE c.program_id = $1 AND qs.set_type = 'PRACTICE' - AND qs.status = 'PUBLISHED'; + AND qs.status = 'PUBLISHED' + AND lp.publish_status = 'PUBLISHED'; -- name: CountUserCompletedPublishedPracticesInProgram :one SELECT @@ -323,7 +327,8 @@ WHERE AND upp.user_id = $2 AND upp.completed_at IS NOT NULL AND qs.set_type = 'PRACTICE' - AND qs.status = 'PUBLISHED'; + AND qs.status = 'PUBLISHED' + AND lp.publish_status = 'PUBLISHED'; -- name: GetPracticeScopeByQuestionSetID :one SELECT diff --git a/db/query/user_recent_activity.sql b/db/query/user_recent_activity.sql index dc5db21..a4fe4b5 100644 --- a/db/query/user_recent_activity.sql +++ b/db/query/user_recent_activity.sql @@ -100,6 +100,7 @@ FROM ( user_practice_progress upp INNER JOIN lms_practices lp ON lp.question_set_id = upp.question_set_id AND lp.lesson_id IS NOT NULL + AND lp.publish_status = 'PUBLISHED' INNER JOIN question_sets qs ON qs.id = upp.question_set_id AND qs.set_type = 'PRACTICE' AND qs.status = 'PUBLISHED' @@ -131,6 +132,7 @@ FROM ( INNER JOIN lms_practices lp ON lp.question_set_id = upp.question_set_id AND lp.module_id IS NOT NULL AND lp.lesson_id IS NULL + AND lp.publish_status = 'PUBLISHED' INNER JOIN question_sets qs ON qs.id = upp.question_set_id AND qs.set_type = 'PRACTICE' AND qs.status = 'PUBLISHED' @@ -162,6 +164,7 @@ FROM ( AND lp.course_id IS NOT NULL AND lp.module_id IS NULL AND lp.lesson_id IS NULL + AND lp.publish_status = 'PUBLISHED' INNER JOIN question_sets qs ON qs.id = upp.question_set_id AND qs.set_type = 'PRACTICE' AND qs.status = 'PUBLISHED' diff --git a/docs/PRACTICE_CREATION_API_GUIDE.md b/docs/PRACTICE_CREATION_API_GUIDE.md index 17e97b2..3494b52 100644 --- a/docs/PRACTICE_CREATION_API_GUIDE.md +++ b/docs/PRACTICE_CREATION_API_GUIDE.md @@ -415,6 +415,8 @@ This creates the practice record scoped to lesson. ### Request +Include `publish_status`: `DRAFT` to hide the practice from subscribed learners until you set it to `PUBLISHED` (via create or `PUT /practices/:id`). Omit the field or send `PUBLISHED` to go live immediately (backward compatible). + ```json { "parent_kind": "LESSON", @@ -423,7 +425,8 @@ This creates the practice record scoped to lesson. "story_description": "A short two-speaker scenario.", "story_image": "https://cdn.example.com/images/story.webp", "question_set_id": 55, - "quick_tips": "Listen carefully before answering." + "quick_tips": "Listen carefully before answering.", + "publish_status": "DRAFT" } ``` diff --git a/gen/db/exam_prep_lesson_practices.sql.go b/gen/db/exam_prep_lesson_practices.sql.go index 596cc85..ab6d1fd 100644 --- a/gen/db/exam_prep_lesson_practices.sql.go +++ b/gen/db/exam_prep_lesson_practices.sql.go @@ -19,9 +19,10 @@ INSERT INTO exam_prep.lesson_practices ( story_image, persona_id, question_set_id, - quick_tips -) VALUES ($1, $2, $3, $4, $5, $6, $7) -RETURNING id, unit_module_lesson_id, title, story_description, story_image, persona_id, question_set_id, quick_tips, created_at, updated_at + quick_tips, + publish_status +) VALUES ($1, $2, $3, $4, $5, $6, $7, $8) +RETURNING id, unit_module_lesson_id, title, story_description, story_image, persona_id, question_set_id, quick_tips, created_at, updated_at, publish_status ` type ExamPrepCreateLessonPracticeParams struct { @@ -32,6 +33,7 @@ type ExamPrepCreateLessonPracticeParams struct { PersonaID pgtype.Int8 `json:"persona_id"` QuestionSetID int64 `json:"question_set_id"` QuickTips pgtype.Text `json:"quick_tips"` + PublishStatus string `json:"publish_status"` } func (q *Queries) ExamPrepCreateLessonPractice(ctx context.Context, arg ExamPrepCreateLessonPracticeParams) (ExamPrepLessonPractice, error) { @@ -43,6 +45,7 @@ func (q *Queries) ExamPrepCreateLessonPractice(ctx context.Context, arg ExamPrep arg.PersonaID, arg.QuestionSetID, arg.QuickTips, + arg.PublishStatus, ) var i ExamPrepLessonPractice err := row.Scan( @@ -56,6 +59,7 @@ func (q *Queries) ExamPrepCreateLessonPractice(ctx context.Context, arg ExamPrep &i.QuickTips, &i.CreatedAt, &i.UpdatedAt, + &i.PublishStatus, ) return i, err } @@ -71,7 +75,7 @@ func (q *Queries) ExamPrepDeleteLessonPractice(ctx context.Context, id int64) er } const ExamPrepGetLessonPracticeByID = `-- name: ExamPrepGetLessonPracticeByID :one -SELECT id, unit_module_lesson_id, title, story_description, story_image, persona_id, question_set_id, quick_tips, created_at, updated_at +SELECT id, unit_module_lesson_id, title, story_description, story_image, persona_id, question_set_id, quick_tips, created_at, updated_at, publish_status FROM exam_prep.lesson_practices WHERE id = $1 ` @@ -90,6 +94,34 @@ func (q *Queries) ExamPrepGetLessonPracticeByID(ctx context.Context, id int64) ( &i.QuickTips, &i.CreatedAt, &i.UpdatedAt, + &i.PublishStatus, + ) + return i, err +} + +const ExamPrepGetLessonPracticeByQuestionSetID = `-- name: ExamPrepGetLessonPracticeByQuestionSetID :one +SELECT id, unit_module_lesson_id, title, story_description, story_image, persona_id, question_set_id, quick_tips, created_at, updated_at, publish_status +FROM exam_prep.lesson_practices +WHERE question_set_id = $1 +ORDER BY id DESC +LIMIT 1 +` + +func (q *Queries) ExamPrepGetLessonPracticeByQuestionSetID(ctx context.Context, questionSetID int64) (ExamPrepLessonPractice, error) { + row := q.db.QueryRow(ctx, ExamPrepGetLessonPracticeByQuestionSetID, questionSetID) + var i ExamPrepLessonPractice + err := row.Scan( + &i.ID, + &i.UnitModuleLessonID, + &i.Title, + &i.StoryDescription, + &i.StoryImage, + &i.PersonaID, + &i.QuestionSetID, + &i.QuickTips, + &i.CreatedAt, + &i.UpdatedAt, + &i.PublishStatus, ) return i, err } @@ -105,10 +137,15 @@ SELECT p.persona_id, p.question_set_id, p.quick_tips, + p.publish_status, p.created_at, p.updated_at FROM exam_prep.lesson_practices p WHERE p.unit_module_lesson_id = $1 + AND ( + $4::boolean = FALSE + OR p.publish_status = 'PUBLISHED'::TEXT + ) ORDER BY p.created_at DESC LIMIT $2 OFFSET $3 @@ -118,6 +155,7 @@ type ExamPrepListLessonPracticesByLessonIDParams struct { UnitModuleLessonID int64 `json:"unit_module_lesson_id"` Limit int32 `json:"limit"` Offset int32 `json:"offset"` + PublishedOnly bool `json:"published_only"` } type ExamPrepListLessonPracticesByLessonIDRow struct { @@ -130,12 +168,18 @@ type ExamPrepListLessonPracticesByLessonIDRow struct { PersonaID pgtype.Int8 `json:"persona_id"` QuestionSetID int64 `json:"question_set_id"` QuickTips pgtype.Text `json:"quick_tips"` + PublishStatus string `json:"publish_status"` CreatedAt pgtype.Timestamptz `json:"created_at"` UpdatedAt pgtype.Timestamptz `json:"updated_at"` } func (q *Queries) ExamPrepListLessonPracticesByLessonID(ctx context.Context, arg ExamPrepListLessonPracticesByLessonIDParams) ([]ExamPrepListLessonPracticesByLessonIDRow, error) { - rows, err := q.db.Query(ctx, ExamPrepListLessonPracticesByLessonID, arg.UnitModuleLessonID, arg.Limit, arg.Offset) + rows, err := q.db.Query(ctx, ExamPrepListLessonPracticesByLessonID, + arg.UnitModuleLessonID, + arg.Limit, + arg.Offset, + arg.PublishedOnly, + ) if err != nil { return nil, err } @@ -153,6 +197,7 @@ func (q *Queries) ExamPrepListLessonPracticesByLessonID(ctx context.Context, arg &i.PersonaID, &i.QuestionSetID, &i.QuickTips, + &i.PublishStatus, &i.CreatedAt, &i.UpdatedAt, ); err != nil { @@ -175,9 +220,10 @@ SET persona_id = coalesce($4::bigint, persona_id), question_set_id = coalesce($5::bigint, question_set_id), quick_tips = coalesce($6::text, quick_tips), + publish_status = coalesce($7::varchar, publish_status), updated_at = CURRENT_TIMESTAMP -WHERE id = $7 -RETURNING id, unit_module_lesson_id, title, story_description, story_image, persona_id, question_set_id, quick_tips, created_at, updated_at +WHERE id = $8 +RETURNING id, unit_module_lesson_id, title, story_description, story_image, persona_id, question_set_id, quick_tips, created_at, updated_at, publish_status ` type ExamPrepUpdateLessonPracticeParams struct { @@ -187,6 +233,7 @@ type ExamPrepUpdateLessonPracticeParams struct { PersonaID pgtype.Int8 `json:"persona_id"` QuestionSetID pgtype.Int8 `json:"question_set_id"` QuickTips pgtype.Text `json:"quick_tips"` + PublishStatus pgtype.Text `json:"publish_status"` ID int64 `json:"id"` } @@ -198,6 +245,7 @@ func (q *Queries) ExamPrepUpdateLessonPractice(ctx context.Context, arg ExamPrep arg.PersonaID, arg.QuestionSetID, arg.QuickTips, + arg.PublishStatus, arg.ID, ) var i ExamPrepLessonPractice @@ -212,6 +260,7 @@ func (q *Queries) ExamPrepUpdateLessonPractice(ctx context.Context, arg ExamPrep &i.QuickTips, &i.CreatedAt, &i.UpdatedAt, + &i.PublishStatus, ) return i, err } diff --git a/gen/db/lms_admin_activity.sql.go b/gen/db/lms_admin_activity.sql.go index 92259d1..e940da6 100644 --- a/gen/db/lms_admin_activity.sql.go +++ b/gen/db/lms_admin_activity.sql.go @@ -97,6 +97,7 @@ UNION ALL user_practice_progress upp INNER JOIN lms_practices lp ON lp.question_set_id = upp.question_set_id AND lp.lesson_id IS NOT NULL + AND lp.publish_status = 'PUBLISHED' INNER JOIN question_sets qs ON qs.id = upp.question_set_id AND qs.set_type = 'PRACTICE' AND qs.status = 'PUBLISHED' @@ -143,6 +144,7 @@ UNION ALL INNER JOIN lms_practices lp ON lp.question_set_id = upp.question_set_id AND lp.module_id IS NOT NULL AND lp.lesson_id IS NULL + AND lp.publish_status = 'PUBLISHED' INNER JOIN question_sets qs ON qs.id = upp.question_set_id AND qs.set_type = 'PRACTICE' AND qs.status = 'PUBLISHED' @@ -187,6 +189,7 @@ UNION ALL AND lp.course_id IS NOT NULL AND lp.module_id IS NULL AND lp.lesson_id IS NULL + AND lp.publish_status = 'PUBLISHED' INNER JOIN question_sets qs ON qs.id = upp.question_set_id AND qs.set_type = 'PRACTICE' AND qs.status = 'PUBLISHED' diff --git a/gen/db/lms_courses.sql.go b/gen/db/lms_courses.sql.go index e812f85..5c8a295 100644 --- a/gen/db/lms_courses.sql.go +++ b/gen/db/lms_courses.sql.go @@ -78,6 +78,7 @@ SELECT WHERE p.course_id = c.id AND p.module_id IS NULL AND p.lesson_id IS NULL + AND p.publish_status = 'PUBLISHED' ) AS has_practice FROM courses c @@ -180,13 +181,15 @@ SELECT WHERE p.course_id = c.id AND p.module_id IS NULL - AND p.lesson_id IS NULL) AS practice_count, + AND p.lesson_id IS NULL + AND p.publish_status = 'PUBLISHED') AS practice_count, EXISTS ( SELECT 1 FROM lms_practices p WHERE p.course_id = c.id AND p.module_id IS NULL AND p.lesson_id IS NULL + AND p.publish_status = 'PUBLISHED' ) AS has_practice FROM courses c diff --git a/gen/db/lms_lessons.sql.go b/gen/db/lms_lessons.sql.go index ece62af..280faec 100644 --- a/gen/db/lms_lessons.sql.go +++ b/gen/db/lms_lessons.sql.go @@ -77,6 +77,7 @@ SELECT SELECT 1 FROM lms_practices p WHERE p.lesson_id = l.id + AND p.publish_status = 'PUBLISHED' ) AS has_practice FROM lessons l @@ -130,6 +131,7 @@ SELECT SELECT 1 FROM lms_practices p WHERE p.lesson_id = l.id + AND p.publish_status = 'PUBLISHED' ) AS has_practice FROM lessons l diff --git a/gen/db/lms_modules.sql.go b/gen/db/lms_modules.sql.go index e7e6285..d7ba147 100644 --- a/gen/db/lms_modules.sql.go +++ b/gen/db/lms_modules.sql.go @@ -78,6 +78,7 @@ SELECT FROM lms_practices p WHERE p.module_id = m.id AND p.lesson_id IS NULL + AND p.publish_status = 'PUBLISHED' ) AS has_practice FROM modules m @@ -163,6 +164,7 @@ SELECT FROM lms_practices p WHERE p.module_id = m.id AND p.lesson_id IS NULL + AND p.publish_status = 'PUBLISHED' ) AS has_practice FROM modules m diff --git a/gen/db/lms_practices.sql.go b/gen/db/lms_practices.sql.go index 5ecb503..8039241 100644 --- a/gen/db/lms_practices.sql.go +++ b/gen/db/lms_practices.sql.go @@ -14,9 +14,9 @@ import ( const CreateLmsPractice = `-- name: CreateLmsPractice :one INSERT INTO lms_practices ( course_id, module_id, lesson_id, - title, story_description, story_image, persona_id, question_set_id, quick_tips -) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9) -RETURNING id, course_id, module_id, lesson_id, title, story_description, story_image, persona_id, question_set_id, quick_tips, created_at, updated_at + title, story_description, story_image, persona_id, question_set_id, quick_tips, publish_status +) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10) +RETURNING id, course_id, module_id, lesson_id, title, story_description, story_image, persona_id, question_set_id, quick_tips, created_at, updated_at, publish_status ` type CreateLmsPracticeParams struct { @@ -29,6 +29,7 @@ type CreateLmsPracticeParams struct { PersonaID pgtype.Int8 `json:"persona_id"` QuestionSetID int64 `json:"question_set_id"` QuickTips pgtype.Text `json:"quick_tips"` + PublishStatus string `json:"publish_status"` } func (q *Queries) CreateLmsPractice(ctx context.Context, arg CreateLmsPracticeParams) (LmsPractice, error) { @@ -42,6 +43,7 @@ func (q *Queries) CreateLmsPractice(ctx context.Context, arg CreateLmsPracticePa arg.PersonaID, arg.QuestionSetID, arg.QuickTips, + arg.PublishStatus, ) var i LmsPractice err := row.Scan( @@ -57,6 +59,7 @@ func (q *Queries) CreateLmsPractice(ctx context.Context, arg CreateLmsPracticePa &i.QuickTips, &i.CreatedAt, &i.UpdatedAt, + &i.PublishStatus, ) return i, err } @@ -72,7 +75,7 @@ func (q *Queries) DeleteLmsPractice(ctx context.Context, id int64) error { } const GetLmsPracticeByID = `-- name: GetLmsPracticeByID :one -SELECT id, course_id, module_id, lesson_id, title, story_description, story_image, persona_id, question_set_id, quick_tips, created_at, updated_at +SELECT id, course_id, module_id, lesson_id, title, story_description, story_image, persona_id, question_set_id, quick_tips, created_at, updated_at, publish_status FROM lms_practices WHERE id = $1 ` @@ -93,6 +96,36 @@ func (q *Queries) GetLmsPracticeByID(ctx context.Context, id int64) (LmsPractice &i.QuickTips, &i.CreatedAt, &i.UpdatedAt, + &i.PublishStatus, + ) + return i, err +} + +const GetLmsPracticeByQuestionSetID = `-- name: GetLmsPracticeByQuestionSetID :one +SELECT id, course_id, module_id, lesson_id, title, story_description, story_image, persona_id, question_set_id, quick_tips, created_at, updated_at, publish_status +FROM lms_practices +WHERE question_set_id = $1 +ORDER BY id DESC +LIMIT 1 +` + +func (q *Queries) GetLmsPracticeByQuestionSetID(ctx context.Context, questionSetID int64) (LmsPractice, error) { + row := q.db.QueryRow(ctx, GetLmsPracticeByQuestionSetID, questionSetID) + var i LmsPractice + err := row.Scan( + &i.ID, + &i.CourseID, + &i.ModuleID, + &i.LessonID, + &i.Title, + &i.StoryDescription, + &i.StoryImage, + &i.PersonaID, + &i.QuestionSetID, + &i.QuickTips, + &i.CreatedAt, + &i.UpdatedAt, + &i.PublishStatus, ) return i, err } @@ -110,18 +143,24 @@ SELECT p.persona_id, p.question_set_id, p.quick_tips, + p.publish_status, p.created_at, p.updated_at FROM lms_practices p WHERE p.course_id = $1 + AND ( + $4::boolean = FALSE + OR p.publish_status = 'PUBLISHED'::TEXT + ) ORDER BY p.created_at DESC LIMIT $2 OFFSET $3 ` type ListLmsPracticesByCourseIDParams struct { - CourseID pgtype.Int8 `json:"course_id"` - Limit int32 `json:"limit"` - Offset int32 `json:"offset"` + CourseID pgtype.Int8 `json:"course_id"` + Limit int32 `json:"limit"` + Offset int32 `json:"offset"` + PublishedOnly bool `json:"published_only"` } type ListLmsPracticesByCourseIDRow struct { @@ -136,12 +175,18 @@ type ListLmsPracticesByCourseIDRow struct { PersonaID pgtype.Int8 `json:"persona_id"` QuestionSetID int64 `json:"question_set_id"` QuickTips pgtype.Text `json:"quick_tips"` + PublishStatus string `json:"publish_status"` CreatedAt pgtype.Timestamptz `json:"created_at"` UpdatedAt pgtype.Timestamptz `json:"updated_at"` } func (q *Queries) ListLmsPracticesByCourseID(ctx context.Context, arg ListLmsPracticesByCourseIDParams) ([]ListLmsPracticesByCourseIDRow, error) { - rows, err := q.db.Query(ctx, ListLmsPracticesByCourseID, arg.CourseID, arg.Limit, arg.Offset) + rows, err := q.db.Query(ctx, ListLmsPracticesByCourseID, + arg.CourseID, + arg.Limit, + arg.Offset, + arg.PublishedOnly, + ) if err != nil { return nil, err } @@ -161,6 +206,7 @@ func (q *Queries) ListLmsPracticesByCourseID(ctx context.Context, arg ListLmsPra &i.PersonaID, &i.QuestionSetID, &i.QuickTips, + &i.PublishStatus, &i.CreatedAt, &i.UpdatedAt, ); err != nil { @@ -187,18 +233,24 @@ SELECT p.persona_id, p.question_set_id, p.quick_tips, + p.publish_status, p.created_at, p.updated_at FROM lms_practices p WHERE p.lesson_id = $1 + AND ( + $4::boolean = FALSE + OR p.publish_status = 'PUBLISHED'::TEXT + ) ORDER BY p.created_at DESC LIMIT $2 OFFSET $3 ` type ListLmsPracticesByLessonIDParams struct { - LessonID pgtype.Int8 `json:"lesson_id"` - Limit int32 `json:"limit"` - Offset int32 `json:"offset"` + LessonID pgtype.Int8 `json:"lesson_id"` + Limit int32 `json:"limit"` + Offset int32 `json:"offset"` + PublishedOnly bool `json:"published_only"` } type ListLmsPracticesByLessonIDRow struct { @@ -213,12 +265,18 @@ type ListLmsPracticesByLessonIDRow struct { PersonaID pgtype.Int8 `json:"persona_id"` QuestionSetID int64 `json:"question_set_id"` QuickTips pgtype.Text `json:"quick_tips"` + PublishStatus string `json:"publish_status"` CreatedAt pgtype.Timestamptz `json:"created_at"` UpdatedAt pgtype.Timestamptz `json:"updated_at"` } func (q *Queries) ListLmsPracticesByLessonID(ctx context.Context, arg ListLmsPracticesByLessonIDParams) ([]ListLmsPracticesByLessonIDRow, error) { - rows, err := q.db.Query(ctx, ListLmsPracticesByLessonID, arg.LessonID, arg.Limit, arg.Offset) + rows, err := q.db.Query(ctx, ListLmsPracticesByLessonID, + arg.LessonID, + arg.Limit, + arg.Offset, + arg.PublishedOnly, + ) if err != nil { return nil, err } @@ -238,6 +296,7 @@ func (q *Queries) ListLmsPracticesByLessonID(ctx context.Context, arg ListLmsPra &i.PersonaID, &i.QuestionSetID, &i.QuickTips, + &i.PublishStatus, &i.CreatedAt, &i.UpdatedAt, ); err != nil { @@ -264,18 +323,24 @@ SELECT p.persona_id, p.question_set_id, p.quick_tips, + p.publish_status, p.created_at, p.updated_at FROM lms_practices p WHERE p.module_id = $1 + AND ( + $4::boolean = FALSE + OR p.publish_status = 'PUBLISHED'::TEXT + ) ORDER BY p.created_at DESC LIMIT $2 OFFSET $3 ` type ListLmsPracticesByModuleIDParams struct { - ModuleID pgtype.Int8 `json:"module_id"` - Limit int32 `json:"limit"` - Offset int32 `json:"offset"` + ModuleID pgtype.Int8 `json:"module_id"` + Limit int32 `json:"limit"` + Offset int32 `json:"offset"` + PublishedOnly bool `json:"published_only"` } type ListLmsPracticesByModuleIDRow struct { @@ -290,12 +355,18 @@ type ListLmsPracticesByModuleIDRow struct { PersonaID pgtype.Int8 `json:"persona_id"` QuestionSetID int64 `json:"question_set_id"` QuickTips pgtype.Text `json:"quick_tips"` + PublishStatus string `json:"publish_status"` CreatedAt pgtype.Timestamptz `json:"created_at"` UpdatedAt pgtype.Timestamptz `json:"updated_at"` } func (q *Queries) ListLmsPracticesByModuleID(ctx context.Context, arg ListLmsPracticesByModuleIDParams) ([]ListLmsPracticesByModuleIDRow, error) { - rows, err := q.db.Query(ctx, ListLmsPracticesByModuleID, arg.ModuleID, arg.Limit, arg.Offset) + rows, err := q.db.Query(ctx, ListLmsPracticesByModuleID, + arg.ModuleID, + arg.Limit, + arg.Offset, + arg.PublishedOnly, + ) if err != nil { return nil, err } @@ -315,6 +386,7 @@ func (q *Queries) ListLmsPracticesByModuleID(ctx context.Context, arg ListLmsPra &i.PersonaID, &i.QuestionSetID, &i.QuickTips, + &i.PublishStatus, &i.CreatedAt, &i.UpdatedAt, ); err != nil { @@ -337,9 +409,10 @@ SET persona_id = COALESCE($4::bigint, persona_id), question_set_id = COALESCE($5::bigint, question_set_id), quick_tips = COALESCE($6::text, quick_tips), + publish_status = COALESCE($7::varchar, publish_status), updated_at = CURRENT_TIMESTAMP -WHERE id = $7 -RETURNING id, course_id, module_id, lesson_id, title, story_description, story_image, persona_id, question_set_id, quick_tips, created_at, updated_at +WHERE id = $8 +RETURNING id, course_id, module_id, lesson_id, title, story_description, story_image, persona_id, question_set_id, quick_tips, created_at, updated_at, publish_status ` type UpdateLmsPracticeParams struct { @@ -349,6 +422,7 @@ type UpdateLmsPracticeParams struct { PersonaID pgtype.Int8 `json:"persona_id"` QuestionSetID pgtype.Int8 `json:"question_set_id"` QuickTips pgtype.Text `json:"quick_tips"` + PublishStatus pgtype.Text `json:"publish_status"` ID int64 `json:"id"` } @@ -360,6 +434,7 @@ func (q *Queries) UpdateLmsPractice(ctx context.Context, arg UpdateLmsPracticePa arg.PersonaID, arg.QuestionSetID, arg.QuickTips, + arg.PublishStatus, arg.ID, ) var i LmsPractice @@ -376,6 +451,7 @@ func (q *Queries) UpdateLmsPractice(ctx context.Context, arg UpdateLmsPracticePa &i.QuickTips, &i.CreatedAt, &i.UpdatedAt, + &i.PublishStatus, ) return i, err } diff --git a/gen/db/lms_progress.sql.go b/gen/db/lms_progress.sql.go index 8376ad9..3358b4f 100644 --- a/gen/db/lms_progress.sql.go +++ b/gen/db/lms_progress.sql.go @@ -106,6 +106,7 @@ WHERE lp.course_id = $1 AND qs.set_type = 'PRACTICE' AND qs.status = 'PUBLISHED' + AND lp.publish_status = 'PUBLISHED' ` func (q *Queries) CountPublishedPracticesInCourse(ctx context.Context, courseID pgtype.Int8) (int32, error) { @@ -125,6 +126,7 @@ WHERE lp.module_id = $1 AND qs.set_type = 'PRACTICE' AND qs.status = 'PUBLISHED' + AND lp.publish_status = 'PUBLISHED' ` // Published practices in a module (module-level and lesson-level practices should carry module_id). @@ -146,6 +148,7 @@ WHERE c.program_id = $1 AND qs.set_type = 'PRACTICE' AND qs.status = 'PUBLISHED' + AND lp.publish_status = 'PUBLISHED' ` func (q *Queries) CountPublishedPracticesInProgram(ctx context.Context, programID int64) (int32, error) { @@ -313,6 +316,7 @@ WHERE AND upp.completed_at IS NOT NULL AND qs.set_type = 'PRACTICE' AND qs.status = 'PUBLISHED' + AND lp.publish_status = 'PUBLISHED' ` type CountUserCompletedPublishedPracticesInModuleParams struct { @@ -341,6 +345,7 @@ WHERE AND upp.completed_at IS NOT NULL AND qs.set_type = 'PRACTICE' AND qs.status = 'PUBLISHED' + AND lp.publish_status = 'PUBLISHED' ` type CountUserCompletedPublishedPracticesInProgramParams struct { diff --git a/gen/db/models.go b/gen/db/models.go index ab2e7da..daf24c0 100644 --- a/gen/db/models.go +++ b/gen/db/models.go @@ -64,6 +64,7 @@ type ExamPrepLessonPractice struct { QuickTips pgtype.Text `json:"quick_tips"` CreatedAt pgtype.Timestamptz `json:"created_at"` UpdatedAt pgtype.Timestamptz `json:"updated_at"` + PublishStatus string `json:"publish_status"` } type ExamPrepUnit struct { @@ -149,6 +150,7 @@ type LmsPractice struct { QuickTips pgtype.Text `json:"quick_tips"` CreatedAt pgtype.Timestamptz `json:"created_at"` UpdatedAt pgtype.Timestamptz `json:"updated_at"` + PublishStatus string `json:"publish_status"` } type LmsUserCourseProgress struct { diff --git a/gen/db/user_recent_activity.sql.go b/gen/db/user_recent_activity.sql.go index ca50f20..b17a7e3 100644 --- a/gen/db/user_recent_activity.sql.go +++ b/gen/db/user_recent_activity.sql.go @@ -223,6 +223,7 @@ FROM ( user_practice_progress upp INNER JOIN lms_practices lp ON lp.question_set_id = upp.question_set_id AND lp.lesson_id IS NOT NULL + AND lp.publish_status = 'PUBLISHED' INNER JOIN question_sets qs ON qs.id = upp.question_set_id AND qs.set_type = 'PRACTICE' AND qs.status = 'PUBLISHED' @@ -254,6 +255,7 @@ FROM ( INNER JOIN lms_practices lp ON lp.question_set_id = upp.question_set_id AND lp.module_id IS NOT NULL AND lp.lesson_id IS NULL + AND lp.publish_status = 'PUBLISHED' INNER JOIN question_sets qs ON qs.id = upp.question_set_id AND qs.set_type = 'PRACTICE' AND qs.status = 'PUBLISHED' @@ -285,6 +287,7 @@ FROM ( AND lp.course_id IS NOT NULL AND lp.module_id IS NULL AND lp.lesson_id IS NULL + AND lp.publish_status = 'PUBLISHED' INNER JOIN question_sets qs ON qs.id = upp.question_set_id AND qs.set_type = 'PRACTICE' AND qs.status = 'PUBLISHED' diff --git a/internal/domain/exam_prep_practice.go b/internal/domain/exam_prep_practice.go index 483b84c..ef780fc 100644 --- a/internal/domain/exam_prep_practice.go +++ b/internal/domain/exam_prep_practice.go @@ -4,16 +4,22 @@ import "time" // ExamPrepPractice is question-set content tied to an exam-prep lesson; uses shared question_sets / questions. type ExamPrepPractice struct { - ID int64 `json:"id"` - LessonID int64 `json:"lesson_id"` // exam_prep.unit_module_lessons.id - Title string `json:"title"` - StoryDescription *string `json:"story_description,omitempty"` - StoryImage *string `json:"story_image,omitempty"` - PersonaID *int64 `json:"persona_id,omitempty"` - QuestionSetID int64 `json:"question_set_id"` - QuickTips *string `json:"quick_tips,omitempty"` - CreatedAt time.Time `json:"created_at"` - UpdatedAt *time.Time `json:"updated_at,omitempty"` + ID int64 `json:"id"` + LessonID int64 `json:"lesson_id"` // exam_prep.unit_module_lessons.id + Title string `json:"title"` + StoryDescription *string `json:"story_description,omitempty"` + StoryImage *string `json:"story_image,omitempty"` + PersonaID *int64 `json:"persona_id,omitempty"` + QuestionSetID int64 `json:"question_set_id"` + PublishStatus PracticePublishStatus `json:"publish_status"` + QuickTips *string `json:"quick_tips,omitempty"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt *time.Time `json:"updated_at,omitempty"` +} + +// VisibleToLearners mirrors LMS practice visibility rules for subscribers. +func (p ExamPrepPractice) VisibleToLearners() bool { + return p.PublishStatus == PracticePublishPublished } // CreateExamPrepPracticeInput is the body for POST .../exam-prep/lessons/{lessonId}/practices (lesson from path). @@ -24,6 +30,7 @@ type CreateExamPrepPracticeInput struct { PersonaID *int64 `json:"persona_id,omitempty"` QuestionSetID int64 `json:"question_set_id" validate:"required,gt=0"` QuickTips *string `json:"quick_tips,omitempty"` + PublishStatus string `json:"publish_status,omitempty" validate:"omitempty,oneof=DRAFT draft PUBLISHED published"` } type UpdateExamPrepPracticeInput struct { @@ -33,4 +40,5 @@ type UpdateExamPrepPracticeInput struct { PersonaID *int64 `json:"persona_id,omitempty"` QuestionSetID *int64 `json:"question_set_id,omitempty"` QuickTips *string `json:"quick_tips,omitempty"` + PublishStatus *string `json:"publish_status,omitempty" validate:"omitempty,oneof=DRAFT draft PUBLISHED published"` } diff --git a/internal/domain/practice.go b/internal/domain/practice.go index f900971..dca0eba 100644 --- a/internal/domain/practice.go +++ b/internal/domain/practice.go @@ -1,6 +1,9 @@ package domain -import "time" +import ( + "strings" + "time" +) // ParentKind identifies which hierarchy entity owns a practice (exactly one). type ParentKind string @@ -11,19 +14,50 @@ const ( ParentKindLesson ParentKind = "LESSON" ) +// PracticePublishStatus controls learner visibility for a practice shell (independent of question_set.status). +type PracticePublishStatus string + +const ( + PracticePublishDraft PracticePublishStatus = "DRAFT" + PracticePublishPublished PracticePublishStatus = "PUBLISHED" +) + +// ParsePracticePublishStatusInput maps API input. Empty or unknown values default to PUBLISHED for backward compatibility. +func ParsePracticePublishStatusInput(raw string) PracticePublishStatus { + switch strings.TrimSpace(strings.ToUpper(raw)) { + case string(PracticePublishDraft): + return PracticePublishDraft + case string(PracticePublishPublished): + return PracticePublishPublished + default: + return PracticePublishPublished + } +} + +// PracticePublishStatusFromDB maps persisted values into the domain type. +func PracticePublishStatusFromDB(raw string) PracticePublishStatus { + return ParsePracticePublishStatusInput(raw) +} + // Practice is question-set content (story, persona, tips) scoped to a course, module, or lesson. type Practice struct { - ID int64 `json:"id"` - ParentKind ParentKind `json:"parent_kind"` - ParentID int64 `json:"parent_id"` - Title string `json:"title"` - StoryDescription *string `json:"story_description,omitempty"` - StoryImage *string `json:"story_image,omitempty"` - PersonaID *int64 `json:"persona_id,omitempty"` - QuestionSetID int64 `json:"question_set_id"` - QuickTips *string `json:"quick_tips,omitempty"` - CreatedAt time.Time `json:"created_at"` - UpdatedAt *time.Time `json:"updated_at,omitempty"` + ID int64 `json:"id"` + ParentKind ParentKind `json:"parent_kind"` + ParentID int64 `json:"parent_id"` + Title string `json:"title"` + StoryDescription *string `json:"story_description,omitempty"` + StoryImage *string `json:"story_image,omitempty"` + PersonaID *int64 `json:"persona_id,omitempty"` + QuestionSetID int64 `json:"question_set_id"` + PublishStatus PracticePublishStatus `json:"publish_status"` + QuickTips *string `json:"quick_tips,omitempty"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt *time.Time `json:"updated_at,omitempty"` +} + +// VisibleToLearners is true when the practice shell should appear in subscribed learner catalogs and progression. +func (p Practice) VisibleToLearners() bool { + return p.PublishStatus == PracticePublishPublished } type CreatePracticeInput struct { @@ -35,6 +69,8 @@ type CreatePracticeInput struct { PersonaID *int64 `json:"persona_id,omitempty"` QuestionSetID int64 `json:"question_set_id" validate:"required,gt=0"` QuickTips *string `json:"quick_tips,omitempty"` + // Omit or empty for backward compatibility defaults to PUBLISHED; set DRAFT to save hidden from learners until published. + PublishStatus string `json:"publish_status,omitempty" validate:"omitempty,oneof=DRAFT draft PUBLISHED published"` } type UpdatePracticeInput struct { @@ -44,4 +80,5 @@ type UpdatePracticeInput struct { PersonaID *int64 `json:"persona_id,omitempty"` QuestionSetID *int64 `json:"question_set_id,omitempty"` QuickTips *string `json:"quick_tips,omitempty"` + PublishStatus *string `json:"publish_status,omitempty" validate:"omitempty,oneof=DRAFT draft PUBLISHED published"` } diff --git a/internal/ports/exam_prep_practice.go b/internal/ports/exam_prep_practice.go index bcfd106..2a43d0f 100644 --- a/internal/ports/exam_prep_practice.go +++ b/internal/ports/exam_prep_practice.go @@ -9,7 +9,8 @@ import ( type ExamPrepPracticeStore interface { CreateExamPrepLessonPractice(ctx context.Context, lessonID int64, in domain.CreateExamPrepPracticeInput) (domain.ExamPrepPractice, error) GetExamPrepLessonPracticeByID(ctx context.Context, id int64) (domain.ExamPrepPractice, error) - ListExamPrepLessonPracticesByLessonID(ctx context.Context, lessonID int64, limit, offset int32) ([]domain.ExamPrepPractice, int64, error) + TryGetExamPrepLessonPracticeByQuestionSetID(ctx context.Context, questionSetID int64) (domain.ExamPrepPractice, bool, error) + ListExamPrepLessonPracticesByLessonID(ctx context.Context, lessonID int64, publishedOnly bool, limit, offset int32) ([]domain.ExamPrepPractice, int64, error) UpdateExamPrepLessonPractice(ctx context.Context, id int64, input domain.UpdateExamPrepPracticeInput) (domain.ExamPrepPractice, error) DeleteExamPrepLessonPractice(ctx context.Context, id int64) error } diff --git a/internal/ports/lms_practice.go b/internal/ports/lms_practice.go index 63a827f..3ddddfa 100644 --- a/internal/ports/lms_practice.go +++ b/internal/ports/lms_practice.go @@ -23,9 +23,11 @@ type LmsPracticeStore interface { courseID, moduleID, lessonID *int64, ) (domain.Practice, error) GetLmsPracticeByID(ctx context.Context, id int64) (domain.Practice, error) - ListLmsPracticesByCourseID(ctx context.Context, courseID int64, limit, offset int32) ([]domain.Practice, int64, error) - ListLmsPracticesByModuleID(ctx context.Context, moduleID int64, limit, offset int32) ([]domain.Practice, int64, error) - ListLmsPracticesByLessonID(ctx context.Context, lessonID int64, limit, offset int32) ([]domain.Practice, int64, error) + // TryGetLmsPracticeByQuestionSetID returns false when no LMS practice row references the question set. + TryGetLmsPracticeByQuestionSetID(ctx context.Context, questionSetID int64) (domain.Practice, bool, error) + ListLmsPracticesByCourseID(ctx context.Context, courseID int64, publishedOnly bool, limit, offset int32) ([]domain.Practice, int64, error) + ListLmsPracticesByModuleID(ctx context.Context, moduleID int64, publishedOnly bool, limit, offset int32) ([]domain.Practice, int64, error) + ListLmsPracticesByLessonID(ctx context.Context, lessonID int64, publishedOnly bool, limit, offset int32) ([]domain.Practice, int64, error) UpdateLmsPractice(ctx context.Context, id int64, input domain.UpdatePracticeInput) (domain.Practice, error) DeleteLmsPractice(ctx context.Context, id int64) error } diff --git a/internal/repository/exam_prep_lesson_practices.go b/internal/repository/exam_prep_lesson_practices.go index cd7b644..a26294a 100644 --- a/internal/repository/exam_prep_lesson_practices.go +++ b/internal/repository/exam_prep_lesson_practices.go @@ -21,6 +21,7 @@ func examPrepPracticeFromListRow(r dbgen.ExamPrepListLessonPracticesByLessonIDRo PersonaID: r.PersonaID, QuestionSetID: r.QuestionSetID, QuickTips: r.QuickTips, + PublishStatus: r.PublishStatus, CreatedAt: r.CreatedAt, UpdatedAt: r.UpdatedAt, }) @@ -32,6 +33,7 @@ func examPrepPracticeToDomain(p dbgen.ExamPrepLessonPractice) domain.ExamPrepPra LessonID: p.UnitModuleLessonID, Title: p.Title, QuestionSetID: p.QuestionSetID, + PublishStatus: domain.PracticePublishStatusFromDB(p.PublishStatus), } out.StoryDescription = fromPgText(p.StoryDescription) out.StoryImage = fromPgText(p.StoryImage) @@ -46,6 +48,7 @@ func examPrepPracticeToDomain(p dbgen.ExamPrepLessonPractice) domain.ExamPrepPra } func (s *Store) CreateExamPrepLessonPractice(ctx context.Context, lessonID int64, in domain.CreateExamPrepPracticeInput) (domain.ExamPrepPractice, error) { + ps := domain.ParsePracticePublishStatusInput(in.PublishStatus) p, err := s.queries.ExamPrepCreateLessonPractice(ctx, dbgen.ExamPrepCreateLessonPracticeParams{ UnitModuleLessonID: lessonID, Title: in.Title, @@ -54,6 +57,7 @@ func (s *Store) CreateExamPrepLessonPractice(ctx context.Context, lessonID int64 PersonaID: int64PtrToPg8(in.PersonaID), QuestionSetID: in.QuestionSetID, QuickTips: toPgText(in.QuickTips), + PublishStatus: string(ps), }) if err != nil { return domain.ExamPrepPractice{}, err @@ -72,9 +76,22 @@ func (s *Store) GetExamPrepLessonPracticeByID(ctx context.Context, id int64) (do return examPrepPracticeToDomain(p), nil } -func (s *Store) ListExamPrepLessonPracticesByLessonID(ctx context.Context, lessonID int64, limit, offset int32) ([]domain.ExamPrepPractice, int64, error) { +// TryGetExamPrepLessonPracticeByQuestionSetID returns false when no row exists. +func (s *Store) TryGetExamPrepLessonPracticeByQuestionSetID(ctx context.Context, questionSetID int64) (domain.ExamPrepPractice, bool, error) { + p, err := s.queries.ExamPrepGetLessonPracticeByQuestionSetID(ctx, questionSetID) + if err != nil { + if errors.Is(err, pgx.ErrNoRows) { + return domain.ExamPrepPractice{}, false, nil + } + return domain.ExamPrepPractice{}, false, err + } + return examPrepPracticeToDomain(p), true, nil +} + +func (s *Store) ListExamPrepLessonPracticesByLessonID(ctx context.Context, lessonID int64, publishedOnly bool, limit, offset int32) ([]domain.ExamPrepPractice, int64, error) { rows, err := s.queries.ExamPrepListLessonPracticesByLessonID(ctx, dbgen.ExamPrepListLessonPracticesByLessonIDParams{ UnitModuleLessonID: lessonID, + PublishedOnly: publishedOnly, Limit: limit, Offset: offset, }) @@ -111,6 +128,7 @@ func (s *Store) UpdateExamPrepLessonPractice(ctx context.Context, id int64, inpu PersonaID: optionalInt8UpdateID(input.PersonaID), QuestionSetID: qs, QuickTips: optionalTextUpdate(input.QuickTips), + PublishStatus: optionalPublishStatusUpdate(input.PublishStatus), }) if err != nil { if errors.Is(err, pgx.ErrNoRows) { diff --git a/internal/repository/lms_practices.go b/internal/repository/lms_practices.go index 2b8774c..4dbd309 100644 --- a/internal/repository/lms_practices.go +++ b/internal/repository/lms_practices.go @@ -26,11 +26,19 @@ func fromPgInt8ID(c pgtype.Int8) *int64 { return &v } +func optionalInt8UpdateID(val *int64) pgtype.Int8 { + if val == nil { + return pgtype.Int8{Valid: false} + } + return pgtype.Int8{Int64: *val, Valid: true} +} + func lmsPracticeToDomain(p dbgen.LmsPractice) domain.Practice { out := domain.Practice{ ID: p.ID, Title: p.Title, QuestionSetID: p.QuestionSetID, + PublishStatus: domain.PracticePublishStatusFromDB(p.PublishStatus), } if p.CourseID.Valid { out.ParentKind = domain.ParentKindCourse @@ -55,7 +63,9 @@ func lmsPracticeToDomain(p dbgen.LmsPractice) domain.Practice { } func lmsFromListRow( - id, qid int64, title string, + id, qid int64, + publishStatus string, + title string, cid, mid, lid pgtype.Int8, sd, si, qt pgtype.Text, pid pgtype.Int8, ca, ua pgtype.Timestamptz, @@ -71,6 +81,7 @@ func lmsFromListRow( PersonaID: pid, QuestionSetID: qid, QuickTips: qt, + PublishStatus: publishStatus, CreatedAt: ca, UpdatedAt: ua, }) @@ -82,6 +93,7 @@ func (s *Store) CreateLmsPractice( in domain.CreatePracticeInput, courseID, moduleID, lessonID *int64, ) (domain.Practice, error) { + ps := domain.ParsePracticePublishStatusInput(in.PublishStatus) p, err := s.queries.CreateLmsPractice(ctx, dbgen.CreateLmsPracticeParams{ CourseID: int64PtrToPg8(courseID), ModuleID: int64PtrToPg8(moduleID), @@ -92,6 +104,7 @@ func (s *Store) CreateLmsPractice( PersonaID: int64PtrToPg8(in.PersonaID), QuestionSetID: in.QuestionSetID, QuickTips: toPgText(in.QuickTips), + PublishStatus: string(ps), }) if err != nil { return domain.Practice{}, err @@ -110,11 +123,24 @@ func (s *Store) GetLmsPracticeByID(ctx context.Context, id int64) (domain.Practi return lmsPracticeToDomain(p), nil } -func (s *Store) ListLmsPracticesByCourseID(ctx context.Context, courseID int64, limit, offset int32) ([]domain.Practice, int64, error) { +// TryGetLmsPracticeByQuestionSetID returns false when no row exists. +func (s *Store) TryGetLmsPracticeByQuestionSetID(ctx context.Context, questionSetID int64) (domain.Practice, bool, error) { + p, err := s.queries.GetLmsPracticeByQuestionSetID(ctx, questionSetID) + if err != nil { + if errors.Is(err, pgx.ErrNoRows) { + return domain.Practice{}, false, nil + } + return domain.Practice{}, false, err + } + return lmsPracticeToDomain(p), true, nil +} + +func (s *Store) ListLmsPracticesByCourseID(ctx context.Context, courseID int64, publishedOnly bool, limit, offset int32) ([]domain.Practice, int64, error) { rows, err := s.queries.ListLmsPracticesByCourseID(ctx, dbgen.ListLmsPracticesByCourseIDParams{ - CourseID: pgtype.Int8{Int64: courseID, Valid: true}, - Limit: limit, - Offset: offset, + CourseID: pgtype.Int8{Int64: courseID, Valid: true}, + PublishedOnly: publishedOnly, + Limit: limit, + Offset: offset, }) if err != nil { return nil, 0, err @@ -129,7 +155,7 @@ func (s *Store) ListLmsPracticesByCourseID(ctx context.Context, courseID int64, total = r.TotalCount } out = append(out, lmsFromListRow( - r.ID, r.QuestionSetID, r.Title, + r.ID, r.QuestionSetID, r.PublishStatus, r.Title, r.CourseID, r.ModuleID, r.LessonID, r.StoryDescription, r.StoryImage, r.QuickTips, r.PersonaID, r.CreatedAt, r.UpdatedAt, @@ -138,11 +164,12 @@ func (s *Store) ListLmsPracticesByCourseID(ctx context.Context, courseID int64, return out, total, nil } -func (s *Store) ListLmsPracticesByModuleID(ctx context.Context, moduleID int64, limit, offset int32) ([]domain.Practice, int64, error) { +func (s *Store) ListLmsPracticesByModuleID(ctx context.Context, moduleID int64, publishedOnly bool, limit, offset int32) ([]domain.Practice, int64, error) { rows, err := s.queries.ListLmsPracticesByModuleID(ctx, dbgen.ListLmsPracticesByModuleIDParams{ - ModuleID: pgtype.Int8{Int64: moduleID, Valid: true}, - Limit: limit, - Offset: offset, + ModuleID: pgtype.Int8{Int64: moduleID, Valid: true}, + PublishedOnly: publishedOnly, + Limit: limit, + Offset: offset, }) if err != nil { return nil, 0, err @@ -157,7 +184,7 @@ func (s *Store) ListLmsPracticesByModuleID(ctx context.Context, moduleID int64, total = r.TotalCount } out = append(out, lmsFromListRow( - r.ID, r.QuestionSetID, r.Title, + r.ID, r.QuestionSetID, r.PublishStatus, r.Title, r.CourseID, r.ModuleID, r.LessonID, r.StoryDescription, r.StoryImage, r.QuickTips, r.PersonaID, r.CreatedAt, r.UpdatedAt, @@ -166,11 +193,12 @@ func (s *Store) ListLmsPracticesByModuleID(ctx context.Context, moduleID int64, return out, total, nil } -func (s *Store) ListLmsPracticesByLessonID(ctx context.Context, lessonID int64, limit, offset int32) ([]domain.Practice, int64, error) { +func (s *Store) ListLmsPracticesByLessonID(ctx context.Context, lessonID int64, publishedOnly bool, limit, offset int32) ([]domain.Practice, int64, error) { rows, err := s.queries.ListLmsPracticesByLessonID(ctx, dbgen.ListLmsPracticesByLessonIDParams{ - LessonID: pgtype.Int8{Int64: lessonID, Valid: true}, - Limit: limit, - Offset: offset, + LessonID: pgtype.Int8{Int64: lessonID, Valid: true}, + PublishedOnly: publishedOnly, + Limit: limit, + Offset: offset, }) if err != nil { return nil, 0, err @@ -185,7 +213,7 @@ func (s *Store) ListLmsPracticesByLessonID(ctx context.Context, lessonID int64, total = r.TotalCount } out = append(out, lmsFromListRow( - r.ID, r.QuestionSetID, r.Title, + r.ID, r.QuestionSetID, r.PublishStatus, r.Title, r.CourseID, r.ModuleID, r.LessonID, r.StoryDescription, r.StoryImage, r.QuickTips, r.PersonaID, r.CreatedAt, r.UpdatedAt, @@ -194,13 +222,6 @@ func (s *Store) ListLmsPracticesByLessonID(ctx context.Context, lessonID int64, return out, total, nil } -func optionalInt8UpdateID(val *int64) pgtype.Int8 { - if val == nil { - return pgtype.Int8{Valid: false} - } - return pgtype.Int8{Int64: *val, Valid: true} -} - func (s *Store) UpdateLmsPractice(ctx context.Context, id int64, input domain.UpdatePracticeInput) (domain.Practice, error) { var titleText pgtype.Text if input.Title != nil { @@ -217,6 +238,7 @@ func (s *Store) UpdateLmsPractice(ctx context.Context, id int64, input domain.Up PersonaID: optionalInt8UpdateID(input.PersonaID), QuestionSetID: qs, QuickTips: optionalTextUpdate(input.QuickTips), + 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 fbbcce3..8243d23 100644 --- a/internal/repository/programs.go +++ b/internal/repository/programs.go @@ -3,6 +3,7 @@ package repository import ( "context" "errors" + "strings" dbgen "Yimaru-Backend/gen/db" "Yimaru-Backend/internal/domain" @@ -117,6 +118,19 @@ func optionalTextUpdate(val *string) pgtype.Text { return pgtype.Text{String: *val, Valid: true} } +func optionalPublishStatusUpdate(val *string) pgtype.Text { + if val == nil { + return pgtype.Text{Valid: false} + } + s := strings.TrimSpace(strings.ToUpper(*val)) + switch s { + case string(domain.PracticePublishDraft), string(domain.PracticePublishPublished): + return pgtype.Text{String: s, Valid: true} + default: + return pgtype.Text{Valid: false} + } +} + func optionalInt4Update(v *int) pgtype.Int4 { if v == nil { return pgtype.Int4{Valid: false} diff --git a/internal/services/examprep/service.go b/internal/services/examprep/service.go index 4994802..61237b9 100644 --- a/internal/services/examprep/service.go +++ b/internal/services/examprep/service.go @@ -358,7 +358,7 @@ func (s *Service) CreateExamPrepPractice(ctx context.Context, lessonID int64, in return s.store.CreateExamPrepLessonPractice(ctx, lessonID, input) } -func (s *Service) ListExamPrepPracticesByLesson(ctx context.Context, lessonID int64, limit, offset int32) ([]domain.ExamPrepPractice, int64, error) { +func (s *Service) ListExamPrepPracticesByLesson(ctx context.Context, lessonID int64, publishedOnly bool, limit, offset int32) ([]domain.ExamPrepPractice, int64, error) { if err := s.ensureLesson(ctx, lessonID); err != nil { return nil, 0, err } @@ -371,7 +371,7 @@ func (s *Service) ListExamPrepPracticesByLesson(ctx context.Context, lessonID in if offset < 0 { offset = 0 } - return s.store.ListExamPrepLessonPracticesByLessonID(ctx, lessonID, limit, offset) + return s.store.ListExamPrepLessonPracticesByLessonID(ctx, lessonID, publishedOnly, limit, offset) } func (s *Service) GetExamPrepPracticeByID(ctx context.Context, id int64) (domain.ExamPrepPractice, error) { @@ -385,6 +385,10 @@ func (s *Service) GetExamPrepPracticeByID(ctx context.Context, id int64) (domain return p, nil } +func (s *Service) TryGetExamPrepPracticeByQuestionSetID(ctx context.Context, questionSetID int64) (domain.ExamPrepPractice, bool, error) { + return s.store.TryGetExamPrepLessonPracticeByQuestionSetID(ctx, questionSetID) +} + func (s *Service) UpdateExamPrepPractice(ctx context.Context, id int64, input domain.UpdateExamPrepPracticeInput) (domain.ExamPrepPractice, error) { p, err := s.store.UpdateExamPrepLessonPractice(ctx, id, input) if err != nil { diff --git a/internal/services/practices/service.go b/internal/services/practices/service.go index 8d6f7fc..db7d985 100644 --- a/internal/services/practices/service.go +++ b/internal/services/practices/service.go @@ -115,6 +115,10 @@ func (s *Service) Create(ctx context.Context, in domain.CreatePracticeInput) (do return s.practices.CreateLmsPractice(ctx, in, courseID, moduleID, lessonID) } +func (s *Service) TryGetByQuestionSetID(ctx context.Context, questionSetID int64) (domain.Practice, bool, error) { + return s.practices.TryGetLmsPracticeByQuestionSetID(ctx, questionSetID) +} + func (s *Service) GetByID(ctx context.Context, id int64) (domain.Practice, error) { p, err := s.practices.GetLmsPracticeByID(ctx, id) if err != nil { @@ -139,7 +143,7 @@ func clampPracticePage(limit, offset int32) (int32, int32) { return limit, offset } -func (s *Service) ListByCourse(ctx context.Context, courseID int64, limit, offset int32) ([]domain.Practice, int64, error) { +func (s *Service) ListByCourse(ctx context.Context, courseID int64, publishedOnly bool, limit, offset int32) ([]domain.Practice, int64, error) { if _, err := s.courses.GetCourseByID(ctx, courseID); err != nil { if errors.Is(err, pgx.ErrNoRows) { return nil, 0, courses.ErrCourseNotFound @@ -147,10 +151,10 @@ func (s *Service) ListByCourse(ctx context.Context, courseID int64, limit, offse return nil, 0, err } limit, offset = clampPracticePage(limit, offset) - return s.practices.ListLmsPracticesByCourseID(ctx, courseID, limit, offset) + return s.practices.ListLmsPracticesByCourseID(ctx, courseID, publishedOnly, limit, offset) } -func (s *Service) ListByModule(ctx context.Context, moduleID int64, limit, offset int32) ([]domain.Practice, int64, error) { +func (s *Service) ListByModule(ctx context.Context, moduleID int64, publishedOnly bool, limit, offset int32) ([]domain.Practice, int64, error) { if _, err := s.modules.GetModuleByID(ctx, moduleID); err != nil { if errors.Is(err, pgx.ErrNoRows) { return nil, 0, modules.ErrModuleNotFound @@ -158,10 +162,10 @@ func (s *Service) ListByModule(ctx context.Context, moduleID int64, limit, offse return nil, 0, err } limit, offset = clampPracticePage(limit, offset) - return s.practices.ListLmsPracticesByModuleID(ctx, moduleID, limit, offset) + return s.practices.ListLmsPracticesByModuleID(ctx, moduleID, publishedOnly, limit, offset) } -func (s *Service) ListByLesson(ctx context.Context, lessonID int64, limit, offset int32) ([]domain.Practice, int64, error) { +func (s *Service) ListByLesson(ctx context.Context, lessonID int64, publishedOnly bool, limit, offset int32) ([]domain.Practice, int64, error) { if _, err := s.lessons.GetLessonByID(ctx, lessonID); err != nil { if errors.Is(err, pgx.ErrNoRows) { return nil, 0, lessons.ErrLessonNotFound @@ -169,7 +173,7 @@ func (s *Service) ListByLesson(ctx context.Context, lessonID int64, limit, offse return nil, 0, err } limit, offset = clampPracticePage(limit, offset) - return s.practices.ListLmsPracticesByLessonID(ctx, lessonID, limit, offset) + return s.practices.ListLmsPracticesByLessonID(ctx, lessonID, publishedOnly, limit, offset) } func (s *Service) Update(ctx context.Context, id int64, input domain.UpdatePracticeInput) (domain.Practice, error) { diff --git a/internal/web_server/handlers/exam_prep_practice_handler.go b/internal/web_server/handlers/exam_prep_practice_handler.go index 518c3e8..34cba6b 100644 --- a/internal/web_server/handlers/exam_prep_practice_handler.go +++ b/internal/web_server/handlers/exam_prep_practice_handler.go @@ -74,7 +74,8 @@ func (h *Handler) ListExamPrepPracticesByLesson(c *fiber.Ctx) error { } limit, _ := strconv.Atoi(c.Query("limit", "20")) offset, _ := strconv.Atoi(c.Query("offset", "0")) - items, total, err := h.examPrepSvc.ListExamPrepPracticesByLesson(c.Context(), lessonID, int32(limit), int32(offset)) + publishedOnly := !h.canManageExamPrepPractices(c) + items, total, err := h.examPrepSvc.ListExamPrepPracticesByLesson(c.Context(), lessonID, publishedOnly, int32(limit), int32(offset)) if err != nil { if errors.Is(err, examprep.ErrLessonNotFound) { return c.Status(fiber.StatusNotFound).JSON(domain.ErrorResponse{ @@ -126,6 +127,9 @@ func (h *Handler) GetExamPrepPracticeByID(c *fiber.Ctx) error { Error: err.Error(), }) } + if !p.VisibleToLearners() && !h.canManageExamPrepPractices(c) { + return c.Status(fiber.StatusNotFound).JSON(domain.ErrorResponse{Message: "Practice not found"}) + } return c.JSON(domain.Response{ Message: "Practice retrieved successfully", Data: p, diff --git a/internal/web_server/handlers/practice_handler.go b/internal/web_server/handlers/practice_handler.go index 1cca038..1241ffe 100644 --- a/internal/web_server/handlers/practice_handler.go +++ b/internal/web_server/handlers/practice_handler.go @@ -76,7 +76,8 @@ func (h *Handler) ListPracticesByCourse(c *fiber.Ctx) error { } limit, _ := strconv.Atoi(c.Query("limit", "20")) offset, _ := strconv.Atoi(c.Query("offset", "0")) - items, total, err := h.practiceSvc.ListByCourse(c.Context(), courseID, int32(limit), int32(offset)) + publishedOnly := !h.canManageLMSPractices(c) + items, total, err := h.practiceSvc.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{Message: "Course not found", Error: err.Error()}) @@ -107,7 +108,8 @@ func (h *Handler) ListPracticesByModule(c *fiber.Ctx) error { } limit, _ := strconv.Atoi(c.Query("limit", "20")) offset, _ := strconv.Atoi(c.Query("offset", "0")) - items, total, err := h.practiceSvc.ListByModule(c.Context(), moduleID, int32(limit), int32(offset)) + publishedOnly := !h.canManageLMSPractices(c) + items, total, err := h.practiceSvc.ListByModule(c.Context(), moduleID, publishedOnly, int32(limit), int32(offset)) if err != nil { if errors.Is(err, modules.ErrModuleNotFound) { return c.Status(fiber.StatusNotFound).JSON(domain.ErrorResponse{Message: "Module not found", Error: err.Error()}) @@ -138,7 +140,8 @@ func (h *Handler) ListPracticesByLesson(c *fiber.Ctx) error { } limit, _ := strconv.Atoi(c.Query("limit", "20")) offset, _ := strconv.Atoi(c.Query("offset", "0")) - items, total, err := h.practiceSvc.ListByLesson(c.Context(), lessonID, int32(limit), int32(offset)) + publishedOnly := !h.canManageLMSPractices(c) + items, total, err := h.practiceSvc.ListByLesson(c.Context(), lessonID, publishedOnly, int32(limit), int32(offset)) if err != nil { if errors.Is(err, lessons.ErrLessonNotFound) { return c.Status(fiber.StatusNotFound).JSON(domain.ErrorResponse{Message: "Lesson not found", Error: err.Error()}) @@ -174,6 +177,9 @@ func (h *Handler) GetPractice(c *fiber.Ctx) error { } return c.Status(fiber.StatusInternalServerError).JSON(domain.ErrorResponse{Message: "Failed to load practice", Error: err.Error()}) } + if !p.VisibleToLearners() && !h.canManageLMSPractices(c) { + return c.Status(fiber.StatusNotFound).JSON(domain.ErrorResponse{Message: "Practice not found"}) + } return c.JSON(domain.Response{Message: "Practice retrieved successfully", Data: p, Success: true, StatusCode: fiber.StatusOK}) } diff --git a/internal/web_server/handlers/practice_publish_gate.go b/internal/web_server/handlers/practice_publish_gate.go new file mode 100644 index 0000000..28506a4 --- /dev/null +++ b/internal/web_server/handlers/practice_publish_gate.go @@ -0,0 +1,51 @@ +package handlers + +import ( + "Yimaru-Backend/internal/domain" + + "github.com/gofiber/fiber/v2" +) + +func (h *Handler) canManageLMSPractices(c *fiber.Ctx) bool { + rn := string(c.Locals("role").(domain.Role)) + return h.rbacSvc.HasPermission(rn, "practices.create") || h.rbacSvc.HasPermission(rn, "practices.update") +} + +func (h *Handler) canManageExamPrepPractices(c *fiber.Ctx) bool { + rn := string(c.Locals("role").(domain.Role)) + return h.rbacSvc.HasPermission(rn, "exam_prep.practices.create") || h.rbacSvc.HasPermission(rn, "exam_prep.practices.update") +} + +// forbidIfLinkedPracticeDraftForSubscriber returns 404 for draft LMS/exam-prep shells when the caller cannot manage content. +func (h *Handler) forbidIfLinkedPracticeDraftForSubscriber(c *fiber.Ctx, questionSetID int64) error { + if lp, ok, err := h.practiceSvc.TryGetByQuestionSetID(c.Context(), questionSetID); err != nil { + return err + } else if ok && !lp.VisibleToLearners() && !h.canManageLMSPractices(c) { + return fiber.NewError(fiber.StatusNotFound, "Practice not found") + } + + if ep, ok, err := h.examPrepSvc.TryGetExamPrepPracticeByQuestionSetID(c.Context(), questionSetID); err != nil { + return err + } else if ok && !ep.VisibleToLearners() && !h.canManageExamPrepPractices(c) { + return fiber.NewError(fiber.StatusNotFound, "Practice not found") + } + + return nil +} + +// forbidCompletingDraftPractice blocks completion when an LMS/exam-prep shell is still in draft (learners/students path). +func (h *Handler) forbidCompletingDraftPractice(c *fiber.Ctx, questionSetID int64) error { + if lp, ok, err := h.practiceSvc.TryGetByQuestionSetID(c.Context(), questionSetID); err != nil { + return err + } else if ok && !lp.VisibleToLearners() { + return fiber.NewError(fiber.StatusForbidden, "Only published practices can be completed") + } + + if ep, ok, err := h.examPrepSvc.TryGetExamPrepPracticeByQuestionSetID(c.Context(), questionSetID); err != nil { + return err + } else if ok && !ep.VisibleToLearners() { + return fiber.NewError(fiber.StatusForbidden, "Only published practices can be completed") + } + + return nil +} diff --git a/internal/web_server/handlers/questions.go b/internal/web_server/handlers/questions.go index c693bbe..19a8b1c 100644 --- a/internal/web_server/handlers/questions.go +++ b/internal/web_server/handlers/questions.go @@ -4,6 +4,7 @@ import ( "Yimaru-Backend/internal/domain" "context" "encoding/json" + "errors" "fmt" "strconv" "strings" @@ -1469,6 +1470,20 @@ func (h *Handler) GetQuestionsByPractice(c *fiber.Ctx) error { }) } + if err := h.forbidIfLinkedPracticeDraftForSubscriber(c, set.ID); err != nil { + code := fiber.StatusInternalServerError + msg := err.Error() + var ferr *fiber.Error + if errors.As(err, &ferr) { + code = ferr.Code + msg = ferr.Message + } + return c.Status(code).JSON(domain.ErrorResponse{ + Message: msg, + Error: err.Error(), + }) + } + if err := h.enforcePracticeSequenceForStudent(c, set); err != nil { status := fiber.StatusForbidden if ferr, ok := err.(*fiber.Error); ok { @@ -1552,6 +1567,11 @@ func (h *Handler) CompletePractice(c *fiber.Ctx) error { var set domain.QuestionSet var setErr error if practiceErr == nil { + if !practice.VisibleToLearners() { + return c.Status(fiber.StatusForbidden).JSON(domain.ErrorResponse{ + Message: "Only published practices can be completed", + }) + } set, setErr = h.questionsSvc.GetQuestionSetByID(c.Context(), practice.QuestionSetID) } else { // Backward compatibility: also accept question_set.id directly. @@ -1566,6 +1586,21 @@ func (h *Handler) CompletePractice(c *fiber.Ctx) error { if !strings.EqualFold(set.SetType, string(domain.QuestionSetTypePractice)) { return c.Status(fiber.StatusNotFound).JSON(domain.ErrorResponse{Message: "Practice not found"}) } + if practiceErr != nil { + if err := h.forbidCompletingDraftPractice(c, set.ID); err != nil { + code := fiber.StatusInternalServerError + msg := err.Error() + var ferr *fiber.Error + if errors.As(err, &ferr) { + code = ferr.Code + msg = ferr.Message + } + return c.Status(code).JSON(domain.ErrorResponse{ + Message: msg, + Error: err.Error(), + }) + } + } // Enforce sequential gating only for published practices. if strings.EqualFold(set.Status, "PUBLISHED") {