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 <cursoragent@cursor.com>
This commit is contained in:
Yared Yemane 2026-05-19 03:57:43 -07:00
parent 37aef49e28
commit 12ad59c409
33 changed files with 521 additions and 99 deletions

View File

@ -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;

View File

@ -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'));

View File

@ -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 *;

View File

@ -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'

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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 *;

View File

@ -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

View File

@ -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'

View File

@ -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"
}
```

View File

@ -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
}

View File

@ -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'

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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,10 +143,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 (
$4::boolean = FALSE
OR p.publish_status = 'PUBLISHED'::TEXT
)
ORDER BY p.created_at DESC
LIMIT $2 OFFSET $3
`
@ -122,6 +160,7 @@ type ListLmsPracticesByCourseIDParams struct {
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,10 +233,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 (
$4::boolean = FALSE
OR p.publish_status = 'PUBLISHED'::TEXT
)
ORDER BY p.created_at DESC
LIMIT $2 OFFSET $3
`
@ -199,6 +250,7 @@ type ListLmsPracticesByLessonIDParams struct {
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,10 +323,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 (
$4::boolean = FALSE
OR p.publish_status = 'PUBLISHED'::TEXT
)
ORDER BY p.created_at DESC
LIMIT $2 OFFSET $3
`
@ -276,6 +340,7 @@ type ListLmsPracticesByModuleIDParams struct {
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
}

View File

@ -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 {

View File

@ -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 {

View File

@ -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'

View File

@ -11,11 +11,17 @@ type ExamPrepPractice struct {
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).
type CreateExamPrepPracticeInput struct {
Title string `json:"title" validate:"required"`
@ -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"`
}

View File

@ -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,6 +14,31 @@ 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"`
@ -21,11 +49,17 @@ type Practice struct {
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 {
ParentKind ParentKind `json:"parent_kind" validate:"required,oneof=COURSE MODULE LESSON"`
ParentID int64 `json:"parent_id" validate:"required,gt=0"`
@ -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"`
}

View File

@ -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
}

View File

@ -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
}

View File

@ -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) {

View File

@ -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,9 +123,22 @@ 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},
PublishedOnly: publishedOnly,
Limit: limit,
Offset: offset,
})
@ -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,9 +164,10 @@ 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},
PublishedOnly: publishedOnly,
Limit: limit,
Offset: offset,
})
@ -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,9 +193,10 @@ 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},
PublishedOnly: publishedOnly,
Limit: limit,
Offset: offset,
})
@ -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) {

View File

@ -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}

View File

@ -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 {

View File

@ -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) {

View File

@ -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,

View File

@ -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})
}

View File

@ -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
}

View File

@ -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") {