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
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.
(

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -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,9 +25,10 @@ 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 {
@ -37,6 +38,7 @@ type CreateLessonParams struct {
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
@ -97,6 +101,7 @@ type GetLessonByIDRow struct {
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"`
}
@ -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
@ -151,6 +162,7 @@ type ListLessonsByModuleIDParams struct {
ModuleID int64 `json:"module_id"`
Limit int32 `json:"limit"`
Offset int32 `json:"offset"`
PublishedOnly bool `json:"published_only"`
}
type ListLessonsByModuleIDRow struct {
@ -162,13 +174,19 @@ type ListLessonsByModuleIDRow struct {
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,11 +226,12 @@ 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 {
@ -220,6 +240,7 @@ type UpdateLessonParams struct {
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"`
}
@ -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
}

View File

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

View File

@ -130,6 +130,7 @@ type Lesson struct {
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 {

View File

@ -1,6 +1,35 @@
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 {
@ -11,12 +40,18 @@ type Lesson struct {
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 {
Title string `json:"title" validate:"required"`
VideoURL *string `json:"video_url,omitempty"`
@ -24,6 +59,8 @@ 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 {
@ -32,4 +69,5 @@ type UpdateLessonInput struct {
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"`
}

View File

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

View File

@ -16,6 +16,7 @@ func lessonToDomain(l dbgen.Lesson) domain.Lesson {
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 {
@ -50,6 +53,7 @@ func (s *Store) CreateLesson(ctx context.Context, moduleID int64, input domain.C
Thumbnail: toPgText(input.Thumbnail),
Description: toPgText(input.Description),
SortOrder: pgtype.Int4{Int32: target, Valid: true},
PublishStatus: pub,
})
if err != nil {
return domain.Lesson{}, err
@ -67,6 +71,7 @@ func (s *Store) CreateLesson(ctx context.Context, moduleID int64, input domain.C
Thumbnail: toPgText(input.Thumbnail),
Description: toPgText(input.Description),
SortOrder: pgtype.Int4{Valid: false},
PublishStatus: pub,
})
if err != nil {
return domain.Lesson{}, err
@ -90,6 +95,7 @@ func (s *Store) GetLessonByID(ctx context.Context, id int64) (domain.Lesson, err
Thumbnail: l.Thumbnail,
Description: l.Description,
SortOrder: l.SortOrder,
PublishStatus: l.PublishStatus,
CreatedAt: l.CreatedAt,
UpdatedAt: l.UpdatedAt,
})
@ -97,11 +103,12 @@ func (s *Store) GetLessonByID(ctx context.Context, id int64) (domain.Lesson, err
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,
PublishedOnly: publishedOnly,
})
if err != nil {
return nil, 0, err
@ -122,9 +129,10 @@ func (s *Store) ListLessonsByModuleID(ctx context.Context, moduleID int64, limit
VideoUrl: r.VideoUrl,
Thumbnail: r.Thumbnail,
Description: r.Description,
SortOrder: r.SortOrder,
PublishStatus: r.PublishStatus,
CreatedAt: r.CreatedAt,
UpdatedAt: r.UpdatedAt,
SortOrder: r.SortOrder,
})
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}
@ -164,6 +174,7 @@ func (s *Store) UpdateLesson(ctx context.Context, id int64, input domain.UpdateL
Thumbnail: optionalTextUpdate(input.Thumbnail),
Description: optionalTextUpdate(input.Description),
SortOrder: pgtype.Int4{Valid: false},
PublishStatus: pubParam,
})
if err != nil {
return domain.Lesson{}, err
@ -185,6 +196,7 @@ func (s *Store) UpdateLesson(ctx context.Context, id int64, input domain.UpdateL
Thumbnail: optionalTextUpdate(input.Thumbnail),
Description: optionalTextUpdate(input.Description),
SortOrder: sortParam,
PublishStatus: pubParam,
})
if err != nil {
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
}
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) {

View File

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

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