Add LMS lesson draft and publish visibility.

Migration 000062 adds lessons.publish_status (DRAFT default for new rows; existing rows published). Editors see all lessons; learners see published-only lists and GET by id. Sequential prerequisites and completion counts ignore drafts. Course lesson_count counts published lessons only. Swagger documents publish_status on create/update bodies.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Yared Yemane 2026-05-20 02:16:42 -07:00
parent fffdff1031
commit bd1767d2a6
18 changed files with 311 additions and 138 deletions

View File

@ -0,0 +1,3 @@
ALTER TABLE lessons DROP CONSTRAINT IF EXISTS chk_lessons_publish_status;
ALTER TABLE lessons DROP COLUMN IF EXISTS publish_status;

View File

@ -0,0 +1,9 @@
-- Draft vs published visibility for LMS lessons (mirrors lms_practices.publish_status).
ALTER TABLE lessons
ADD COLUMN publish_status VARCHAR(16) NOT NULL DEFAULT 'PUBLISHED'
CONSTRAINT chk_lessons_publish_status CHECK (publish_status IN ('DRAFT', 'PUBLISHED'));
-- New inserts default to draft unless the API sends PUBLISHED; existing rows stay published.
ALTER TABLE lessons
ALTER COLUMN publish_status SET DEFAULT 'DRAFT';

View File

@ -65,7 +65,8 @@ SELECT
lessons l lessons l
INNER JOIN modules m ON l.module_id = m.id INNER JOIN modules m ON l.module_id = m.id
WHERE WHERE
m.course_id = c.id) AS lesson_count, m.course_id = c.id
AND l.publish_status = 'PUBLISHED') AS lesson_count,
-- Practices whose parent is the course only (lms_practices.course_id). Excludes -- Practices whose parent is the course only (lms_practices.course_id). Excludes
-- practices linked via module_id or lesson_id, even for modules/lessons in this course. -- practices linked via module_id or lesson_id, even for modules/lessons in this course.
( (

View File

@ -1,5 +1,5 @@
-- name: CreateLesson :one -- name: CreateLesson :one
INSERT INTO lessons (module_id, title, video_url, thumbnail, description, sort_order) INSERT INTO lessons (module_id, title, video_url, thumbnail, description, sort_order, publish_status)
SELECT SELECT
sqlc.arg('module_id'), sqlc.arg('module_id'),
sqlc.arg('title'), sqlc.arg('title'),
@ -12,7 +12,8 @@ SELECT
max(l.sort_order) max(l.sort_order)
FROM lessons l FROM lessons l
WHERE WHERE
l.module_id = sqlc.arg('module_id')), 0) + 1) l.module_id = sqlc.arg('module_id')), 0) + 1),
sqlc.arg('publish_status')
RETURNING RETURNING
*; *;
@ -39,6 +40,7 @@ SELECT
l.thumbnail, l.thumbnail,
l.description, l.description,
l.sort_order, l.sort_order,
l.publish_status,
l.created_at, l.created_at,
l.updated_at, l.updated_at,
EXISTS ( EXISTS (
@ -51,6 +53,10 @@ FROM
lessons l lessons l
WHERE WHERE
l.module_id = $1 l.module_id = $1
AND (
sqlc.arg('published_only')::boolean = FALSE
OR l.publish_status = 'PUBLISHED'::TEXT
)
ORDER BY ORDER BY
l.sort_order ASC, l.sort_order ASC,
l.id ASC l.id ASC
@ -65,6 +71,7 @@ SET
thumbnail = COALESCE(sqlc.narg('thumbnail')::text, thumbnail), thumbnail = COALESCE(sqlc.narg('thumbnail')::text, thumbnail),
description = COALESCE(sqlc.narg('description')::text, description), description = COALESCE(sqlc.narg('description')::text, description),
sort_order = coalesce(sqlc.narg('sort_order')::int, sort_order), sort_order = coalesce(sqlc.narg('sort_order')::int, sort_order),
publish_status = COALESCE(sqlc.narg('publish_status')::varchar, publish_status),
updated_at = CURRENT_TIMESTAMP updated_at = CURRENT_TIMESTAMP
WHERE WHERE
id = sqlc.arg('id') id = sqlc.arg('id')

View File

@ -33,9 +33,21 @@ SELECT
FROM FROM
lessons AS l1 lessons AS l1
INNER JOIN lessons AS l2 ON l2.module_id = l1.module_id INNER JOIN lessons AS l2 ON l2.module_id = l1.module_id
AND l2.sort_order = l1.sort_order - 1 AND l2.publish_status = 'PUBLISHED'
AND l1.publish_status = 'PUBLISHED'
AND (
l2.sort_order < l1.sort_order
OR (
l2.sort_order = l1.sort_order
AND l2.id < l1.id
)
)
WHERE WHERE
l1.id = $1; l1.id = $1
ORDER BY
l2.sort_order DESC,
l2.id DESC
LIMIT 1;
-- name: UserHasProgramProgress :one -- name: UserHasProgramProgress :one
SELECT SELECT
@ -111,7 +123,8 @@ SELECT
FROM FROM
lessons lessons
WHERE WHERE
module_id = $1; module_id = $1
AND publish_status = 'PUBLISHED';
-- name: CountUserCompletedLessonsInModule :one -- name: CountUserCompletedLessonsInModule :one
SELECT SELECT
@ -121,7 +134,8 @@ FROM
INNER JOIN lessons l ON l.id = ulp.lesson_id INNER JOIN lessons l ON l.id = ulp.lesson_id
WHERE WHERE
l.module_id = $1 l.module_id = $1
AND ulp.user_id = $2; AND ulp.user_id = $2
AND l.publish_status = 'PUBLISHED';
-- name: CountModulesInCourse :one -- name: CountModulesInCourse :one
SELECT SELECT
@ -211,7 +225,8 @@ FROM
lessons l lessons l
INNER JOIN modules m ON m.id = l.module_id INNER JOIN modules m ON m.id = l.module_id
WHERE WHERE
m.course_id = $1; m.course_id = $1
AND l.publish_status = 'PUBLISHED';
-- name: CountUserCompletedLessonsInCourse :one -- name: CountUserCompletedLessonsInCourse :one
SELECT SELECT
@ -222,7 +237,8 @@ FROM
INNER JOIN modules m ON m.id = l.module_id INNER JOIN modules m ON m.id = l.module_id
WHERE WHERE
m.course_id = $1 m.course_id = $1
AND ulp.user_id = $2; AND ulp.user_id = $2
AND l.publish_status = 'PUBLISHED';
-- Lesson-based progress within a program (all courses). -- Lesson-based progress within a program (all courses).
-- name: CountLessonsInProgram :one -- name: CountLessonsInProgram :one
@ -233,7 +249,8 @@ FROM
INNER JOIN modules m ON m.id = l.module_id INNER JOIN modules m ON m.id = l.module_id
INNER JOIN courses c ON c.id = m.course_id INNER JOIN courses c ON c.id = m.course_id
WHERE WHERE
c.program_id = $1; c.program_id = $1
AND l.publish_status = 'PUBLISHED';
-- name: CountUserCompletedLessonsInProgram :one -- name: CountUserCompletedLessonsInProgram :one
SELECT SELECT
@ -245,7 +262,8 @@ FROM
INNER JOIN courses c ON c.id = m.course_id INNER JOIN courses c ON c.id = m.course_id
WHERE WHERE
c.program_id = $1 c.program_id = $1
AND ulp.user_id = $2; AND ulp.user_id = $2
AND l.publish_status = 'PUBLISHED';
-- Published practices in a module (module-level and lesson-level practices should carry module_id). -- Published practices in a module (module-level and lesson-level practices should carry module_id).
-- name: CountPublishedPracticesInModule :one -- name: CountPublishedPracticesInModule :one

View File

@ -10484,6 +10484,10 @@ const docTemplate = `{
"description": { "description": {
"type": "string" "type": "string"
}, },
"publish_status": {
"description": "Omit or DRAFT (default) for drafts; PUBLISHED for learner-visible lessons.",
"type": "string"
},
"sort_order": { "sort_order": {
"type": "integer" "type": "integer"
}, },
@ -11375,6 +11379,10 @@ const docTemplate = `{
"description": { "description": {
"type": "string" "type": "string"
}, },
"publish_status": {
"description": "DRAFT or PUBLISHED",
"type": "string"
},
"sort_order": { "sort_order": {
"type": "integer" "type": "integer"
}, },

View File

@ -10476,6 +10476,10 @@
"description": { "description": {
"type": "string" "type": "string"
}, },
"publish_status": {
"description": "Omit or DRAFT (default) for drafts; PUBLISHED for learner-visible lessons.",
"type": "string"
},
"sort_order": { "sort_order": {
"type": "integer" "type": "integer"
}, },
@ -11367,6 +11371,10 @@
"description": { "description": {
"type": "string" "type": "string"
}, },
"publish_status": {
"description": "DRAFT or PUBLISHED",
"type": "string"
},
"sort_order": { "sort_order": {
"type": "integer" "type": "integer"
}, },

View File

@ -418,6 +418,9 @@ definitions:
properties: properties:
description: description:
type: string type: string
publish_status:
description: Omit or DRAFT (default) for drafts; PUBLISHED for learner-visible lessons.
type: string
sort_order: sort_order:
type: integer type: integer
thumbnail: thumbnail:
@ -1027,6 +1030,9 @@ definitions:
properties: properties:
description: description:
type: string type: string
publish_status:
description: DRAFT or PUBLISHED
type: string
sort_order: sort_order:
type: integer type: integer
thumbnail: thumbnail:

View File

@ -170,7 +170,8 @@ SELECT
lessons l lessons l
INNER JOIN modules m ON l.module_id = m.id INNER JOIN modules m ON l.module_id = m.id
WHERE WHERE
m.course_id = c.id) AS lesson_count, m.course_id = c.id
AND l.publish_status = 'PUBLISHED') AS lesson_count,
-- Practices whose parent is the course only (lms_practices.course_id). Excludes -- Practices whose parent is the course only (lms_practices.course_id). Excludes
-- practices linked via module_id or lesson_id, even for modules/lessons in this course. -- practices linked via module_id or lesson_id, even for modules/lessons in this course.
( (

View File

@ -12,7 +12,7 @@ import (
) )
const CreateLesson = `-- name: CreateLesson :one const CreateLesson = `-- name: CreateLesson :one
INSERT INTO lessons (module_id, title, video_url, thumbnail, description, sort_order) INSERT INTO lessons (module_id, title, video_url, thumbnail, description, sort_order, publish_status)
SELECT SELECT
$1, $1,
$2, $2,
@ -25,18 +25,20 @@ SELECT
max(l.sort_order) max(l.sort_order)
FROM lessons l FROM lessons l
WHERE WHERE
l.module_id = $1), 0) + 1) l.module_id = $1), 0) + 1),
$7
RETURNING RETURNING
id, module_id, title, video_url, thumbnail, description, created_at, updated_at, sort_order id, module_id, title, video_url, thumbnail, description, created_at, updated_at, sort_order, publish_status
` `
type CreateLessonParams struct { type CreateLessonParams struct {
ModuleID int64 `json:"module_id"` ModuleID int64 `json:"module_id"`
Title string `json:"title"` Title string `json:"title"`
VideoUrl pgtype.Text `json:"video_url"` VideoUrl pgtype.Text `json:"video_url"`
Thumbnail pgtype.Text `json:"thumbnail"` Thumbnail pgtype.Text `json:"thumbnail"`
Description pgtype.Text `json:"description"` Description pgtype.Text `json:"description"`
SortOrder pgtype.Int4 `json:"sort_order"` SortOrder pgtype.Int4 `json:"sort_order"`
PublishStatus string `json:"publish_status"`
} }
func (q *Queries) CreateLesson(ctx context.Context, arg CreateLessonParams) (Lesson, error) { func (q *Queries) CreateLesson(ctx context.Context, arg CreateLessonParams) (Lesson, error) {
@ -47,6 +49,7 @@ func (q *Queries) CreateLesson(ctx context.Context, arg CreateLessonParams) (Les
arg.Thumbnail, arg.Thumbnail,
arg.Description, arg.Description,
arg.SortOrder, arg.SortOrder,
arg.PublishStatus,
) )
var i Lesson var i Lesson
err := row.Scan( err := row.Scan(
@ -59,6 +62,7 @@ func (q *Queries) CreateLesson(ctx context.Context, arg CreateLessonParams) (Les
&i.CreatedAt, &i.CreatedAt,
&i.UpdatedAt, &i.UpdatedAt,
&i.SortOrder, &i.SortOrder,
&i.PublishStatus,
) )
return i, err return i, err
} }
@ -75,7 +79,7 @@ func (q *Queries) DeleteLesson(ctx context.Context, id int64) error {
const GetLessonByID = `-- name: GetLessonByID :one const GetLessonByID = `-- name: GetLessonByID :one
SELECT SELECT
l.id, l.module_id, l.title, l.video_url, l.thumbnail, l.description, l.created_at, l.updated_at, l.sort_order, l.id, l.module_id, l.title, l.video_url, l.thumbnail, l.description, l.created_at, l.updated_at, l.sort_order, l.publish_status,
EXISTS ( EXISTS (
SELECT 1 SELECT 1
FROM lms_practices p FROM lms_practices p
@ -88,16 +92,17 @@ WHERE l.id = $1
` `
type GetLessonByIDRow struct { type GetLessonByIDRow struct {
ID int64 `json:"id"` ID int64 `json:"id"`
ModuleID int64 `json:"module_id"` ModuleID int64 `json:"module_id"`
Title string `json:"title"` Title string `json:"title"`
VideoUrl pgtype.Text `json:"video_url"` VideoUrl pgtype.Text `json:"video_url"`
Thumbnail pgtype.Text `json:"thumbnail"` Thumbnail pgtype.Text `json:"thumbnail"`
Description pgtype.Text `json:"description"` Description pgtype.Text `json:"description"`
CreatedAt pgtype.Timestamptz `json:"created_at"` CreatedAt pgtype.Timestamptz `json:"created_at"`
UpdatedAt pgtype.Timestamptz `json:"updated_at"` UpdatedAt pgtype.Timestamptz `json:"updated_at"`
SortOrder int32 `json:"sort_order"` SortOrder int32 `json:"sort_order"`
HasPractice bool `json:"has_practice"` PublishStatus string `json:"publish_status"`
HasPractice bool `json:"has_practice"`
} }
func (q *Queries) GetLessonByID(ctx context.Context, id int64) (GetLessonByIDRow, error) { func (q *Queries) GetLessonByID(ctx context.Context, id int64) (GetLessonByIDRow, error) {
@ -113,6 +118,7 @@ func (q *Queries) GetLessonByID(ctx context.Context, id int64) (GetLessonByIDRow
&i.CreatedAt, &i.CreatedAt,
&i.UpdatedAt, &i.UpdatedAt,
&i.SortOrder, &i.SortOrder,
&i.PublishStatus,
&i.HasPractice, &i.HasPractice,
) )
return i, err return i, err
@ -128,6 +134,7 @@ SELECT
l.thumbnail, l.thumbnail,
l.description, l.description,
l.sort_order, l.sort_order,
l.publish_status,
l.created_at, l.created_at,
l.updated_at, l.updated_at,
EXISTS ( EXISTS (
@ -140,6 +147,10 @@ FROM
lessons l lessons l
WHERE WHERE
l.module_id = $1 l.module_id = $1
AND (
$4::boolean = FALSE
OR l.publish_status = 'PUBLISHED'::TEXT
)
ORDER BY ORDER BY
l.sort_order ASC, l.sort_order ASC,
l.id ASC l.id ASC
@ -148,27 +159,34 @@ OFFSET $3
` `
type ListLessonsByModuleIDParams struct { type ListLessonsByModuleIDParams struct {
ModuleID int64 `json:"module_id"` ModuleID int64 `json:"module_id"`
Limit int32 `json:"limit"` Limit int32 `json:"limit"`
Offset int32 `json:"offset"` Offset int32 `json:"offset"`
PublishedOnly bool `json:"published_only"`
} }
type ListLessonsByModuleIDRow struct { type ListLessonsByModuleIDRow struct {
TotalCount int64 `json:"total_count"` TotalCount int64 `json:"total_count"`
ID int64 `json:"id"` ID int64 `json:"id"`
ModuleID int64 `json:"module_id"` ModuleID int64 `json:"module_id"`
Title string `json:"title"` Title string `json:"title"`
VideoUrl pgtype.Text `json:"video_url"` VideoUrl pgtype.Text `json:"video_url"`
Thumbnail pgtype.Text `json:"thumbnail"` Thumbnail pgtype.Text `json:"thumbnail"`
Description pgtype.Text `json:"description"` Description pgtype.Text `json:"description"`
SortOrder int32 `json:"sort_order"` SortOrder int32 `json:"sort_order"`
CreatedAt pgtype.Timestamptz `json:"created_at"` PublishStatus string `json:"publish_status"`
UpdatedAt pgtype.Timestamptz `json:"updated_at"` CreatedAt pgtype.Timestamptz `json:"created_at"`
HasPractice bool `json:"has_practice"` UpdatedAt pgtype.Timestamptz `json:"updated_at"`
HasPractice bool `json:"has_practice"`
} }
func (q *Queries) ListLessonsByModuleID(ctx context.Context, arg ListLessonsByModuleIDParams) ([]ListLessonsByModuleIDRow, error) { func (q *Queries) ListLessonsByModuleID(ctx context.Context, arg ListLessonsByModuleIDParams) ([]ListLessonsByModuleIDRow, error) {
rows, err := q.db.Query(ctx, ListLessonsByModuleID, arg.ModuleID, arg.Limit, arg.Offset) rows, err := q.db.Query(ctx, ListLessonsByModuleID,
arg.ModuleID,
arg.Limit,
arg.Offset,
arg.PublishedOnly,
)
if err != nil { if err != nil {
return nil, err return nil, err
} }
@ -185,6 +203,7 @@ func (q *Queries) ListLessonsByModuleID(ctx context.Context, arg ListLessonsByMo
&i.Thumbnail, &i.Thumbnail,
&i.Description, &i.Description,
&i.SortOrder, &i.SortOrder,
&i.PublishStatus,
&i.CreatedAt, &i.CreatedAt,
&i.UpdatedAt, &i.UpdatedAt,
&i.HasPractice, &i.HasPractice,
@ -207,20 +226,22 @@ SET
thumbnail = COALESCE($3::text, thumbnail), thumbnail = COALESCE($3::text, thumbnail),
description = COALESCE($4::text, description), description = COALESCE($4::text, description),
sort_order = coalesce($5::int, sort_order), sort_order = coalesce($5::int, sort_order),
publish_status = COALESCE($6::varchar, publish_status),
updated_at = CURRENT_TIMESTAMP updated_at = CURRENT_TIMESTAMP
WHERE WHERE
id = $6 id = $7
RETURNING RETURNING
id, module_id, title, video_url, thumbnail, description, created_at, updated_at, sort_order id, module_id, title, video_url, thumbnail, description, created_at, updated_at, sort_order, publish_status
` `
type UpdateLessonParams struct { type UpdateLessonParams struct {
Title pgtype.Text `json:"title"` Title pgtype.Text `json:"title"`
VideoUrl pgtype.Text `json:"video_url"` VideoUrl pgtype.Text `json:"video_url"`
Thumbnail pgtype.Text `json:"thumbnail"` Thumbnail pgtype.Text `json:"thumbnail"`
Description pgtype.Text `json:"description"` Description pgtype.Text `json:"description"`
SortOrder pgtype.Int4 `json:"sort_order"` SortOrder pgtype.Int4 `json:"sort_order"`
ID int64 `json:"id"` PublishStatus pgtype.Text `json:"publish_status"`
ID int64 `json:"id"`
} }
func (q *Queries) UpdateLesson(ctx context.Context, arg UpdateLessonParams) (Lesson, error) { func (q *Queries) UpdateLesson(ctx context.Context, arg UpdateLessonParams) (Lesson, error) {
@ -230,6 +251,7 @@ func (q *Queries) UpdateLesson(ctx context.Context, arg UpdateLessonParams) (Les
arg.Thumbnail, arg.Thumbnail,
arg.Description, arg.Description,
arg.SortOrder, arg.SortOrder,
arg.PublishStatus,
arg.ID, arg.ID,
) )
var i Lesson var i Lesson
@ -243,6 +265,7 @@ func (q *Queries) UpdateLesson(ctx context.Context, arg UpdateLessonParams) (Les
&i.CreatedAt, &i.CreatedAt,
&i.UpdatedAt, &i.UpdatedAt,
&i.SortOrder, &i.SortOrder,
&i.PublishStatus,
) )
return i, err return i, err
} }

View File

@ -35,6 +35,7 @@ FROM
INNER JOIN modules m ON m.id = l.module_id INNER JOIN modules m ON m.id = l.module_id
WHERE WHERE
m.course_id = $1 m.course_id = $1
AND l.publish_status = 'PUBLISHED'
` `
// Lesson-based progress within a course (all modules). // Lesson-based progress within a course (all modules).
@ -52,6 +53,7 @@ FROM
lessons lessons
WHERE WHERE
module_id = $1 module_id = $1
AND publish_status = 'PUBLISHED'
` `
func (q *Queries) CountLessonsInModule(ctx context.Context, moduleID int64) (int32, error) { func (q *Queries) CountLessonsInModule(ctx context.Context, moduleID int64) (int32, error) {
@ -70,6 +72,7 @@ FROM
INNER JOIN courses c ON c.id = m.course_id INNER JOIN courses c ON c.id = m.course_id
WHERE WHERE
c.program_id = $1 c.program_id = $1
AND l.publish_status = 'PUBLISHED'
` `
// Lesson-based progress within a program (all courses). // Lesson-based progress within a program (all courses).
@ -191,6 +194,7 @@ FROM
WHERE WHERE
m.course_id = $1 m.course_id = $1
AND ulp.user_id = $2 AND ulp.user_id = $2
AND l.publish_status = 'PUBLISHED'
` `
type CountUserCompletedLessonsInCourseParams struct { type CountUserCompletedLessonsInCourseParams struct {
@ -214,6 +218,7 @@ FROM
WHERE WHERE
l.module_id = $1 l.module_id = $1
AND ulp.user_id = $2 AND ulp.user_id = $2
AND l.publish_status = 'PUBLISHED'
` `
type CountUserCompletedLessonsInModuleParams struct { type CountUserCompletedLessonsInModuleParams struct {
@ -239,6 +244,7 @@ FROM
WHERE WHERE
c.program_id = $1 c.program_id = $1
AND ulp.user_id = $2 AND ulp.user_id = $2
AND l.publish_status = 'PUBLISHED'
` `
type CountUserCompletedLessonsInProgramParams struct { type CountUserCompletedLessonsInProgramParams struct {
@ -423,13 +429,25 @@ func (q *Queries) GetPreviousCourseInProgram(ctx context.Context, id int64) (Cou
const GetPreviousLessonInModule = `-- name: GetPreviousLessonInModule :one const GetPreviousLessonInModule = `-- name: GetPreviousLessonInModule :one
SELECT SELECT
l2.id, l2.module_id, l2.title, l2.video_url, l2.thumbnail, l2.description, l2.created_at, l2.updated_at, l2.sort_order l2.id, l2.module_id, l2.title, l2.video_url, l2.thumbnail, l2.description, l2.created_at, l2.updated_at, l2.sort_order, l2.publish_status
FROM FROM
lessons AS l1 lessons AS l1
INNER JOIN lessons AS l2 ON l2.module_id = l1.module_id INNER JOIN lessons AS l2 ON l2.module_id = l1.module_id
AND l2.sort_order = l1.sort_order - 1 AND l2.publish_status = 'PUBLISHED'
AND l1.publish_status = 'PUBLISHED'
AND (
l2.sort_order < l1.sort_order
OR (
l2.sort_order = l1.sort_order
AND l2.id < l1.id
)
)
WHERE WHERE
l1.id = $1 l1.id = $1
ORDER BY
l2.sort_order DESC,
l2.id DESC
LIMIT 1
` `
func (q *Queries) GetPreviousLessonInModule(ctx context.Context, id int64) (Lesson, error) { func (q *Queries) GetPreviousLessonInModule(ctx context.Context, id int64) (Lesson, error) {
@ -445,6 +463,7 @@ func (q *Queries) GetPreviousLessonInModule(ctx context.Context, id int64) (Less
&i.CreatedAt, &i.CreatedAt,
&i.UpdatedAt, &i.UpdatedAt,
&i.SortOrder, &i.SortOrder,
&i.PublishStatus,
) )
return i, err return i, err
} }

View File

@ -121,15 +121,16 @@ type GlobalSetting struct {
} }
type Lesson struct { type Lesson struct {
ID int64 `json:"id"` ID int64 `json:"id"`
ModuleID int64 `json:"module_id"` ModuleID int64 `json:"module_id"`
Title string `json:"title"` Title string `json:"title"`
VideoUrl pgtype.Text `json:"video_url"` VideoUrl pgtype.Text `json:"video_url"`
Thumbnail pgtype.Text `json:"thumbnail"` Thumbnail pgtype.Text `json:"thumbnail"`
Description pgtype.Text `json:"description"` Description pgtype.Text `json:"description"`
CreatedAt pgtype.Timestamptz `json:"created_at"` CreatedAt pgtype.Timestamptz `json:"created_at"`
UpdatedAt pgtype.Timestamptz `json:"updated_at"` UpdatedAt pgtype.Timestamptz `json:"updated_at"`
SortOrder int32 `json:"sort_order"` SortOrder int32 `json:"sort_order"`
PublishStatus string `json:"publish_status"`
} }
type LevelToSubCourse struct { type LevelToSubCourse struct {

View File

@ -1,20 +1,55 @@
package domain package domain
import "time" import (
"strings"
"time"
)
// LessonPublishStatus controls learner visibility for an LMS lesson row (like PracticePublishStatus).
type LessonPublishStatus string
const (
LessonPublishDraft LessonPublishStatus = "DRAFT"
LessonPublishPublished LessonPublishStatus = "PUBLISHED"
)
// LessonPublishStatusFromDB normalizes persisted values.
func LessonPublishStatusFromDB(raw string) LessonPublishStatus {
switch strings.TrimSpace(strings.ToUpper(raw)) {
case string(LessonPublishPublished):
return LessonPublishPublished
default:
return LessonPublishDraft
}
}
// LessonPublishStatusFromCreateInput resolves create body: omit → draft; explicit value validated separately.
func LessonPublishStatusFromCreateInput(raw *string) LessonPublishStatus {
if raw == nil || strings.TrimSpace(*raw) == "" {
return LessonPublishDraft
}
return LessonPublishStatusFromDB(*raw)
}
// Lesson belongs to a Module. // Lesson belongs to a Module.
type Lesson struct { type Lesson struct {
ID int64 `json:"id"` ID int64 `json:"id"`
ModuleID int64 `json:"module_id"` ModuleID int64 `json:"module_id"`
Title string `json:"title"` Title string `json:"title"`
VideoURL *string `json:"video_url,omitempty"` VideoURL *string `json:"video_url,omitempty"`
Thumbnail *string `json:"thumbnail,omitempty"` Thumbnail *string `json:"thumbnail,omitempty"`
Description *string `json:"description,omitempty"` Description *string `json:"description,omitempty"`
SortOrder int `json:"sort_order"` SortOrder int `json:"sort_order"`
HasPractice bool `json:"has_practice"` PublishStatus LessonPublishStatus `json:"publish_status"`
CreatedAt time.Time `json:"created_at"` HasPractice bool `json:"has_practice"`
UpdatedAt *time.Time `json:"updated_at,omitempty"` CreatedAt time.Time `json:"created_at"`
Access *LMSEntityAccess `json:"access,omitempty"` UpdatedAt *time.Time `json:"updated_at,omitempty"`
Access *LMSEntityAccess `json:"access,omitempty"`
}
// VisibleToLearners is true when the lesson appears in subscriber/catalog LMS APIs.
func (l Lesson) VisibleToLearners() bool {
return l.PublishStatus == LessonPublishPublished
} }
type CreateLessonInput struct { type CreateLessonInput struct {
@ -24,12 +59,15 @@ type CreateLessonInput struct {
Description *string `json:"description,omitempty"` Description *string `json:"description,omitempty"`
// SortOrder within the module when set; omit to append after current max within module_id. // SortOrder within the module when set; omit to append after current max within module_id.
SortOrder *int `json:"sort_order,omitempty" validate:"omitempty,min=0"` SortOrder *int `json:"sort_order,omitempty" validate:"omitempty,min=0"`
// Omit or empty defaults to DRAFT; set PUBLISHED to make visible to learners immediately.
PublishStatus *string `json:"publish_status,omitempty" validate:"omitempty,oneof=DRAFT draft PUBLISHED published"`
} }
type UpdateLessonInput struct { type UpdateLessonInput struct {
Title *string `json:"title,omitempty"` Title *string `json:"title,omitempty"`
VideoURL *string `json:"video_url,omitempty"` VideoURL *string `json:"video_url,omitempty"`
Thumbnail *string `json:"thumbnail,omitempty"` Thumbnail *string `json:"thumbnail,omitempty"`
Description *string `json:"description,omitempty"` Description *string `json:"description,omitempty"`
SortOrder *int `json:"sort_order,omitempty"` SortOrder *int `json:"sort_order,omitempty"`
PublishStatus *string `json:"publish_status,omitempty" validate:"omitempty,oneof=DRAFT draft PUBLISHED published"`
} }

View File

@ -8,7 +8,7 @@ import (
type LessonStore interface { type LessonStore interface {
CreateLesson(ctx context.Context, moduleID int64, input domain.CreateLessonInput) (domain.Lesson, error) CreateLesson(ctx context.Context, moduleID int64, input domain.CreateLessonInput) (domain.Lesson, error)
GetLessonByID(ctx context.Context, id int64) (domain.Lesson, error) GetLessonByID(ctx context.Context, id int64) (domain.Lesson, error)
ListLessonsByModuleID(ctx context.Context, moduleID int64, limit, offset int32) ([]domain.Lesson, int64, error) ListLessonsByModuleID(ctx context.Context, moduleID int64, publishedOnly bool, limit, offset int32) ([]domain.Lesson, int64, error)
UpdateLesson(ctx context.Context, id int64, input domain.UpdateLessonInput) (domain.Lesson, error) UpdateLesson(ctx context.Context, id int64, input domain.UpdateLessonInput) (domain.Lesson, error)
DeleteLesson(ctx context.Context, id int64) error DeleteLesson(ctx context.Context, id int64) error
} }

View File

@ -13,9 +13,10 @@ import (
func lessonToDomain(l dbgen.Lesson) domain.Lesson { func lessonToDomain(l dbgen.Lesson) domain.Lesson {
out := domain.Lesson{ out := domain.Lesson{
ID: l.ID, ID: l.ID,
ModuleID: l.ModuleID, ModuleID: l.ModuleID,
Title: l.Title, Title: l.Title,
PublishStatus: domain.LessonPublishStatusFromDB(l.PublishStatus),
} }
out.VideoURL = fromPgText(l.VideoUrl) out.VideoURL = fromPgText(l.VideoUrl)
out.Thumbnail = fromPgText(l.Thumbnail) out.Thumbnail = fromPgText(l.Thumbnail)
@ -30,6 +31,8 @@ func lessonToDomain(l dbgen.Lesson) domain.Lesson {
} }
func (s *Store) CreateLesson(ctx context.Context, moduleID int64, input domain.CreateLessonInput) (domain.Lesson, error) { func (s *Store) CreateLesson(ctx context.Context, moduleID int64, input domain.CreateLessonInput) (domain.Lesson, error) {
pub := string(domain.LessonPublishStatusFromCreateInput(input.PublishStatus))
if input.SortOrder != nil { if input.SortOrder != nil {
q, tx, err := s.BeginTx(ctx) q, tx, err := s.BeginTx(ctx)
if err != nil { if err != nil {
@ -44,12 +47,13 @@ func (s *Store) CreateLesson(ctx context.Context, moduleID int64, input domain.C
return domain.Lesson{}, err return domain.Lesson{}, err
} }
l, err := q.CreateLesson(ctx, dbgen.CreateLessonParams{ l, err := q.CreateLesson(ctx, dbgen.CreateLessonParams{
ModuleID: moduleID, ModuleID: moduleID,
Title: input.Title, Title: input.Title,
VideoUrl: toPgText(input.VideoURL), VideoUrl: toPgText(input.VideoURL),
Thumbnail: toPgText(input.Thumbnail), Thumbnail: toPgText(input.Thumbnail),
Description: toPgText(input.Description), Description: toPgText(input.Description),
SortOrder: pgtype.Int4{Int32: target, Valid: true}, SortOrder: pgtype.Int4{Int32: target, Valid: true},
PublishStatus: pub,
}) })
if err != nil { if err != nil {
return domain.Lesson{}, err return domain.Lesson{}, err
@ -61,12 +65,13 @@ func (s *Store) CreateLesson(ctx context.Context, moduleID int64, input domain.C
} }
l, err := s.queries.CreateLesson(ctx, dbgen.CreateLessonParams{ l, err := s.queries.CreateLesson(ctx, dbgen.CreateLessonParams{
ModuleID: moduleID, ModuleID: moduleID,
Title: input.Title, Title: input.Title,
VideoUrl: toPgText(input.VideoURL), VideoUrl: toPgText(input.VideoURL),
Thumbnail: toPgText(input.Thumbnail), Thumbnail: toPgText(input.Thumbnail),
Description: toPgText(input.Description), Description: toPgText(input.Description),
SortOrder: pgtype.Int4{Valid: false}, SortOrder: pgtype.Int4{Valid: false},
PublishStatus: pub,
}) })
if err != nil { if err != nil {
return domain.Lesson{}, err return domain.Lesson{}, err
@ -83,25 +88,27 @@ func (s *Store) GetLessonByID(ctx context.Context, id int64) (domain.Lesson, err
return domain.Lesson{}, err return domain.Lesson{}, err
} }
out := lessonToDomain(dbgen.Lesson{ out := lessonToDomain(dbgen.Lesson{
ID: l.ID, ID: l.ID,
ModuleID: l.ModuleID, ModuleID: l.ModuleID,
Title: l.Title, Title: l.Title,
VideoUrl: l.VideoUrl, VideoUrl: l.VideoUrl,
Thumbnail: l.Thumbnail, Thumbnail: l.Thumbnail,
Description: l.Description, Description: l.Description,
SortOrder: l.SortOrder, SortOrder: l.SortOrder,
CreatedAt: l.CreatedAt, PublishStatus: l.PublishStatus,
UpdatedAt: l.UpdatedAt, CreatedAt: l.CreatedAt,
UpdatedAt: l.UpdatedAt,
}) })
out.HasPractice = l.HasPractice out.HasPractice = l.HasPractice
return out, nil return out, nil
} }
func (s *Store) ListLessonsByModuleID(ctx context.Context, moduleID int64, limit, offset int32) ([]domain.Lesson, int64, error) { func (s *Store) ListLessonsByModuleID(ctx context.Context, moduleID int64, publishedOnly bool, limit, offset int32) ([]domain.Lesson, int64, error) {
rows, err := s.queries.ListLessonsByModuleID(ctx, dbgen.ListLessonsByModuleIDParams{ rows, err := s.queries.ListLessonsByModuleID(ctx, dbgen.ListLessonsByModuleIDParams{
ModuleID: moduleID, ModuleID: moduleID,
Limit: limit, Limit: limit,
Offset: offset, Offset: offset,
PublishedOnly: publishedOnly,
}) })
if err != nil { if err != nil {
return nil, 0, err return nil, 0, err
@ -116,15 +123,16 @@ func (s *Store) ListLessonsByModuleID(ctx context.Context, moduleID int64, limit
total = r.TotalCount total = r.TotalCount
} }
lesson := lessonToDomain(dbgen.Lesson{ lesson := lessonToDomain(dbgen.Lesson{
ID: r.ID, ID: r.ID,
ModuleID: r.ModuleID, ModuleID: r.ModuleID,
Title: r.Title, Title: r.Title,
VideoUrl: r.VideoUrl, VideoUrl: r.VideoUrl,
Thumbnail: r.Thumbnail, Thumbnail: r.Thumbnail,
Description: r.Description, Description: r.Description,
CreatedAt: r.CreatedAt, SortOrder: r.SortOrder,
UpdatedAt: r.UpdatedAt, PublishStatus: r.PublishStatus,
SortOrder: r.SortOrder, CreatedAt: r.CreatedAt,
UpdatedAt: r.UpdatedAt,
}) })
lesson.HasPractice = r.HasPractice lesson.HasPractice = r.HasPractice
out = append(out, lesson) out = append(out, lesson)
@ -134,6 +142,8 @@ func (s *Store) ListLessonsByModuleID(ctx context.Context, moduleID int64, limit
func (s *Store) UpdateLesson(ctx context.Context, id int64, input domain.UpdateLessonInput) (domain.Lesson, error) { func (s *Store) UpdateLesson(ctx context.Context, id int64, input domain.UpdateLessonInput) (domain.Lesson, error) {
sortParam := optionalInt4Update(input.SortOrder) sortParam := optionalInt4Update(input.SortOrder)
pubParam := optionalPublishStatusUpdate(input.PublishStatus)
var titleText pgtype.Text var titleText pgtype.Text
if input.Title != nil { if input.Title != nil {
titleText = pgtype.Text{String: *input.Title, Valid: true} titleText = pgtype.Text{String: *input.Title, Valid: true}
@ -158,12 +168,13 @@ func (s *Store) UpdateLesson(ctx context.Context, id int64, input domain.UpdateL
return domain.Lesson{}, err return domain.Lesson{}, err
} }
l, err := q.UpdateLesson(ctx, dbgen.UpdateLessonParams{ l, err := q.UpdateLesson(ctx, dbgen.UpdateLessonParams{
ID: id, ID: id,
Title: titleText, Title: titleText,
VideoUrl: optionalTextUpdate(input.VideoURL), VideoUrl: optionalTextUpdate(input.VideoURL),
Thumbnail: optionalTextUpdate(input.Thumbnail), Thumbnail: optionalTextUpdate(input.Thumbnail),
Description: optionalTextUpdate(input.Description), Description: optionalTextUpdate(input.Description),
SortOrder: pgtype.Int4{Valid: false}, SortOrder: pgtype.Int4{Valid: false},
PublishStatus: pubParam,
}) })
if err != nil { if err != nil {
return domain.Lesson{}, err return domain.Lesson{}, err
@ -179,12 +190,13 @@ func (s *Store) UpdateLesson(ctx context.Context, id int64, input domain.UpdateL
} }
l, err := s.queries.UpdateLesson(ctx, dbgen.UpdateLessonParams{ l, err := s.queries.UpdateLesson(ctx, dbgen.UpdateLessonParams{
ID: id, ID: id,
Title: titleText, Title: titleText,
VideoUrl: optionalTextUpdate(input.VideoURL), VideoUrl: optionalTextUpdate(input.VideoURL),
Thumbnail: optionalTextUpdate(input.Thumbnail), Thumbnail: optionalTextUpdate(input.Thumbnail),
Description: optionalTextUpdate(input.Description), Description: optionalTextUpdate(input.Description),
SortOrder: sortParam, SortOrder: sortParam,
PublishStatus: pubParam,
}) })
if err != nil { if err != nil {
if errors.Is(err, pgx.ErrNoRows) { if errors.Is(err, pgx.ErrNoRows) {

View File

@ -50,7 +50,7 @@ func (s *Service) GetByID(ctx context.Context, id int64) (domain.Lesson, error)
return l, nil return l, nil
} }
func (s *Service) ListByModule(ctx context.Context, moduleID int64, limit, offset int32) ([]domain.Lesson, int64, error) { func (s *Service) ListByModule(ctx context.Context, moduleID int64, publishedOnly bool, limit, offset int32) ([]domain.Lesson, int64, error) {
if err := s.getModuleOrErr(ctx, moduleID); err != nil { if err := s.getModuleOrErr(ctx, moduleID); err != nil {
return nil, 0, err return nil, 0, err
} }
@ -63,7 +63,7 @@ func (s *Service) ListByModule(ctx context.Context, moduleID int64, limit, offse
if offset < 0 { if offset < 0 {
offset = 0 offset = 0
} }
return s.lessons.ListLessonsByModuleID(ctx, moduleID, limit, offset) return s.lessons.ListLessonsByModuleID(ctx, moduleID, publishedOnly, limit, offset)
} }
func (s *Service) Update(ctx context.Context, id int64, input domain.UpdateLessonInput) (domain.Lesson, error) { func (s *Service) Update(ctx context.Context, id int64, input domain.UpdateLessonInput) (domain.Lesson, error) {

View File

@ -84,7 +84,8 @@ func (h *Handler) ListLessonsByModule(c *fiber.Ctx) error {
} }
limit, _ := strconv.Atoi(c.Query("limit", "20")) limit, _ := strconv.Atoi(c.Query("limit", "20"))
offset, _ := strconv.Atoi(c.Query("offset", "0")) offset, _ := strconv.Atoi(c.Query("offset", "0"))
items, total, err := h.lessonSvc.ListByModule(c.Context(), moduleID, int32(limit), int32(offset)) publishedOnly := !h.canManageLessons(c)
items, total, err := h.lessonSvc.ListByModule(c.Context(), moduleID, publishedOnly, int32(limit), int32(offset))
if err != nil { if err != nil {
if errors.Is(err, modules.ErrModuleNotFound) { if errors.Is(err, modules.ErrModuleNotFound) {
return c.Status(fiber.StatusNotFound).JSON(domain.ErrorResponse{ return c.Status(fiber.StatusNotFound).JSON(domain.ErrorResponse{
@ -145,6 +146,12 @@ func (h *Handler) GetLesson(c *fiber.Ctx) error {
Error: err.Error(), Error: err.Error(),
}) })
} }
if !les.VisibleToLearners() && !h.canManageLessons(c) {
return c.Status(fiber.StatusNotFound).JSON(domain.ErrorResponse{
Message: "Lesson not found",
Error: lessons.ErrLessonNotFound.Error(),
})
}
uid := c.Locals("user_id").(int64) uid := c.Locals("user_id").(int64)
role := c.Locals("role").(domain.Role) role := c.Locals("role").(domain.Role)
if err := h.lmsProgressSvc.ApplyAccessLesson(c.Context(), role, uid, &les); err != nil { if err := h.lmsProgressSvc.ApplyAccessLesson(c.Context(), role, uid, &les); err != nil {
@ -265,7 +272,8 @@ func (h *Handler) CompleteLesson(c *fiber.Ctx) error {
Error: err.Error(), Error: err.Error(),
}) })
} }
if _, err := h.lessonSvc.GetByID(c.Context(), id); err != nil { les, err := h.lessonSvc.GetByID(c.Context(), id)
if err != nil {
if errors.Is(err, lessons.ErrLessonNotFound) { if errors.Is(err, lessons.ErrLessonNotFound) {
return c.Status(fiber.StatusNotFound).JSON(domain.ErrorResponse{ return c.Status(fiber.StatusNotFound).JSON(domain.ErrorResponse{
Message: "Lesson not found", Message: "Lesson not found",
@ -277,8 +285,14 @@ func (h *Handler) CompleteLesson(c *fiber.Ctx) error {
Error: err.Error(), Error: err.Error(),
}) })
} }
uid := c.Locals("user_id").(int64)
role := c.Locals("role").(domain.Role) role := c.Locals("role").(domain.Role)
if role.IsCustomerLearnerRole() && !les.VisibleToLearners() {
return c.Status(fiber.StatusForbidden).JSON(domain.ErrorResponse{
Message: "Only published lessons can be completed",
Error: "LESSON_NOT_PUBLISHED",
})
}
uid := c.Locals("user_id").(int64)
if role.UsesLMSSequentialGating() { if role.UsesLMSSequentialGating() {
ok, reason, err := h.lmsProgressSvc.CanAccessLesson(c.Context(), uid, id) ok, reason, err := h.lmsProgressSvc.CanAccessLesson(c.Context(), uid, id)
if err != nil { if err != nil {

View File

@ -11,6 +11,11 @@ func (h *Handler) canManageLMSPractices(c *fiber.Ctx) bool {
return h.rbacSvc.HasPermission(rn, "practices.create") || h.rbacSvc.HasPermission(rn, "practices.update") return h.rbacSvc.HasPermission(rn, "practices.create") || h.rbacSvc.HasPermission(rn, "practices.update")
} }
func (h *Handler) canManageLessons(c *fiber.Ctx) bool {
rn := string(c.Locals("role").(domain.Role))
return h.rbacSvc.HasPermission(rn, "lessons.create") || h.rbacSvc.HasPermission(rn, "lessons.update")
}
func (h *Handler) canManageExamPrepPractices(c *fiber.Ctx) bool { func (h *Handler) canManageExamPrepPractices(c *fiber.Ctx) bool {
rn := string(c.Locals("role").(domain.Role)) rn := string(c.Locals("role").(domain.Role))
return h.rbacSvc.HasPermission(rn, "exam_prep.practices.create") || h.rbacSvc.HasPermission(rn, "exam_prep.practices.update") return h.rbacSvc.HasPermission(rn, "exam_prep.practices.create") || h.rbacSvc.HasPermission(rn, "exam_prep.practices.update")