diff --git a/db/migrations/000081_content_access_tier.down.sql b/db/migrations/000081_content_access_tier.down.sql new file mode 100644 index 0000000..68df5fa --- /dev/null +++ b/db/migrations/000081_content_access_tier.down.sql @@ -0,0 +1,23 @@ +ALTER TABLE exam_prep.unit_module_lessons DROP CONSTRAINT IF EXISTS chk_exam_prep_unit_module_lessons_access_tier; +ALTER TABLE exam_prep.unit_module_lessons DROP COLUMN IF EXISTS access_tier; + +ALTER TABLE exam_prep.unit_modules DROP CONSTRAINT IF EXISTS chk_exam_prep_unit_modules_access_tier; +ALTER TABLE exam_prep.unit_modules DROP COLUMN IF EXISTS access_tier; + +ALTER TABLE exam_prep.units DROP CONSTRAINT IF EXISTS chk_exam_prep_units_access_tier; +ALTER TABLE exam_prep.units DROP COLUMN IF EXISTS access_tier; + +ALTER TABLE exam_prep.catalog_courses DROP CONSTRAINT IF EXISTS chk_exam_prep_catalog_courses_access_tier; +ALTER TABLE exam_prep.catalog_courses DROP COLUMN IF EXISTS access_tier; + +ALTER TABLE lessons DROP CONSTRAINT IF EXISTS chk_lessons_access_tier; +ALTER TABLE lessons DROP COLUMN IF EXISTS access_tier; + +ALTER TABLE modules DROP CONSTRAINT IF EXISTS chk_modules_access_tier; +ALTER TABLE modules DROP COLUMN IF EXISTS access_tier; + +ALTER TABLE courses DROP CONSTRAINT IF EXISTS chk_courses_access_tier; +ALTER TABLE courses DROP COLUMN IF EXISTS access_tier; + +ALTER TABLE programs DROP CONSTRAINT IF EXISTS chk_programs_access_tier; +ALTER TABLE programs DROP COLUMN IF EXISTS access_tier; diff --git a/db/migrations/000081_content_access_tier.up.sql b/db/migrations/000081_content_access_tier.up.sql new file mode 100644 index 0000000..805efa3 --- /dev/null +++ b/db/migrations/000081_content_access_tier.up.sql @@ -0,0 +1,34 @@ +-- FREE content is accessible without a subscription; PREMIUM requires one. +-- Effective tier cascades at read time: any PREMIUM ancestor makes descendants PREMIUM. + +ALTER TABLE programs + ADD COLUMN access_tier VARCHAR(16) NOT NULL DEFAULT 'PREMIUM' + CONSTRAINT chk_programs_access_tier CHECK (access_tier IN ('FREE', 'PREMIUM')); + +ALTER TABLE courses + ADD COLUMN access_tier VARCHAR(16) NOT NULL DEFAULT 'PREMIUM' + CONSTRAINT chk_courses_access_tier CHECK (access_tier IN ('FREE', 'PREMIUM')); + +ALTER TABLE modules + ADD COLUMN access_tier VARCHAR(16) NOT NULL DEFAULT 'PREMIUM' + CONSTRAINT chk_modules_access_tier CHECK (access_tier IN ('FREE', 'PREMIUM')); + +ALTER TABLE lessons + ADD COLUMN access_tier VARCHAR(16) NOT NULL DEFAULT 'PREMIUM' + CONSTRAINT chk_lessons_access_tier CHECK (access_tier IN ('FREE', 'PREMIUM')); + +ALTER TABLE exam_prep.catalog_courses + ADD COLUMN access_tier VARCHAR(16) NOT NULL DEFAULT 'PREMIUM' + CONSTRAINT chk_exam_prep_catalog_courses_access_tier CHECK (access_tier IN ('FREE', 'PREMIUM')); + +ALTER TABLE exam_prep.units + ADD COLUMN access_tier VARCHAR(16) NOT NULL DEFAULT 'PREMIUM' + CONSTRAINT chk_exam_prep_units_access_tier CHECK (access_tier IN ('FREE', 'PREMIUM')); + +ALTER TABLE exam_prep.unit_modules + ADD COLUMN access_tier VARCHAR(16) NOT NULL DEFAULT 'PREMIUM' + CONSTRAINT chk_exam_prep_unit_modules_access_tier CHECK (access_tier IN ('FREE', 'PREMIUM')); + +ALTER TABLE exam_prep.unit_module_lessons + ADD COLUMN access_tier VARCHAR(16) NOT NULL DEFAULT 'PREMIUM' + CONSTRAINT chk_exam_prep_unit_module_lessons_access_tier CHECK (access_tier IN ('FREE', 'PREMIUM')); diff --git a/db/query/exam_prep_catalog_courses.sql b/db/query/exam_prep_catalog_courses.sql index cca87fb..6f38a29 100644 --- a/db/query/exam_prep_catalog_courses.sql +++ b/db/query/exam_prep_catalog_courses.sql @@ -1,5 +1,5 @@ -- name: ExamPrepCreateCatalogCourse :one -INSERT INTO exam_prep.catalog_courses (name, description, category, thumbnail, sort_order, publish_status) +INSERT INTO exam_prep.catalog_courses (name, description, category, thumbnail, sort_order, publish_status, access_tier) SELECT $1, $2, @@ -9,7 +9,8 @@ SELECT SELECT max(c.sort_order) FROM exam_prep.catalog_courses AS c), 0) + 1, - $5 + $5, + $6 RETURNING *; @@ -55,6 +56,7 @@ SELECT c.thumbnail, c.sort_order, c.publish_status, + c.access_tier, COALESCE(cc.units_count, 0)::BIGINT AS units_count, COALESCE(cc.modules_count, 0)::BIGINT AS modules_count, COALESCE(cc.lessons_count, 0)::BIGINT AS lessons_count, @@ -96,6 +98,7 @@ SET thumbnail = coalesce(sqlc.narg('thumbnail')::text, thumbnail), sort_order = coalesce(sqlc.narg('sort_order')::int, sort_order), publish_status = coalesce(sqlc.narg('publish_status')::varchar, publish_status), + access_tier = coalesce(sqlc.narg('access_tier')::varchar, access_tier), updated_at = CURRENT_TIMESTAMP WHERE id = sqlc.arg('id') RETURNING diff --git a/db/query/exam_prep_unit_module_lessons.sql b/db/query/exam_prep_unit_module_lessons.sql index df9c815..3b186a4 100644 --- a/db/query/exam_prep_unit_module_lessons.sql +++ b/db/query/exam_prep_unit_module_lessons.sql @@ -1,5 +1,5 @@ -- name: ExamPrepCreateUnitModuleLesson :one -INSERT INTO exam_prep.unit_module_lessons (unit_module_id, title, video_url, thumbnail, description, sort_order, publish_status) +INSERT INTO exam_prep.unit_module_lessons (unit_module_id, title, video_url, thumbnail, description, sort_order, publish_status, access_tier) SELECT $1, $2, @@ -12,7 +12,8 @@ SELECT FROM exam_prep.unit_module_lessons l WHERE l.unit_module_id = $1), 0) + 1, - $6 + $6, + $7 RETURNING *; @@ -59,6 +60,7 @@ SELECT l.description, l.sort_order, l.publish_status, + l.access_tier, EXISTS ( SELECT 1 FROM exam_prep.lesson_practices p @@ -89,6 +91,7 @@ SET 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), + access_tier = coalesce(sqlc.narg('access_tier')::varchar, access_tier), updated_at = CURRENT_TIMESTAMP WHERE id = sqlc.arg('id') RETURNING diff --git a/db/query/exam_prep_unit_modules.sql b/db/query/exam_prep_unit_modules.sql index 7ce61f7..953fc09 100644 --- a/db/query/exam_prep_unit_modules.sql +++ b/db/query/exam_prep_unit_modules.sql @@ -1,5 +1,5 @@ -- name: ExamPrepCreateUnitModule :one -INSERT INTO exam_prep.unit_modules (unit_id, name, description, thumbnail, icon, sort_order, publish_status) +INSERT INTO exam_prep.unit_modules (unit_id, name, description, thumbnail, icon, sort_order, publish_status, access_tier) SELECT $1, $2, @@ -12,7 +12,8 @@ SELECT FROM exam_prep.unit_modules m WHERE m.unit_id = $1), 0) + 1, - $6 + $6, + $7 RETURNING *; @@ -73,6 +74,7 @@ SELECT m.icon, m.sort_order, m.publish_status, + m.access_tier, COALESCE(mc.lessons_count, 0)::BIGINT AS lessons_count, COALESCE(mc.practices_count, 0)::BIGINT AS practices_count, (COALESCE(mc.practices_count, 0)::BIGINT > 0) AS has_practice, @@ -101,6 +103,7 @@ SET icon = coalesce(sqlc.narg('icon')::text, icon), sort_order = coalesce(sqlc.narg('sort_order')::int, sort_order), publish_status = coalesce(sqlc.narg('publish_status')::varchar, publish_status), + access_tier = coalesce(sqlc.narg('access_tier')::varchar, access_tier), updated_at = CURRENT_TIMESTAMP WHERE id = sqlc.arg('id') RETURNING diff --git a/db/query/exam_prep_units.sql b/db/query/exam_prep_units.sql index 2fd4697..29f32a3 100644 --- a/db/query/exam_prep_units.sql +++ b/db/query/exam_prep_units.sql @@ -1,5 +1,5 @@ -- name: ExamPrepCreateUnit :one -INSERT INTO exam_prep.units (catalog_course_id, name, description, thumbnail, sort_order, publish_status) +INSERT INTO exam_prep.units (catalog_course_id, name, description, thumbnail, sort_order, publish_status, access_tier) SELECT sqlc.arg('catalog_course_id'), sqlc.arg('name'), @@ -12,7 +12,8 @@ SELECT FROM exam_prep.units u WHERE u.catalog_course_id = sqlc.arg('catalog_course_id')), 0) + 1), - sqlc.arg('publish_status') + sqlc.arg('publish_status'), + sqlc.arg('access_tier') RETURNING *; @@ -77,6 +78,7 @@ SELECT u.thumbnail, u.sort_order, u.publish_status, + u.access_tier, COALESCE(uc.modules_count, 0)::BIGINT AS modules_count, COALESCE(uc.lessons_count, 0)::BIGINT AS lessons_count, COALESCE(uc.practices_count, 0)::BIGINT AS practices_count, @@ -105,6 +107,7 @@ SET thumbnail = coalesce(sqlc.narg('thumbnail')::text, thumbnail), sort_order = coalesce(sqlc.narg('sort_order')::int, sort_order), publish_status = coalesce(sqlc.narg('publish_status')::varchar, publish_status), + access_tier = coalesce(sqlc.narg('access_tier')::varchar, access_tier), updated_at = CURRENT_TIMESTAMP WHERE id = sqlc.arg('id') RETURNING diff --git a/db/query/lms_courses.sql b/db/query/lms_courses.sql index 29c6966..cee8c7c 100644 --- a/db/query/lms_courses.sql +++ b/db/query/lms_courses.sql @@ -1,5 +1,5 @@ -- name: CreateCourse :one -INSERT INTO courses (program_id, name, description, thumbnail, sort_order, publish_status) +INSERT INTO courses (program_id, name, description, thumbnail, sort_order, publish_status, access_tier) SELECT sqlc.arg('program_id'), sqlc.arg('name'), @@ -12,7 +12,8 @@ SELECT FROM courses c WHERE c.program_id = sqlc.arg('program_id')), 0) + 1), - sqlc.arg('publish_status') + sqlc.arg('publish_status'), + sqlc.arg('access_tier') RETURNING *; @@ -63,6 +64,7 @@ SELECT c.thumbnail, c.sort_order, c.publish_status, + c.access_tier, c.created_at, c.updated_at, ( @@ -123,6 +125,7 @@ SET thumbnail = COALESCE(sqlc.narg('thumbnail')::text, thumbnail), sort_order = coalesce(sqlc.narg('sort_order')::int, sort_order), publish_status = COALESCE(sqlc.narg('publish_status')::varchar, publish_status), + access_tier = COALESCE(sqlc.narg('access_tier')::varchar, access_tier), updated_at = CURRENT_TIMESTAMP WHERE id = sqlc.arg('id') diff --git a/db/query/lms_lessons.sql b/db/query/lms_lessons.sql index 63f0e0e..36c7a1a 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, publish_status) +INSERT INTO lessons (module_id, title, video_url, thumbnail, description, sort_order, publish_status, access_tier) SELECT sqlc.arg('module_id'), sqlc.arg('title'), @@ -13,7 +13,8 @@ SELECT FROM lessons l WHERE l.module_id = sqlc.arg('module_id')), 0) + 1), - sqlc.arg('publish_status') + sqlc.arg('publish_status'), + sqlc.arg('access_tier') RETURNING *; @@ -51,6 +52,7 @@ SELECT l.description, l.sort_order, l.publish_status, + l.access_tier, l.created_at, l.updated_at, EXISTS ( @@ -82,6 +84,7 @@ SET 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), + access_tier = COALESCE(sqlc.narg('access_tier')::varchar, access_tier), updated_at = CURRENT_TIMESTAMP WHERE id = sqlc.arg('id') diff --git a/db/query/lms_modules.sql b/db/query/lms_modules.sql index ea245ee..47d3648 100644 --- a/db/query/lms_modules.sql +++ b/db/query/lms_modules.sql @@ -1,5 +1,5 @@ -- name: CreateModule :one -INSERT INTO modules (program_id, course_id, name, description, icon, sort_order, publish_status) +INSERT INTO modules (program_id, course_id, name, description, icon, sort_order, publish_status, access_tier) SELECT sqlc.arg('program_id'), sqlc.arg('course_id'), @@ -13,7 +13,8 @@ SELECT FROM modules m WHERE m.course_id = sqlc.arg('course_id')), 0) + 1), - sqlc.arg('publish_status') + sqlc.arg('publish_status'), + sqlc.arg('access_tier') RETURNING *; @@ -64,6 +65,7 @@ SELECT m.icon, m.sort_order, m.publish_status, + m.access_tier, m.created_at, m.updated_at, EXISTS ( @@ -96,6 +98,7 @@ SET icon = COALESCE(sqlc.narg('icon')::text, icon), sort_order = coalesce(sqlc.narg('sort_order')::int, sort_order), publish_status = COALESCE(sqlc.narg('publish_status')::varchar, publish_status), + access_tier = COALESCE(sqlc.narg('access_tier')::varchar, access_tier), updated_at = CURRENT_TIMESTAMP WHERE id = sqlc.arg('id') diff --git a/db/query/programs.sql b/db/query/programs.sql index 41f31e2..a8d04a5 100644 --- a/db/query/programs.sql +++ b/db/query/programs.sql @@ -1,5 +1,5 @@ -- name: CreateProgram :one -INSERT INTO programs (name, description, category, thumbnail, sort_order, publish_status) +INSERT INTO programs (name, description, category, thumbnail, sort_order, publish_status, access_tier) SELECT sqlc.arg('name'), sqlc.arg('description'), @@ -9,7 +9,8 @@ SELECT SELECT max(p.sort_order) FROM programs AS p), 0) + 1), - sqlc.arg('publish_status') + sqlc.arg('publish_status'), + sqlc.arg('access_tier') RETURNING *; @@ -36,6 +37,7 @@ SELECT p.thumbnail, p.sort_order, p.publish_status, + p.access_tier, p.created_at, p.updated_at FROM programs p @@ -59,6 +61,7 @@ SET thumbnail = COALESCE(sqlc.narg('thumbnail')::text, thumbnail), sort_order = coalesce(sqlc.narg('sort_order')::int, sort_order), publish_status = COALESCE(sqlc.narg('publish_status')::varchar, publish_status), + access_tier = COALESCE(sqlc.narg('access_tier')::varchar, access_tier), updated_at = CURRENT_TIMESTAMP WHERE id = sqlc.arg('id') diff --git a/gen/db/exam_prep_catalog_courses.sql.go b/gen/db/exam_prep_catalog_courses.sql.go index 5b686e7..0b58feb 100644 --- a/gen/db/exam_prep_catalog_courses.sql.go +++ b/gen/db/exam_prep_catalog_courses.sql.go @@ -12,7 +12,7 @@ import ( ) const ExamPrepCreateCatalogCourse = `-- name: ExamPrepCreateCatalogCourse :one -INSERT INTO exam_prep.catalog_courses (name, description, category, thumbnail, sort_order, publish_status) +INSERT INTO exam_prep.catalog_courses (name, description, category, thumbnail, sort_order, publish_status, access_tier) SELECT $1, $2, @@ -22,9 +22,10 @@ SELECT SELECT max(c.sort_order) FROM exam_prep.catalog_courses AS c), 0) + 1, - $5 + $5, + $6 RETURNING - id, name, description, thumbnail, sort_order, created_at, updated_at, category, publish_status + id, name, description, thumbnail, sort_order, created_at, updated_at, category, publish_status, access_tier ` type ExamPrepCreateCatalogCourseParams struct { @@ -33,6 +34,7 @@ type ExamPrepCreateCatalogCourseParams struct { Category string `json:"category"` Thumbnail pgtype.Text `json:"thumbnail"` PublishStatus string `json:"publish_status"` + AccessTier string `json:"access_tier"` } func (q *Queries) ExamPrepCreateCatalogCourse(ctx context.Context, arg ExamPrepCreateCatalogCourseParams) (ExamPrepCatalogCourse, error) { @@ -42,6 +44,7 @@ func (q *Queries) ExamPrepCreateCatalogCourse(ctx context.Context, arg ExamPrepC arg.Category, arg.Thumbnail, arg.PublishStatus, + arg.AccessTier, ) var i ExamPrepCatalogCourse err := row.Scan( @@ -54,6 +57,7 @@ func (q *Queries) ExamPrepCreateCatalogCourse(ctx context.Context, arg ExamPrepC &i.UpdatedAt, &i.Category, &i.PublishStatus, + &i.AccessTier, ) return i, err } @@ -70,7 +74,7 @@ func (q *Queries) ExamPrepDeleteCatalogCourse(ctx context.Context, id int64) err const ExamPrepGetCatalogCourseByID = `-- name: ExamPrepGetCatalogCourseByID :one SELECT - c.id, c.name, c.description, c.thumbnail, c.sort_order, c.created_at, c.updated_at, c.category, c.publish_status, + c.id, c.name, c.description, c.thumbnail, c.sort_order, c.created_at, c.updated_at, c.category, c.publish_status, c.access_tier, EXISTS ( SELECT 1 FROM exam_prep.lesson_practices p @@ -97,6 +101,7 @@ type ExamPrepGetCatalogCourseByIDRow struct { UpdatedAt pgtype.Timestamptz `json:"updated_at"` Category string `json:"category"` PublishStatus string `json:"publish_status"` + AccessTier string `json:"access_tier"` HasPractice bool `json:"has_practice"` } @@ -113,6 +118,7 @@ func (q *Queries) ExamPrepGetCatalogCourseByID(ctx context.Context, id int64) (E &i.UpdatedAt, &i.Category, &i.PublishStatus, + &i.AccessTier, &i.HasPractice, ) return i, err @@ -169,6 +175,7 @@ SELECT c.thumbnail, c.sort_order, c.publish_status, + c.access_tier, COALESCE(cc.units_count, 0)::BIGINT AS units_count, COALESCE(cc.modules_count, 0)::BIGINT AS modules_count, COALESCE(cc.lessons_count, 0)::BIGINT AS lessons_count, @@ -211,6 +218,7 @@ type ExamPrepListCatalogCoursesRow struct { Thumbnail pgtype.Text `json:"thumbnail"` SortOrder int32 `json:"sort_order"` PublishStatus string `json:"publish_status"` + AccessTier string `json:"access_tier"` UnitsCount int64 `json:"units_count"` ModulesCount int64 `json:"modules_count"` LessonsCount int64 `json:"lessons_count"` @@ -237,6 +245,7 @@ func (q *Queries) ExamPrepListCatalogCourses(ctx context.Context, arg ExamPrepLi &i.Thumbnail, &i.SortOrder, &i.PublishStatus, + &i.AccessTier, &i.UnitsCount, &i.ModulesCount, &i.LessonsCount, @@ -263,10 +272,11 @@ SET thumbnail = coalesce($4::text, thumbnail), sort_order = coalesce($5::int, sort_order), publish_status = coalesce($6::varchar, publish_status), + access_tier = coalesce($7::varchar, access_tier), updated_at = CURRENT_TIMESTAMP -WHERE id = $7 +WHERE id = $8 RETURNING - id, name, description, thumbnail, sort_order, created_at, updated_at, category, publish_status + id, name, description, thumbnail, sort_order, created_at, updated_at, category, publish_status, access_tier ` type ExamPrepUpdateCatalogCourseParams struct { @@ -276,6 +286,7 @@ type ExamPrepUpdateCatalogCourseParams struct { Thumbnail pgtype.Text `json:"thumbnail"` SortOrder pgtype.Int4 `json:"sort_order"` PublishStatus pgtype.Text `json:"publish_status"` + AccessTier pgtype.Text `json:"access_tier"` ID int64 `json:"id"` } @@ -287,6 +298,7 @@ func (q *Queries) ExamPrepUpdateCatalogCourse(ctx context.Context, arg ExamPrepU arg.Thumbnail, arg.SortOrder, arg.PublishStatus, + arg.AccessTier, arg.ID, ) var i ExamPrepCatalogCourse @@ -300,6 +312,7 @@ func (q *Queries) ExamPrepUpdateCatalogCourse(ctx context.Context, arg ExamPrepU &i.UpdatedAt, &i.Category, &i.PublishStatus, + &i.AccessTier, ) return i, err } diff --git a/gen/db/exam_prep_unit_module_lessons.sql.go b/gen/db/exam_prep_unit_module_lessons.sql.go index 03df8f1..a991ae3 100644 --- a/gen/db/exam_prep_unit_module_lessons.sql.go +++ b/gen/db/exam_prep_unit_module_lessons.sql.go @@ -12,7 +12,7 @@ import ( ) const ExamPrepCreateUnitModuleLesson = `-- name: ExamPrepCreateUnitModuleLesson :one -INSERT INTO exam_prep.unit_module_lessons (unit_module_id, title, video_url, thumbnail, description, sort_order, publish_status) +INSERT INTO exam_prep.unit_module_lessons (unit_module_id, title, video_url, thumbnail, description, sort_order, publish_status, access_tier) SELECT $1, $2, @@ -25,9 +25,10 @@ SELECT FROM exam_prep.unit_module_lessons l WHERE l.unit_module_id = $1), 0) + 1, - $6 + $6, + $7 RETURNING - id, unit_module_id, title, video_url, thumbnail, description, sort_order, created_at, updated_at, publish_status + id, unit_module_id, title, video_url, thumbnail, description, sort_order, created_at, updated_at, publish_status, access_tier ` type ExamPrepCreateUnitModuleLessonParams struct { @@ -37,6 +38,7 @@ type ExamPrepCreateUnitModuleLessonParams struct { Thumbnail pgtype.Text `json:"thumbnail"` Description pgtype.Text `json:"description"` PublishStatus string `json:"publish_status"` + AccessTier string `json:"access_tier"` } func (q *Queries) ExamPrepCreateUnitModuleLesson(ctx context.Context, arg ExamPrepCreateUnitModuleLessonParams) (ExamPrepUnitModuleLesson, error) { @@ -47,6 +49,7 @@ func (q *Queries) ExamPrepCreateUnitModuleLesson(ctx context.Context, arg ExamPr arg.Thumbnail, arg.Description, arg.PublishStatus, + arg.AccessTier, ) var i ExamPrepUnitModuleLesson err := row.Scan( @@ -60,6 +63,7 @@ func (q *Queries) ExamPrepCreateUnitModuleLesson(ctx context.Context, arg ExamPr &i.CreatedAt, &i.UpdatedAt, &i.PublishStatus, + &i.AccessTier, ) return i, err } @@ -76,7 +80,7 @@ func (q *Queries) ExamPrepDeleteUnitModuleLesson(ctx context.Context, id int64) const ExamPrepGetUnitModuleLessonByID = `-- name: ExamPrepGetUnitModuleLessonByID :one SELECT - l.id, l.unit_module_id, l.title, l.video_url, l.thumbnail, l.description, l.sort_order, l.created_at, l.updated_at, l.publish_status, + l.id, l.unit_module_id, l.title, l.video_url, l.thumbnail, l.description, l.sort_order, l.created_at, l.updated_at, l.publish_status, l.access_tier, EXISTS ( SELECT 1 FROM exam_prep.lesson_practices p @@ -98,6 +102,7 @@ type ExamPrepGetUnitModuleLessonByIDRow struct { CreatedAt pgtype.Timestamptz `json:"created_at"` UpdatedAt pgtype.Timestamptz `json:"updated_at"` PublishStatus string `json:"publish_status"` + AccessTier string `json:"access_tier"` HasPractice bool `json:"has_practice"` } @@ -115,6 +120,7 @@ func (q *Queries) ExamPrepGetUnitModuleLessonByID(ctx context.Context, id int64) &i.CreatedAt, &i.UpdatedAt, &i.PublishStatus, + &i.AccessTier, &i.HasPractice, ) return i, err @@ -193,6 +199,7 @@ SELECT l.description, l.sort_order, l.publish_status, + l.access_tier, EXISTS ( SELECT 1 FROM exam_prep.lesson_practices p @@ -232,6 +239,7 @@ type ExamPrepListUnitModuleLessonsByUnitModuleIDRow struct { Description pgtype.Text `json:"description"` SortOrder int32 `json:"sort_order"` PublishStatus string `json:"publish_status"` + AccessTier string `json:"access_tier"` HasPractice bool `json:"has_practice"` CreatedAt pgtype.Timestamptz `json:"created_at"` UpdatedAt pgtype.Timestamptz `json:"updated_at"` @@ -261,6 +269,7 @@ func (q *Queries) ExamPrepListUnitModuleLessonsByUnitModuleID(ctx context.Contex &i.Description, &i.SortOrder, &i.PublishStatus, + &i.AccessTier, &i.HasPractice, &i.CreatedAt, &i.UpdatedAt, @@ -284,10 +293,11 @@ SET description = coalesce($4::text, description), sort_order = coalesce($5::int, sort_order), publish_status = coalesce($6::varchar, publish_status), + access_tier = coalesce($7::varchar, access_tier), updated_at = CURRENT_TIMESTAMP -WHERE id = $7 +WHERE id = $8 RETURNING - id, unit_module_id, title, video_url, thumbnail, description, sort_order, created_at, updated_at, publish_status + id, unit_module_id, title, video_url, thumbnail, description, sort_order, created_at, updated_at, publish_status, access_tier ` type ExamPrepUpdateUnitModuleLessonParams struct { @@ -297,6 +307,7 @@ type ExamPrepUpdateUnitModuleLessonParams struct { Description pgtype.Text `json:"description"` SortOrder pgtype.Int4 `json:"sort_order"` PublishStatus pgtype.Text `json:"publish_status"` + AccessTier pgtype.Text `json:"access_tier"` ID int64 `json:"id"` } @@ -308,6 +319,7 @@ func (q *Queries) ExamPrepUpdateUnitModuleLesson(ctx context.Context, arg ExamPr arg.Description, arg.SortOrder, arg.PublishStatus, + arg.AccessTier, arg.ID, ) var i ExamPrepUnitModuleLesson @@ -322,6 +334,7 @@ func (q *Queries) ExamPrepUpdateUnitModuleLesson(ctx context.Context, arg ExamPr &i.CreatedAt, &i.UpdatedAt, &i.PublishStatus, + &i.AccessTier, ) return i, err } diff --git a/gen/db/exam_prep_unit_modules.sql.go b/gen/db/exam_prep_unit_modules.sql.go index 2f5553c..c3d2f47 100644 --- a/gen/db/exam_prep_unit_modules.sql.go +++ b/gen/db/exam_prep_unit_modules.sql.go @@ -12,7 +12,7 @@ import ( ) const ExamPrepCreateUnitModule = `-- name: ExamPrepCreateUnitModule :one -INSERT INTO exam_prep.unit_modules (unit_id, name, description, thumbnail, icon, sort_order, publish_status) +INSERT INTO exam_prep.unit_modules (unit_id, name, description, thumbnail, icon, sort_order, publish_status, access_tier) SELECT $1, $2, @@ -25,9 +25,10 @@ SELECT FROM exam_prep.unit_modules m WHERE m.unit_id = $1), 0) + 1, - $6 + $6, + $7 RETURNING - id, unit_id, name, description, thumbnail, icon, sort_order, created_at, updated_at, publish_status + id, unit_id, name, description, thumbnail, icon, sort_order, created_at, updated_at, publish_status, access_tier ` type ExamPrepCreateUnitModuleParams struct { @@ -37,6 +38,7 @@ type ExamPrepCreateUnitModuleParams struct { Thumbnail pgtype.Text `json:"thumbnail"` Icon pgtype.Text `json:"icon"` PublishStatus string `json:"publish_status"` + AccessTier string `json:"access_tier"` } func (q *Queries) ExamPrepCreateUnitModule(ctx context.Context, arg ExamPrepCreateUnitModuleParams) (ExamPrepUnitModule, error) { @@ -47,6 +49,7 @@ func (q *Queries) ExamPrepCreateUnitModule(ctx context.Context, arg ExamPrepCrea arg.Thumbnail, arg.Icon, arg.PublishStatus, + arg.AccessTier, ) var i ExamPrepUnitModule err := row.Scan( @@ -60,6 +63,7 @@ func (q *Queries) ExamPrepCreateUnitModule(ctx context.Context, arg ExamPrepCrea &i.CreatedAt, &i.UpdatedAt, &i.PublishStatus, + &i.AccessTier, ) return i, err } @@ -76,7 +80,7 @@ func (q *Queries) ExamPrepDeleteUnitModule(ctx context.Context, id int64) error const ExamPrepGetUnitModuleByID = `-- name: ExamPrepGetUnitModuleByID :one SELECT - m.id, m.unit_id, m.name, m.description, m.thumbnail, m.icon, m.sort_order, m.created_at, m.updated_at, m.publish_status, + m.id, m.unit_id, m.name, m.description, m.thumbnail, m.icon, m.sort_order, m.created_at, m.updated_at, m.publish_status, m.access_tier, EXISTS ( SELECT 1 FROM exam_prep.lesson_practices p @@ -100,6 +104,7 @@ type ExamPrepGetUnitModuleByIDRow struct { CreatedAt pgtype.Timestamptz `json:"created_at"` UpdatedAt pgtype.Timestamptz `json:"updated_at"` PublishStatus string `json:"publish_status"` + AccessTier string `json:"access_tier"` HasPractice bool `json:"has_practice"` } @@ -117,6 +122,7 @@ func (q *Queries) ExamPrepGetUnitModuleByID(ctx context.Context, id int64) (Exam &i.CreatedAt, &i.UpdatedAt, &i.PublishStatus, + &i.AccessTier, &i.HasPractice, ) return i, err @@ -207,6 +213,7 @@ SELECT m.icon, m.sort_order, m.publish_status, + m.access_tier, COALESCE(mc.lessons_count, 0)::BIGINT AS lessons_count, COALESCE(mc.practices_count, 0)::BIGINT AS practices_count, (COALESCE(mc.practices_count, 0)::BIGINT > 0) AS has_practice, @@ -244,6 +251,7 @@ type ExamPrepListUnitModulesByUnitRow struct { Icon pgtype.Text `json:"icon"` SortOrder int32 `json:"sort_order"` PublishStatus string `json:"publish_status"` + AccessTier string `json:"access_tier"` LessonsCount int64 `json:"lessons_count"` PracticesCount int64 `json:"practices_count"` HasPractice bool `json:"has_practice"` @@ -275,6 +283,7 @@ func (q *Queries) ExamPrepListUnitModulesByUnit(ctx context.Context, arg ExamPre &i.Icon, &i.SortOrder, &i.PublishStatus, + &i.AccessTier, &i.LessonsCount, &i.PracticesCount, &i.HasPractice, @@ -300,10 +309,11 @@ SET icon = coalesce($4::text, icon), sort_order = coalesce($5::int, sort_order), publish_status = coalesce($6::varchar, publish_status), + access_tier = coalesce($7::varchar, access_tier), updated_at = CURRENT_TIMESTAMP -WHERE id = $7 +WHERE id = $8 RETURNING - id, unit_id, name, description, thumbnail, icon, sort_order, created_at, updated_at, publish_status + id, unit_id, name, description, thumbnail, icon, sort_order, created_at, updated_at, publish_status, access_tier ` type ExamPrepUpdateUnitModuleParams struct { @@ -313,6 +323,7 @@ type ExamPrepUpdateUnitModuleParams struct { Icon pgtype.Text `json:"icon"` SortOrder pgtype.Int4 `json:"sort_order"` PublishStatus pgtype.Text `json:"publish_status"` + AccessTier pgtype.Text `json:"access_tier"` ID int64 `json:"id"` } @@ -324,6 +335,7 @@ func (q *Queries) ExamPrepUpdateUnitModule(ctx context.Context, arg ExamPrepUpda arg.Icon, arg.SortOrder, arg.PublishStatus, + arg.AccessTier, arg.ID, ) var i ExamPrepUnitModule @@ -338,6 +350,7 @@ func (q *Queries) ExamPrepUpdateUnitModule(ctx context.Context, arg ExamPrepUpda &i.CreatedAt, &i.UpdatedAt, &i.PublishStatus, + &i.AccessTier, ) return i, err } diff --git a/gen/db/exam_prep_units.sql.go b/gen/db/exam_prep_units.sql.go index 8b8d013..648655a 100644 --- a/gen/db/exam_prep_units.sql.go +++ b/gen/db/exam_prep_units.sql.go @@ -12,7 +12,7 @@ import ( ) const ExamPrepCreateUnit = `-- name: ExamPrepCreateUnit :one -INSERT INTO exam_prep.units (catalog_course_id, name, description, thumbnail, sort_order, publish_status) +INSERT INTO exam_prep.units (catalog_course_id, name, description, thumbnail, sort_order, publish_status, access_tier) SELECT $1, $2, @@ -25,9 +25,10 @@ SELECT FROM exam_prep.units u WHERE u.catalog_course_id = $1), 0) + 1), - $6 + $6, + $7 RETURNING - id, catalog_course_id, name, description, thumbnail, sort_order, created_at, updated_at, publish_status + id, catalog_course_id, name, description, thumbnail, sort_order, created_at, updated_at, publish_status, access_tier ` type ExamPrepCreateUnitParams struct { @@ -37,6 +38,7 @@ type ExamPrepCreateUnitParams struct { Thumbnail pgtype.Text `json:"thumbnail"` SortOrder pgtype.Int4 `json:"sort_order"` PublishStatus string `json:"publish_status"` + AccessTier string `json:"access_tier"` } func (q *Queries) ExamPrepCreateUnit(ctx context.Context, arg ExamPrepCreateUnitParams) (ExamPrepUnit, error) { @@ -47,6 +49,7 @@ func (q *Queries) ExamPrepCreateUnit(ctx context.Context, arg ExamPrepCreateUnit arg.Thumbnail, arg.SortOrder, arg.PublishStatus, + arg.AccessTier, ) var i ExamPrepUnit err := row.Scan( @@ -59,6 +62,7 @@ func (q *Queries) ExamPrepCreateUnit(ctx context.Context, arg ExamPrepCreateUnit &i.CreatedAt, &i.UpdatedAt, &i.PublishStatus, + &i.AccessTier, ) return i, err } @@ -75,7 +79,7 @@ func (q *Queries) ExamPrepDeleteUnit(ctx context.Context, id int64) error { const ExamPrepGetUnitByID = `-- name: ExamPrepGetUnitByID :one SELECT - u.id, u.catalog_course_id, u.name, u.description, u.thumbnail, u.sort_order, u.created_at, u.updated_at, u.publish_status, + u.id, u.catalog_course_id, u.name, u.description, u.thumbnail, u.sort_order, u.created_at, u.updated_at, u.publish_status, u.access_tier, EXISTS ( SELECT 1 FROM exam_prep.lesson_practices p @@ -100,6 +104,7 @@ type ExamPrepGetUnitByIDRow struct { CreatedAt pgtype.Timestamptz `json:"created_at"` UpdatedAt pgtype.Timestamptz `json:"updated_at"` PublishStatus string `json:"publish_status"` + AccessTier string `json:"access_tier"` HasPractice bool `json:"has_practice"` } @@ -116,6 +121,7 @@ func (q *Queries) ExamPrepGetUnitByID(ctx context.Context, id int64) (ExamPrepGe &i.CreatedAt, &i.UpdatedAt, &i.PublishStatus, + &i.AccessTier, &i.HasPractice, ) return i, err @@ -208,6 +214,7 @@ SELECT u.thumbnail, u.sort_order, u.publish_status, + u.access_tier, COALESCE(uc.modules_count, 0)::BIGINT AS modules_count, COALESCE(uc.lessons_count, 0)::BIGINT AS lessons_count, COALESCE(uc.practices_count, 0)::BIGINT AS practices_count, @@ -245,6 +252,7 @@ type ExamPrepListUnitsByCatalogCourseRow struct { Thumbnail pgtype.Text `json:"thumbnail"` SortOrder int32 `json:"sort_order"` PublishStatus string `json:"publish_status"` + AccessTier string `json:"access_tier"` ModulesCount int64 `json:"modules_count"` LessonsCount int64 `json:"lessons_count"` PracticesCount int64 `json:"practices_count"` @@ -276,6 +284,7 @@ func (q *Queries) ExamPrepListUnitsByCatalogCourse(ctx context.Context, arg Exam &i.Thumbnail, &i.SortOrder, &i.PublishStatus, + &i.AccessTier, &i.ModulesCount, &i.LessonsCount, &i.PracticesCount, @@ -301,10 +310,11 @@ SET thumbnail = coalesce($3::text, thumbnail), sort_order = coalesce($4::int, sort_order), publish_status = coalesce($5::varchar, publish_status), + access_tier = coalesce($6::varchar, access_tier), updated_at = CURRENT_TIMESTAMP -WHERE id = $6 +WHERE id = $7 RETURNING - id, catalog_course_id, name, description, thumbnail, sort_order, created_at, updated_at, publish_status + id, catalog_course_id, name, description, thumbnail, sort_order, created_at, updated_at, publish_status, access_tier ` type ExamPrepUpdateUnitParams struct { @@ -313,6 +323,7 @@ type ExamPrepUpdateUnitParams struct { Thumbnail pgtype.Text `json:"thumbnail"` SortOrder pgtype.Int4 `json:"sort_order"` PublishStatus pgtype.Text `json:"publish_status"` + AccessTier pgtype.Text `json:"access_tier"` ID int64 `json:"id"` } @@ -323,6 +334,7 @@ func (q *Queries) ExamPrepUpdateUnit(ctx context.Context, arg ExamPrepUpdateUnit arg.Thumbnail, arg.SortOrder, arg.PublishStatus, + arg.AccessTier, arg.ID, ) var i ExamPrepUnit @@ -336,6 +348,7 @@ func (q *Queries) ExamPrepUpdateUnit(ctx context.Context, arg ExamPrepUpdateUnit &i.CreatedAt, &i.UpdatedAt, &i.PublishStatus, + &i.AccessTier, ) return i, err } diff --git a/gen/db/lms_courses.sql.go b/gen/db/lms_courses.sql.go index 0afe015..e9b1b38 100644 --- a/gen/db/lms_courses.sql.go +++ b/gen/db/lms_courses.sql.go @@ -12,7 +12,7 @@ import ( ) const CreateCourse = `-- name: CreateCourse :one -INSERT INTO courses (program_id, name, description, thumbnail, sort_order, publish_status) +INSERT INTO courses (program_id, name, description, thumbnail, sort_order, publish_status, access_tier) SELECT $1, $2, @@ -25,9 +25,10 @@ SELECT FROM courses c WHERE c.program_id = $1), 0) + 1), - $6 + $6, + $7 RETURNING - id, program_id, name, description, thumbnail, created_at, updated_at, sort_order, publish_status + id, program_id, name, description, thumbnail, created_at, updated_at, sort_order, publish_status, access_tier ` type CreateCourseParams struct { @@ -37,6 +38,7 @@ type CreateCourseParams struct { Thumbnail pgtype.Text `json:"thumbnail"` SortOrder pgtype.Int4 `json:"sort_order"` PublishStatus string `json:"publish_status"` + AccessTier string `json:"access_tier"` } func (q *Queries) CreateCourse(ctx context.Context, arg CreateCourseParams) (Course, error) { @@ -47,6 +49,7 @@ func (q *Queries) CreateCourse(ctx context.Context, arg CreateCourseParams) (Cou arg.Thumbnail, arg.SortOrder, arg.PublishStatus, + arg.AccessTier, ) var i Course err := row.Scan( @@ -59,6 +62,7 @@ func (q *Queries) CreateCourse(ctx context.Context, arg CreateCourseParams) (Cou &i.UpdatedAt, &i.SortOrder, &i.PublishStatus, + &i.AccessTier, ) return i, err } @@ -75,7 +79,7 @@ func (q *Queries) DeleteCourse(ctx context.Context, id int64) error { const GetCourseByID = `-- name: GetCourseByID :one SELECT - c.id, c.program_id, c.name, c.description, c.thumbnail, c.created_at, c.updated_at, c.sort_order, c.publish_status, + c.id, c.program_id, c.name, c.description, c.thumbnail, c.created_at, c.updated_at, c.sort_order, c.publish_status, c.access_tier, EXISTS ( SELECT 1 FROM lms_practices p @@ -99,6 +103,7 @@ type GetCourseByIDRow struct { UpdatedAt pgtype.Timestamptz `json:"updated_at"` SortOrder int32 `json:"sort_order"` PublishStatus string `json:"publish_status"` + AccessTier string `json:"access_tier"` HasPractice bool `json:"has_practice"` } @@ -115,6 +120,7 @@ func (q *Queries) GetCourseByID(ctx context.Context, id int64) (GetCourseByIDRow &i.UpdatedAt, &i.SortOrder, &i.PublishStatus, + &i.AccessTier, &i.HasPractice, ) return i, err @@ -161,6 +167,7 @@ SELECT c.thumbnail, c.sort_order, c.publish_status, + c.access_tier, c.created_at, c.updated_at, ( @@ -230,6 +237,7 @@ type ListCoursesByProgramIDRow struct { Thumbnail pgtype.Text `json:"thumbnail"` SortOrder int32 `json:"sort_order"` PublishStatus string `json:"publish_status"` + AccessTier string `json:"access_tier"` CreatedAt pgtype.Timestamptz `json:"created_at"` UpdatedAt pgtype.Timestamptz `json:"updated_at"` ModuleCount int64 `json:"module_count"` @@ -261,6 +269,7 @@ func (q *Queries) ListCoursesByProgramID(ctx context.Context, arg ListCoursesByP &i.Thumbnail, &i.SortOrder, &i.PublishStatus, + &i.AccessTier, &i.CreatedAt, &i.UpdatedAt, &i.ModuleCount, @@ -319,11 +328,12 @@ SET thumbnail = COALESCE($3::text, thumbnail), sort_order = coalesce($4::int, sort_order), publish_status = COALESCE($5::varchar, publish_status), + access_tier = COALESCE($6::varchar, access_tier), updated_at = CURRENT_TIMESTAMP WHERE - id = $6 + id = $7 RETURNING - id, program_id, name, description, thumbnail, created_at, updated_at, sort_order, publish_status + id, program_id, name, description, thumbnail, created_at, updated_at, sort_order, publish_status, access_tier ` type UpdateCourseParams struct { @@ -332,6 +342,7 @@ type UpdateCourseParams struct { Thumbnail pgtype.Text `json:"thumbnail"` SortOrder pgtype.Int4 `json:"sort_order"` PublishStatus pgtype.Text `json:"publish_status"` + AccessTier pgtype.Text `json:"access_tier"` ID int64 `json:"id"` } @@ -342,6 +353,7 @@ func (q *Queries) UpdateCourse(ctx context.Context, arg UpdateCourseParams) (Cou arg.Thumbnail, arg.SortOrder, arg.PublishStatus, + arg.AccessTier, arg.ID, ) var i Course @@ -355,6 +367,7 @@ func (q *Queries) UpdateCourse(ctx context.Context, arg UpdateCourseParams) (Cou &i.UpdatedAt, &i.SortOrder, &i.PublishStatus, + &i.AccessTier, ) return i, err } diff --git a/gen/db/lms_lessons.sql.go b/gen/db/lms_lessons.sql.go index 50da319..f49b75e 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, publish_status) +INSERT INTO lessons (module_id, title, video_url, thumbnail, description, sort_order, publish_status, access_tier) SELECT $1, $2, @@ -26,9 +26,10 @@ SELECT FROM lessons l WHERE l.module_id = $1), 0) + 1), - $7 + $7, + $8 RETURNING - id, module_id, title, video_url, thumbnail, description, created_at, updated_at, sort_order, publish_status + id, module_id, title, video_url, thumbnail, description, created_at, updated_at, sort_order, publish_status, access_tier ` type CreateLessonParams struct { @@ -39,6 +40,7 @@ type CreateLessonParams struct { Description pgtype.Text `json:"description"` SortOrder pgtype.Int4 `json:"sort_order"` PublishStatus string `json:"publish_status"` + AccessTier string `json:"access_tier"` } func (q *Queries) CreateLesson(ctx context.Context, arg CreateLessonParams) (Lesson, error) { @@ -50,6 +52,7 @@ func (q *Queries) CreateLesson(ctx context.Context, arg CreateLessonParams) (Les arg.Description, arg.SortOrder, arg.PublishStatus, + arg.AccessTier, ) var i Lesson err := row.Scan( @@ -63,6 +66,7 @@ func (q *Queries) CreateLesson(ctx context.Context, arg CreateLessonParams) (Les &i.UpdatedAt, &i.SortOrder, &i.PublishStatus, + &i.AccessTier, ) return i, err } @@ -79,7 +83,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.publish_status, + 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, l.access_tier, EXISTS ( SELECT 1 FROM lms_practices p @@ -102,6 +106,7 @@ type GetLessonByIDRow struct { UpdatedAt pgtype.Timestamptz `json:"updated_at"` SortOrder int32 `json:"sort_order"` PublishStatus string `json:"publish_status"` + AccessTier string `json:"access_tier"` HasPractice bool `json:"has_practice"` } @@ -119,6 +124,7 @@ func (q *Queries) GetLessonByID(ctx context.Context, id int64) (GetLessonByIDRow &i.UpdatedAt, &i.SortOrder, &i.PublishStatus, + &i.AccessTier, &i.HasPractice, ) return i, err @@ -166,6 +172,7 @@ SELECT l.description, l.sort_order, l.publish_status, + l.access_tier, l.created_at, l.updated_at, EXISTS ( @@ -206,6 +213,7 @@ type ListLessonsByModuleIDRow struct { Description pgtype.Text `json:"description"` SortOrder int32 `json:"sort_order"` PublishStatus string `json:"publish_status"` + AccessTier string `json:"access_tier"` CreatedAt pgtype.Timestamptz `json:"created_at"` UpdatedAt pgtype.Timestamptz `json:"updated_at"` HasPractice bool `json:"has_practice"` @@ -235,6 +243,7 @@ func (q *Queries) ListLessonsByModuleID(ctx context.Context, arg ListLessonsByMo &i.Description, &i.SortOrder, &i.PublishStatus, + &i.AccessTier, &i.CreatedAt, &i.UpdatedAt, &i.HasPractice, @@ -258,11 +267,12 @@ SET description = COALESCE($4::text, description), sort_order = coalesce($5::int, sort_order), publish_status = COALESCE($6::varchar, publish_status), + access_tier = COALESCE($7::varchar, access_tier), updated_at = CURRENT_TIMESTAMP WHERE - id = $7 + id = $8 RETURNING - id, module_id, title, video_url, thumbnail, description, created_at, updated_at, sort_order, publish_status + id, module_id, title, video_url, thumbnail, description, created_at, updated_at, sort_order, publish_status, access_tier ` type UpdateLessonParams struct { @@ -272,6 +282,7 @@ type UpdateLessonParams struct { Description pgtype.Text `json:"description"` SortOrder pgtype.Int4 `json:"sort_order"` PublishStatus pgtype.Text `json:"publish_status"` + AccessTier pgtype.Text `json:"access_tier"` ID int64 `json:"id"` } @@ -283,6 +294,7 @@ func (q *Queries) UpdateLesson(ctx context.Context, arg UpdateLessonParams) (Les arg.Description, arg.SortOrder, arg.PublishStatus, + arg.AccessTier, arg.ID, ) var i Lesson @@ -297,6 +309,7 @@ func (q *Queries) UpdateLesson(ctx context.Context, arg UpdateLessonParams) (Les &i.UpdatedAt, &i.SortOrder, &i.PublishStatus, + &i.AccessTier, ) return i, err } diff --git a/gen/db/lms_modules.sql.go b/gen/db/lms_modules.sql.go index db53765..71b7601 100644 --- a/gen/db/lms_modules.sql.go +++ b/gen/db/lms_modules.sql.go @@ -12,7 +12,7 @@ import ( ) const CreateModule = `-- name: CreateModule :one -INSERT INTO modules (program_id, course_id, name, description, icon, sort_order, publish_status) +INSERT INTO modules (program_id, course_id, name, description, icon, sort_order, publish_status, access_tier) SELECT $1, $2, @@ -26,9 +26,10 @@ SELECT FROM modules m WHERE m.course_id = $2), 0) + 1), - $7 + $7, + $8 RETURNING - id, program_id, course_id, name, description, icon, created_at, updated_at, sort_order, publish_status + id, program_id, course_id, name, description, icon, created_at, updated_at, sort_order, publish_status, access_tier ` type CreateModuleParams struct { @@ -39,6 +40,7 @@ type CreateModuleParams struct { Icon pgtype.Text `json:"icon"` SortOrder pgtype.Int4 `json:"sort_order"` PublishStatus string `json:"publish_status"` + AccessTier string `json:"access_tier"` } func (q *Queries) CreateModule(ctx context.Context, arg CreateModuleParams) (Module, error) { @@ -50,6 +52,7 @@ func (q *Queries) CreateModule(ctx context.Context, arg CreateModuleParams) (Mod arg.Icon, arg.SortOrder, arg.PublishStatus, + arg.AccessTier, ) var i Module err := row.Scan( @@ -63,6 +66,7 @@ func (q *Queries) CreateModule(ctx context.Context, arg CreateModuleParams) (Mod &i.UpdatedAt, &i.SortOrder, &i.PublishStatus, + &i.AccessTier, ) return i, err } @@ -79,7 +83,7 @@ func (q *Queries) DeleteModule(ctx context.Context, id int64) error { const GetModuleByID = `-- name: GetModuleByID :one SELECT - m.id, m.program_id, m.course_id, m.name, m.description, m.icon, m.created_at, m.updated_at, m.sort_order, m.publish_status, + m.id, m.program_id, m.course_id, m.name, m.description, m.icon, m.created_at, m.updated_at, m.sort_order, m.publish_status, m.access_tier, EXISTS ( SELECT 1 FROM lms_practices p @@ -103,6 +107,7 @@ type GetModuleByIDRow struct { UpdatedAt pgtype.Timestamptz `json:"updated_at"` SortOrder int32 `json:"sort_order"` PublishStatus string `json:"publish_status"` + AccessTier string `json:"access_tier"` HasPractice bool `json:"has_practice"` } @@ -120,6 +125,7 @@ func (q *Queries) GetModuleByID(ctx context.Context, id int64) (GetModuleByIDRow &i.UpdatedAt, &i.SortOrder, &i.PublishStatus, + &i.AccessTier, &i.HasPractice, ) return i, err @@ -167,6 +173,7 @@ SELECT m.icon, m.sort_order, m.publish_status, + m.access_tier, m.created_at, m.updated_at, EXISTS ( @@ -210,6 +217,7 @@ type ListModulesByProgramAndCourseRow struct { Icon pgtype.Text `json:"icon"` SortOrder int32 `json:"sort_order"` PublishStatus string `json:"publish_status"` + AccessTier string `json:"access_tier"` CreatedAt pgtype.Timestamptz `json:"created_at"` UpdatedAt pgtype.Timestamptz `json:"updated_at"` HasPractice bool `json:"has_practice"` @@ -240,6 +248,7 @@ func (q *Queries) ListModulesByProgramAndCourse(ctx context.Context, arg ListMod &i.Icon, &i.SortOrder, &i.PublishStatus, + &i.AccessTier, &i.CreatedAt, &i.UpdatedAt, &i.HasPractice, @@ -295,11 +304,12 @@ SET icon = COALESCE($3::text, icon), sort_order = coalesce($4::int, sort_order), publish_status = COALESCE($5::varchar, publish_status), + access_tier = COALESCE($6::varchar, access_tier), updated_at = CURRENT_TIMESTAMP WHERE - id = $6 + id = $7 RETURNING - id, program_id, course_id, name, description, icon, created_at, updated_at, sort_order, publish_status + id, program_id, course_id, name, description, icon, created_at, updated_at, sort_order, publish_status, access_tier ` type UpdateModuleParams struct { @@ -308,6 +318,7 @@ type UpdateModuleParams struct { Icon pgtype.Text `json:"icon"` SortOrder pgtype.Int4 `json:"sort_order"` PublishStatus pgtype.Text `json:"publish_status"` + AccessTier pgtype.Text `json:"access_tier"` ID int64 `json:"id"` } @@ -318,6 +329,7 @@ func (q *Queries) UpdateModule(ctx context.Context, arg UpdateModuleParams) (Mod arg.Icon, arg.SortOrder, arg.PublishStatus, + arg.AccessTier, arg.ID, ) var i Module @@ -332,6 +344,7 @@ func (q *Queries) UpdateModule(ctx context.Context, arg UpdateModuleParams) (Mod &i.UpdatedAt, &i.SortOrder, &i.PublishStatus, + &i.AccessTier, ) return i, err } diff --git a/gen/db/lms_progress.sql.go b/gen/db/lms_progress.sql.go index 0f8c4b9..cd30bcc 100644 --- a/gen/db/lms_progress.sql.go +++ b/gen/db/lms_progress.sql.go @@ -667,7 +667,7 @@ func (q *Queries) GetPracticeScopeByQuestionSetID(ctx context.Context, questionS const GetPreviousCourseInProgram = `-- name: GetPreviousCourseInProgram :one SELECT - c2.id, c2.program_id, c2.name, c2.description, c2.thumbnail, c2.created_at, c2.updated_at, c2.sort_order, c2.publish_status + c2.id, c2.program_id, c2.name, c2.description, c2.thumbnail, c2.created_at, c2.updated_at, c2.sort_order, c2.publish_status, c2.access_tier FROM courses AS c1 INNER JOIN courses AS c2 ON c2.program_id = c1.program_id @@ -700,13 +700,14 @@ func (q *Queries) GetPreviousCourseInProgram(ctx context.Context, id int64) (Cou &i.UpdatedAt, &i.SortOrder, &i.PublishStatus, + &i.AccessTier, ) return i, err } 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.publish_status + 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, l2.access_tier FROM lessons AS l1 INNER JOIN lessons AS l2 ON l2.module_id = l1.module_id @@ -741,13 +742,14 @@ func (q *Queries) GetPreviousLessonInModule(ctx context.Context, id int64) (Less &i.UpdatedAt, &i.SortOrder, &i.PublishStatus, + &i.AccessTier, ) return i, err } const GetPreviousModuleInCourse = `-- name: GetPreviousModuleInCourse :one SELECT - m2.id, m2.program_id, m2.course_id, m2.name, m2.description, m2.icon, m2.created_at, m2.updated_at, m2.sort_order, m2.publish_status + m2.id, m2.program_id, m2.course_id, m2.name, m2.description, m2.icon, m2.created_at, m2.updated_at, m2.sort_order, m2.publish_status, m2.access_tier FROM modules AS m1 INNER JOIN modules AS m2 ON m2.course_id = m1.course_id @@ -781,13 +783,14 @@ func (q *Queries) GetPreviousModuleInCourse(ctx context.Context, id int64) (Modu &i.UpdatedAt, &i.SortOrder, &i.PublishStatus, + &i.AccessTier, ) return i, err } const GetPreviousProgram = `-- name: GetPreviousProgram :one SELECT - p2.id, p2.name, p2.description, p2.thumbnail, p2.created_at, p2.updated_at, p2.sort_order, p2.category, p2.publish_status + p2.id, p2.name, p2.description, p2.thumbnail, p2.created_at, p2.updated_at, p2.sort_order, p2.category, p2.publish_status, p2.access_tier FROM programs AS p1 INNER JOIN programs AS p2 ON p2.category = p1.category @@ -821,6 +824,7 @@ func (q *Queries) GetPreviousProgram(ctx context.Context, id int64) (Program, er &i.SortOrder, &i.Category, &i.PublishStatus, + &i.AccessTier, ) return i, err } diff --git a/gen/db/models.go b/gen/db/models.go index 0f90706..2df1dec 100644 --- a/gen/db/models.go +++ b/gen/db/models.go @@ -32,6 +32,7 @@ type Course struct { UpdatedAt pgtype.Timestamptz `json:"updated_at"` SortOrder int32 `json:"sort_order"` PublishStatus string `json:"publish_status"` + AccessTier string `json:"access_tier"` } type Device struct { @@ -68,6 +69,7 @@ type ExamPrepCatalogCourse struct { UpdatedAt pgtype.Timestamptz `json:"updated_at"` Category string `json:"category"` PublishStatus string `json:"publish_status"` + AccessTier string `json:"access_tier"` } type ExamPrepLessonPractice struct { @@ -94,6 +96,7 @@ type ExamPrepUnit struct { CreatedAt pgtype.Timestamptz `json:"created_at"` UpdatedAt pgtype.Timestamptz `json:"updated_at"` PublishStatus string `json:"publish_status"` + AccessTier string `json:"access_tier"` } type ExamPrepUnitModule struct { @@ -107,6 +110,7 @@ type ExamPrepUnitModule struct { CreatedAt pgtype.Timestamptz `json:"created_at"` UpdatedAt pgtype.Timestamptz `json:"updated_at"` PublishStatus string `json:"publish_status"` + AccessTier string `json:"access_tier"` } type ExamPrepUnitModuleLesson struct { @@ -120,6 +124,7 @@ type ExamPrepUnitModuleLesson struct { CreatedAt pgtype.Timestamptz `json:"created_at"` UpdatedAt pgtype.Timestamptz `json:"updated_at"` PublishStatus string `json:"publish_status"` + AccessTier string `json:"access_tier"` } type Faq struct { @@ -162,6 +167,7 @@ type Lesson struct { UpdatedAt pgtype.Timestamptz `json:"updated_at"` SortOrder int32 `json:"sort_order"` PublishStatus string `json:"publish_status"` + AccessTier string `json:"access_tier"` } type LevelToSubCourse struct { @@ -245,6 +251,7 @@ type Module struct { UpdatedAt pgtype.Timestamptz `json:"updated_at"` SortOrder int32 `json:"sort_order"` PublishStatus string `json:"publish_status"` + AccessTier string `json:"access_tier"` } type ModuleToSubCourse struct { @@ -318,6 +325,7 @@ type Program struct { SortOrder int32 `json:"sort_order"` Category string `json:"category"` PublishStatus string `json:"publish_status"` + AccessTier string `json:"access_tier"` } type Question struct { diff --git a/gen/db/programs.sql.go b/gen/db/programs.sql.go index 098659c..05959be 100644 --- a/gen/db/programs.sql.go +++ b/gen/db/programs.sql.go @@ -12,7 +12,7 @@ import ( ) const CreateProgram = `-- name: CreateProgram :one -INSERT INTO programs (name, description, category, thumbnail, sort_order, publish_status) +INSERT INTO programs (name, description, category, thumbnail, sort_order, publish_status, access_tier) SELECT $1, $2, @@ -22,9 +22,10 @@ SELECT SELECT max(p.sort_order) FROM programs AS p), 0) + 1), - $6 + $6, + $7 RETURNING - id, name, description, thumbnail, created_at, updated_at, sort_order, category, publish_status + id, name, description, thumbnail, created_at, updated_at, sort_order, category, publish_status, access_tier ` type CreateProgramParams struct { @@ -34,6 +35,7 @@ type CreateProgramParams struct { Thumbnail pgtype.Text `json:"thumbnail"` SortOrder pgtype.Int4 `json:"sort_order"` PublishStatus string `json:"publish_status"` + AccessTier string `json:"access_tier"` } func (q *Queries) CreateProgram(ctx context.Context, arg CreateProgramParams) (Program, error) { @@ -44,6 +46,7 @@ func (q *Queries) CreateProgram(ctx context.Context, arg CreateProgramParams) (P arg.Thumbnail, arg.SortOrder, arg.PublishStatus, + arg.AccessTier, ) var i Program err := row.Scan( @@ -56,6 +59,7 @@ func (q *Queries) CreateProgram(ctx context.Context, arg CreateProgramParams) (P &i.SortOrder, &i.Category, &i.PublishStatus, + &i.AccessTier, ) return i, err } @@ -71,7 +75,7 @@ func (q *Queries) DeleteProgram(ctx context.Context, id int64) error { } const GetProgramByID = `-- name: GetProgramByID :one -SELECT id, name, description, thumbnail, created_at, updated_at, sort_order, category, publish_status +SELECT id, name, description, thumbnail, created_at, updated_at, sort_order, category, publish_status, access_tier FROM programs WHERE id = $1 ` @@ -89,6 +93,7 @@ func (q *Queries) GetProgramByID(ctx context.Context, id int64) (Program, error) &i.SortOrder, &i.Category, &i.PublishStatus, + &i.AccessTier, ) return i, err } @@ -132,6 +137,7 @@ SELECT p.thumbnail, p.sort_order, p.publish_status, + p.access_tier, p.created_at, p.updated_at FROM programs p @@ -163,6 +169,7 @@ type ListProgramsRow struct { Thumbnail pgtype.Text `json:"thumbnail"` SortOrder int32 `json:"sort_order"` PublishStatus string `json:"publish_status"` + AccessTier string `json:"access_tier"` CreatedAt pgtype.Timestamptz `json:"created_at"` UpdatedAt pgtype.Timestamptz `json:"updated_at"` } @@ -190,6 +197,7 @@ func (q *Queries) ListPrograms(ctx context.Context, arg ListProgramsParams) ([]L &i.Thumbnail, &i.SortOrder, &i.PublishStatus, + &i.AccessTier, &i.CreatedAt, &i.UpdatedAt, ); err != nil { @@ -212,11 +220,12 @@ SET thumbnail = COALESCE($4::text, thumbnail), sort_order = coalesce($5::int, sort_order), publish_status = COALESCE($6::varchar, publish_status), + access_tier = COALESCE($7::varchar, access_tier), updated_at = CURRENT_TIMESTAMP WHERE - id = $7 + id = $8 RETURNING - id, name, description, thumbnail, created_at, updated_at, sort_order, category, publish_status + id, name, description, thumbnail, created_at, updated_at, sort_order, category, publish_status, access_tier ` type UpdateProgramParams struct { @@ -226,6 +235,7 @@ type UpdateProgramParams struct { Thumbnail pgtype.Text `json:"thumbnail"` SortOrder pgtype.Int4 `json:"sort_order"` PublishStatus pgtype.Text `json:"publish_status"` + AccessTier pgtype.Text `json:"access_tier"` ID int64 `json:"id"` } @@ -237,6 +247,7 @@ func (q *Queries) UpdateProgram(ctx context.Context, arg UpdateProgramParams) (P arg.Thumbnail, arg.SortOrder, arg.PublishStatus, + arg.AccessTier, arg.ID, ) var i Program @@ -250,6 +261,7 @@ func (q *Queries) UpdateProgram(ctx context.Context, arg UpdateProgramParams) (P &i.SortOrder, &i.Category, &i.PublishStatus, + &i.AccessTier, ) return i, err } diff --git a/internal/domain/content_access_tier.go b/internal/domain/content_access_tier.go new file mode 100644 index 0000000..75813db --- /dev/null +++ b/internal/domain/content_access_tier.go @@ -0,0 +1,45 @@ +package domain + +import "strings" + +// ContentAccessTier controls whether learners need an active subscription to consume content. +// Effective tier cascades at read time: any PREMIUM ancestor makes descendants PREMIUM. +type ContentAccessTier string + +const ( + ContentAccessFree ContentAccessTier = "FREE" + ContentAccessPremium ContentAccessTier = "PREMIUM" +) + +// ContentAccessTierFromDB normalizes persisted values. +func ContentAccessTierFromDB(raw string) ContentAccessTier { + switch strings.TrimSpace(strings.ToUpper(raw)) { + case string(ContentAccessFree): + return ContentAccessFree + default: + return ContentAccessPremium + } +} + +// ContentAccessTierFromCreateInput resolves create body: omit → premium; explicit value validated separately. +func ContentAccessTierFromCreateInput(raw *string) ContentAccessTier { + if raw == nil || strings.TrimSpace(*raw) == "" { + return ContentAccessPremium + } + return ContentAccessTierFromDB(*raw) +} + +// EffectiveContentAccessTier returns PREMIUM when any tier in the chain is PREMIUM. +func EffectiveContentAccessTier(tiers ...ContentAccessTier) ContentAccessTier { + for _, tier := range tiers { + if tier == ContentAccessPremium { + return ContentAccessPremium + } + } + return ContentAccessFree +} + +// RequiresSubscription is true when the effective tier needs an active plan. +func (t ContentAccessTier) RequiresSubscription() bool { + return t == ContentAccessPremium +} diff --git a/internal/domain/content_access_tier_test.go b/internal/domain/content_access_tier_test.go new file mode 100644 index 0000000..6bb97a9 --- /dev/null +++ b/internal/domain/content_access_tier_test.go @@ -0,0 +1,15 @@ +package domain + +import "testing" + +func TestEffectiveContentAccessTier(t *testing.T) { + if got := EffectiveContentAccessTier(ContentAccessFree, ContentAccessFree); got != ContentAccessFree { + t.Fatalf("expected FREE, got %s", got) + } + if got := EffectiveContentAccessTier(ContentAccessFree, ContentAccessPremium); got != ContentAccessPremium { + t.Fatalf("expected PREMIUM, got %s", got) + } + if got := EffectiveContentAccessTier(ContentAccessPremium, ContentAccessFree); got != ContentAccessPremium { + t.Fatalf("expected cascaded PREMIUM, got %s", got) + } +} diff --git a/internal/domain/course.go b/internal/domain/course.go index 9d0669e..894365c 100644 --- a/internal/domain/course.go +++ b/internal/domain/course.go @@ -21,8 +21,10 @@ type Course struct { Description *string `json:"description,omitempty"` Thumbnail *string `json:"thumbnail,omitempty"` SortOrder int `json:"sort_order"` - PublishStatus ContentPublishStatus `json:"publish_status"` - CreatedAt time.Time `json:"created_at"` + PublishStatus ContentPublishStatus `json:"publish_status"` + AccessTier ContentAccessTier `json:"access_tier"` + EffectiveAccessTier ContentAccessTier `json:"effective_access_tier,omitempty"` + CreatedAt time.Time `json:"created_at"` UpdatedAt *time.Time `json:"updated_at,omitempty"` // Populated on list-by-program. Practice count: lms_practices rows with course_id = course only // (not practices attached to a module or lesson under this course). @@ -46,6 +48,7 @@ type CreateCourseInput struct { 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"` + AccessTier *string `json:"access_tier,omitempty" validate:"omitempty,oneof=FREE free PREMIUM premium"` } type UpdateCourseInput struct { @@ -54,4 +57,5 @@ type UpdateCourseInput struct { Thumbnail *string `json:"thumbnail,omitempty"` SortOrder *int `json:"sort_order,omitempty"` PublishStatus *string `json:"publish_status,omitempty" validate:"omitempty,oneof=DRAFT draft PUBLISHED published"` + AccessTier *string `json:"access_tier,omitempty" validate:"omitempty,oneof=FREE free PREMIUM premium"` } diff --git a/internal/domain/exam_prep_catalog_course.go b/internal/domain/exam_prep_catalog_course.go index bc6f6f7..73d3590 100644 --- a/internal/domain/exam_prep_catalog_course.go +++ b/internal/domain/exam_prep_catalog_course.go @@ -10,8 +10,10 @@ type ExamPrepCatalogCourse struct { Category string `json:"category"` Thumbnail *string `json:"thumbnail,omitempty"` SortOrder int `json:"sort_order"` - PublishStatus ContentPublishStatus `json:"publish_status"` - UnitsCount *int64 `json:"units_count,omitempty"` + PublishStatus ContentPublishStatus `json:"publish_status"` + AccessTier ContentAccessTier `json:"access_tier"` + EffectiveAccessTier ContentAccessTier `json:"effective_access_tier,omitempty"` + UnitsCount *int64 `json:"units_count,omitempty"` ModulesCount *int64 `json:"modules_count,omitempty"` LessonsCount *int64 `json:"lessons_count,omitempty"` HasPractice bool `json:"has_practice"` @@ -32,6 +34,7 @@ type CreateExamPrepCatalogCourseInput struct { Thumbnail *string `json:"thumbnail,omitempty"` // 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"` + AccessTier *string `json:"access_tier,omitempty" validate:"omitempty,oneof=FREE free PREMIUM premium"` } type UpdateExamPrepCatalogCourseInput struct { @@ -41,4 +44,5 @@ type UpdateExamPrepCatalogCourseInput struct { Thumbnail *string `json:"thumbnail,omitempty"` SortOrder *int `json:"sort_order,omitempty"` PublishStatus *string `json:"publish_status,omitempty" validate:"omitempty,oneof=DRAFT draft PUBLISHED published"` + AccessTier *string `json:"access_tier,omitempty" validate:"omitempty,oneof=FREE free PREMIUM premium"` } diff --git a/internal/domain/exam_prep_lesson.go b/internal/domain/exam_prep_lesson.go index 8857e0e..b266324 100644 --- a/internal/domain/exam_prep_lesson.go +++ b/internal/domain/exam_prep_lesson.go @@ -11,8 +11,10 @@ type ExamPrepLesson struct { Thumbnail *string `json:"thumbnail,omitempty"` Description *string `json:"description,omitempty"` SortOrder int `json:"sort_order"` - PublishStatus ContentPublishStatus `json:"publish_status"` - HasPractice bool `json:"has_practice"` + PublishStatus ContentPublishStatus `json:"publish_status"` + AccessTier ContentAccessTier `json:"access_tier"` + EffectiveAccessTier ContentAccessTier `json:"effective_access_tier,omitempty"` + HasPractice bool `json:"has_practice"` Access *LMSEntityAccess `json:"access,omitempty"` CreatedAt time.Time `json:"created_at"` UpdatedAt *time.Time `json:"updated_at,omitempty"` @@ -30,6 +32,7 @@ type CreateExamPrepLessonInput struct { Description *string `json:"description,omitempty"` // 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"` + AccessTier *string `json:"access_tier,omitempty" validate:"omitempty,oneof=FREE free PREMIUM premium"` } type UpdateExamPrepLessonInput struct { @@ -39,4 +42,5 @@ type UpdateExamPrepLessonInput struct { Description *string `json:"description,omitempty"` SortOrder *int `json:"sort_order,omitempty"` PublishStatus *string `json:"publish_status,omitempty" validate:"omitempty,oneof=DRAFT draft PUBLISHED published"` + AccessTier *string `json:"access_tier,omitempty" validate:"omitempty,oneof=FREE free PREMIUM premium"` } diff --git a/internal/domain/exam_prep_module.go b/internal/domain/exam_prep_module.go index a495003..65309f1 100644 --- a/internal/domain/exam_prep_module.go +++ b/internal/domain/exam_prep_module.go @@ -11,8 +11,10 @@ type ExamPrepModule struct { Thumbnail *string `json:"thumbnail,omitempty"` Icon *string `json:"icon,omitempty"` SortOrder int `json:"sort_order"` - PublishStatus ContentPublishStatus `json:"publish_status"` - LessonsCount *int64 `json:"lessons_count,omitempty"` + PublishStatus ContentPublishStatus `json:"publish_status"` + AccessTier ContentAccessTier `json:"access_tier"` + EffectiveAccessTier ContentAccessTier `json:"effective_access_tier,omitempty"` + LessonsCount *int64 `json:"lessons_count,omitempty"` PracticesCount *int64 `json:"practices_count,omitempty"` HasPractice bool `json:"has_practice"` Access *LMSEntityAccess `json:"access,omitempty"` @@ -32,6 +34,7 @@ type CreateExamPrepModuleInput struct { Icon *string `json:"icon,omitempty"` // 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"` + AccessTier *string `json:"access_tier,omitempty" validate:"omitempty,oneof=FREE free PREMIUM premium"` } type UpdateExamPrepModuleInput struct { @@ -41,4 +44,5 @@ type UpdateExamPrepModuleInput struct { Icon *string `json:"icon,omitempty"` SortOrder *int `json:"sort_order,omitempty"` PublishStatus *string `json:"publish_status,omitempty" validate:"omitempty,oneof=DRAFT draft PUBLISHED published"` + AccessTier *string `json:"access_tier,omitempty" validate:"omitempty,oneof=FREE free PREMIUM premium"` } diff --git a/internal/domain/exam_prep_unit.go b/internal/domain/exam_prep_unit.go index a8780f2..c373cbc 100644 --- a/internal/domain/exam_prep_unit.go +++ b/internal/domain/exam_prep_unit.go @@ -10,8 +10,10 @@ type ExamPrepUnit struct { Description *string `json:"description,omitempty"` Thumbnail *string `json:"thumbnail,omitempty"` SortOrder int `json:"sort_order"` - PublishStatus ContentPublishStatus `json:"publish_status"` - ModulesCount *int64 `json:"modules_count,omitempty"` + PublishStatus ContentPublishStatus `json:"publish_status"` + AccessTier ContentAccessTier `json:"access_tier"` + EffectiveAccessTier ContentAccessTier `json:"effective_access_tier,omitempty"` + ModulesCount *int64 `json:"modules_count,omitempty"` LessonsCount *int64 `json:"lessons_count,omitempty"` PracticesCount *int64 `json:"practices_count,omitempty"` HasPractice bool `json:"has_practice"` @@ -33,6 +35,7 @@ type CreateExamPrepUnitInput struct { 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"` + AccessTier *string `json:"access_tier,omitempty" validate:"omitempty,oneof=FREE free PREMIUM premium"` } type UpdateExamPrepUnitInput struct { @@ -41,4 +44,5 @@ type UpdateExamPrepUnitInput struct { Thumbnail *string `json:"thumbnail,omitempty"` SortOrder *int `json:"sort_order,omitempty"` PublishStatus *string `json:"publish_status,omitempty" validate:"omitempty,oneof=DRAFT draft PUBLISHED published"` + AccessTier *string `json:"access_tier,omitempty" validate:"omitempty,oneof=FREE free PREMIUM premium"` } diff --git a/internal/domain/lesson.go b/internal/domain/lesson.go index 00b501d..0ec6a3a 100644 --- a/internal/domain/lesson.go +++ b/internal/domain/lesson.go @@ -40,8 +40,10 @@ 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"` + PublishStatus LessonPublishStatus `json:"publish_status"` + AccessTier ContentAccessTier `json:"access_tier"` + EffectiveAccessTier ContentAccessTier `json:"effective_access_tier,omitempty"` + HasPractice bool `json:"has_practice"` CreatedAt time.Time `json:"created_at"` UpdatedAt *time.Time `json:"updated_at,omitempty"` Access *LMSEntityAccess `json:"access,omitempty"` @@ -61,6 +63,7 @@ type CreateLessonInput struct { 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"` + AccessTier *string `json:"access_tier,omitempty" validate:"omitempty,oneof=FREE free PREMIUM premium"` } type UpdateLessonInput struct { @@ -70,4 +73,5 @@ type UpdateLessonInput struct { Description *string `json:"description,omitempty"` SortOrder *int `json:"sort_order,omitempty"` PublishStatus *string `json:"publish_status,omitempty" validate:"omitempty,oneof=DRAFT draft PUBLISHED published"` + AccessTier *string `json:"access_tier,omitempty" validate:"omitempty,oneof=FREE free PREMIUM premium"` } diff --git a/internal/domain/module.go b/internal/domain/module.go index 8cfc8c0..9666e3a 100644 --- a/internal/domain/module.go +++ b/internal/domain/module.go @@ -11,8 +11,10 @@ type Module struct { Description *string `json:"description,omitempty"` Icon *string `json:"icon,omitempty"` SortOrder int `json:"sort_order"` - PublishStatus ContentPublishStatus `json:"publish_status"` - HasPractice bool `json:"has_practice"` + PublishStatus ContentPublishStatus `json:"publish_status"` + AccessTier ContentAccessTier `json:"access_tier"` + EffectiveAccessTier ContentAccessTier `json:"effective_access_tier,omitempty"` + HasPractice bool `json:"has_practice"` CreatedAt time.Time `json:"created_at"` UpdatedAt *time.Time `json:"updated_at,omitempty"` Access *LMSEntityAccess `json:"access,omitempty"` @@ -31,6 +33,7 @@ type CreateModuleInput struct { 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"` + AccessTier *string `json:"access_tier,omitempty" validate:"omitempty,oneof=FREE free PREMIUM premium"` } type UpdateModuleInput struct { @@ -39,4 +42,5 @@ type UpdateModuleInput struct { Icon *string `json:"icon,omitempty"` SortOrder *int `json:"sort_order,omitempty"` PublishStatus *string `json:"publish_status,omitempty" validate:"omitempty,oneof=DRAFT draft PUBLISHED published"` + AccessTier *string `json:"access_tier,omitempty" validate:"omitempty,oneof=FREE free PREMIUM premium"` } diff --git a/internal/domain/program.go b/internal/domain/program.go index a7cc7a9..e5b076f 100644 --- a/internal/domain/program.go +++ b/internal/domain/program.go @@ -10,8 +10,10 @@ type Program struct { Category string `json:"category"` Thumbnail *string `json:"thumbnail,omitempty"` SortOrder int `json:"sort_order"` - PublishStatus ContentPublishStatus `json:"publish_status"` - CreatedAt time.Time `json:"created_at"` + PublishStatus ContentPublishStatus `json:"publish_status"` + AccessTier ContentAccessTier `json:"access_tier"` + EffectiveAccessTier ContentAccessTier `json:"effective_access_tier,omitempty"` + CreatedAt time.Time `json:"created_at"` UpdatedAt *time.Time `json:"updated_at,omitempty"` Access *LMSEntityAccess `json:"access,omitempty"` } @@ -30,6 +32,7 @@ type CreateProgramInput struct { 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"` + AccessTier *string `json:"access_tier,omitempty" validate:"omitempty,oneof=FREE free PREMIUM premium"` } type UpdateProgramInput struct { @@ -39,4 +42,5 @@ type UpdateProgramInput struct { Thumbnail *string `json:"thumbnail,omitempty"` SortOrder *int `json:"sort_order,omitempty"` PublishStatus *string `json:"publish_status,omitempty" validate:"omitempty,oneof=DRAFT draft PUBLISHED published"` + AccessTier *string `json:"access_tier,omitempty" validate:"omitempty,oneof=FREE free PREMIUM premium"` } diff --git a/internal/repository/exam_prep_catalog_courses.go b/internal/repository/exam_prep_catalog_courses.go index 7d62959..856dc4a 100644 --- a/internal/repository/exam_prep_catalog_courses.go +++ b/internal/repository/exam_prep_catalog_courses.go @@ -18,6 +18,7 @@ func examPrepCatalogCourseToDomain(c dbgen.ExamPrepCatalogCourse) domain.ExamPre Category: c.Category, SortOrder: int(c.SortOrder), PublishStatus: domain.ContentPublishStatusFromDB(c.PublishStatus), + AccessTier: domain.ContentAccessTierFromDB(c.AccessTier), } out.Description = fromPgText(c.Description) out.Thumbnail = fromPgText(c.Thumbnail) @@ -36,6 +37,7 @@ func (s *Store) CreateExamPrepCatalogCourse(ctx context.Context, input domain.Cr Category: input.Category, Thumbnail: toPgText(input.Thumbnail), PublishStatus: string(domain.ContentPublishStatusFromCreateInput(input.PublishStatus)), + AccessTier: contentAccessTierForCreate(input.AccessTier), }) if err != nil { return domain.ExamPrepCatalogCourse{}, err @@ -61,6 +63,7 @@ func (s *Store) GetExamPrepCatalogCourseByID(ctx context.Context, id int64) (dom CreatedAt: c.CreatedAt, UpdatedAt: c.UpdatedAt, PublishStatus: c.PublishStatus, + AccessTier: c.AccessTier, }) out.HasPractice = c.HasPractice return out, nil @@ -94,6 +97,7 @@ func (s *Store) ListExamPrepCatalogCourses(ctx context.Context, publishedOnly bo CreatedAt: r.CreatedAt, UpdatedAt: r.UpdatedAt, PublishStatus: r.PublishStatus, + AccessTier: r.AccessTier, }) item.UnitsCount = &r.UnitsCount item.ModulesCount = &r.ModulesCount @@ -123,6 +127,7 @@ func (s *Store) UpdateExamPrepCatalogCourse(ctx context.Context, id int64, input Thumbnail: optionalTextUpdate(input.Thumbnail), SortOrder: optionalInt4Update(input.SortOrder), PublishStatus: optionalPublishStatusUpdate(input.PublishStatus), + AccessTier: optionalAccessTierUpdate(input.AccessTier), }) if err != nil { if errors.Is(err, pgx.ErrNoRows) { diff --git a/internal/repository/exam_prep_unit_module_lessons.go b/internal/repository/exam_prep_unit_module_lessons.go index 9baad81..199a7e7 100644 --- a/internal/repository/exam_prep_unit_module_lessons.go +++ b/internal/repository/exam_prep_unit_module_lessons.go @@ -18,6 +18,7 @@ func examPrepLessonToDomain(l dbgen.ExamPrepUnitModuleLesson) domain.ExamPrepLes Title: l.Title, SortOrder: int(l.SortOrder), PublishStatus: domain.ContentPublishStatusFromDB(l.PublishStatus), + AccessTier: domain.ContentAccessTierFromDB(l.AccessTier), } out.VideoURL = fromPgText(l.VideoUrl) out.Thumbnail = fromPgText(l.Thumbnail) @@ -38,6 +39,7 @@ func (s *Store) CreateExamPrepUnitModuleLesson(ctx context.Context, unitModuleID Thumbnail: toPgText(input.Thumbnail), Description: toPgText(input.Description), PublishStatus: string(domain.ContentPublishStatusFromCreateInput(input.PublishStatus)), + AccessTier: contentAccessTierForCreate(input.AccessTier), }) if err != nil { return domain.ExamPrepLesson{}, err @@ -64,6 +66,7 @@ func (s *Store) GetExamPrepUnitModuleLessonByID(ctx context.Context, id int64) ( CreatedAt: l.CreatedAt, UpdatedAt: l.UpdatedAt, PublishStatus: l.PublishStatus, + AccessTier: l.AccessTier, }) out.HasPractice = l.HasPractice return out, nil @@ -99,6 +102,7 @@ func (s *Store) ListExamPrepUnitModuleLessonsByUnitModuleID(ctx context.Context, CreatedAt: r.CreatedAt, UpdatedAt: r.UpdatedAt, PublishStatus: r.PublishStatus, + AccessTier: r.AccessTier, }) item.HasPractice = r.HasPractice out = append(out, item) @@ -129,6 +133,7 @@ func (s *Store) UpdateExamPrepUnitModuleLesson(ctx context.Context, id int64, in Description: optionalTextUpdate(input.Description), SortOrder: optionalInt4Update(input.SortOrder), PublishStatus: optionalPublishStatusUpdate(input.PublishStatus), + AccessTier: optionalAccessTierUpdate(input.AccessTier), }) if err != nil { if errors.Is(err, pgx.ErrNoRows) { diff --git a/internal/repository/exam_prep_unit_modules.go b/internal/repository/exam_prep_unit_modules.go index 105472c..eab6cd2 100644 --- a/internal/repository/exam_prep_unit_modules.go +++ b/internal/repository/exam_prep_unit_modules.go @@ -18,6 +18,7 @@ func examPrepModuleToDomain(m dbgen.ExamPrepUnitModule) domain.ExamPrepModule { Name: m.Name, SortOrder: int(m.SortOrder), PublishStatus: domain.ContentPublishStatusFromDB(m.PublishStatus), + AccessTier: domain.ContentAccessTierFromDB(m.AccessTier), } out.Description = fromPgText(m.Description) out.Thumbnail = fromPgText(m.Thumbnail) @@ -38,6 +39,7 @@ func (s *Store) CreateExamPrepUnitModule(ctx context.Context, unitID int64, inpu Thumbnail: toPgText(input.Thumbnail), Icon: toPgText(input.Icon), PublishStatus: string(domain.ContentPublishStatusFromCreateInput(input.PublishStatus)), + AccessTier: contentAccessTierForCreate(input.AccessTier), }) if err != nil { return domain.ExamPrepModule{}, err @@ -64,6 +66,7 @@ func (s *Store) GetExamPrepUnitModuleByID(ctx context.Context, id int64) (domain CreatedAt: m.CreatedAt, UpdatedAt: m.UpdatedAt, PublishStatus: m.PublishStatus, + AccessTier: m.AccessTier, }) out.HasPractice = m.HasPractice return out, nil @@ -99,6 +102,7 @@ func (s *Store) ListExamPrepUnitModulesByUnit(ctx context.Context, unitID int64, CreatedAt: r.CreatedAt, UpdatedAt: r.UpdatedAt, PublishStatus: r.PublishStatus, + AccessTier: r.AccessTier, }) item.LessonsCount = &r.LessonsCount item.PracticesCount = &r.PracticesCount @@ -131,6 +135,7 @@ func (s *Store) UpdateExamPrepUnitModule(ctx context.Context, id int64, input do Icon: optionalTextUpdate(input.Icon), SortOrder: optionalInt4Update(input.SortOrder), PublishStatus: optionalPublishStatusUpdate(input.PublishStatus), + AccessTier: optionalAccessTierUpdate(input.AccessTier), }) if err != nil { if errors.Is(err, pgx.ErrNoRows) { diff --git a/internal/repository/exam_prep_units.go b/internal/repository/exam_prep_units.go index 0c016be..88eb0ae 100644 --- a/internal/repository/exam_prep_units.go +++ b/internal/repository/exam_prep_units.go @@ -18,6 +18,7 @@ func examPrepUnitToDomain(u dbgen.ExamPrepUnit) domain.ExamPrepUnit { Name: u.Name, SortOrder: int(u.SortOrder), PublishStatus: domain.ContentPublishStatusFromDB(u.PublishStatus), + AccessTier: domain.ContentAccessTierFromDB(u.AccessTier), } out.Description = fromPgText(u.Description) out.Thumbnail = fromPgText(u.Thumbnail) @@ -51,6 +52,7 @@ func (s *Store) CreateExamPrepUnit(ctx context.Context, catalogCourseID int64, i Thumbnail: toPgText(input.Thumbnail), SortOrder: pgtype.Int4{Int32: target, Valid: true}, PublishStatus: pub, + AccessTier: contentAccessTierForCreate(input.AccessTier), }) if err != nil { return domain.ExamPrepUnit{}, err @@ -68,6 +70,7 @@ func (s *Store) CreateExamPrepUnit(ctx context.Context, catalogCourseID int64, i Thumbnail: toPgText(input.Thumbnail), SortOrder: pgtype.Int4{Valid: false}, PublishStatus: pub, + AccessTier: contentAccessTierForCreate(input.AccessTier), }) if err != nil { return domain.ExamPrepUnit{}, err @@ -93,6 +96,7 @@ func (s *Store) GetExamPrepUnitByID(ctx context.Context, id int64) (domain.ExamP CreatedAt: u.CreatedAt, UpdatedAt: u.UpdatedAt, PublishStatus: u.PublishStatus, + AccessTier: u.AccessTier, }) out.HasPractice = u.HasPractice return out, nil @@ -127,6 +131,7 @@ func (s *Store) ListExamPrepUnitsByCatalogCourse(ctx context.Context, catalogCou CreatedAt: r.CreatedAt, UpdatedAt: r.UpdatedAt, PublishStatus: r.PublishStatus, + AccessTier: r.AccessTier, }) item.ModulesCount = &r.ModulesCount item.LessonsCount = &r.LessonsCount @@ -159,6 +164,7 @@ func (s *Store) UpdateExamPrepUnit(ctx context.Context, id int64, input domain.U Thumbnail: optionalTextUpdate(input.Thumbnail), SortOrder: optionalInt4Update(input.SortOrder), PublishStatus: optionalPublishStatusUpdate(input.PublishStatus), + AccessTier: optionalAccessTierUpdate(input.AccessTier), }) if err != nil { if errors.Is(err, pgx.ErrNoRows) { diff --git a/internal/repository/lms_courses.go b/internal/repository/lms_courses.go index 01c4373..b4e2116 100644 --- a/internal/repository/lms_courses.go +++ b/internal/repository/lms_courses.go @@ -17,6 +17,7 @@ func courseToDomain(c dbgen.Course) domain.Course { ProgramID: c.ProgramID, Name: c.Name, PublishStatus: domain.ContentPublishStatusFromDB(c.PublishStatus), + AccessTier: domain.ContentAccessTierFromDB(c.AccessTier), } out.Description = fromPgText(c.Description) out.Thumbnail = fromPgText(c.Thumbnail) @@ -51,6 +52,7 @@ func (s *Store) CreateCourse(ctx context.Context, programID int64, input domain. Thumbnail: toPgText(input.Thumbnail), SortOrder: pgtype.Int4{Int32: target, Valid: true}, PublishStatus: pub, + AccessTier: contentAccessTierForCreate(input.AccessTier), }) if err != nil { return domain.Course{}, err @@ -68,6 +70,7 @@ func (s *Store) CreateCourse(ctx context.Context, programID int64, input domain. Thumbnail: toPgText(input.Thumbnail), SortOrder: pgtype.Int4{Valid: false}, PublishStatus: pub, + AccessTier: contentAccessTierForCreate(input.AccessTier), }) if err != nil { return domain.Course{}, err @@ -101,6 +104,7 @@ func (s *Store) GetCourseByID(ctx context.Context, id int64) (domain.Course, err CreatedAt: c.CreatedAt, UpdatedAt: c.UpdatedAt, PublishStatus: c.PublishStatus, + AccessTier: c.AccessTier, }) out.HasPractice = c.HasPractice return out, nil @@ -135,6 +139,7 @@ func (s *Store) ListCoursesByProgramID(ctx context.Context, programID int64, pub UpdatedAt: r.UpdatedAt, SortOrder: r.SortOrder, PublishStatus: r.PublishStatus, + AccessTier: r.AccessTier, }) co.ModuleCount = int(r.ModuleCount) co.LessonCount = int(r.LessonCount) @@ -177,6 +182,7 @@ func (s *Store) UpdateCourse(ctx context.Context, id int64, input domain.UpdateC Thumbnail: optionalTextUpdate(input.Thumbnail), SortOrder: pgtype.Int4{Valid: false}, PublishStatus: optionalPublishStatusUpdate(input.PublishStatus), + AccessTier: optionalAccessTierUpdate(input.AccessTier), }) if err != nil { return domain.Course{}, err @@ -198,6 +204,7 @@ func (s *Store) UpdateCourse(ctx context.Context, id int64, input domain.UpdateC Thumbnail: optionalTextUpdate(input.Thumbnail), SortOrder: sortParam, PublishStatus: optionalPublishStatusUpdate(input.PublishStatus), + AccessTier: optionalAccessTierUpdate(input.AccessTier), }) if err != nil { if errors.Is(err, pgx.ErrNoRows) { diff --git a/internal/repository/lms_lessons.go b/internal/repository/lms_lessons.go index 1906936..62c6a49 100644 --- a/internal/repository/lms_lessons.go +++ b/internal/repository/lms_lessons.go @@ -17,6 +17,7 @@ func lessonToDomain(l dbgen.Lesson) domain.Lesson { ModuleID: l.ModuleID, Title: l.Title, PublishStatus: domain.LessonPublishStatusFromDB(l.PublishStatus), + AccessTier: domain.ContentAccessTierFromDB(l.AccessTier), } out.VideoURL = fromPgText(l.VideoUrl) out.Thumbnail = fromPgText(l.Thumbnail) @@ -54,6 +55,7 @@ func (s *Store) CreateLesson(ctx context.Context, moduleID int64, input domain.C Description: toPgText(input.Description), SortOrder: pgtype.Int4{Int32: target, Valid: true}, PublishStatus: pub, + AccessTier: contentAccessTierForCreate(input.AccessTier), }) if err != nil { return domain.Lesson{}, err @@ -72,6 +74,7 @@ func (s *Store) CreateLesson(ctx context.Context, moduleID int64, input domain.C Description: toPgText(input.Description), SortOrder: pgtype.Int4{Valid: false}, PublishStatus: pub, + AccessTier: contentAccessTierForCreate(input.AccessTier), }) if err != nil { return domain.Lesson{}, err @@ -96,6 +99,7 @@ func (s *Store) GetLessonByID(ctx context.Context, id int64) (domain.Lesson, err Description: l.Description, SortOrder: l.SortOrder, PublishStatus: l.PublishStatus, + AccessTier: l.AccessTier, CreatedAt: l.CreatedAt, UpdatedAt: l.UpdatedAt, }) @@ -135,6 +139,7 @@ func (s *Store) ListLessonsByModuleID(ctx context.Context, moduleID int64, publi Description: r.Description, SortOrder: r.SortOrder, PublishStatus: r.PublishStatus, + AccessTier: r.AccessTier, CreatedAt: r.CreatedAt, UpdatedAt: r.UpdatedAt, }) @@ -179,6 +184,7 @@ func (s *Store) UpdateLesson(ctx context.Context, id int64, input domain.UpdateL Description: optionalTextUpdate(input.Description), SortOrder: pgtype.Int4{Valid: false}, PublishStatus: pubParam, + AccessTier: optionalAccessTierUpdate(input.AccessTier), }) if err != nil { return domain.Lesson{}, err @@ -201,6 +207,7 @@ func (s *Store) UpdateLesson(ctx context.Context, id int64, input domain.UpdateL Description: optionalTextUpdate(input.Description), SortOrder: sortParam, PublishStatus: pubParam, + AccessTier: optionalAccessTierUpdate(input.AccessTier), }) if err != nil { if errors.Is(err, pgx.ErrNoRows) { diff --git a/internal/repository/lms_modules.go b/internal/repository/lms_modules.go index 6b98ebd..27a0054 100644 --- a/internal/repository/lms_modules.go +++ b/internal/repository/lms_modules.go @@ -18,6 +18,7 @@ func moduleToDomain(m dbgen.Module) domain.Module { CourseID: m.CourseID, Name: m.Name, PublishStatus: domain.ContentPublishStatusFromDB(m.PublishStatus), + AccessTier: domain.ContentAccessTierFromDB(m.AccessTier), } out.Description = fromPgText(m.Description) out.Icon = fromPgText(m.Icon) @@ -53,6 +54,7 @@ func (s *Store) CreateModule(ctx context.Context, programID, courseID int64, inp Icon: toPgText(input.Icon), SortOrder: pgtype.Int4{Int32: target, Valid: true}, PublishStatus: pub, + AccessTier: contentAccessTierForCreate(input.AccessTier), }) if err != nil { return domain.Module{}, err @@ -71,6 +73,7 @@ func (s *Store) CreateModule(ctx context.Context, programID, courseID int64, inp Icon: toPgText(input.Icon), SortOrder: pgtype.Int4{Valid: false}, PublishStatus: pub, + AccessTier: contentAccessTierForCreate(input.AccessTier), }) if err != nil { return domain.Module{}, err @@ -105,6 +108,7 @@ func (s *Store) GetModuleByID(ctx context.Context, id int64) (domain.Module, err CreatedAt: m.CreatedAt, UpdatedAt: m.UpdatedAt, PublishStatus: m.PublishStatus, + AccessTier: m.AccessTier, }) out.HasPractice = m.HasPractice return out, nil @@ -141,6 +145,7 @@ func (s *Store) ListModulesByProgramAndCourse(ctx context.Context, programID, co UpdatedAt: r.UpdatedAt, SortOrder: r.SortOrder, PublishStatus: r.PublishStatus, + AccessTier: r.AccessTier, }) mod.HasPractice = r.HasPractice out = append(out, mod) @@ -185,6 +190,7 @@ func (s *Store) UpdateModule(ctx context.Context, id int64, input domain.UpdateM Icon: optionalTextUpdate(input.Icon), SortOrder: sortParam, PublishStatus: optionalPublishStatusUpdate(input.PublishStatus), + AccessTier: optionalAccessTierUpdate(input.AccessTier), }) if err != nil { if errors.Is(err, pgx.ErrNoRows) { diff --git a/internal/repository/programs.go b/internal/repository/programs.go index 6097876..e83f8e9 100644 --- a/internal/repository/programs.go +++ b/internal/repository/programs.go @@ -18,6 +18,7 @@ func programToDomain(p dbgen.Program) domain.Program { Name: p.Name, Category: p.Category, PublishStatus: domain.ContentPublishStatusFromDB(p.PublishStatus), + AccessTier: domain.ContentAccessTierFromDB(p.AccessTier), } out.Description = fromPgText(p.Description) out.Thumbnail = fromPgText(p.Thumbnail) @@ -49,6 +50,7 @@ func (s *Store) CreateProgram(ctx context.Context, input domain.CreateProgramInp Thumbnail: toPgText(input.Thumbnail), SortOrder: pgtype.Int4{Int32: target, Valid: true}, PublishStatus: pub, + AccessTier: contentAccessTierForCreate(input.AccessTier), }) if err != nil { return domain.Program{}, err @@ -66,6 +68,7 @@ func (s *Store) CreateProgram(ctx context.Context, input domain.CreateProgramInp Thumbnail: toPgText(input.Thumbnail), SortOrder: pgtype.Int4{Valid: false}, PublishStatus: pub, + AccessTier: contentAccessTierForCreate(input.AccessTier), }) if err != nil { return domain.Program{}, err @@ -117,6 +120,7 @@ func (s *Store) ListPrograms(ctx context.Context, publishedOnly bool, category s UpdatedAt: r.UpdatedAt, SortOrder: r.SortOrder, PublishStatus: r.PublishStatus, + AccessTier: r.AccessTier, })) } return out, total, nil @@ -129,6 +133,23 @@ func optionalTextUpdate(val *string) pgtype.Text { return pgtype.Text{String: *val, Valid: true} } +func contentAccessTierForCreate(raw *string) string { + return string(domain.ContentAccessTierFromCreateInput(raw)) +} + +func optionalAccessTierUpdate(val *string) pgtype.Text { + if val == nil { + return pgtype.Text{Valid: false} + } + s := strings.TrimSpace(strings.ToUpper(*val)) + switch s { + case string(domain.ContentAccessFree), string(domain.ContentAccessPremium): + return pgtype.Text{String: s, Valid: true} + default: + return pgtype.Text{Valid: false} + } +} + func optionalPublishStatusUpdate(val *string) pgtype.Text { if val == nil { return pgtype.Text{Valid: false} @@ -181,6 +202,7 @@ func (s *Store) UpdateProgram(ctx context.Context, id int64, input domain.Update Thumbnail: optionalTextUpdate(input.Thumbnail), SortOrder: pgtype.Int4{Valid: false}, PublishStatus: optionalPublishStatusUpdate(input.PublishStatus), + AccessTier: optionalAccessTierUpdate(input.AccessTier), }) if err != nil { return domain.Program{}, err @@ -207,6 +229,7 @@ func (s *Store) UpdateProgram(ctx context.Context, id int64, input domain.Update Thumbnail: optionalTextUpdate(input.Thumbnail), SortOrder: sortParam, PublishStatus: optionalPublishStatusUpdate(input.PublishStatus), + AccessTier: optionalAccessTierUpdate(input.AccessTier), }) if err != nil { if errors.Is(err, pgx.ErrNoRows) { diff --git a/internal/web_server/content_access_middleware.go b/internal/web_server/content_access_middleware.go new file mode 100644 index 0000000..030765c --- /dev/null +++ b/internal/web_server/content_access_middleware.go @@ -0,0 +1,335 @@ +package httpserver + +import ( + "context" + + "Yimaru-Backend/internal/domain" + lessonsvc "Yimaru-Backend/internal/services/lessons" + coursessvc "Yimaru-Backend/internal/services/courses" + modulesvc "Yimaru-Backend/internal/services/modules" + programssvc "Yimaru-Backend/internal/services/programs" + practicessvc "Yimaru-Backend/internal/services/practices" + "errors" + "fmt" + "strings" + + "github.com/gofiber/fiber/v2" + "go.uber.org/zap" +) + +func (a *App) RequireLMSSubscriptionUnlessFree() fiber.Handler { + return func(c *fiber.Ctx) error { + role, userID, err := subscriptionScopedUser(c) + if err != nil { + return err + } + if bypassSubscriptionForRole(role) { + return c.Next() + } + if role != domain.RoleStudent && role != domain.RoleOpenLearner { + return c.Next() + } + if domain.CategorySubscriptionGateDisabled { + return c.Next() + } + + tier, resolved, err := a.resolveLMSEffectiveAccessTier(c) + if err != nil { + switch { + case errors.Is(err, programssvc.ErrProgramNotFound), + errors.Is(err, coursessvc.ErrCourseNotFound), + errors.Is(err, modulesvc.ErrModuleNotFound), + errors.Is(err, lessonsvc.ErrLessonNotFound), + errors.Is(err, practicessvc.ErrPracticeNotFound): + return fiber.NewError(fiber.StatusNotFound, err.Error()) + default: + return fiber.NewError(fiber.StatusInternalServerError, "Failed to verify content access") + } + } + if !resolved || !tier.RequiresSubscription() { + return c.Next() + } + + active, err := a.subscriptionsSvc.HasActiveSubscriptionByCategory(c.Context(), userID, domain.SubscriptionCategoryLearnEnglish) + if err != nil { + a.mongoLoggerSvc.Error("category subscription check failed", + zap.Int64("userID", userID), + zap.String("category", string(domain.SubscriptionCategoryLearnEnglish)), + zap.String("path", c.Path()), + zap.Error(err), + ) + return fiber.NewError(fiber.StatusInternalServerError, "Failed to verify subscription") + } + if !active { + return fiber.NewError(fiber.StatusForbidden, fmt.Sprintf("An active %s subscription is required", humanizeSubscriptionCategory(domain.SubscriptionCategoryLearnEnglish))) + } + return c.Next() + } +} + +func (a *App) resolveLMSEffectiveAccessTier(c *fiber.Ctx) (domain.ContentAccessTier, bool, error) { + ctx := c.Context() + routePath := c.Route().Path + + if strings.Contains(routePath, "/practices/:practiceId/questions") || strings.Contains(routePath, "/practices/:id") { + practiceID, ok, err := parseRouteInt64(c, "practiceId") + if err != nil { + return "", false, err + } + if !ok { + practiceID, ok, err = parseRouteInt64(c, "id") + if err != nil { + return "", false, err + } + if !ok { + goto unresolved + } + } + return a.lmsEffectiveTierForPractice(ctx, practiceID) + } + + if lessonID, ok, err := parseRouteInt64(c, "lessonId"); err != nil { + return "", false, err + } else if ok { + return a.lmsEffectiveTierForLesson(ctx, lessonID) + } + + if moduleID, ok, err := parseRouteInt64(c, "moduleId"); err != nil { + return "", false, err + } else if ok { + return a.lmsEffectiveTierForModule(ctx, moduleID) + } + + if _, ok, err := parseRouteInt64(c, "courseId"); err != nil { + return "", false, err + } else if ok { + return "", false, nil + } + + switch { + case strings.Contains(routePath, "/lessons/:id"): + if id, ok, err := parseRouteInt64(c, "id"); err != nil { + return "", false, err + } else if ok { + return a.lmsEffectiveTierForLesson(ctx, id) + } + case strings.Contains(routePath, "/modules/:id"): + if id, ok, err := parseRouteInt64(c, "id"); err != nil { + return "", false, err + } else if ok { + return a.lmsEffectiveTierForModule(ctx, id) + } + case strings.Contains(routePath, "/courses/:id"): + if id, ok, err := parseRouteInt64(c, "id"); err != nil { + return "", false, err + } else if ok { + return a.lmsEffectiveTierForCourse(ctx, id) + } + case strings.Contains(routePath, "/programs/:id"): + if id, ok, err := parseRouteInt64(c, "id"); err != nil { + return "", false, err + } else if ok { + return a.lmsEffectiveTierForProgram(ctx, id) + } + } + +unresolved: + return "", false, nil +} + +func (a *App) lmsEffectiveTierForProgram(ctx context.Context, programID int64) (domain.ContentAccessTier, bool, error) { + program, err := a.programSvc.GetByID(ctx, programID) + if err != nil { + return "", false, err + } + return program.AccessTier, true, nil +} + +func (a *App) lmsEffectiveTierForCourse(ctx context.Context, courseID int64) (domain.ContentAccessTier, bool, error) { + course, err := a.courseSvc.GetByID(ctx, courseID) + if err != nil { + return "", false, err + } + program, err := a.programSvc.GetByID(ctx, course.ProgramID) + if err != nil { + return "", false, err + } + return domain.EffectiveContentAccessTier(program.AccessTier, course.AccessTier), true, nil +} + +func (a *App) lmsEffectiveTierForModule(ctx context.Context, moduleID int64) (domain.ContentAccessTier, bool, error) { + module, err := a.moduleSvc.GetByID(ctx, moduleID) + if err != nil { + return "", false, err + } + course, err := a.courseSvc.GetByID(ctx, module.CourseID) + if err != nil { + return "", false, err + } + program, err := a.programSvc.GetByID(ctx, course.ProgramID) + if err != nil { + return "", false, err + } + return domain.EffectiveContentAccessTier(program.AccessTier, course.AccessTier, module.AccessTier), true, nil +} + +func (a *App) lmsEffectiveTierForLesson(ctx context.Context, lessonID int64) (domain.ContentAccessTier, bool, error) { + lesson, err := a.lessonSvc.GetByID(ctx, lessonID) + if err != nil { + return "", false, err + } + module, err := a.moduleSvc.GetByID(ctx, lesson.ModuleID) + if err != nil { + return "", false, err + } + course, err := a.courseSvc.GetByID(ctx, module.CourseID) + if err != nil { + return "", false, err + } + program, err := a.programSvc.GetByID(ctx, course.ProgramID) + if err != nil { + return "", false, err + } + return domain.EffectiveContentAccessTier(program.AccessTier, course.AccessTier, module.AccessTier, lesson.AccessTier), true, nil +} + +func (a *App) resolveExamPrepEffectiveAccessTier(c *fiber.Ctx) (domain.ContentAccessTier, bool, error) { + ctx := c.Context() + routePath := c.Route().Path + + if _, ok, err := parseRouteInt64(c, "catalogCourseId"); err != nil { + return "", false, err + } else if ok { + return "", false, nil + } + if _, ok, err := parseRouteInt64(c, "unitId"); err != nil { + return "", false, err + } else if ok { + return "", false, nil + } + if _, ok, err := parseRouteInt64(c, "moduleId"); err != nil { + return "", false, err + } else if ok { + return "", false, nil + } + if _, ok, err := parseRouteInt64(c, "lessonId"); err != nil { + return "", false, err + } else if ok { + return "", false, nil + } + + switch { + case strings.Contains(routePath, "/catalog-courses/:id"): + if id, ok, err := parseRouteInt64(c, "id"); err != nil { + return "", false, err + } else if ok { + return a.examPrepEffectiveTierForCatalogCourse(ctx, id) + } + case strings.Contains(routePath, "/units/:id"): + if id, ok, err := parseRouteInt64(c, "id"); err != nil { + return "", false, err + } else if ok { + return a.examPrepEffectiveTierForUnit(ctx, id) + } + case strings.Contains(routePath, "/modules/:id"): + if id, ok, err := parseRouteInt64(c, "id"); err != nil { + return "", false, err + } else if ok { + return a.examPrepEffectiveTierForModule(ctx, id) + } + case strings.Contains(routePath, "/lessons/:id"): + if id, ok, err := parseRouteInt64(c, "id"); err != nil { + return "", false, err + } else if ok { + return a.examPrepEffectiveTierForLesson(ctx, id) + } + case strings.Contains(routePath, "/practices/:id"): + if id, ok, err := parseRouteInt64(c, "id"); err != nil { + return "", false, err + } else if ok { + return a.examPrepEffectiveTierForPractice(ctx, id) + } + } + return "", false, nil +} + +func (a *App) examPrepEffectiveTierForCatalogCourse(ctx context.Context, catalogCourseID int64) (domain.ContentAccessTier, bool, error) { + cc, err := a.examPrepSvc.GetCatalogCourseByID(ctx, catalogCourseID) + if err != nil { + return "", false, err + } + return cc.AccessTier, true, nil +} + +func (a *App) examPrepEffectiveTierForUnit(ctx context.Context, unitID int64) (domain.ContentAccessTier, bool, error) { + unit, err := a.examPrepSvc.GetUnitByID(ctx, unitID) + if err != nil { + return "", false, err + } + cc, err := a.examPrepSvc.GetCatalogCourseByID(ctx, unit.CatalogCourseID) + if err != nil { + return "", false, err + } + return domain.EffectiveContentAccessTier(cc.AccessTier, unit.AccessTier), true, nil +} + +func (a *App) examPrepEffectiveTierForModule(ctx context.Context, moduleID int64) (domain.ContentAccessTier, bool, error) { + module, err := a.examPrepSvc.GetModuleByID(ctx, moduleID) + if err != nil { + return "", false, err + } + unit, err := a.examPrepSvc.GetUnitByID(ctx, module.UnitID) + if err != nil { + return "", false, err + } + cc, err := a.examPrepSvc.GetCatalogCourseByID(ctx, unit.CatalogCourseID) + if err != nil { + return "", false, err + } + return domain.EffectiveContentAccessTier(cc.AccessTier, unit.AccessTier, module.AccessTier), true, nil +} + +func (a *App) examPrepEffectiveTierForLesson(ctx context.Context, lessonID int64) (domain.ContentAccessTier, bool, error) { + lesson, err := a.examPrepSvc.GetLessonByID(ctx, lessonID) + if err != nil { + return "", false, err + } + module, err := a.examPrepSvc.GetModuleByID(ctx, lesson.UnitModuleID) + if err != nil { + return "", false, err + } + unit, err := a.examPrepSvc.GetUnitByID(ctx, module.UnitID) + if err != nil { + return "", false, err + } + cc, err := a.examPrepSvc.GetCatalogCourseByID(ctx, unit.CatalogCourseID) + if err != nil { + return "", false, err + } + return domain.EffectiveContentAccessTier(cc.AccessTier, unit.AccessTier, module.AccessTier, lesson.AccessTier), true, nil +} + +func (a *App) examPrepEffectiveTierForPractice(ctx context.Context, practiceID int64) (domain.ContentAccessTier, bool, error) { + practice, err := a.examPrepSvc.GetExamPrepPracticeByID(ctx, practiceID) + if err != nil { + return "", false, err + } + return a.examPrepEffectiveTierForLesson(ctx, practice.LessonID) +} + +func (a *App) lmsEffectiveTierForPractice(ctx context.Context, practiceID int64) (domain.ContentAccessTier, bool, error) { + practice, err := a.practiceSvc.GetByID(ctx, practiceID) + if err != nil { + return "", false, err + } + switch practice.ParentKind { + case domain.ParentKindLesson: + return a.lmsEffectiveTierForLesson(ctx, practice.ParentID) + case domain.ParentKindModule: + return a.lmsEffectiveTierForModule(ctx, practice.ParentID) + case domain.ParentKindCourse: + return a.lmsEffectiveTierForCourse(ctx, practice.ParentID) + default: + return "", false, nil + } +} diff --git a/internal/web_server/handlers/content_access_gate.go b/internal/web_server/handlers/content_access_gate.go new file mode 100644 index 0000000..c0f6a00 --- /dev/null +++ b/internal/web_server/handlers/content_access_gate.go @@ -0,0 +1,115 @@ +package handlers + +import ( + "Yimaru-Backend/internal/domain" + "fmt" + + "github.com/gofiber/fiber/v2" +) + +func (h *Handler) learnerHasLearnEnglishSubscription(c *fiber.Ctx) (bool, error) { + return h.learnerHasSubscriptionCategory(c, domain.SubscriptionCategoryLearnEnglish) +} + +func (h *Handler) ensureLearnerPremiumContentAccess(c *fiber.Ctx, effectiveTier domain.ContentAccessTier, category domain.SubscriptionCategory) error { + role, _ := c.Locals("role").(domain.Role) + if !role.IsCustomerLearnerRole() || domain.CategorySubscriptionGateDisabled { + return nil + } + if !effectiveTier.RequiresSubscription() { + return nil + } + active, err := h.learnerHasSubscriptionCategory(c, category) + if err != nil { + return c.Status(fiber.StatusInternalServerError).JSON(domain.ErrorResponse{ + Message: fmt.Sprintf("Failed to verify %s subscription", category), + Error: err.Error(), + }) + } + if !active { + return c.Status(fiber.StatusForbidden).JSON(domain.ErrorResponse{ + Message: fmt.Sprintf("An active %s subscription is required", humanizeSubscriptionCategory(category)), + }) + } + return nil +} + +func applyProgramEffectiveAccessTier(p *domain.Program) { + p.EffectiveAccessTier = p.AccessTier +} + +func applyCourseEffectiveAccessTier(course *domain.Course, program domain.Program) { + course.EffectiveAccessTier = domain.EffectiveContentAccessTier(program.AccessTier, course.AccessTier) +} + +func applyModuleEffectiveAccessTier(module *domain.Module, program domain.Program, course domain.Course) { + module.EffectiveAccessTier = domain.EffectiveContentAccessTier(program.AccessTier, course.AccessTier, module.AccessTier) +} + +func applyLessonEffectiveAccessTier(lesson *domain.Lesson, program domain.Program, course domain.Course, module domain.Module) { + lesson.EffectiveAccessTier = domain.EffectiveContentAccessTier(program.AccessTier, course.AccessTier, module.AccessTier, lesson.AccessTier) +} + +func applyExamPrepCatalogCourseEffectiveAccessTier(cc *domain.ExamPrepCatalogCourse) { + cc.EffectiveAccessTier = cc.AccessTier +} + +func applyExamPrepUnitEffectiveAccessTier(unit *domain.ExamPrepUnit, catalogCourse domain.ExamPrepCatalogCourse) { + unit.EffectiveAccessTier = domain.EffectiveContentAccessTier(catalogCourse.AccessTier, unit.AccessTier) +} + +func applyExamPrepModuleEffectiveAccessTier(module *domain.ExamPrepModule, catalogCourse domain.ExamPrepCatalogCourse, unit domain.ExamPrepUnit) { + module.EffectiveAccessTier = domain.EffectiveContentAccessTier(catalogCourse.AccessTier, unit.AccessTier, module.AccessTier) +} + +func applyExamPrepLessonEffectiveAccessTier(lesson *domain.ExamPrepLesson, catalogCourse domain.ExamPrepCatalogCourse, unit domain.ExamPrepUnit, module domain.ExamPrepModule) { + lesson.EffectiveAccessTier = domain.EffectiveContentAccessTier(catalogCourse.AccessTier, unit.AccessTier, module.AccessTier, lesson.AccessTier) +} + +func learnerCanViewEffectiveTier(hasSubscription bool, effectiveTier domain.ContentAccessTier) bool { + return !effectiveTier.RequiresSubscription() || hasSubscription +} + +func filterProgramsForLearner(items []domain.Program, hasLearnEnglish bool) []domain.Program { + filtered := make([]domain.Program, 0, len(items)) + for _, item := range items { + applyProgramEffectiveAccessTier(&item) + if learnerCanViewEffectiveTier(hasLearnEnglish, item.EffectiveAccessTier) { + filtered = append(filtered, item) + } + } + return filtered +} + +func filterCoursesForLearner(items []domain.Course, program domain.Program, hasLearnEnglish bool) []domain.Course { + filtered := make([]domain.Course, 0, len(items)) + for i := range items { + applyCourseEffectiveAccessTier(&items[i], program) + if learnerCanViewEffectiveTier(hasLearnEnglish, items[i].EffectiveAccessTier) { + filtered = append(filtered, items[i]) + } + } + return filtered +} + +func filterExamPrepCatalogCoursesForLearner(items []domain.ExamPrepCatalogCourse, hasIELTS, hasDuolingo bool) []domain.ExamPrepCatalogCourse { + filtered := make([]domain.ExamPrepCatalogCourse, 0, len(items)) + for _, item := range items { + applyExamPrepCatalogCourseEffectiveAccessTier(&item) + if !item.EffectiveAccessTier.RequiresSubscription() { + filtered = append(filtered, item) + continue + } + switch domain.SubscriptionCategory(item.Category) { + case domain.SubscriptionCategoryIELTS: + if hasIELTS { + filtered = append(filtered, item) + } + case domain.SubscriptionCategoryDuolingo: + if hasDuolingo { + filtered = append(filtered, item) + } + } + } + return filtered +} diff --git a/internal/web_server/handlers/course_handler.go b/internal/web_server/handlers/course_handler.go index da8dbda..16ea9f9 100644 --- a/internal/web_server/handlers/course_handler.go +++ b/internal/web_server/handlers/course_handler.go @@ -93,6 +93,8 @@ func (h *Handler) ListCoursesByProgram(c *fiber.Ctx) error { limit, _ := strconv.Atoi(c.Query("limit", "20")) offset, _ := strconv.Atoi(c.Query("offset", "0")) publishedOnly := !h.canManageLMSCourses(c) + role := c.Locals("role").(domain.Role) + var program domain.Program if publishedOnly { // Draft programs hide their courses from non-managers. p, err := h.programSvc.GetByID(c.Context(), programID) @@ -117,6 +119,7 @@ func (h *Handler) ListCoursesByProgram(c *fiber.Ctx) error { if err := h.blockLearnerIfNotLMSProgram(c, p); err != nil { return err } + program = p } items, total, err := h.courseSvc.ListByProgram(c.Context(), programID, publishedOnly, int32(limit), int32(offset)) if err != nil { @@ -131,8 +134,18 @@ func (h *Handler) ListCoursesByProgram(c *fiber.Ctx) error { Error: err.Error(), }) } + if role.IsCustomerLearnerRole() && publishedOnly { + hasLearnEnglish, err := h.learnerHasLearnEnglishSubscription(c) + if err != nil { + return c.Status(fiber.StatusInternalServerError).JSON(domain.ErrorResponse{ + Message: "Failed to verify Learn English subscription", + Error: err.Error(), + }) + } + items = filterCoursesForLearner(items, program, hasLearnEnglish) + total = int64(len(items)) + } uid := c.Locals("user_id").(int64) - role := c.Locals("role").(domain.Role) for i := range items { if err := h.lmsProgressSvc.ApplyAccessCourse(c.Context(), role, uid, &items[i]); err != nil { return c.Status(fiber.StatusInternalServerError).JSON(domain.ErrorResponse{ @@ -205,6 +218,10 @@ func (h *Handler) GetCourse(c *fiber.Ctx) error { if err := h.blockLearnerIfNotLMSProgram(c, p); err != nil { return err } + applyCourseEffectiveAccessTier(&course, p) + if err := h.ensureLearnerPremiumContentAccess(c, course.EffectiveAccessTier, domain.SubscriptionCategoryLearnEnglish); err != nil { + return err + } uid := c.Locals("user_id").(int64) role := c.Locals("role").(domain.Role) if err := h.lmsProgressSvc.ApplyAccessCourse(c.Context(), role, uid, &course); err != nil { diff --git a/internal/web_server/handlers/exam_prep_catalog_course_handler.go b/internal/web_server/handlers/exam_prep_catalog_course_handler.go index c8f8e10..ad2973d 100644 --- a/internal/web_server/handlers/exam_prep_catalog_course_handler.go +++ b/internal/web_server/handlers/exam_prep_catalog_course_handler.go @@ -220,7 +220,8 @@ func (h *Handler) GetExamPrepCatalogCourseByID(c *fiber.Ctx) error { Error: examprep.ErrCatalogCourseNotFound.Error(), }) } - if err := h.ensureLearnerExamPrepContentAccess(c, out.Category); err != nil { + applyExamPrepCatalogCourseEffectiveAccessTier(&out) + if err := h.ensureLearnerExamPrepContentAccess(c, out.Category, out.EffectiveAccessTier); err != nil { return err } if err := h.applyExamPrepAccessCatalogCourse(c.Context(), c, &out); err != nil { diff --git a/internal/web_server/handlers/program_handler.go b/internal/web_server/handlers/program_handler.go index d68e7e9..13a1228 100644 --- a/internal/web_server/handlers/program_handler.go +++ b/internal/web_server/handlers/program_handler.go @@ -82,6 +82,17 @@ func (h *Handler) ListPrograms(c *fiber.Ctx) error { Error: err.Error(), }) } + if role.IsCustomerLearnerRole() { + hasLearnEnglish, err := h.learnerHasLearnEnglishSubscription(c) + if err != nil { + return c.Status(fiber.StatusInternalServerError).JSON(domain.ErrorResponse{ + Message: "Failed to verify Learn English subscription", + Error: err.Error(), + }) + } + items = filterProgramsForLearner(items, hasLearnEnglish) + total = int64(len(items)) + } uid := c.Locals("user_id").(int64) for i := range items { if err := h.lmsProgressSvc.ApplyAccessProgram(c.Context(), role, uid, &items[i]); err != nil { @@ -142,6 +153,10 @@ func (h *Handler) GetProgram(c *fiber.Ctx) error { if err := h.blockLearnerIfNotLMSProgram(c, p); err != nil { return err } + applyProgramEffectiveAccessTier(&p) + if err := h.ensureLearnerPremiumContentAccess(c, p.EffectiveAccessTier, domain.SubscriptionCategoryLearnEnglish); err != nil { + return err + } uid := c.Locals("user_id").(int64) role := c.Locals("role").(domain.Role) if err := h.lmsProgressSvc.ApplyAccessProgram(c.Context(), role, uid, &p); err != nil { diff --git a/internal/web_server/handlers/subscription_content_gate.go b/internal/web_server/handlers/subscription_content_gate.go index be33420..56df69a 100644 --- a/internal/web_server/handlers/subscription_content_gate.go +++ b/internal/web_server/handlers/subscription_content_gate.go @@ -2,7 +2,6 @@ package handlers import ( "Yimaru-Backend/internal/domain" - "fmt" "github.com/gofiber/fiber/v2" ) @@ -15,7 +14,7 @@ func (h *Handler) learnerHasSubscriptionCategory(c *fiber.Ctx, category domain.S return h.subscriptionsSvc.HasActiveSubscriptionByCategory(c.Context(), userID, category) } -func (h *Handler) ensureLearnerExamPrepContentAccess(c *fiber.Ctx, contentCategory string) error { +func (h *Handler) ensureLearnerExamPrepContentAccess(c *fiber.Ctx, contentCategory string, effectiveTier domain.ContentAccessTier) error { role, _ := c.Locals("role").(domain.Role) if !role.IsCustomerLearnerRole() { return nil @@ -25,19 +24,7 @@ func (h *Handler) ensureLearnerExamPrepContentAccess(c *fiber.Ctx, contentCatego Message: "Catalog course not found", }) } - active, err := h.learnerHasSubscriptionCategory(c, domain.SubscriptionCategory(contentCategory)) - if err != nil { - return c.Status(fiber.StatusInternalServerError).JSON(domain.ErrorResponse{ - Message: fmt.Sprintf("Failed to verify %s subscription", contentCategory), - Error: err.Error(), - }) - } - if !active { - return c.Status(fiber.StatusForbidden).JSON(domain.ErrorResponse{ - Message: fmt.Sprintf("An active %s subscription is required", humanizeSubscriptionCategory(domain.SubscriptionCategory(contentCategory))), - }) - } - return nil + return h.ensureLearnerPremiumContentAccess(c, effectiveTier, domain.SubscriptionCategory(contentCategory)) } func (h *Handler) blockLearnerIfNotLMSProgram(c *fiber.Ctx, program domain.Program) error { @@ -66,19 +53,3 @@ func humanizeSubscriptionCategory(category domain.SubscriptionCategory) string { } } -func filterExamPrepCatalogCoursesForLearner(items []domain.ExamPrepCatalogCourse, hasIELTS, hasDuolingo bool) []domain.ExamPrepCatalogCourse { - filtered := make([]domain.ExamPrepCatalogCourse, 0, len(items)) - for _, item := range items { - switch domain.SubscriptionCategory(item.Category) { - case domain.SubscriptionCategoryIELTS: - if hasIELTS { - filtered = append(filtered, item) - } - case domain.SubscriptionCategoryDuolingo: - if hasDuolingo { - filtered = append(filtered, item) - } - } - } - return filtered -} diff --git a/internal/web_server/middleware.go b/internal/web_server/middleware.go index 9d56813..f2bd71f 100644 --- a/internal/web_server/middleware.go +++ b/internal/web_server/middleware.go @@ -282,17 +282,23 @@ func (a *App) RequireExamPrepSubscription() fiber.Handler { } if !scoped { - hasIELTS, err := a.subscriptionsSvc.HasActiveSubscriptionByCategory(c.Context(), userID, domain.SubscriptionCategoryIELTS) - if err != nil { - return fiber.NewError(fiber.StatusInternalServerError, "Failed to verify subscription") - } - hasDuolingo, err := a.subscriptionsSvc.HasActiveSubscriptionByCategory(c.Context(), userID, domain.SubscriptionCategoryDuolingo) - if err != nil { - return fiber.NewError(fiber.StatusInternalServerError, "Failed to verify subscription") - } - if !hasIELTS && !hasDuolingo { - return fiber.NewError(fiber.StatusForbidden, "An active IELTS or Duolingo subscription is required") + return c.Next() + } + + tier, tierResolved, err := a.resolveExamPrepEffectiveAccessTier(c) + if err != nil { + switch { + case errors.Is(err, examprepsvc.ErrCatalogCourseNotFound), + errors.Is(err, examprepsvc.ErrUnitNotFound), + errors.Is(err, examprepsvc.ErrModuleNotFound), + errors.Is(err, examprepsvc.ErrLessonNotFound), + errors.Is(err, examprepsvc.ErrPracticeNotFound): + return fiber.NewError(fiber.StatusNotFound, err.Error()) + default: + return fiber.NewError(fiber.StatusInternalServerError, "Failed to verify content access") } + } + if tierResolved && !tier.RequiresSubscription() { return c.Next() } diff --git a/internal/web_server/routes.go b/internal/web_server/routes.go index 3b43ab1..e6d3e26 100644 --- a/internal/web_server/routes.go +++ b/internal/web_server/routes.go @@ -84,11 +84,11 @@ func (a *App) initAppRoutes() { // Programs (LMS top-level) groupV1.Post("/programs", a.authMiddleware, a.RequirePermission("programs.create"), h.CreateProgram) - groupV1.Get("/programs", a.authMiddleware, a.RequireSubscriptionCategory(domain.SubscriptionCategoryLearnEnglish), a.RequirePermission("programs.list"), h.ListPrograms) + groupV1.Get("/programs", a.authMiddleware, a.RequireLMSSubscriptionUnlessFree(), a.RequirePermission("programs.list"), h.ListPrograms) groupV1.Put("/programs/reorder", a.authMiddleware, a.RequirePermission("programs.reorder"), h.ReorderPrograms) - groupV1.Get("/lms/progress", a.authMiddleware, a.RequireSubscriptionCategory(domain.SubscriptionCategoryLearnEnglish), a.RequirePermission("lms.get_my_progress"), h.GetMyLMSProgress) - groupV1.Get("/lms/progress-summary", a.authMiddleware, a.RequireSubscriptionCategory(domain.SubscriptionCategoryLearnEnglish), a.RequirePermission("lms.get_my_progress"), h.GetMyLMSProgressSummary) - groupV1.Get("/programs/:id", a.authMiddleware, a.RequireSubscriptionCategory(domain.SubscriptionCategoryLearnEnglish), a.RequirePermission("programs.get"), h.GetProgram) + groupV1.Get("/lms/progress", a.authMiddleware, a.RequireLMSSubscriptionUnlessFree(), a.RequirePermission("lms.get_my_progress"), h.GetMyLMSProgress) + groupV1.Get("/lms/progress-summary", a.authMiddleware, a.RequireLMSSubscriptionUnlessFree(), a.RequirePermission("lms.get_my_progress"), h.GetMyLMSProgressSummary) + groupV1.Get("/programs/:id", a.authMiddleware, a.RequireLMSSubscriptionUnlessFree(), a.RequirePermission("programs.get"), h.GetProgram) groupV1.Put("/programs/:id", a.authMiddleware, a.RequirePermission("programs.update"), h.UpdateProgram) groupV1.Delete("/programs/:id", a.authMiddleware, a.RequirePermission("programs.delete"), h.DeleteProgram) @@ -133,33 +133,33 @@ func (a *App) initAppRoutes() { // Courses groupV1.Post("/programs/:id/courses", a.authMiddleware, a.RequirePermission("courses.create"), h.CreateCourse) groupV1.Put("/programs/:id/courses/reorder", a.authMiddleware, a.RequirePermission("courses.reorder"), h.ReorderCoursesInProgram) - groupV1.Get("/programs/:id/courses", a.authMiddleware, a.RequireSubscriptionCategory(domain.SubscriptionCategoryLearnEnglish), a.RequirePermission("courses.list_by_program"), h.ListCoursesByProgram) - groupV1.Get("/courses/:id/practices", a.authMiddleware, a.RequireSubscriptionCategory(domain.SubscriptionCategoryLearnEnglish), a.RequirePermission("practices.list"), h.ListPracticesByCourse) - groupV1.Get("/courses/:id", a.authMiddleware, a.RequireSubscriptionCategory(domain.SubscriptionCategoryLearnEnglish), a.RequirePermission("courses.get"), h.GetCourse) + groupV1.Get("/programs/:id/courses", a.authMiddleware, a.RequireLMSSubscriptionUnlessFree(), a.RequirePermission("courses.list_by_program"), h.ListCoursesByProgram) + groupV1.Get("/courses/:id/practices", a.authMiddleware, a.RequireLMSSubscriptionUnlessFree(), a.RequirePermission("practices.list"), h.ListPracticesByCourse) + groupV1.Get("/courses/:id", a.authMiddleware, a.RequireLMSSubscriptionUnlessFree(), a.RequirePermission("courses.get"), h.GetCourse) groupV1.Put("/courses/:id", a.authMiddleware, a.RequirePermission("courses.update"), h.UpdateCourse) groupV1.Delete("/courses/:id", a.authMiddleware, a.RequirePermission("courses.delete"), h.DeleteCourse) groupV1.Post("/courses/:courseId/modules", a.authMiddleware, a.RequirePermission("modules.create"), h.CreateModule) groupV1.Put("/courses/:courseId/modules/reorder", a.authMiddleware, a.RequirePermission("modules.reorder"), h.ReorderModulesInCourse) - groupV1.Get("/courses/:courseId/modules", a.authMiddleware, a.RequireSubscriptionCategory(domain.SubscriptionCategoryLearnEnglish), a.RequirePermission("modules.list_by_course"), h.ListModulesByCourse) + groupV1.Get("/courses/:courseId/modules", a.authMiddleware, a.RequireLMSSubscriptionUnlessFree(), a.RequirePermission("modules.list_by_course"), h.ListModulesByCourse) // /modules/:moduleId/lessons before /modules/:id; /modules/:id/practices before /modules/:id groupV1.Post("/modules/:moduleId/lessons", a.authMiddleware, a.RequirePermission("lessons.create"), h.CreateLesson) - groupV1.Get("/modules/:moduleId/lessons", a.authMiddleware, a.RequireSubscriptionCategory(domain.SubscriptionCategoryLearnEnglish), a.RequirePermission("lessons.list_by_module"), h.ListLessonsByModule) + groupV1.Get("/modules/:moduleId/lessons", a.authMiddleware, a.RequireLMSSubscriptionUnlessFree(), a.RequirePermission("lessons.list_by_module"), h.ListLessonsByModule) groupV1.Put("/modules/:moduleId/lessons/reorder", a.authMiddleware, a.RequirePermission("lessons.reorder"), h.ReorderLessonsInModule) - groupV1.Get("/modules/:id/practices", a.authMiddleware, a.RequireSubscriptionCategory(domain.SubscriptionCategoryLearnEnglish), a.RequirePermission("practices.list"), h.ListPracticesByModule) - groupV1.Get("/modules/:id", a.authMiddleware, a.RequireSubscriptionCategory(domain.SubscriptionCategoryLearnEnglish), a.RequirePermission("modules.get"), h.GetModule) + groupV1.Get("/modules/:id/practices", a.authMiddleware, a.RequireLMSSubscriptionUnlessFree(), a.RequirePermission("practices.list"), h.ListPracticesByModule) + groupV1.Get("/modules/:id", a.authMiddleware, a.RequireLMSSubscriptionUnlessFree(), a.RequirePermission("modules.get"), h.GetModule) groupV1.Put("/modules/:id", a.authMiddleware, a.RequirePermission("modules.update"), h.UpdateModule) groupV1.Delete("/modules/:id", a.authMiddleware, a.RequirePermission("modules.delete"), h.DeleteModule) - groupV1.Get("/lessons/:id/practices", a.authMiddleware, a.RequireSubscriptionCategory(domain.SubscriptionCategoryLearnEnglish), a.RequirePermission("practices.list"), h.ListPracticesByLesson) - groupV1.Post("/lessons/:id/complete", a.authMiddleware, a.RequireSubscriptionCategory(domain.SubscriptionCategoryLearnEnglish), a.RequirePermission("lessons.complete"), h.CompleteLesson) - groupV1.Post("/videos/engagement/heartbeat", a.authMiddleware, a.RequireSubscriptionCategory(domain.SubscriptionCategoryLearnEnglish), a.RequirePermission("videos.track_engagement"), h.RecordVideoEngagementHeartbeat) - groupV1.Post("/progress/practices/:id/complete", a.authMiddleware, a.RequireSubscriptionCategory(domain.SubscriptionCategoryLearnEnglish), a.RequirePermission("progress.complete"), h.CompletePractice) - groupV1.Get("/lessons/:id", a.authMiddleware, a.RequireSubscriptionCategory(domain.SubscriptionCategoryLearnEnglish), a.RequirePermission("lessons.get"), h.GetLesson) + groupV1.Get("/lessons/:id/practices", a.authMiddleware, a.RequireLMSSubscriptionUnlessFree(), a.RequirePermission("practices.list"), h.ListPracticesByLesson) + groupV1.Post("/lessons/:id/complete", a.authMiddleware, a.RequireLMSSubscriptionUnlessFree(), a.RequirePermission("lessons.complete"), h.CompleteLesson) + groupV1.Post("/videos/engagement/heartbeat", a.authMiddleware, a.RequireLMSSubscriptionUnlessFree(), a.RequirePermission("videos.track_engagement"), h.RecordVideoEngagementHeartbeat) + groupV1.Post("/progress/practices/:id/complete", a.authMiddleware, a.RequireLMSSubscriptionUnlessFree(), a.RequirePermission("progress.complete"), h.CompletePractice) + groupV1.Get("/lessons/:id", a.authMiddleware, a.RequireLMSSubscriptionUnlessFree(), a.RequirePermission("lessons.get"), h.GetLesson) groupV1.Put("/lessons/:id", a.authMiddleware, a.RequirePermission("lessons.update"), h.UpdateLesson) groupV1.Delete("/lessons/:id", a.authMiddleware, a.RequirePermission("lessons.delete"), h.DeleteLesson) groupV1.Post("/practices", a.authMiddleware, a.RequirePermission("practices.create"), h.CreatePractice) - groupV1.Get("/practices/:id", a.authMiddleware, a.RequireSubscriptionCategory(domain.SubscriptionCategoryLearnEnglish), a.RequirePermission("practices.get"), h.GetPractice) + groupV1.Get("/practices/:id", a.authMiddleware, a.RequireLMSSubscriptionUnlessFree(), a.RequirePermission("practices.get"), h.GetPractice) groupV1.Put("/practices/:id", a.authMiddleware, a.RequirePermission("practices.update"), h.UpdatePractice) groupV1.Put("/practices/:id/full", a.authMiddleware, a.RequirePermission("practices.update"), h.UpdateLmsPracticeFull) groupV1.Delete("/practices/:id", a.authMiddleware, a.RequirePermission("practices.delete"), h.DeletePractice) @@ -250,7 +250,7 @@ func (a *App) initAppRoutes() { groupV1.Post("/question-sets/:setId/questions", a.authMiddleware, a.RequirePermission("question_set_items.add"), h.AddQuestionToSet) groupV1.Get("/question-sets/:setId/question-types", a.authMiddleware, a.RequirePermission("question_set_items.list"), h.GetQuestionTypesInSet) groupV1.Get("/question-sets/:setId/questions", a.authMiddleware, a.RequirePermission("question_set_items.list"), h.GetQuestionsInSet) - groupV1.Get("/practices/:practiceId/questions", a.authMiddleware, a.RequireSubscriptionCategory(domain.SubscriptionCategoryLearnEnglish), a.RequirePermission("question_set_items.list"), h.GetQuestionsByPractice) + groupV1.Get("/practices/:practiceId/questions", a.authMiddleware, a.RequireLMSSubscriptionUnlessFree(), a.RequirePermission("question_set_items.list"), h.GetQuestionsByPractice) groupV1.Delete("/question-sets/:setId/questions/:questionId", a.authMiddleware, a.RequirePermission("question_set_items.remove"), h.RemoveQuestionFromSet) groupV1.Put("/question-sets/:setId/questions/:questionId/order", a.authMiddleware, a.RequirePermission("question_set_items.update_order"), h.UpdateQuestionOrderInSet)