From bd1767d2a6e2e67cae424dc58fbb45f8e2c2b4c5 Mon Sep 17 00:00:00 2001 From: Yared Yemane Date: Wed, 20 May 2026 02:16:42 -0700 Subject: [PATCH] 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 --- .../000062_lesson_publish_status.down.sql | 3 + .../000062_lesson_publish_status.up.sql | 9 ++ db/query/lms_courses.sql | 3 +- db/query/lms_lessons.sql | 11 +- db/query/lms_progress.sql | 34 ++++-- docs/docs.go | 8 ++ docs/swagger.json | 8 ++ docs/swagger.yaml | 6 + gen/db/lms_courses.sql.go | 3 +- gen/db/lms_lessons.sql.go | 109 ++++++++++------- gen/db/lms_progress.sql.go | 23 +++- gen/db/models.go | 19 +-- internal/domain/lesson.go | 72 +++++++++--- internal/ports/lms_lesson.go | 2 +- internal/repository/lms_lessons.go | 110 ++++++++++-------- internal/services/lessons/service.go | 4 +- .../web_server/handlers/lesson_handler.go | 20 +++- .../handlers/practice_publish_gate.go | 5 + 18 files changed, 311 insertions(+), 138 deletions(-) create mode 100644 db/migrations/000062_lesson_publish_status.down.sql create mode 100644 db/migrations/000062_lesson_publish_status.up.sql diff --git a/db/migrations/000062_lesson_publish_status.down.sql b/db/migrations/000062_lesson_publish_status.down.sql new file mode 100644 index 0000000..2860c9c --- /dev/null +++ b/db/migrations/000062_lesson_publish_status.down.sql @@ -0,0 +1,3 @@ +ALTER TABLE lessons DROP CONSTRAINT IF EXISTS chk_lessons_publish_status; + +ALTER TABLE lessons DROP COLUMN IF EXISTS publish_status; diff --git a/db/migrations/000062_lesson_publish_status.up.sql b/db/migrations/000062_lesson_publish_status.up.sql new file mode 100644 index 0000000..73b4b16 --- /dev/null +++ b/db/migrations/000062_lesson_publish_status.up.sql @@ -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'; diff --git a/db/query/lms_courses.sql b/db/query/lms_courses.sql index a3cbf1c..8d17a6c 100644 --- a/db/query/lms_courses.sql +++ b/db/query/lms_courses.sql @@ -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. ( diff --git a/db/query/lms_lessons.sql b/db/query/lms_lessons.sql index b4c4c62..dfc58e5 100644 --- a/db/query/lms_lessons.sql +++ b/db/query/lms_lessons.sql @@ -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') diff --git a/db/query/lms_progress.sql b/db/query/lms_progress.sql index 6ef393f..72bcd90 100644 --- a/db/query/lms_progress.sql +++ b/db/query/lms_progress.sql @@ -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 diff --git a/docs/docs.go b/docs/docs.go index 77b53fa..b303066 100644 --- a/docs/docs.go +++ b/docs/docs.go @@ -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" }, diff --git a/docs/swagger.json b/docs/swagger.json index cf08c63..6e65d4f 100644 --- a/docs/swagger.json +++ b/docs/swagger.json @@ -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" }, diff --git a/docs/swagger.yaml b/docs/swagger.yaml index 36ea335..5ff816a 100644 --- a/docs/swagger.yaml +++ b/docs/swagger.yaml @@ -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: diff --git a/gen/db/lms_courses.sql.go b/gen/db/lms_courses.sql.go index 5c8a295..4065d55 100644 --- a/gen/db/lms_courses.sql.go +++ b/gen/db/lms_courses.sql.go @@ -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. ( diff --git a/gen/db/lms_lessons.sql.go b/gen/db/lms_lessons.sql.go index e2fcfe2..7bcaace 100644 --- a/gen/db/lms_lessons.sql.go +++ b/gen/db/lms_lessons.sql.go @@ -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 } diff --git a/gen/db/lms_progress.sql.go b/gen/db/lms_progress.sql.go index 3358b4f..106eec8 100644 --- a/gen/db/lms_progress.sql.go +++ b/gen/db/lms_progress.sql.go @@ -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 } diff --git a/gen/db/models.go b/gen/db/models.go index daf24c0..a4e6e91 100644 --- a/gen/db/models.go +++ b/gen/db/models.go @@ -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 { diff --git a/internal/domain/lesson.go b/internal/domain/lesson.go index 69e0b7e..00b501d 100644 --- a/internal/domain/lesson.go +++ b/internal/domain/lesson.go @@ -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"` } diff --git a/internal/ports/lms_lesson.go b/internal/ports/lms_lesson.go index 4bb4a90..05536e7 100644 --- a/internal/ports/lms_lesson.go +++ b/internal/ports/lms_lesson.go @@ -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 } diff --git a/internal/repository/lms_lessons.go b/internal/repository/lms_lessons.go index c88e5cf..02f90a5 100644 --- a/internal/repository/lms_lessons.go +++ b/internal/repository/lms_lessons.go @@ -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) { diff --git a/internal/services/lessons/service.go b/internal/services/lessons/service.go index 2be3f72..34104f8 100644 --- a/internal/services/lessons/service.go +++ b/internal/services/lessons/service.go @@ -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) { diff --git a/internal/web_server/handlers/lesson_handler.go b/internal/web_server/handlers/lesson_handler.go index 528a18f..60e2f46 100644 --- a/internal/web_server/handlers/lesson_handler.go +++ b/internal/web_server/handlers/lesson_handler.go @@ -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 { diff --git a/internal/web_server/handlers/practice_publish_gate.go b/internal/web_server/handlers/practice_publish_gate.go index 28506a4..e17ebc8 100644 --- a/internal/web_server/handlers/practice_publish_gate.go +++ b/internal/web_server/handlers/practice_publish_gate.go @@ -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")