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:
parent
fffdff1031
commit
bd1767d2a6
3
db/migrations/000062_lesson_publish_status.down.sql
Normal file
3
db/migrations/000062_lesson_publish_status.down.sql
Normal 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;
|
||||
9
db/migrations/000062_lesson_publish_status.up.sql
Normal file
9
db/migrations/000062_lesson_publish_status.up.sql
Normal 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';
|
||||
|
|
@ -65,7 +65,8 @@ SELECT
|
|||
lessons l
|
||||
INNER JOIN modules m ON l.module_id = m.id
|
||||
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 linked via module_id or lesson_id, even for modules/lessons in this course.
|
||||
(
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
-- 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
|
||||
sqlc.arg('module_id'),
|
||||
sqlc.arg('title'),
|
||||
|
|
@ -12,7 +12,8 @@ SELECT
|
|||
max(l.sort_order)
|
||||
FROM lessons l
|
||||
WHERE
|
||||
l.module_id = sqlc.arg('module_id')), 0) + 1)
|
||||
l.module_id = sqlc.arg('module_id')), 0) + 1),
|
||||
sqlc.arg('publish_status')
|
||||
RETURNING
|
||||
*;
|
||||
|
||||
|
|
@ -39,6 +40,7 @@ SELECT
|
|||
l.thumbnail,
|
||||
l.description,
|
||||
l.sort_order,
|
||||
l.publish_status,
|
||||
l.created_at,
|
||||
l.updated_at,
|
||||
EXISTS (
|
||||
|
|
@ -51,6 +53,10 @@ FROM
|
|||
lessons l
|
||||
WHERE
|
||||
l.module_id = $1
|
||||
AND (
|
||||
sqlc.arg('published_only')::boolean = FALSE
|
||||
OR l.publish_status = 'PUBLISHED'::TEXT
|
||||
)
|
||||
ORDER BY
|
||||
l.sort_order ASC,
|
||||
l.id ASC
|
||||
|
|
@ -65,6 +71,7 @@ SET
|
|||
thumbnail = COALESCE(sqlc.narg('thumbnail')::text, thumbnail),
|
||||
description = COALESCE(sqlc.narg('description')::text, description),
|
||||
sort_order = coalesce(sqlc.narg('sort_order')::int, sort_order),
|
||||
publish_status = COALESCE(sqlc.narg('publish_status')::varchar, publish_status),
|
||||
updated_at = CURRENT_TIMESTAMP
|
||||
WHERE
|
||||
id = sqlc.arg('id')
|
||||
|
|
|
|||
|
|
@ -33,9 +33,21 @@ SELECT
|
|||
FROM
|
||||
lessons AS l1
|
||||
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
|
||||
l1.id = $1;
|
||||
l1.id = $1
|
||||
ORDER BY
|
||||
l2.sort_order DESC,
|
||||
l2.id DESC
|
||||
LIMIT 1;
|
||||
|
||||
-- name: UserHasProgramProgress :one
|
||||
SELECT
|
||||
|
|
@ -111,7 +123,8 @@ SELECT
|
|||
FROM
|
||||
lessons
|
||||
WHERE
|
||||
module_id = $1;
|
||||
module_id = $1
|
||||
AND publish_status = 'PUBLISHED';
|
||||
|
||||
-- name: CountUserCompletedLessonsInModule :one
|
||||
SELECT
|
||||
|
|
@ -121,7 +134,8 @@ FROM
|
|||
INNER JOIN lessons l ON l.id = ulp.lesson_id
|
||||
WHERE
|
||||
l.module_id = $1
|
||||
AND ulp.user_id = $2;
|
||||
AND ulp.user_id = $2
|
||||
AND l.publish_status = 'PUBLISHED';
|
||||
|
||||
-- name: CountModulesInCourse :one
|
||||
SELECT
|
||||
|
|
@ -211,7 +225,8 @@ FROM
|
|||
lessons l
|
||||
INNER JOIN modules m ON m.id = l.module_id
|
||||
WHERE
|
||||
m.course_id = $1;
|
||||
m.course_id = $1
|
||||
AND l.publish_status = 'PUBLISHED';
|
||||
|
||||
-- name: CountUserCompletedLessonsInCourse :one
|
||||
SELECT
|
||||
|
|
@ -222,7 +237,8 @@ FROM
|
|||
INNER JOIN modules m ON m.id = l.module_id
|
||||
WHERE
|
||||
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).
|
||||
-- name: CountLessonsInProgram :one
|
||||
|
|
@ -233,7 +249,8 @@ FROM
|
|||
INNER JOIN modules m ON m.id = l.module_id
|
||||
INNER JOIN courses c ON c.id = m.course_id
|
||||
WHERE
|
||||
c.program_id = $1;
|
||||
c.program_id = $1
|
||||
AND l.publish_status = 'PUBLISHED';
|
||||
|
||||
-- name: CountUserCompletedLessonsInProgram :one
|
||||
SELECT
|
||||
|
|
@ -245,7 +262,8 @@ FROM
|
|||
INNER JOIN courses c ON c.id = m.course_id
|
||||
WHERE
|
||||
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).
|
||||
-- name: CountPublishedPracticesInModule :one
|
||||
|
|
|
|||
|
|
@ -10484,6 +10484,10 @@ const docTemplate = `{
|
|||
"description": {
|
||||
"type": "string"
|
||||
},
|
||||
"publish_status": {
|
||||
"description": "Omit or DRAFT (default) for drafts; PUBLISHED for learner-visible lessons.",
|
||||
"type": "string"
|
||||
},
|
||||
"sort_order": {
|
||||
"type": "integer"
|
||||
},
|
||||
|
|
@ -11375,6 +11379,10 @@ const docTemplate = `{
|
|||
"description": {
|
||||
"type": "string"
|
||||
},
|
||||
"publish_status": {
|
||||
"description": "DRAFT or PUBLISHED",
|
||||
"type": "string"
|
||||
},
|
||||
"sort_order": {
|
||||
"type": "integer"
|
||||
},
|
||||
|
|
|
|||
|
|
@ -10476,6 +10476,10 @@
|
|||
"description": {
|
||||
"type": "string"
|
||||
},
|
||||
"publish_status": {
|
||||
"description": "Omit or DRAFT (default) for drafts; PUBLISHED for learner-visible lessons.",
|
||||
"type": "string"
|
||||
},
|
||||
"sort_order": {
|
||||
"type": "integer"
|
||||
},
|
||||
|
|
@ -11367,6 +11371,10 @@
|
|||
"description": {
|
||||
"type": "string"
|
||||
},
|
||||
"publish_status": {
|
||||
"description": "DRAFT or PUBLISHED",
|
||||
"type": "string"
|
||||
},
|
||||
"sort_order": {
|
||||
"type": "integer"
|
||||
},
|
||||
|
|
|
|||
|
|
@ -418,6 +418,9 @@ definitions:
|
|||
properties:
|
||||
description:
|
||||
type: string
|
||||
publish_status:
|
||||
description: Omit or DRAFT (default) for drafts; PUBLISHED for learner-visible lessons.
|
||||
type: string
|
||||
sort_order:
|
||||
type: integer
|
||||
thumbnail:
|
||||
|
|
@ -1027,6 +1030,9 @@ definitions:
|
|||
properties:
|
||||
description:
|
||||
type: string
|
||||
publish_status:
|
||||
description: DRAFT or PUBLISHED
|
||||
type: string
|
||||
sort_order:
|
||||
type: integer
|
||||
thumbnail:
|
||||
|
|
|
|||
|
|
@ -170,7 +170,8 @@ SELECT
|
|||
lessons l
|
||||
INNER JOIN modules m ON l.module_id = m.id
|
||||
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 linked via module_id or lesson_id, even for modules/lessons in this course.
|
||||
(
|
||||
|
|
|
|||
|
|
@ -12,7 +12,7 @@ import (
|
|||
)
|
||||
|
||||
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
|
||||
$1,
|
||||
$2,
|
||||
|
|
@ -25,18 +25,20 @@ SELECT
|
|||
max(l.sort_order)
|
||||
FROM lessons l
|
||||
WHERE
|
||||
l.module_id = $1), 0) + 1)
|
||||
l.module_id = $1), 0) + 1),
|
||||
$7
|
||||
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 {
|
||||
ModuleID int64 `json:"module_id"`
|
||||
Title string `json:"title"`
|
||||
VideoUrl pgtype.Text `json:"video_url"`
|
||||
Thumbnail pgtype.Text `json:"thumbnail"`
|
||||
Description pgtype.Text `json:"description"`
|
||||
SortOrder pgtype.Int4 `json:"sort_order"`
|
||||
ModuleID int64 `json:"module_id"`
|
||||
Title string `json:"title"`
|
||||
VideoUrl pgtype.Text `json:"video_url"`
|
||||
Thumbnail pgtype.Text `json:"thumbnail"`
|
||||
Description pgtype.Text `json:"description"`
|
||||
SortOrder pgtype.Int4 `json:"sort_order"`
|
||||
PublishStatus string `json:"publish_status"`
|
||||
}
|
||||
|
||||
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.Description,
|
||||
arg.SortOrder,
|
||||
arg.PublishStatus,
|
||||
)
|
||||
var i Lesson
|
||||
err := row.Scan(
|
||||
|
|
@ -59,6 +62,7 @@ func (q *Queries) CreateLesson(ctx context.Context, arg CreateLessonParams) (Les
|
|||
&i.CreatedAt,
|
||||
&i.UpdatedAt,
|
||||
&i.SortOrder,
|
||||
&i.PublishStatus,
|
||||
)
|
||||
return i, err
|
||||
}
|
||||
|
|
@ -75,7 +79,7 @@ func (q *Queries) DeleteLesson(ctx context.Context, id int64) error {
|
|||
|
||||
const GetLessonByID = `-- name: GetLessonByID :one
|
||||
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 (
|
||||
SELECT 1
|
||||
FROM lms_practices p
|
||||
|
|
@ -88,16 +92,17 @@ WHERE l.id = $1
|
|||
`
|
||||
|
||||
type GetLessonByIDRow struct {
|
||||
ID int64 `json:"id"`
|
||||
ModuleID int64 `json:"module_id"`
|
||||
Title string `json:"title"`
|
||||
VideoUrl pgtype.Text `json:"video_url"`
|
||||
Thumbnail pgtype.Text `json:"thumbnail"`
|
||||
Description pgtype.Text `json:"description"`
|
||||
CreatedAt pgtype.Timestamptz `json:"created_at"`
|
||||
UpdatedAt pgtype.Timestamptz `json:"updated_at"`
|
||||
SortOrder int32 `json:"sort_order"`
|
||||
HasPractice bool `json:"has_practice"`
|
||||
ID int64 `json:"id"`
|
||||
ModuleID int64 `json:"module_id"`
|
||||
Title string `json:"title"`
|
||||
VideoUrl pgtype.Text `json:"video_url"`
|
||||
Thumbnail pgtype.Text `json:"thumbnail"`
|
||||
Description pgtype.Text `json:"description"`
|
||||
CreatedAt pgtype.Timestamptz `json:"created_at"`
|
||||
UpdatedAt pgtype.Timestamptz `json:"updated_at"`
|
||||
SortOrder int32 `json:"sort_order"`
|
||||
PublishStatus string `json:"publish_status"`
|
||||
HasPractice bool `json:"has_practice"`
|
||||
}
|
||||
|
||||
func (q *Queries) 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.UpdatedAt,
|
||||
&i.SortOrder,
|
||||
&i.PublishStatus,
|
||||
&i.HasPractice,
|
||||
)
|
||||
return i, err
|
||||
|
|
@ -128,6 +134,7 @@ SELECT
|
|||
l.thumbnail,
|
||||
l.description,
|
||||
l.sort_order,
|
||||
l.publish_status,
|
||||
l.created_at,
|
||||
l.updated_at,
|
||||
EXISTS (
|
||||
|
|
@ -140,6 +147,10 @@ FROM
|
|||
lessons l
|
||||
WHERE
|
||||
l.module_id = $1
|
||||
AND (
|
||||
$4::boolean = FALSE
|
||||
OR l.publish_status = 'PUBLISHED'::TEXT
|
||||
)
|
||||
ORDER BY
|
||||
l.sort_order ASC,
|
||||
l.id ASC
|
||||
|
|
@ -148,27 +159,34 @@ OFFSET $3
|
|||
`
|
||||
|
||||
type ListLessonsByModuleIDParams struct {
|
||||
ModuleID int64 `json:"module_id"`
|
||||
Limit int32 `json:"limit"`
|
||||
Offset int32 `json:"offset"`
|
||||
ModuleID int64 `json:"module_id"`
|
||||
Limit int32 `json:"limit"`
|
||||
Offset int32 `json:"offset"`
|
||||
PublishedOnly bool `json:"published_only"`
|
||||
}
|
||||
|
||||
type ListLessonsByModuleIDRow struct {
|
||||
TotalCount int64 `json:"total_count"`
|
||||
ID int64 `json:"id"`
|
||||
ModuleID int64 `json:"module_id"`
|
||||
Title string `json:"title"`
|
||||
VideoUrl pgtype.Text `json:"video_url"`
|
||||
Thumbnail pgtype.Text `json:"thumbnail"`
|
||||
Description pgtype.Text `json:"description"`
|
||||
SortOrder int32 `json:"sort_order"`
|
||||
CreatedAt pgtype.Timestamptz `json:"created_at"`
|
||||
UpdatedAt pgtype.Timestamptz `json:"updated_at"`
|
||||
HasPractice bool `json:"has_practice"`
|
||||
TotalCount int64 `json:"total_count"`
|
||||
ID int64 `json:"id"`
|
||||
ModuleID int64 `json:"module_id"`
|
||||
Title string `json:"title"`
|
||||
VideoUrl pgtype.Text `json:"video_url"`
|
||||
Thumbnail pgtype.Text `json:"thumbnail"`
|
||||
Description pgtype.Text `json:"description"`
|
||||
SortOrder int32 `json:"sort_order"`
|
||||
PublishStatus string `json:"publish_status"`
|
||||
CreatedAt pgtype.Timestamptz `json:"created_at"`
|
||||
UpdatedAt pgtype.Timestamptz `json:"updated_at"`
|
||||
HasPractice bool `json:"has_practice"`
|
||||
}
|
||||
|
||||
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 {
|
||||
return nil, err
|
||||
}
|
||||
|
|
@ -185,6 +203,7 @@ func (q *Queries) ListLessonsByModuleID(ctx context.Context, arg ListLessonsByMo
|
|||
&i.Thumbnail,
|
||||
&i.Description,
|
||||
&i.SortOrder,
|
||||
&i.PublishStatus,
|
||||
&i.CreatedAt,
|
||||
&i.UpdatedAt,
|
||||
&i.HasPractice,
|
||||
|
|
@ -207,20 +226,22 @@ SET
|
|||
thumbnail = COALESCE($3::text, thumbnail),
|
||||
description = COALESCE($4::text, description),
|
||||
sort_order = coalesce($5::int, sort_order),
|
||||
publish_status = COALESCE($6::varchar, publish_status),
|
||||
updated_at = CURRENT_TIMESTAMP
|
||||
WHERE
|
||||
id = $6
|
||||
id = $7
|
||||
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 {
|
||||
Title pgtype.Text `json:"title"`
|
||||
VideoUrl pgtype.Text `json:"video_url"`
|
||||
Thumbnail pgtype.Text `json:"thumbnail"`
|
||||
Description pgtype.Text `json:"description"`
|
||||
SortOrder pgtype.Int4 `json:"sort_order"`
|
||||
ID int64 `json:"id"`
|
||||
Title pgtype.Text `json:"title"`
|
||||
VideoUrl pgtype.Text `json:"video_url"`
|
||||
Thumbnail pgtype.Text `json:"thumbnail"`
|
||||
Description pgtype.Text `json:"description"`
|
||||
SortOrder pgtype.Int4 `json:"sort_order"`
|
||||
PublishStatus pgtype.Text `json:"publish_status"`
|
||||
ID int64 `json:"id"`
|
||||
}
|
||||
|
||||
func (q *Queries) 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.Description,
|
||||
arg.SortOrder,
|
||||
arg.PublishStatus,
|
||||
arg.ID,
|
||||
)
|
||||
var i Lesson
|
||||
|
|
@ -243,6 +265,7 @@ func (q *Queries) UpdateLesson(ctx context.Context, arg UpdateLessonParams) (Les
|
|||
&i.CreatedAt,
|
||||
&i.UpdatedAt,
|
||||
&i.SortOrder,
|
||||
&i.PublishStatus,
|
||||
)
|
||||
return i, err
|
||||
}
|
||||
|
|
|
|||
|
|
@ -35,6 +35,7 @@ FROM
|
|||
INNER JOIN modules m ON m.id = l.module_id
|
||||
WHERE
|
||||
m.course_id = $1
|
||||
AND l.publish_status = 'PUBLISHED'
|
||||
`
|
||||
|
||||
// Lesson-based progress within a course (all modules).
|
||||
|
|
@ -52,6 +53,7 @@ FROM
|
|||
lessons
|
||||
WHERE
|
||||
module_id = $1
|
||||
AND publish_status = 'PUBLISHED'
|
||||
`
|
||||
|
||||
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
|
||||
WHERE
|
||||
c.program_id = $1
|
||||
AND l.publish_status = 'PUBLISHED'
|
||||
`
|
||||
|
||||
// Lesson-based progress within a program (all courses).
|
||||
|
|
@ -191,6 +194,7 @@ FROM
|
|||
WHERE
|
||||
m.course_id = $1
|
||||
AND ulp.user_id = $2
|
||||
AND l.publish_status = 'PUBLISHED'
|
||||
`
|
||||
|
||||
type CountUserCompletedLessonsInCourseParams struct {
|
||||
|
|
@ -214,6 +218,7 @@ FROM
|
|||
WHERE
|
||||
l.module_id = $1
|
||||
AND ulp.user_id = $2
|
||||
AND l.publish_status = 'PUBLISHED'
|
||||
`
|
||||
|
||||
type CountUserCompletedLessonsInModuleParams struct {
|
||||
|
|
@ -239,6 +244,7 @@ FROM
|
|||
WHERE
|
||||
c.program_id = $1
|
||||
AND ulp.user_id = $2
|
||||
AND l.publish_status = 'PUBLISHED'
|
||||
`
|
||||
|
||||
type CountUserCompletedLessonsInProgramParams struct {
|
||||
|
|
@ -423,13 +429,25 @@ func (q *Queries) GetPreviousCourseInProgram(ctx context.Context, id int64) (Cou
|
|||
|
||||
const GetPreviousLessonInModule = `-- name: GetPreviousLessonInModule :one
|
||||
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
|
||||
lessons AS l1
|
||||
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
|
||||
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) {
|
||||
|
|
@ -445,6 +463,7 @@ func (q *Queries) GetPreviousLessonInModule(ctx context.Context, id int64) (Less
|
|||
&i.CreatedAt,
|
||||
&i.UpdatedAt,
|
||||
&i.SortOrder,
|
||||
&i.PublishStatus,
|
||||
)
|
||||
return i, err
|
||||
}
|
||||
|
|
|
|||
|
|
@ -121,15 +121,16 @@ type GlobalSetting struct {
|
|||
}
|
||||
|
||||
type Lesson struct {
|
||||
ID int64 `json:"id"`
|
||||
ModuleID int64 `json:"module_id"`
|
||||
Title string `json:"title"`
|
||||
VideoUrl pgtype.Text `json:"video_url"`
|
||||
Thumbnail pgtype.Text `json:"thumbnail"`
|
||||
Description pgtype.Text `json:"description"`
|
||||
CreatedAt pgtype.Timestamptz `json:"created_at"`
|
||||
UpdatedAt pgtype.Timestamptz `json:"updated_at"`
|
||||
SortOrder int32 `json:"sort_order"`
|
||||
ID int64 `json:"id"`
|
||||
ModuleID int64 `json:"module_id"`
|
||||
Title string `json:"title"`
|
||||
VideoUrl pgtype.Text `json:"video_url"`
|
||||
Thumbnail pgtype.Text `json:"thumbnail"`
|
||||
Description pgtype.Text `json:"description"`
|
||||
CreatedAt pgtype.Timestamptz `json:"created_at"`
|
||||
UpdatedAt pgtype.Timestamptz `json:"updated_at"`
|
||||
SortOrder int32 `json:"sort_order"`
|
||||
PublishStatus string `json:"publish_status"`
|
||||
}
|
||||
|
||||
type LevelToSubCourse struct {
|
||||
|
|
|
|||
|
|
@ -1,20 +1,55 @@
|
|||
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.
|
||||
type Lesson struct {
|
||||
ID int64 `json:"id"`
|
||||
ModuleID int64 `json:"module_id"`
|
||||
Title string `json:"title"`
|
||||
VideoURL *string `json:"video_url,omitempty"`
|
||||
Thumbnail *string `json:"thumbnail,omitempty"`
|
||||
Description *string `json:"description,omitempty"`
|
||||
SortOrder int `json:"sort_order"`
|
||||
HasPractice bool `json:"has_practice"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt *time.Time `json:"updated_at,omitempty"`
|
||||
Access *LMSEntityAccess `json:"access,omitempty"`
|
||||
ID int64 `json:"id"`
|
||||
ModuleID int64 `json:"module_id"`
|
||||
Title string `json:"title"`
|
||||
VideoURL *string `json:"video_url,omitempty"`
|
||||
Thumbnail *string `json:"thumbnail,omitempty"`
|
||||
Description *string `json:"description,omitempty"`
|
||||
SortOrder int `json:"sort_order"`
|
||||
PublishStatus LessonPublishStatus `json:"publish_status"`
|
||||
HasPractice bool `json:"has_practice"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt *time.Time `json:"updated_at,omitempty"`
|
||||
Access *LMSEntityAccess `json:"access,omitempty"`
|
||||
}
|
||||
|
||||
// VisibleToLearners is true when the lesson appears in subscriber/catalog LMS APIs.
|
||||
func (l Lesson) VisibleToLearners() bool {
|
||||
return l.PublishStatus == LessonPublishPublished
|
||||
}
|
||||
|
||||
type CreateLessonInput struct {
|
||||
|
|
@ -24,12 +59,15 @@ type CreateLessonInput struct {
|
|||
Description *string `json:"description,omitempty"`
|
||||
// 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"`
|
||||
// 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 {
|
||||
Title *string `json:"title,omitempty"`
|
||||
VideoURL *string `json:"video_url,omitempty"`
|
||||
Thumbnail *string `json:"thumbnail,omitempty"`
|
||||
Description *string `json:"description,omitempty"`
|
||||
SortOrder *int `json:"sort_order,omitempty"`
|
||||
Title *string `json:"title,omitempty"`
|
||||
VideoURL *string `json:"video_url,omitempty"`
|
||||
Thumbnail *string `json:"thumbnail,omitempty"`
|
||||
Description *string `json:"description,omitempty"`
|
||||
SortOrder *int `json:"sort_order,omitempty"`
|
||||
PublishStatus *string `json:"publish_status,omitempty" validate:"omitempty,oneof=DRAFT draft PUBLISHED published"`
|
||||
}
|
||||
|
|
|
|||
|
|
@ -8,7 +8,7 @@ import (
|
|||
type LessonStore interface {
|
||||
CreateLesson(ctx context.Context, moduleID int64, input domain.CreateLessonInput) (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)
|
||||
DeleteLesson(ctx context.Context, id int64) error
|
||||
}
|
||||
|
|
|
|||
|
|
@ -13,9 +13,10 @@ import (
|
|||
|
||||
func lessonToDomain(l dbgen.Lesson) domain.Lesson {
|
||||
out := domain.Lesson{
|
||||
ID: l.ID,
|
||||
ModuleID: l.ModuleID,
|
||||
Title: l.Title,
|
||||
ID: l.ID,
|
||||
ModuleID: l.ModuleID,
|
||||
Title: l.Title,
|
||||
PublishStatus: domain.LessonPublishStatusFromDB(l.PublishStatus),
|
||||
}
|
||||
out.VideoURL = fromPgText(l.VideoUrl)
|
||||
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) {
|
||||
pub := string(domain.LessonPublishStatusFromCreateInput(input.PublishStatus))
|
||||
|
||||
if input.SortOrder != nil {
|
||||
q, tx, err := s.BeginTx(ctx)
|
||||
if err != nil {
|
||||
|
|
@ -44,12 +47,13 @@ func (s *Store) CreateLesson(ctx context.Context, moduleID int64, input domain.C
|
|||
return domain.Lesson{}, err
|
||||
}
|
||||
l, err := q.CreateLesson(ctx, dbgen.CreateLessonParams{
|
||||
ModuleID: moduleID,
|
||||
Title: input.Title,
|
||||
VideoUrl: toPgText(input.VideoURL),
|
||||
Thumbnail: toPgText(input.Thumbnail),
|
||||
Description: toPgText(input.Description),
|
||||
SortOrder: pgtype.Int4{Int32: target, Valid: true},
|
||||
ModuleID: moduleID,
|
||||
Title: input.Title,
|
||||
VideoUrl: toPgText(input.VideoURL),
|
||||
Thumbnail: toPgText(input.Thumbnail),
|
||||
Description: toPgText(input.Description),
|
||||
SortOrder: pgtype.Int4{Int32: target, Valid: true},
|
||||
PublishStatus: pub,
|
||||
})
|
||||
if err != nil {
|
||||
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{
|
||||
ModuleID: moduleID,
|
||||
Title: input.Title,
|
||||
VideoUrl: toPgText(input.VideoURL),
|
||||
Thumbnail: toPgText(input.Thumbnail),
|
||||
Description: toPgText(input.Description),
|
||||
SortOrder: pgtype.Int4{Valid: false},
|
||||
ModuleID: moduleID,
|
||||
Title: input.Title,
|
||||
VideoUrl: toPgText(input.VideoURL),
|
||||
Thumbnail: toPgText(input.Thumbnail),
|
||||
Description: toPgText(input.Description),
|
||||
SortOrder: pgtype.Int4{Valid: false},
|
||||
PublishStatus: pub,
|
||||
})
|
||||
if err != nil {
|
||||
return domain.Lesson{}, err
|
||||
|
|
@ -83,25 +88,27 @@ func (s *Store) GetLessonByID(ctx context.Context, id int64) (domain.Lesson, err
|
|||
return domain.Lesson{}, err
|
||||
}
|
||||
out := lessonToDomain(dbgen.Lesson{
|
||||
ID: l.ID,
|
||||
ModuleID: l.ModuleID,
|
||||
Title: l.Title,
|
||||
VideoUrl: l.VideoUrl,
|
||||
Thumbnail: l.Thumbnail,
|
||||
Description: l.Description,
|
||||
SortOrder: l.SortOrder,
|
||||
CreatedAt: l.CreatedAt,
|
||||
UpdatedAt: l.UpdatedAt,
|
||||
ID: l.ID,
|
||||
ModuleID: l.ModuleID,
|
||||
Title: l.Title,
|
||||
VideoUrl: l.VideoUrl,
|
||||
Thumbnail: l.Thumbnail,
|
||||
Description: l.Description,
|
||||
SortOrder: l.SortOrder,
|
||||
PublishStatus: l.PublishStatus,
|
||||
CreatedAt: l.CreatedAt,
|
||||
UpdatedAt: l.UpdatedAt,
|
||||
})
|
||||
out.HasPractice = l.HasPractice
|
||||
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{
|
||||
ModuleID: moduleID,
|
||||
Limit: limit,
|
||||
Offset: offset,
|
||||
ModuleID: moduleID,
|
||||
Limit: limit,
|
||||
Offset: offset,
|
||||
PublishedOnly: publishedOnly,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, 0, err
|
||||
|
|
@ -116,15 +123,16 @@ func (s *Store) ListLessonsByModuleID(ctx context.Context, moduleID int64, limit
|
|||
total = r.TotalCount
|
||||
}
|
||||
lesson := lessonToDomain(dbgen.Lesson{
|
||||
ID: r.ID,
|
||||
ModuleID: r.ModuleID,
|
||||
Title: r.Title,
|
||||
VideoUrl: r.VideoUrl,
|
||||
Thumbnail: r.Thumbnail,
|
||||
Description: r.Description,
|
||||
CreatedAt: r.CreatedAt,
|
||||
UpdatedAt: r.UpdatedAt,
|
||||
SortOrder: r.SortOrder,
|
||||
ID: r.ID,
|
||||
ModuleID: r.ModuleID,
|
||||
Title: r.Title,
|
||||
VideoUrl: r.VideoUrl,
|
||||
Thumbnail: r.Thumbnail,
|
||||
Description: r.Description,
|
||||
SortOrder: r.SortOrder,
|
||||
PublishStatus: r.PublishStatus,
|
||||
CreatedAt: r.CreatedAt,
|
||||
UpdatedAt: r.UpdatedAt,
|
||||
})
|
||||
lesson.HasPractice = r.HasPractice
|
||||
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) {
|
||||
sortParam := optionalInt4Update(input.SortOrder)
|
||||
pubParam := optionalPublishStatusUpdate(input.PublishStatus)
|
||||
|
||||
var titleText pgtype.Text
|
||||
if input.Title != nil {
|
||||
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
|
||||
}
|
||||
l, err := q.UpdateLesson(ctx, dbgen.UpdateLessonParams{
|
||||
ID: id,
|
||||
Title: titleText,
|
||||
VideoUrl: optionalTextUpdate(input.VideoURL),
|
||||
Thumbnail: optionalTextUpdate(input.Thumbnail),
|
||||
Description: optionalTextUpdate(input.Description),
|
||||
SortOrder: pgtype.Int4{Valid: false},
|
||||
ID: id,
|
||||
Title: titleText,
|
||||
VideoUrl: optionalTextUpdate(input.VideoURL),
|
||||
Thumbnail: optionalTextUpdate(input.Thumbnail),
|
||||
Description: optionalTextUpdate(input.Description),
|
||||
SortOrder: pgtype.Int4{Valid: false},
|
||||
PublishStatus: pubParam,
|
||||
})
|
||||
if err != nil {
|
||||
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{
|
||||
ID: id,
|
||||
Title: titleText,
|
||||
VideoUrl: optionalTextUpdate(input.VideoURL),
|
||||
Thumbnail: optionalTextUpdate(input.Thumbnail),
|
||||
Description: optionalTextUpdate(input.Description),
|
||||
SortOrder: sortParam,
|
||||
ID: id,
|
||||
Title: titleText,
|
||||
VideoUrl: optionalTextUpdate(input.VideoURL),
|
||||
Thumbnail: optionalTextUpdate(input.Thumbnail),
|
||||
Description: optionalTextUpdate(input.Description),
|
||||
SortOrder: sortParam,
|
||||
PublishStatus: pubParam,
|
||||
})
|
||||
if err != nil {
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
|
|
|
|||
|
|
@ -50,7 +50,7 @@ func (s *Service) GetByID(ctx context.Context, id int64) (domain.Lesson, error)
|
|||
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 {
|
||||
return nil, 0, err
|
||||
}
|
||||
|
|
@ -63,7 +63,7 @@ func (s *Service) ListByModule(ctx context.Context, moduleID int64, limit, offse
|
|||
if 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) {
|
||||
|
|
|
|||
|
|
@ -84,7 +84,8 @@ func (h *Handler) ListLessonsByModule(c *fiber.Ctx) error {
|
|||
}
|
||||
limit, _ := strconv.Atoi(c.Query("limit", "20"))
|
||||
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 errors.Is(err, modules.ErrModuleNotFound) {
|
||||
return c.Status(fiber.StatusNotFound).JSON(domain.ErrorResponse{
|
||||
|
|
@ -145,6 +146,12 @@ func (h *Handler) GetLesson(c *fiber.Ctx) 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)
|
||||
role := c.Locals("role").(domain.Role)
|
||||
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(),
|
||||
})
|
||||
}
|
||||
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) {
|
||||
return c.Status(fiber.StatusNotFound).JSON(domain.ErrorResponse{
|
||||
Message: "Lesson not found",
|
||||
|
|
@ -277,8 +285,14 @@ func (h *Handler) CompleteLesson(c *fiber.Ctx) error {
|
|||
Error: err.Error(),
|
||||
})
|
||||
}
|
||||
uid := c.Locals("user_id").(int64)
|
||||
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() {
|
||||
ok, reason, err := h.lmsProgressSvc.CanAccessLesson(c.Context(), uid, id)
|
||||
if err != nil {
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
}
|
||||
|
||||
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 {
|
||||
rn := string(c.Locals("role").(domain.Role))
|
||||
return h.rbacSvc.HasPermission(rn, "exam_prep.practices.create") || h.rbacSvc.HasPermission(rn, "exam_prep.practices.update")
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user