added program

This commit is contained in:
Yared Yemane 2026-04-23 00:59:01 -07:00
parent 9154dec067
commit 152478a96c
47 changed files with 810 additions and 9464 deletions

View File

@ -14,10 +14,10 @@ import (
"Yimaru-Backend/internal/services/arifpay" "Yimaru-Backend/internal/services/arifpay"
"Yimaru-Backend/internal/services/assessment" "Yimaru-Backend/internal/services/assessment"
"Yimaru-Backend/internal/services/authentication" "Yimaru-Backend/internal/services/authentication"
"Yimaru-Backend/internal/services/course_management"
issuereporting "Yimaru-Backend/internal/services/issue_reporting" issuereporting "Yimaru-Backend/internal/services/issue_reporting"
"Yimaru-Backend/internal/services/messenger" "Yimaru-Backend/internal/services/messenger"
notificationservice "Yimaru-Backend/internal/services/notification" notificationservice "Yimaru-Backend/internal/services/notification"
programsservice "Yimaru-Backend/internal/services/programs"
"Yimaru-Backend/internal/services/questions" "Yimaru-Backend/internal/services/questions"
"Yimaru-Backend/internal/services/recommendation" "Yimaru-Backend/internal/services/recommendation"
"Yimaru-Backend/internal/services/settings" "Yimaru-Backend/internal/services/settings"
@ -360,24 +360,10 @@ func main() {
logger.Info("Vimeo service disabled (VIMEO_ENABLED not set or missing access token)") logger.Info("Vimeo service disabled (VIMEO_ENABLED not set or missing access token)")
} }
// Course management service // CloudConvert service for image/video optimization
courseSvc := course_management.NewService(
repository.NewUserStore(store),
repository.NewCourseStore(store),
repository.NewProgressionStore(store),
notificationSvc,
cfg,
)
// Wire up Vimeo service to course management
if vimeoSvc != nil {
courseSvc.SetVimeoService(vimeoSvc)
}
// CloudConvert service for video compression
var ccSvc *cloudconvertservice.Service var ccSvc *cloudconvertservice.Service
if cfg.CloudConvert.Enabled && cfg.CloudConvert.APIKey != "" { if cfg.CloudConvert.Enabled && cfg.CloudConvert.APIKey != "" {
ccSvc = cloudconvertservice.NewService(cfg.CloudConvert.APIKey, domain.MongoDBLogger) ccSvc = cloudconvertservice.NewService(cfg.CloudConvert.APIKey, domain.MongoDBLogger)
courseSvc.SetCloudConvertService(ccSvc)
logger.Info("CloudConvert service initialized") logger.Info("CloudConvert service initialized")
} else { } else {
logger.Info("CloudConvert service disabled (CLOUDCONVERT_ENABLED not set or missing API key)") logger.Info("CloudConvert service disabled (CLOUDCONVERT_ENABLED not set or missing API key)")
@ -402,6 +388,9 @@ func main() {
// Questions service (unified questions system) // Questions service (unified questions system)
questionsSvc := questions.NewService(store) questionsSvc := questions.NewService(store)
// LMS programs (top-level hierarchy)
programSvc := programsservice.NewService(store)
// Subscriptions service // Subscriptions service
subscriptionsSvc := subscriptions.NewService(store) subscriptionsSvc := subscriptions.NewService(store)
@ -442,8 +431,8 @@ func main() {
// Initialize and start HTTP server // Initialize and start HTTP server
app := httpserver.NewApp( app := httpserver.NewApp(
assessmentSvc, assessmentSvc,
courseSvc,
questionsSvc, questionsSvc,
programSvc,
subscriptionsSvc, subscriptionsSvc,
arifpaySvc, arifpaySvc,
issueReportingSvc, issueReportingSvc,

View File

@ -1,2 +0,0 @@
-- Intentionally empty: course hierarchy is not seeded from SQL.
-- Use admin/API or migrations to create content.

View File

@ -0,0 +1 @@
-- Restoring the removed course hierarchy is not supported; apply new migrations for the next model.

View File

@ -0,0 +1,46 @@
-- Tear down the legacy course / learning-tree schema so a new hierarchy can be introduced.
BEGIN;
-- Entry-assessment automation on sub_courses (from 000024)
DROP TRIGGER IF EXISTS trg_sub_courses_create_entry_assessment ON sub_courses;
DROP FUNCTION IF EXISTS create_sub_course_entry_assessment();
DROP FUNCTION IF EXISTS clone_default_initial_assessment_items(BIGINT);
DROP INDEX IF EXISTS idx_question_sets_unique_subcourse_initial_assessment;
ALTER TABLE question_sets DROP COLUMN IF EXISTS sub_course_video_id;
-- Dependent objects first
DROP TABLE IF EXISTS user_sub_course_video_progress CASCADE;
DROP TABLE IF EXISTS user_practice_progress CASCADE;
DROP TABLE IF EXISTS sub_course_prerequisites CASCADE;
DROP TABLE IF EXISTS user_sub_course_progress CASCADE;
DROP TABLE IF EXISTS sub_module_practices CASCADE;
DROP TABLE IF EXISTS sub_module_capstones CASCADE;
DROP TABLE IF EXISTS sub_module_lessons CASCADE;
DROP TABLE IF EXISTS sub_module_videos CASCADE;
DROP TABLE IF EXISTS sub_modules CASCADE;
DROP TABLE IF EXISTS module_capstones CASCADE;
DROP TABLE IF EXISTS modules CASCADE;
DROP TABLE IF EXISTS levels CASCADE;
DROP TABLE IF EXISTS sub_course_videos CASCADE;
DROP TABLE IF EXISTS sub_courses CASCADE;
DROP TABLE IF EXISTS course_sub_categories CASCADE;
DROP TABLE IF EXISTS courses CASCADE;
DROP TABLE IF EXISTS course_categories CASCADE;
-- Keep learner practice completion for the questions system (no sub_course column)
CREATE TABLE user_practice_progress (
id BIGSERIAL PRIMARY KEY,
user_id BIGINT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
question_set_id BIGINT NOT NULL REFERENCES question_sets(id) ON DELETE CASCADE,
completed_at TIMESTAMPTZ,
updated_at TIMESTAMPTZ,
created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
UNIQUE (user_id, question_set_id)
);
CREATE INDEX idx_user_practice_progress_user_id ON user_practice_progress(user_id);
COMMIT;

View File

@ -0,0 +1 @@
DROP TABLE IF EXISTS programs;

View File

@ -0,0 +1,11 @@
-- Top-level LMS program (e.g. Beginner / Intermediate / Advanced — labels come from admin config later).
CREATE TABLE programs (
id BIGSERIAL PRIMARY KEY,
name VARCHAR(255) NOT NULL,
description TEXT,
thumbnail TEXT,
created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMPTZ
);
CREATE INDEX idx_programs_created_at ON programs (created_at DESC);

View File

@ -163,10 +163,10 @@ ORDER BY d.date;
-- name: AnalyticsCourseCounts :one -- name: AnalyticsCourseCounts :one
SELECT SELECT
(SELECT COUNT(*)::bigint FROM course_categories) AS total_categories, 0::bigint AS total_categories,
(SELECT COUNT(*)::bigint FROM courses) AS total_courses, 0::bigint AS total_courses,
(SELECT COUNT(*)::bigint FROM sub_courses) AS total_sub_courses, 0::bigint AS total_sub_courses,
(SELECT COUNT(*)::bigint FROM sub_course_videos) AS total_videos; 0::bigint AS total_videos;
-- ===================== -- =====================
-- Content Analytics -- Content Analytics

View File

@ -1,47 +0,0 @@
-- name: CreateCourseCategory :one
INSERT INTO course_categories (
name,
is_active
)
VALUES ($1, COALESCE($2, true))
RETURNING *;
-- name: GetCourseCategoryByID :one
SELECT *
FROM course_categories
WHERE id = $1;
-- name: GetAllCourseCategories :many
SELECT
COUNT(*) OVER () AS total_count,
id,
name,
is_active,
created_at
FROM course_categories
ORDER BY display_order ASC, created_at DESC
LIMIT sqlc.narg('limit')::INT
OFFSET sqlc.narg('offset')::INT;
-- name: UpdateCourseCategory :exec
UPDATE course_categories
SET
name = COALESCE($1, name),
is_active = COALESCE($2, is_active)
WHERE id = $3;
-- name: DeleteCourseCategory :exec
DELETE FROM course_categories
WHERE id = $1;
-- name: ReorderCourseCategories :exec
UPDATE course_categories
SET display_order = bulk.position
FROM (
SELECT unnest(@ids::BIGINT[]) AS id, unnest(@positions::INT[]) AS position
) AS bulk
WHERE course_categories.id = bulk.id;

View File

@ -1,109 +0,0 @@
-- name: CreateCourse :one
INSERT INTO courses (
category_id,
title,
description,
thumbnail,
intro_video_url,
is_active
)
VALUES ($1, $2, $3, $4, $5, COALESCE($6, true))
RETURNING *;
-- name: GetCourseByID :one
SELECT *
FROM courses
WHERE id = $1;
-- name: GetCoursesByCategory :many
SELECT
COUNT(*) OVER () AS total_count,
id,
category_id,
title,
description,
thumbnail,
intro_video_url,
is_active
FROM courses
WHERE category_id = $1
ORDER BY display_order ASC, id ASC
LIMIT sqlc.narg('limit')::INT
OFFSET sqlc.narg('offset')::INT;
-- name: GetAllCourses :many
SELECT
COUNT(*) OVER () AS total_count,
c.id,
c.category_id,
c.sub_category_id,
c.title,
c.description,
c.thumbnail,
c.intro_video_url,
c.is_active
FROM courses c
ORDER BY c.display_order ASC, c.id ASC
LIMIT sqlc.narg('limit')::INT
OFFSET sqlc.narg('offset')::INT;
-- name: GetHumanLanguageCourses :many
SELECT
COUNT(*) OVER () AS total_count,
c.id,
c.category_id,
c.sub_category_id,
c.title,
c.description,
c.thumbnail,
c.intro_video_url,
c.is_active
FROM courses c
JOIN course_categories cc ON cc.id = c.category_id
WHERE lower(trim(cc.name)) = 'human language'
ORDER BY c.display_order ASC, c.id ASC
LIMIT sqlc.narg('limit')::INT
OFFSET sqlc.narg('offset')::INT;
-- name: GetCoursesBySubCategory :many
SELECT
COUNT(*) OVER () AS total_count,
c.id,
c.category_id,
c.sub_category_id,
c.title,
c.description,
c.thumbnail,
c.intro_video_url,
c.is_active
FROM courses c
WHERE c.sub_category_id = $1
ORDER BY c.display_order ASC, c.id ASC
LIMIT sqlc.narg('limit')::INT
OFFSET sqlc.narg('offset')::INT;
-- name: UpdateCourse :exec
UPDATE courses
SET
title = COALESCE($1, title),
description = COALESCE($2, description),
thumbnail = COALESCE($3, thumbnail),
intro_video_url = COALESCE($4, intro_video_url),
is_active = COALESCE($5, is_active)
WHERE id = $6;
-- name: DeleteCourse :exec
DELETE FROM courses
WHERE id = $1;
-- name: ReorderCourses :exec
UPDATE courses
SET display_order = bulk.position
FROM (
SELECT unnest(@ids::BIGINT[]) AS id, unnest(@positions::INT[]) AS position
) AS bulk
WHERE courses.id = bulk.id;

View File

@ -1,586 +0,0 @@
-- name: GetCoursesWithHierarchy :many
SELECT
cc.id AS category_id,
cc.name AS category_name,
csc.id AS sub_category_id,
csc.name AS sub_category_name,
c.id AS course_id,
c.title AS course_title
FROM course_categories cc
LEFT JOIN course_sub_categories csc ON csc.category_id = cc.id AND csc.is_active = TRUE
LEFT JOIN courses c ON c.sub_category_id = csc.id AND c.is_active = TRUE
WHERE cc.is_active = TRUE
ORDER BY cc.id, csc.display_order, csc.id, c.id;
-- name: GetLevelsByCourseID :many
SELECT *
FROM levels
WHERE course_id = $1
AND is_active = TRUE
ORDER BY display_order ASC, id ASC;
-- name: GetAllLevels :many
SELECT
COUNT(*) OVER () AS total_count,
l.*
FROM levels l
ORDER BY l.display_order ASC, l.id ASC
LIMIT sqlc.narg('limit')::INT
OFFSET sqlc.narg('offset')::INT;
-- name: GetLevelByID :one
SELECT *
FROM levels
WHERE id = $1;
-- name: GetModulesByLevelID :many
SELECT *
FROM modules
WHERE level_id = $1
AND is_active = TRUE
ORDER BY display_order ASC, id ASC;
-- name: GetAllModules :many
SELECT
COUNT(*) OVER () AS total_count,
m.*
FROM modules m
ORDER BY m.display_order ASC, m.id ASC
LIMIT sqlc.narg('limit')::INT
OFFSET sqlc.narg('offset')::INT;
-- name: GetModuleByID :one
SELECT *
FROM modules
WHERE id = $1;
-- name: GetSubModulesByModuleID :many
SELECT *
FROM sub_modules
WHERE module_id = $1
AND is_active = TRUE
ORDER BY display_order ASC, id ASC;
-- name: GetAllSubModules :many
SELECT
COUNT(*) OVER () AS total_count,
sm.*
FROM sub_modules sm
ORDER BY sm.display_order ASC, sm.id ASC
LIMIT sqlc.narg('limit')::INT
OFFSET sqlc.narg('offset')::INT;
-- name: GetSubModuleByID :one
SELECT *
FROM sub_modules
WHERE id = $1;
-- name: GetSubModuleVideos :many
SELECT *
FROM sub_module_videos
WHERE sub_module_id = $1
AND status != 'ARCHIVED'
ORDER BY display_order ASC, id ASC;
-- name: GetSubModuleLessons :many
SELECT *
FROM sub_module_lessons
WHERE sub_module_id = $1
AND is_active = TRUE
ORDER BY display_order ASC, id ASC;
-- name: GetSubModuleLessonsAll :many
SELECT *
FROM sub_module_lessons
WHERE sub_module_id = $1
ORDER BY display_order ASC, id ASC;
-- name: GetSubModuleLessonByID :one
SELECT *
FROM sub_module_lessons
WHERE id = $1;
-- name: GetSubModulePractices :many
SELECT
smp.id,
smp.sub_module_id,
smp.title,
smp.description,
smp.thumbnail,
smp.intro_video_url,
smp.question_set_id,
smp.display_order,
smp.is_active,
smp.inactive_since,
qs.status,
qs.set_type,
(SELECT COUNT(*) FROM question_set_items qsi WHERE qsi.set_id = qs.id) AS question_count
FROM sub_module_practices smp
JOIN question_sets qs ON qs.id = smp.question_set_id
WHERE smp.sub_module_id = $1
AND smp.is_active = TRUE
ORDER BY smp.display_order ASC, smp.id ASC;
-- name: GetSubModulePracticeByID :one
SELECT
smp.id,
smp.sub_module_id,
smp.title,
smp.description,
smp.thumbnail,
smp.intro_video_url,
smp.question_set_id,
smp.display_order,
smp.is_active,
smp.inactive_since,
qs.status,
qs.set_type,
(SELECT COUNT(*) FROM question_set_items qsi WHERE qsi.set_id = qs.id) AS question_count
FROM sub_module_practices smp
JOIN question_sets qs ON qs.id = smp.question_set_id
WHERE smp.is_active = TRUE
AND (smp.id = $1 OR smp.question_set_id = $1)
ORDER BY (smp.id = $1) DESC
LIMIT 1;
-- name: GetSubModuleCapstones :many
SELECT
smc.id,
smc.sub_module_id,
smc.title,
smc.description,
smc.tips,
smc.thumbnail,
smc.question_set_id,
smc.display_order,
smc.is_active,
smc.inactive_since,
qs.status,
qs.set_type,
qs.time_limit_minutes,
qs.passing_score,
qs.shuffle_questions,
(SELECT COUNT(*) FROM question_set_items qsi WHERE qsi.set_id = qs.id) AS question_count
FROM sub_module_capstones smc
JOIN question_sets qs ON qs.id = smc.question_set_id
WHERE smc.sub_module_id = $1
AND smc.is_active = TRUE
AND qs.set_type = 'CAPSTONE'
ORDER BY smc.display_order ASC, smc.id ASC;
-- name: GetSubModuleCapstoneByID :one
SELECT
smc.id,
smc.sub_module_id,
smc.title,
smc.description,
smc.tips,
smc.thumbnail,
smc.question_set_id,
smc.display_order,
smc.is_active,
smc.inactive_since,
qs.status,
qs.set_type,
qs.time_limit_minutes,
qs.passing_score,
qs.shuffle_questions,
(SELECT COUNT(*) FROM question_set_items qsi WHERE qsi.set_id = qs.id) AS question_count
FROM sub_module_capstones smc
JOIN question_sets qs ON qs.id = smc.question_set_id
WHERE smc.id = $1
AND smc.is_active = TRUE
AND qs.set_type = 'CAPSTONE';
-- name: GetFullHierarchyByCourseID :many
SELECT
c.id AS course_id,
c.title AS course_title,
l.id AS level_id,
l.cefr_level,
l.title AS level_title,
l.description AS level_description,
l.thumbnail AS level_thumbnail,
m.id AS module_id,
m.title AS module_title,
m.icon_url AS module_icon_url,
sm.id AS sub_module_id,
sm.title AS sub_module_title,
sm.description AS sub_module_description,
sm.thumbnail AS sub_module_thumbnail,
sm.tips AS sub_module_tips,
sm.display_order AS sub_module_display_order
FROM courses c
LEFT JOIN levels l ON l.course_id = c.id AND l.is_active = TRUE
LEFT JOIN modules m ON m.level_id = l.id AND m.is_active = TRUE
LEFT JOIN sub_modules sm ON sm.module_id = m.id AND sm.is_active = TRUE
WHERE c.id = $1
ORDER BY l.display_order, l.id, m.display_order, m.id, sm.display_order, sm.id;
-- name: CreateCourseSubCategory :one
INSERT INTO course_sub_categories (
category_id,
name,
description,
display_order,
is_active
)
VALUES ($1, $2, $3, COALESCE($4, 0), COALESCE($5, TRUE))
RETURNING *;
-- name: GetCourseSubCategories :many
SELECT
COUNT(*) OVER () AS total_count,
csc.id,
csc.category_id,
cc.name AS category_name,
csc.name,
csc.description,
csc.display_order,
csc.is_active,
csc.created_at
FROM course_sub_categories csc
JOIN course_categories cc ON cc.id = csc.category_id
WHERE csc.is_active = TRUE
ORDER BY csc.display_order ASC, csc.id ASC
LIMIT sqlc.narg('limit')::INT
OFFSET sqlc.narg('offset')::INT;
-- name: GetCourseSubCategoriesByCategoryID :many
SELECT
COUNT(*) OVER () AS total_count,
csc.id,
csc.category_id,
cc.name AS category_name,
csc.name,
csc.description,
csc.display_order,
csc.is_active,
csc.created_at
FROM course_sub_categories csc
JOIN course_categories cc ON cc.id = csc.category_id
WHERE csc.category_id = $1
AND csc.is_active = TRUE
ORDER BY csc.display_order ASC, csc.id ASC
LIMIT sqlc.narg('limit')::INT
OFFSET sqlc.narg('offset')::INT;
-- name: GetHumanLanguageCourseSubCategories :many
SELECT
COUNT(*) OVER () AS total_count,
csc.id,
csc.category_id,
cc.name AS category_name,
csc.name,
csc.description,
csc.display_order,
csc.is_active,
csc.created_at
FROM course_sub_categories csc
JOIN course_categories cc ON cc.id = csc.category_id
WHERE csc.is_active = TRUE
AND cc.is_active = TRUE
AND lower(trim(cc.name)) = 'human language'
ORDER BY csc.display_order ASC, csc.id ASC
LIMIT sqlc.narg('limit')::INT
OFFSET sqlc.narg('offset')::INT;
-- name: CreateLevel :one
INSERT INTO levels (
course_id,
cefr_level,
title,
description,
thumbnail,
display_order,
is_active
)
VALUES ($1, $2, $3, $4, $5, COALESCE($6, 0), COALESCE($7, TRUE))
RETURNING *;
-- name: UpdateLevel :one
UPDATE levels
SET
title = $1,
description = $2,
thumbnail = $3,
display_order = $4,
is_active = $5
WHERE id = $6
RETURNING *;
-- name: CreateModule :one
INSERT INTO modules (
level_id,
title,
description,
icon_url,
display_order,
is_active
)
VALUES ($1, $2, $3, $4, COALESCE($5, 0), COALESCE($6, TRUE))
RETURNING *;
-- name: UpdateModule :one
UPDATE modules
SET
title = $1,
description = $2,
icon_url = $3,
display_order = $4,
is_active = $5
WHERE id = $6
RETURNING *;
-- name: CreateSubModule :one
INSERT INTO sub_modules (
module_id,
title,
description,
thumbnail,
tips,
display_order,
is_active
)
VALUES ($1, $2, $3, $4, $5, COALESCE($6, 0), COALESCE($7, TRUE))
RETURNING *;
-- name: UpdateSubModule :one
UPDATE sub_modules
SET
title = $1,
description = $2,
thumbnail = $3,
tips = $4,
display_order = $5,
is_active = $6
WHERE id = $7
RETURNING *;
-- name: CreateSubModuleVideo :one
INSERT INTO sub_module_videos (
sub_module_id,
title,
description,
video_url,
duration,
resolution,
is_published,
publish_date,
visibility,
instructor_id,
thumbnail,
display_order,
status,
vimeo_id,
vimeo_embed_url,
vimeo_player_html,
vimeo_status,
video_host_provider
)
VALUES (
$1, $2, $3, $4, $5, $6,
COALESCE($7, FALSE), $8, $9, $10, $11,
COALESCE($12, 0), COALESCE($13, 'DRAFT'),
$14, $15, $16, $17, COALESCE($18, 'DIRECT')
)
RETURNING *;
-- name: CreateSubModuleLesson :one
INSERT INTO sub_module_lessons (
sub_module_id,
title,
description,
thumbnail,
teaching_text,
teaching_image_url,
teaching_audio_url,
teaching_video_url,
display_order,
is_active,
inactive_since
)
VALUES (
$1,
$2,
$3,
$4,
$5,
$6,
$7,
$8,
COALESCE($9, 0),
COALESCE($10, TRUE),
CASE WHEN COALESCE($10, TRUE) THEN NULL ELSE NOW() END
)
RETURNING *;
-- name: UpdateSubModuleLesson :one
UPDATE sub_module_lessons
SET
sub_module_id = $1,
title = $2,
description = $3,
thumbnail = $4,
teaching_text = $5,
teaching_image_url = $6,
teaching_audio_url = $7,
teaching_video_url = $8,
display_order = $9,
is_active = $10,
inactive_since = CASE
WHEN $10 THEN NULL
WHEN is_active THEN NOW()
ELSE inactive_since
END
WHERE id = $11
RETURNING *;
-- name: CreateSubModulePractice :one
INSERT INTO sub_module_practices (
sub_module_id,
title,
description,
thumbnail,
intro_video_url,
question_set_id,
display_order,
is_active,
inactive_since
)
VALUES ($1, $2, $3, $4, $5, $6, COALESCE($7, 0), COALESCE($8, TRUE), CASE WHEN COALESCE($8, TRUE) THEN NULL ELSE NOW() END)
RETURNING *;
-- name: CreateSubModuleCapstone :one
INSERT INTO sub_module_capstones (
sub_module_id,
title,
description,
tips,
thumbnail,
question_set_id,
display_order,
is_active,
inactive_since
)
VALUES ($1, $2, $3, $4, $5, $6, COALESCE($7, 0), COALESCE($8, TRUE), CASE WHEN COALESCE($8, TRUE) THEN NULL ELSE NOW() END)
RETURNING *;
-- name: UpdateSubModuleCapstone :one
UPDATE sub_module_capstones
SET
title = $1,
description = $2,
tips = $3,
thumbnail = $4,
display_order = $5,
is_active = $6,
inactive_since = CASE
WHEN $6 THEN NULL
WHEN is_active THEN NOW()
ELSE inactive_since
END
WHERE id = $7
RETURNING *;
-- name: PurgeInactiveSubModuleLessonsBefore :execrows
DELETE FROM sub_module_lessons
WHERE is_active = FALSE
AND inactive_since IS NOT NULL
AND inactive_since < $1;
-- name: PurgeInactiveSubModulePracticesBefore :execrows
DELETE FROM question_sets qs
USING (
SELECT question_set_id
FROM sub_module_practices
WHERE is_active = FALSE
AND inactive_since IS NOT NULL
AND inactive_since < $1
) doomed
WHERE qs.id = doomed.question_set_id;
-- name: PurgeInactiveSubModuleCapstonesBefore :execrows
DELETE FROM question_sets qs
USING (
SELECT question_set_id
FROM sub_module_capstones
WHERE is_active = FALSE
AND inactive_since IS NOT NULL
AND inactive_since < $1
) doomed
WHERE qs.id = doomed.question_set_id;
-- name: GetModuleCapstones :many
SELECT
mc.id,
mc.module_id,
mc.title,
mc.description,
mc.tips,
mc.thumbnail,
mc.question_set_id,
mc.display_order,
mc.is_active,
qs.status,
qs.set_type,
qs.time_limit_minutes,
qs.passing_score,
qs.shuffle_questions,
(SELECT COUNT(*) FROM question_set_items qsi WHERE qsi.set_id = qs.id) AS question_count
FROM module_capstones mc
JOIN question_sets qs ON qs.id = mc.question_set_id
WHERE mc.module_id = $1
AND mc.is_active = TRUE
AND qs.set_type = 'CAPSTONE'
ORDER BY mc.display_order ASC, mc.id ASC;
-- name: GetModuleCapstoneByID :one
SELECT
mc.id,
mc.module_id,
mc.title,
mc.description,
mc.tips,
mc.thumbnail,
mc.question_set_id,
mc.display_order,
mc.is_active,
qs.status,
qs.set_type,
qs.time_limit_minutes,
qs.passing_score,
qs.shuffle_questions,
(SELECT COUNT(*) FROM question_set_items qsi WHERE qsi.set_id = qs.id) AS question_count
FROM module_capstones mc
JOIN question_sets qs ON qs.id = mc.question_set_id
WHERE mc.id = $1
AND mc.is_active = TRUE
AND qs.set_type = 'CAPSTONE';
-- name: CreateModuleCapstone :one
INSERT INTO module_capstones (
module_id,
title,
description,
tips,
thumbnail,
question_set_id,
display_order,
is_active
)
VALUES ($1, $2, $3, $4, $5, $6, COALESCE($7, 0), COALESCE($8, TRUE))
RETURNING *;
-- name: UpdateModuleCapstone :one
UPDATE module_capstones
SET
title = $1,
description = $2,
tips = $3,
thumbnail = $4,
display_order = $5,
is_active = $6
WHERE id = $7
RETURNING *;

View File

@ -29,27 +29,16 @@ LIMIT 1;
-- name: MarkPracticeCompleted :execrows -- name: MarkPracticeCompleted :execrows
INSERT INTO user_practice_progress ( INSERT INTO user_practice_progress (
user_id, user_id,
sub_course_id,
question_set_id, question_set_id,
completed_at, completed_at,
updated_at updated_at
) )
SELECT VALUES (
@user_id::BIGINT, @user_id::BIGINT,
CASE @question_set_id::BIGINT,
WHEN qs.owner_type = 'SUB_COURSE' THEN qs.owner_id
WHEN qs.owner_type = 'SUB_MODULE' THEN sm.legacy_sub_course_id
ELSE NULL
END,
qs.id,
CURRENT_TIMESTAMP, CURRENT_TIMESTAMP,
CURRENT_TIMESTAMP CURRENT_TIMESTAMP
FROM question_sets qs )
LEFT JOIN sub_modules sm
ON qs.owner_type = 'SUB_MODULE'
AND qs.owner_id = sm.id
WHERE qs.id = @question_set_id::BIGINT
ON CONFLICT (user_id, question_set_id) DO UPDATE ON CONFLICT (user_id, question_set_id) DO UPDATE
SET completed_at = EXCLUDED.completed_at, SET completed_at = EXCLUDED.completed_at,
updated_at = EXCLUDED.updated_at; updated_at = EXCLUDED.updated_at;

36
db/query/programs.sql Normal file
View File

@ -0,0 +1,36 @@
-- name: CreateProgram :one
INSERT INTO programs (name, description, thumbnail)
VALUES ($1, $2, $3)
RETURNING *;
-- name: GetProgramByID :one
SELECT *
FROM programs
WHERE id = $1;
-- name: ListPrograms :many
SELECT
COUNT(*) OVER () AS total_count,
p.id,
p.name,
p.description,
p.thumbnail,
p.created_at,
p.updated_at
FROM programs p
ORDER BY p.created_at DESC
LIMIT $1 OFFSET $2;
-- name: UpdateProgram :one
UPDATE programs
SET
name = COALESCE(sqlc.narg('name')::varchar, name),
description = COALESCE(sqlc.narg('description')::text, description),
thumbnail = COALESCE(sqlc.narg('thumbnail')::text, thumbnail),
updated_at = CURRENT_TIMESTAMP
WHERE id = sqlc.arg('id')
RETURNING *;
-- name: DeleteProgram :exec
DELETE FROM programs
WHERE id = $1;

View File

@ -11,10 +11,9 @@ INSERT INTO question_sets (
passing_score, passing_score,
shuffle_questions, shuffle_questions,
status, status,
sub_course_video_id,
intro_video_url intro_video_url
) )
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, COALESCE($10, false), COALESCE($11, 'DRAFT'), $12, $13) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, COALESCE($10, false), COALESCE($11, 'DRAFT'), $12)
RETURNING *; RETURNING *;
-- name: GetQuestionSetByID :one -- name: GetQuestionSetByID :one
@ -61,9 +60,8 @@ SET
shuffle_questions = COALESCE($7, shuffle_questions), shuffle_questions = COALESCE($7, shuffle_questions),
status = COALESCE($8, status), status = COALESCE($8, status),
intro_video_url = COALESCE($9, intro_video_url), intro_video_url = COALESCE($9, intro_video_url),
sub_course_video_id = COALESCE($10, sub_course_video_id),
updated_at = CURRENT_TIMESTAMP updated_at = CURRENT_TIMESTAMP
WHERE id = $11; WHERE id = $10;
-- name: ArchiveQuestionSet :exec -- name: ArchiveQuestionSet :exec
UPDATE question_sets UPDATE question_sets
@ -82,16 +80,6 @@ WHERE set_type = 'INITIAL_ASSESSMENT'
ORDER BY created_at DESC ORDER BY created_at DESC
LIMIT 1; LIMIT 1;
-- name: GetSubCourseInitialAssessmentSet :one
SELECT *
FROM question_sets
WHERE set_type = 'INITIAL_ASSESSMENT'
AND owner_type = 'SUB_COURSE'
AND owner_id = $1
AND status = 'PUBLISHED'
ORDER BY created_at DESC
LIMIT 1;
-- name: AddUserPersonaToQuestionSet :one -- name: AddUserPersonaToQuestionSet :one
INSERT INTO question_set_personas ( INSERT INTO question_set_personas (
question_set_id, question_set_id,
@ -120,13 +108,6 @@ INNER JOIN question_set_personas qsp ON qsp.user_id = u.id
WHERE qsp.question_set_id = $1 WHERE qsp.question_set_id = $1
ORDER BY qsp.display_order ASC; ORDER BY qsp.display_order ASC;
-- name: UpdateQuestionSetVideoLink :exec
UPDATE question_sets
SET
sub_course_video_id = $1,
updated_at = CURRENT_TIMESTAMP
WHERE id = $2;
-- name: ReorderQuestionSets :exec -- name: ReorderQuestionSets :exec
UPDATE question_sets UPDATE question_sets
SET display_order = bulk.position SET display_order = bulk.position

View File

@ -1,825 +0,0 @@
{
"info": {
"_postman_id": "d8d17a29-5f9c-4f06-95fd-12e9862f97f8",
"name": "Yimaru Backend - Course Management APIs",
"description": "Fully documented Postman collection for all course-management related endpoints in Yimaru Backend.\n\nAuthentication:\n- All endpoints require `Authorization: Bearer {{accessToken}}`.\n\nBase URL:\n- `{{baseUrl}}/api/{{apiVersion}}`\n\nNotes:\n- IDs in path params must be positive integers.\n- Some update endpoints support partial updates.\n- Endpoint-level permission requirements are documented in each request description.",
"schema": "https://schema.getpostman.com/json/collection/v2.1.0/collection.json"
},
"variable": [
{
"key": "baseUrl",
"value": "https://api.yimaruacademy.com",
"type": "string"
},
{
"key": "apiVersion",
"value": "v1",
"type": "string"
},
{
"key": "accessToken",
"value": "",
"type": "string"
},
{
"key": "categoryId",
"value": "1",
"type": "string"
},
{
"key": "subCategoryId",
"value": "1",
"type": "string"
},
{
"key": "courseId",
"value": "1",
"type": "string"
},
{
"key": "levelId",
"value": "1",
"type": "string"
},
{
"key": "moduleId",
"value": "1",
"type": "string"
},
{
"key": "subModuleId",
"value": "1",
"type": "string"
},
{
"key": "videoId",
"value": "1",
"type": "string"
},
{
"key": "questionSetId",
"value": "1",
"type": "string"
},
{
"key": "practiceId",
"value": "1",
"type": "string"
}
],
"auth": {
"type": "bearer",
"bearer": [
{
"key": "token",
"value": "{{accessToken}}",
"type": "string"
}
]
},
"item": [
{
"name": "Categories & Courses",
"item": [
{
"name": "List Course Categories",
"request": {
"method": "GET",
"header": [],
"url": {
"raw": "{{baseUrl}}/api/{{apiVersion}}/course-management/categories",
"host": [
"{{baseUrl}}"
],
"path": [
"api",
"{{apiVersion}}",
"course-management",
"categories"
]
},
"description": "Returns all course categories.\n\nPermission: `learning_tree.get`."
}
},
{
"name": "Create Course Category",
"request": {
"method": "POST",
"header": [
{
"key": "Content-Type",
"value": "application/json"
}
],
"body": {
"mode": "raw",
"raw": "{\n \"name\": \"Grammar\",\n \"is_active\": true\n}"
},
"url": {
"raw": "{{baseUrl}}/api/{{apiVersion}}/course-management/categories",
"host": [
"{{baseUrl}}"
],
"path": [
"api",
"{{apiVersion}}",
"course-management",
"categories"
]
},
"description": "Creates a course category.\n\nRequired fields:\n- `name` (string)\n\nOptional fields:\n- `is_active` (boolean)\n\nPermission: `course_categories.create`."
}
},
{
"name": "Delete Course Category",
"request": {
"method": "DELETE",
"header": [],
"url": {
"raw": "{{baseUrl}}/api/{{apiVersion}}/course-management/categories/{{categoryId}}",
"host": [
"{{baseUrl}}"
],
"path": [
"api",
"{{apiVersion}}",
"course-management",
"categories",
"{{categoryId}}"
]
},
"description": "Deletes a category by ID.\n\nPath params:\n- `categoryId` (int, required)\n\nPermission: `course_categories.delete`."
}
},
{
"name": "List Courses By Category",
"request": {
"method": "GET",
"header": [],
"url": {
"raw": "{{baseUrl}}/api/{{apiVersion}}/course-management/categories/{{categoryId}}/courses?offset=0&limit=50",
"host": [
"{{baseUrl}}"
],
"path": [
"api",
"{{apiVersion}}",
"course-management",
"categories",
"{{categoryId}}",
"courses"
],
"query": [
{
"key": "offset",
"value": "0"
},
{
"key": "limit",
"value": "50"
}
]
},
"description": "Returns all courses under a category.\n\nPath params:\n- `categoryId` (int, required)\n\nQuery params:\n- `offset` (int, optional, default 0)\n- `limit` (int, optional, default 10000)\n\nPermission: `learning_tree.get`."
}
},
{
"name": "Create Course",
"request": {
"method": "POST",
"header": [
{
"key": "Content-Type",
"value": "application/json"
}
],
"body": {
"mode": "raw",
"raw": "{\n \"category_id\": 1,\n \"sub_category_id\": 1,\n \"title\": \"English Basics\",\n \"description\": \"Beginner-level course\",\n \"thumbnail\": \"https://cdn.example.com/course-thumb.jpg\",\n \"intro_video_url\": \"https://cdn.example.com/intro.mp4\",\n \"is_active\": true\n}"
},
"url": {
"raw": "{{baseUrl}}/api/{{apiVersion}}/course-management/courses",
"host": [
"{{baseUrl}}"
],
"path": [
"api",
"{{apiVersion}}",
"course-management",
"courses"
]
},
"description": "Creates a course.\n\nRequired fields:\n- `category_id` (int)\n- `title` (string)\n\nOptional fields:\n- `sub_category_id` (int)\n- `description` (string)\n- `thumbnail` (string URL)\n- `intro_video_url` (string URL)\n- `is_active` (boolean)\n\nPermission: `courses.create`."
}
},
{
"name": "Update Course",
"request": {
"method": "PUT",
"header": [
{
"key": "Content-Type",
"value": "application/json"
}
],
"body": {
"mode": "raw",
"raw": "{\n \"title\": \"English Basics - Updated\",\n \"description\": \"Updated course description\",\n \"thumbnail\": \"https://cdn.example.com/new-thumb.jpg\",\n \"intro_video_url\": \"https://cdn.example.com/new-intro.mp4\",\n \"is_active\": true\n}"
},
"url": {
"raw": "{{baseUrl}}/api/{{apiVersion}}/course-management/courses/{{courseId}}",
"host": [
"{{baseUrl}}"
],
"path": [
"api",
"{{apiVersion}}",
"course-management",
"courses",
"{{courseId}}"
]
},
"description": "Updates an existing course (partial update accepted).\n\nPath params:\n- `courseId` (int, required)\n\nOptional fields:\n- `title`, `description`, `thumbnail`, `intro_video_url`, `is_active`\n\nPermission: `courses.update`."
}
},
{
"name": "Delete Course",
"request": {
"method": "DELETE",
"header": [],
"url": {
"raw": "{{baseUrl}}/api/{{apiVersion}}/course-management/courses/{{courseId}}",
"host": [
"{{baseUrl}}"
],
"path": [
"api",
"{{apiVersion}}",
"course-management",
"courses",
"{{courseId}}"
]
},
"description": "Deletes a course.\n\nPath params:\n- `courseId` (int, required)\n\nPermission: `courses.delete`."
}
},
{
"name": "Update Course Thumbnail",
"request": {
"method": "POST",
"header": [
{
"key": "Content-Type",
"value": "application/json"
}
],
"body": {
"mode": "raw",
"raw": "{\n \"thumbnail_url\": \"https://cdn.example.com/new-thumbnail.jpg\"\n}"
},
"url": {
"raw": "{{baseUrl}}/api/{{apiVersion}}/course-management/courses/{{courseId}}/thumbnail",
"host": [
"{{baseUrl}}"
],
"path": [
"api",
"{{apiVersion}}",
"course-management",
"courses",
"{{courseId}}",
"thumbnail"
]
},
"description": "Updates only the thumbnail of a course.\n\nPath params:\n- `courseId` (int, required)\n\nRequired fields:\n- `thumbnail_url` (string)\n\nPermission: `courses.upload_thumbnail`."
}
}
]
},
{
"name": "Hierarchy & Learning Path",
"item": [
{
"name": "Get Unified Hierarchy",
"request": {
"method": "GET",
"header": [],
"url": {
"raw": "{{baseUrl}}/api/{{apiVersion}}/course-management/hierarchy",
"host": [
"{{baseUrl}}"
],
"path": [
"api",
"{{apiVersion}}",
"course-management",
"hierarchy"
]
},
"description": "Returns global hierarchy data.\n\nPermission: `learning_tree.get`."
}
},
{
"name": "Get Human Language Hierarchy",
"request": {
"method": "GET",
"header": [],
"url": {
"raw": "{{baseUrl}}/api/{{apiVersion}}/course-management/human-language/hierarchy",
"host": [
"{{baseUrl}}"
],
"path": [
"api",
"{{apiVersion}}",
"course-management",
"human-language",
"hierarchy"
]
},
"description": "Alias endpoint for unified hierarchy under human-language path.\n\nPermission: `learning_tree.get`."
}
},
{
"name": "Get Course Hierarchy",
"request": {
"method": "GET",
"header": [],
"url": {
"raw": "{{baseUrl}}/api/{{apiVersion}}/course-management/courses/{{courseId}}/hierarchy",
"host": [
"{{baseUrl}}"
],
"path": [
"api",
"{{apiVersion}}",
"course-management",
"courses",
"{{courseId}}",
"hierarchy"
]
},
"description": "Returns hierarchy nodes for one course.\n\nPath params:\n- `courseId` (int, required)\n\nPermission: `learning_tree.get`."
}
},
{
"name": "Get Course Learning Path",
"request": {
"method": "GET",
"header": [],
"url": {
"raw": "{{baseUrl}}/api/{{apiVersion}}/course-management/courses/{{courseId}}/learning-path",
"host": [
"{{baseUrl}}"
],
"path": [
"api",
"{{apiVersion}}",
"course-management",
"courses",
"{{courseId}}",
"learning-path"
]
},
"description": "Returns learning-path projection for a course including sub-modules, videos, and practices.\n\nPath params:\n- `courseId` (int, required)\n\nPermission: `learning_tree.get`."
}
}
]
},
{
"name": "Sub-Categories, Levels, Modules, Sub-Modules",
"item": [
{
"name": "Create Course Sub-Category",
"request": {
"method": "POST",
"header": [
{
"key": "Content-Type",
"value": "application/json"
}
],
"body": {
"mode": "raw",
"raw": "{\n \"category_id\": 1,\n \"name\": \"Everyday Conversation\",\n \"description\": \"Spoken communication track\",\n \"display_order\": 1,\n \"is_active\": true\n}"
},
"url": {
"raw": "{{baseUrl}}/api/{{apiVersion}}/course-management/sub-categories",
"host": [
"{{baseUrl}}"
],
"path": [
"api",
"{{apiVersion}}",
"course-management",
"sub-categories"
]
},
"description": "Creates a sub-category under a course category.\n\nRequired fields:\n- `category_id` (int)\n- `name` (string)\n\nOptional fields:\n- `description` (string)\n- `display_order` (int)\n- `is_active` (boolean)\n\nPermission: `course_categories.create`."
}
},
{
"name": "Delete Course Sub-Category",
"request": {
"method": "DELETE",
"header": [],
"url": {
"raw": "{{baseUrl}}/api/{{apiVersion}}/course-management/sub-categories/{{subCategoryId}}",
"host": [
"{{baseUrl}}"
],
"path": [
"api",
"{{apiVersion}}",
"course-management",
"sub-categories",
"{{subCategoryId}}"
]
},
"description": "Deletes a sub-category.\n\nPath params:\n- `subCategoryId` (int, required)\n\nPermission: `course_categories.delete`."
}
},
{
"name": "Create Level",
"request": {
"method": "POST",
"header": [
{
"key": "Content-Type",
"value": "application/json"
}
],
"body": {
"mode": "raw",
"raw": "{\n \"course_id\": 1,\n \"cefr_level\": \"A1\",\n \"display_order\": 1,\n \"is_active\": true\n}"
},
"url": {
"raw": "{{baseUrl}}/api/{{apiVersion}}/course-management/levels",
"host": [
"{{baseUrl}}"
],
"path": [
"api",
"{{apiVersion}}",
"course-management",
"levels"
]
},
"description": "Creates a CEFR level under a course.\n\nRequired fields:\n- `course_id` (int)\n- `cefr_level` (one of: A1, A2, A3, B1, B2, B3, C1, C2, C3)\n\nOptional fields:\n- `display_order` (int)\n- `is_active` (boolean)\n\nPermission: `subcourses.create`."
}
},
{
"name": "Create Module",
"request": {
"method": "POST",
"header": [
{
"key": "Content-Type",
"value": "application/json"
}
],
"body": {
"mode": "raw",
"raw": "{\n \"level_id\": 1,\n \"title\": \"Module 1: Introductions\",\n \"description\": \"Core introduction module\",\n \"display_order\": 1,\n \"is_active\": true\n}"
},
"url": {
"raw": "{{baseUrl}}/api/{{apiVersion}}/course-management/modules",
"host": [
"{{baseUrl}}"
],
"path": [
"api",
"{{apiVersion}}",
"course-management",
"modules"
]
},
"description": "Creates a module under a level.\n\nRequired fields:\n- `level_id` (int)\n- `title` (string)\n\nOptional fields:\n- `description`, `display_order`, `is_active`\n\nPermission: `subcourses.create`."
}
},
{
"name": "Delete Module",
"request": {
"method": "DELETE",
"header": [],
"url": {
"raw": "{{baseUrl}}/api/{{apiVersion}}/course-management/modules/{{moduleId}}",
"host": [
"{{baseUrl}}"
],
"path": [
"api",
"{{apiVersion}}",
"course-management",
"modules",
"{{moduleId}}"
]
},
"description": "Deletes a module.\n\nPath params:\n- `moduleId` (int, required)\n\nPermission: `subcourses.delete`."
}
},
{
"name": "Create Sub-Module",
"request": {
"method": "POST",
"header": [
{
"key": "Content-Type",
"value": "application/json"
}
],
"body": {
"mode": "raw",
"raw": "{\n \"module_id\": 1,\n \"title\": \"Sub-Module 1: Greetings\",\n \"description\": \"Greetings and polite expressions\",\n \"display_order\": 1,\n \"is_active\": true\n}"
},
"url": {
"raw": "{{baseUrl}}/api/{{apiVersion}}/course-management/sub-modules",
"host": [
"{{baseUrl}}"
],
"path": [
"api",
"{{apiVersion}}",
"course-management",
"sub-modules"
]
},
"description": "Creates a sub-module under a module.\n\nRequired fields:\n- `module_id` (int)\n- `title` (string)\n\nOptional fields:\n- `description`, `display_order`, `is_active`\n\nPermission: `subcourses.create`."
}
},
{
"name": "Update Sub-Module",
"request": {
"method": "PUT",
"header": [
{
"key": "Content-Type",
"value": "application/json"
}
],
"body": {
"mode": "raw",
"raw": "{\n \"title\": \"Sub-Module 1: Greetings (Updated)\",\n \"description\": \"Updated sub-module description\",\n \"is_active\": true\n}"
},
"url": {
"raw": "{{baseUrl}}/api/{{apiVersion}}/course-management/sub-modules/{{subModuleId}}",
"host": [
"{{baseUrl}}"
],
"path": [
"api",
"{{apiVersion}}",
"course-management",
"sub-modules",
"{{subModuleId}}"
]
},
"description": "Updates a sub-module.\n\nPath params:\n- `subModuleId` (int, required)\n\nOptional fields:\n- `title`, `description`, `is_active`\n\nPermission: `subcourses.update`."
}
},
{
"name": "Delete Sub-Module",
"request": {
"method": "DELETE",
"header": [],
"url": {
"raw": "{{baseUrl}}/api/{{apiVersion}}/course-management/sub-modules/{{subModuleId}}",
"host": [
"{{baseUrl}}"
],
"path": [
"api",
"{{apiVersion}}",
"course-management",
"sub-modules",
"{{subModuleId}}"
]
},
"description": "Deletes a sub-module.\n\nPath params:\n- `subModuleId` (int, required)\n\nPermission: `subcourses.delete`."
}
}
]
},
{
"name": "Sub-Module Videos",
"item": [
{
"name": "Get Sub-Module Videos",
"request": {
"method": "GET",
"header": [],
"url": {
"raw": "{{baseUrl}}/api/{{apiVersion}}/course-management/sub-modules/{{subModuleId}}/videos",
"host": [
"{{baseUrl}}"
],
"path": [
"api",
"{{apiVersion}}",
"course-management",
"sub-modules",
"{{subModuleId}}",
"videos"
]
},
"description": "Lists videos for a given sub-module.\n\nPath params:\n- `subModuleId` (int, required)\n\nPermission: `videos.list`."
}
},
{
"name": "Create Sub-Module Video",
"request": {
"method": "POST",
"header": [
{
"key": "Content-Type",
"value": "application/json"
}
],
"body": {
"mode": "raw",
"raw": "{\n \"sub_module_id\": 1,\n \"title\": \"Greeting Expressions\",\n \"description\": \"Main lesson video\",\n \"video_url\": \"https://cdn.example.com/videos/greetings.mp4\",\n \"duration\": 480,\n \"resolution\": \"1080p\",\n \"visibility\": \"public\",\n \"instructor_id\": \"instructor-123\",\n \"thumbnail\": \"https://cdn.example.com/thumbs/greetings.jpg\",\n \"display_order\": 1,\n \"status\": \"published\"\n}"
},
"url": {
"raw": "{{baseUrl}}/api/{{apiVersion}}/course-management/sub-module-videos",
"host": [
"{{baseUrl}}"
],
"path": [
"api",
"{{apiVersion}}",
"course-management",
"sub-module-videos"
]
},
"description": "Creates a video under a sub-module.\n\nRequired fields:\n- `sub_module_id` (int)\n- `title` (string)\n- `video_url` (string URL)\n\nOptional fields:\n- `description`, `duration`, `resolution`, `visibility`, `instructor_id`, `thumbnail`, `display_order`, `status`\n\nPermission: `videos.create`."
}
},
{
"name": "Update Sub-Module Video",
"request": {
"method": "PUT",
"header": [
{
"key": "Content-Type",
"value": "application/json"
}
],
"body": {
"mode": "raw",
"raw": "{\n \"title\": \"Greeting Expressions - Updated\",\n \"description\": \"Updated video description\",\n \"video_url\": \"https://cdn.example.com/videos/greetings-v2.mp4\"\n}"
},
"url": {
"raw": "{{baseUrl}}/api/{{apiVersion}}/course-management/sub-module-videos/{{videoId}}",
"host": [
"{{baseUrl}}"
],
"path": [
"api",
"{{apiVersion}}",
"course-management",
"sub-module-videos",
"{{videoId}}"
]
},
"description": "Updates a sub-module video.\n\nPath params:\n- `videoId` (int, required)\n\nRequired fields:\n- `title` (string)\n- `video_url` (string URL)\n\nOptional fields:\n- `description`\n\nPermission: `videos.update`."
}
},
{
"name": "Delete Sub-Module Video",
"request": {
"method": "DELETE",
"header": [],
"url": {
"raw": "{{baseUrl}}/api/{{apiVersion}}/course-management/sub-module-videos/{{videoId}}",
"host": [
"{{baseUrl}}"
],
"path": [
"api",
"{{apiVersion}}",
"course-management",
"sub-module-videos",
"{{videoId}}"
]
},
"description": "Deletes a sub-module video.\n\nPath params:\n- `videoId` (int, required)\n\nPermission: `videos.delete`."
}
}
]
},
{
"name": "Lessons & Practices",
"item": [
{
"name": "Attach Sub-Module Lesson (Question Set)",
"request": {
"method": "POST",
"header": [
{
"key": "Content-Type",
"value": "application/json"
}
],
"body": {
"mode": "raw",
"raw": "{\n \"sub_module_id\": 1,\n \"question_set_id\": 1,\n \"intro_video_url\": \"https://cdn.example.com/intro-lesson.mp4\",\n \"display_order\": 1,\n \"is_active\": true\n}"
},
"url": {
"raw": "{{baseUrl}}/api/{{apiVersion}}/course-management/sub-module-lessons",
"host": [
"{{baseUrl}}"
],
"path": [
"api",
"{{apiVersion}}",
"course-management",
"sub-module-lessons"
]
},
"description": "Links a question set lesson to a sub-module.\n\nRequired fields:\n- `sub_module_id` (int)\n- `question_set_id` (int)\n\nOptional fields:\n- `intro_video_url`, `display_order`, `is_active`\n\nPermission: `question_sets.update`."
}
},
{
"name": "Create Sub-Module Practice",
"request": {
"method": "POST",
"header": [
{
"key": "Content-Type",
"value": "application/json"
}
],
"body": {
"mode": "raw",
"raw": "{\n \"sub_module_id\": 1,\n \"title\": \"Practice: Basic Greetings\",\n \"description\": \"Practice set for greetings\",\n \"thumbnail\": \"https://cdn.example.com/practice-thumb.jpg\",\n \"intro_video_url\": \"https://cdn.example.com/practice-intro.mp4\",\n \"question_set_id\": 1,\n \"display_order\": 1,\n \"is_active\": true\n}"
},
"url": {
"raw": "{{baseUrl}}/api/{{apiVersion}}/course-management/sub-module-practices",
"host": [
"{{baseUrl}}"
],
"path": [
"api",
"{{apiVersion}}",
"course-management",
"sub-module-practices"
]
},
"description": "Creates a practice under a sub-module.\n\nRequired fields:\n- `sub_module_id` (int)\n- `title` (string)\n- `question_set_id` (int)\n\nOptional fields:\n- `description`, `thumbnail`, `intro_video_url`, `display_order`, `is_active`\n\nPermission: `question_sets.update`."
}
},
{
"name": "Update Practice",
"request": {
"method": "PUT",
"header": [
{
"key": "Content-Type",
"value": "application/json"
}
],
"body": {
"mode": "raw",
"raw": "{\n \"title\": \"Practice: Basic Greetings - Updated\",\n \"description\": \"Updated practice details\",\n \"persona\": \"student\",\n \"is_active\": true\n}"
},
"url": {
"raw": "{{baseUrl}}/api/{{apiVersion}}/course-management/practices/{{practiceId}}",
"host": [
"{{baseUrl}}"
],
"path": [
"api",
"{{apiVersion}}",
"course-management",
"practices",
"{{practiceId}}"
]
},
"description": "Updates a practice.\n\nPath params:\n- `practiceId` (int, required)\n\nBehavior:\n- If `is_active` is provided, this endpoint updates practice status.\n- Otherwise, `title` is required and metadata update is applied.\n\nPermission: `question_sets.update`."
}
},
{
"name": "Delete Practice",
"request": {
"method": "DELETE",
"header": [],
"url": {
"raw": "{{baseUrl}}/api/{{apiVersion}}/course-management/practices/{{practiceId}}",
"host": [
"{{baseUrl}}"
],
"path": [
"api",
"{{apiVersion}}",
"course-management",
"practices",
"{{practiceId}}"
]
},
"description": "Deletes a practice.\n\nPath params:\n- `practiceId` (int, required)\n\nPermission: `question_sets.delete`."
}
}
]
}
]
}

View File

@ -12,10 +12,10 @@ import (
const AnalyticsCourseCounts = `-- name: AnalyticsCourseCounts :one const AnalyticsCourseCounts = `-- name: AnalyticsCourseCounts :one
SELECT SELECT
(SELECT COUNT(*)::bigint FROM course_categories) AS total_categories, 0::bigint AS total_categories,
(SELECT COUNT(*)::bigint FROM courses) AS total_courses, 0::bigint AS total_courses,
(SELECT COUNT(*)::bigint FROM sub_courses) AS total_sub_courses, 0::bigint AS total_sub_courses,
(SELECT COUNT(*)::bigint FROM sub_course_videos) AS total_videos 0::bigint AS total_videos
` `
type AnalyticsCourseCountsRow struct { type AnalyticsCourseCountsRow struct {

View File

@ -1,227 +0,0 @@
package dbgen
import (
"context"
"github.com/jackc/pgx/v5/pgtype"
)
func (q *Queries) GetSubModuleByIDCompat(ctx context.Context, id int64) (SubModule, error) {
row := q.db.QueryRow(ctx, `
SELECT id, module_id, title, description, display_order, is_active, created_at, legacy_sub_course_id, thumbnail, tips
FROM sub_modules
WHERE id = $1
`, id)
var i SubModule
err := row.Scan(
&i.ID,
&i.ModuleID,
&i.Title,
&i.Description,
&i.DisplayOrder,
&i.IsActive,
&i.CreatedAt,
&i.LegacySubCourseID,
&i.Thumbnail,
&i.Tips,
)
return i, err
}
func (q *Queries) UpdateSubModuleCompat(ctx context.Context, id int64, title string, description string, thumbnail string, tips string, displayOrder int32, isActive bool) error {
_, err := q.db.Exec(ctx, `
UPDATE sub_modules
SET
title = $1,
description = NULLIF($2, ''),
thumbnail = NULLIF($3, ''),
tips = NULLIF($4, ''),
display_order = $5,
is_active = $6
WHERE id = $7
`, title, description, thumbnail, tips, displayOrder, isActive, id)
return err
}
func (q *Queries) DeleteSubModuleCompat(ctx context.Context, id int64) error {
_, err := q.db.Exec(ctx, `DELETE FROM sub_modules WHERE id = $1`, id)
return err
}
func (q *Queries) DeleteModuleCompat(ctx context.Context, id int64) error {
_, err := q.db.Exec(ctx, `DELETE FROM modules WHERE id = $1`, id)
return err
}
func (q *Queries) UpdateSubModuleVideoCompat(ctx context.Context, id int64, title string, description string, videoURL string) error {
_, err := q.db.Exec(ctx, `
UPDATE sub_module_videos
SET
title = $1,
description = NULLIF($2, ''),
video_url = $3
WHERE id = $4
`, title, description, videoURL, id)
return err
}
func (q *Queries) DeleteSubModuleVideoCompat(ctx context.Context, id int64) error {
_, err := q.db.Exec(ctx, `DELETE FROM sub_module_videos WHERE id = $1`, id)
return err
}
func (q *Queries) UpdatePracticeCompat(ctx context.Context, id int64, title string, description string, persona string) error {
_, err := q.db.Exec(ctx, `
UPDATE question_sets
SET
title = $1,
description = NULLIF($2, ''),
persona = NULLIF($3, ''),
updated_at = CURRENT_TIMESTAMP
WHERE id = $4
`, title, description, persona, id)
if err != nil {
return err
}
_, err = q.db.Exec(ctx, `
UPDATE sub_module_practices
SET
title = $1,
description = NULLIF($2, '')
WHERE question_set_id = $3
`, title, description, id)
return err
}
func (q *Queries) UpdatePracticeStatusCompat(ctx context.Context, id int64, isActive bool) error {
status := "ARCHIVED"
if isActive {
status = "PUBLISHED"
}
_, err := q.db.Exec(ctx, `
UPDATE question_sets
SET
status = $1,
updated_at = CURRENT_TIMESTAMP
WHERE id = $2
`, status, id)
if err != nil {
return err
}
_, err = q.db.Exec(ctx, `
UPDATE sub_module_practices
SET
is_active = $1,
inactive_since = CASE
WHEN $1 THEN NULL
WHEN is_active THEN NOW()
ELSE inactive_since
END
WHERE question_set_id = $2
`, isActive, id)
return err
}
func (q *Queries) DeletePracticeCompat(ctx context.Context, id int64) error {
_, err := q.db.Exec(ctx, `DELETE FROM question_sets WHERE id = $1`, id)
return err
}
// DeleteCapstoneCompat removes the backing question set (and cascades sub_module_capstones).
func (q *Queries) DeleteCapstoneCompat(ctx context.Context, capstoneID int64) error {
_, err := q.db.Exec(ctx, `
DELETE FROM question_sets
WHERE id = (SELECT question_set_id FROM sub_module_capstones WHERE id = $1)
`, capstoneID)
return err
}
// DeleteModuleCapstoneCompat removes the backing question set (and cascades module_capstones).
func (q *Queries) DeleteModuleCapstoneCompat(ctx context.Context, capstoneID int64) error {
_, err := q.db.Exec(ctx, `
DELETE FROM question_sets
WHERE id = (SELECT question_set_id FROM module_capstones WHERE id = $1)
`, capstoneID)
return err
}
func (q *Queries) CreateCourseCompat(
ctx context.Context,
categoryID int64,
subCategoryID *int64,
title string,
description string,
thumbnail string,
introVideoURL string,
isActive bool,
) (Course, error) {
row := q.db.QueryRow(ctx, `
INSERT INTO courses (
category_id,
sub_category_id,
title,
description,
thumbnail,
intro_video_url,
is_active
)
VALUES (
$1,
$2,
$3,
NULLIF($4, ''),
NULLIF($5, ''),
NULLIF($6, ''),
$7
)
RETURNING id, category_id, title, description, is_active, thumbnail, intro_video_url, display_order, sub_category_id
`, categoryID, subCategoryID, title, description, thumbnail, introVideoURL, isActive)
var i Course
err := row.Scan(
&i.ID,
&i.CategoryID,
&i.Title,
&i.Description,
&i.IsActive,
&i.Thumbnail,
&i.IntroVideoUrl,
&i.DisplayOrder,
&i.SubCategoryID,
)
if err != nil {
return Course{}, err
}
if !i.SubCategoryID.Valid {
i.SubCategoryID = pgtype.Int8{Valid: false}
}
return i, nil
}
func (q *Queries) DeleteCourseSubCategoryCompat(ctx context.Context, subCategoryID int64) error {
_, err := q.db.Exec(ctx, `DELETE FROM courses WHERE sub_category_id = $1`, subCategoryID)
if err != nil {
return err
}
_, err = q.db.Exec(ctx, `DELETE FROM course_sub_categories WHERE id = $1`, subCategoryID)
return err
}
func (q *Queries) DeleteCourseCategoryCompat(ctx context.Context, categoryID int64) error {
_, err := q.db.Exec(ctx, `DELETE FROM courses WHERE category_id = $1`, categoryID)
if err != nil {
return err
}
_, err = q.db.Exec(ctx, `DELETE FROM course_sub_categories WHERE category_id = $1`, categoryID)
if err != nil {
return err
}
_, err = q.db.Exec(ctx, `DELETE FROM course_categories WHERE id = $1`, categoryID)
return err
}

View File

@ -1,158 +0,0 @@
// Code generated by sqlc. DO NOT EDIT.
// versions:
// sqlc v1.30.0
// source: course_catagories.sql
package dbgen
import (
"context"
"github.com/jackc/pgx/v5/pgtype"
)
const CreateCourseCategory = `-- name: CreateCourseCategory :one
INSERT INTO course_categories (
name,
is_active
)
VALUES ($1, COALESCE($2, true))
RETURNING id, name, is_active, created_at, display_order
`
type CreateCourseCategoryParams struct {
Name string `json:"name"`
Column2 interface{} `json:"column_2"`
}
func (q *Queries) CreateCourseCategory(ctx context.Context, arg CreateCourseCategoryParams) (CourseCategory, error) {
row := q.db.QueryRow(ctx, CreateCourseCategory, arg.Name, arg.Column2)
var i CourseCategory
err := row.Scan(
&i.ID,
&i.Name,
&i.IsActive,
&i.CreatedAt,
&i.DisplayOrder,
)
return i, err
}
const DeleteCourseCategory = `-- name: DeleteCourseCategory :exec
DELETE FROM course_categories
WHERE id = $1
`
func (q *Queries) DeleteCourseCategory(ctx context.Context, id int64) error {
_, err := q.db.Exec(ctx, DeleteCourseCategory, id)
return err
}
const GetAllCourseCategories = `-- name: GetAllCourseCategories :many
SELECT
COUNT(*) OVER () AS total_count,
id,
name,
is_active,
created_at
FROM course_categories
ORDER BY display_order ASC, created_at DESC
LIMIT $2::INT
OFFSET $1::INT
`
type GetAllCourseCategoriesParams struct {
Offset pgtype.Int4 `json:"offset"`
Limit pgtype.Int4 `json:"limit"`
}
type GetAllCourseCategoriesRow struct {
TotalCount int64 `json:"total_count"`
ID int64 `json:"id"`
Name string `json:"name"`
IsActive bool `json:"is_active"`
CreatedAt pgtype.Timestamptz `json:"created_at"`
}
func (q *Queries) GetAllCourseCategories(ctx context.Context, arg GetAllCourseCategoriesParams) ([]GetAllCourseCategoriesRow, error) {
rows, err := q.db.Query(ctx, GetAllCourseCategories, arg.Offset, arg.Limit)
if err != nil {
return nil, err
}
defer rows.Close()
var items []GetAllCourseCategoriesRow
for rows.Next() {
var i GetAllCourseCategoriesRow
if err := rows.Scan(
&i.TotalCount,
&i.ID,
&i.Name,
&i.IsActive,
&i.CreatedAt,
); err != nil {
return nil, err
}
items = append(items, i)
}
if err := rows.Err(); err != nil {
return nil, err
}
return items, nil
}
const GetCourseCategoryByID = `-- name: GetCourseCategoryByID :one
SELECT id, name, is_active, created_at, display_order
FROM course_categories
WHERE id = $1
`
func (q *Queries) GetCourseCategoryByID(ctx context.Context, id int64) (CourseCategory, error) {
row := q.db.QueryRow(ctx, GetCourseCategoryByID, id)
var i CourseCategory
err := row.Scan(
&i.ID,
&i.Name,
&i.IsActive,
&i.CreatedAt,
&i.DisplayOrder,
)
return i, err
}
const ReorderCourseCategories = `-- name: ReorderCourseCategories :exec
UPDATE course_categories
SET display_order = bulk.position
FROM (
SELECT unnest($1::BIGINT[]) AS id, unnest($2::INT[]) AS position
) AS bulk
WHERE course_categories.id = bulk.id
`
type ReorderCourseCategoriesParams struct {
Ids []int64 `json:"ids"`
Positions []int32 `json:"positions"`
}
func (q *Queries) ReorderCourseCategories(ctx context.Context, arg ReorderCourseCategoriesParams) error {
_, err := q.db.Exec(ctx, ReorderCourseCategories, arg.Ids, arg.Positions)
return err
}
const UpdateCourseCategory = `-- name: UpdateCourseCategory :exec
UPDATE course_categories
SET
name = COALESCE($1, name),
is_active = COALESCE($2, is_active)
WHERE id = $3
`
type UpdateCourseCategoryParams struct {
Name string `json:"name"`
IsActive bool `json:"is_active"`
ID int64 `json:"id"`
}
func (q *Queries) UpdateCourseCategory(ctx context.Context, arg UpdateCourseCategoryParams) error {
_, err := q.db.Exec(ctx, UpdateCourseCategory, arg.Name, arg.IsActive, arg.ID)
return err
}

View File

@ -1,401 +0,0 @@
// Code generated by sqlc. DO NOT EDIT.
// versions:
// sqlc v1.30.0
// source: courses.sql
package dbgen
import (
"context"
"github.com/jackc/pgx/v5/pgtype"
)
const CreateCourse = `-- name: CreateCourse :one
INSERT INTO courses (
category_id,
title,
description,
thumbnail,
intro_video_url,
is_active
)
VALUES ($1, $2, $3, $4, $5, COALESCE($6, true))
RETURNING id, category_id, title, description, is_active, thumbnail, intro_video_url, display_order, sub_category_id
`
type CreateCourseParams struct {
CategoryID int64 `json:"category_id"`
Title string `json:"title"`
Description pgtype.Text `json:"description"`
Thumbnail pgtype.Text `json:"thumbnail"`
IntroVideoUrl pgtype.Text `json:"intro_video_url"`
Column6 interface{} `json:"column_6"`
}
func (q *Queries) CreateCourse(ctx context.Context, arg CreateCourseParams) (Course, error) {
row := q.db.QueryRow(ctx, CreateCourse,
arg.CategoryID,
arg.Title,
arg.Description,
arg.Thumbnail,
arg.IntroVideoUrl,
arg.Column6,
)
var i Course
err := row.Scan(
&i.ID,
&i.CategoryID,
&i.Title,
&i.Description,
&i.IsActive,
&i.Thumbnail,
&i.IntroVideoUrl,
&i.DisplayOrder,
&i.SubCategoryID,
)
return i, err
}
const DeleteCourse = `-- name: DeleteCourse :exec
DELETE FROM courses
WHERE id = $1
`
func (q *Queries) DeleteCourse(ctx context.Context, id int64) error {
_, err := q.db.Exec(ctx, DeleteCourse, id)
return err
}
const GetAllCourses = `-- name: GetAllCourses :many
SELECT
COUNT(*) OVER () AS total_count,
c.id,
c.category_id,
c.sub_category_id,
c.title,
c.description,
c.thumbnail,
c.intro_video_url,
c.is_active
FROM courses c
ORDER BY c.display_order ASC, c.id ASC
LIMIT $2::INT
OFFSET $1::INT
`
type GetAllCoursesParams struct {
Offset pgtype.Int4 `json:"offset"`
Limit pgtype.Int4 `json:"limit"`
}
type GetAllCoursesRow struct {
TotalCount int64 `json:"total_count"`
ID int64 `json:"id"`
CategoryID int64 `json:"category_id"`
SubCategoryID pgtype.Int8 `json:"sub_category_id"`
Title string `json:"title"`
Description pgtype.Text `json:"description"`
Thumbnail pgtype.Text `json:"thumbnail"`
IntroVideoUrl pgtype.Text `json:"intro_video_url"`
IsActive bool `json:"is_active"`
}
func (q *Queries) GetAllCourses(ctx context.Context, arg GetAllCoursesParams) ([]GetAllCoursesRow, error) {
rows, err := q.db.Query(ctx, GetAllCourses, arg.Offset, arg.Limit)
if err != nil {
return nil, err
}
defer rows.Close()
var items []GetAllCoursesRow
for rows.Next() {
var i GetAllCoursesRow
if err := rows.Scan(
&i.TotalCount,
&i.ID,
&i.CategoryID,
&i.SubCategoryID,
&i.Title,
&i.Description,
&i.Thumbnail,
&i.IntroVideoUrl,
&i.IsActive,
); err != nil {
return nil, err
}
items = append(items, i)
}
if err := rows.Err(); err != nil {
return nil, err
}
return items, nil
}
const GetCourseByID = `-- name: GetCourseByID :one
SELECT id, category_id, title, description, is_active, thumbnail, intro_video_url, display_order, sub_category_id
FROM courses
WHERE id = $1
`
func (q *Queries) GetCourseByID(ctx context.Context, id int64) (Course, error) {
row := q.db.QueryRow(ctx, GetCourseByID, id)
var i Course
err := row.Scan(
&i.ID,
&i.CategoryID,
&i.Title,
&i.Description,
&i.IsActive,
&i.Thumbnail,
&i.IntroVideoUrl,
&i.DisplayOrder,
&i.SubCategoryID,
)
return i, err
}
const GetCoursesByCategory = `-- name: GetCoursesByCategory :many
SELECT
COUNT(*) OVER () AS total_count,
id,
category_id,
title,
description,
thumbnail,
intro_video_url,
is_active
FROM courses
WHERE category_id = $1
ORDER BY display_order ASC, id ASC
LIMIT $3::INT
OFFSET $2::INT
`
type GetCoursesByCategoryParams struct {
CategoryID int64 `json:"category_id"`
Offset pgtype.Int4 `json:"offset"`
Limit pgtype.Int4 `json:"limit"`
}
type GetCoursesByCategoryRow struct {
TotalCount int64 `json:"total_count"`
ID int64 `json:"id"`
CategoryID int64 `json:"category_id"`
Title string `json:"title"`
Description pgtype.Text `json:"description"`
Thumbnail pgtype.Text `json:"thumbnail"`
IntroVideoUrl pgtype.Text `json:"intro_video_url"`
IsActive bool `json:"is_active"`
}
func (q *Queries) GetCoursesByCategory(ctx context.Context, arg GetCoursesByCategoryParams) ([]GetCoursesByCategoryRow, error) {
rows, err := q.db.Query(ctx, GetCoursesByCategory, arg.CategoryID, arg.Offset, arg.Limit)
if err != nil {
return nil, err
}
defer rows.Close()
var items []GetCoursesByCategoryRow
for rows.Next() {
var i GetCoursesByCategoryRow
if err := rows.Scan(
&i.TotalCount,
&i.ID,
&i.CategoryID,
&i.Title,
&i.Description,
&i.Thumbnail,
&i.IntroVideoUrl,
&i.IsActive,
); err != nil {
return nil, err
}
items = append(items, i)
}
if err := rows.Err(); err != nil {
return nil, err
}
return items, nil
}
const GetCoursesBySubCategory = `-- name: GetCoursesBySubCategory :many
SELECT
COUNT(*) OVER () AS total_count,
c.id,
c.category_id,
c.sub_category_id,
c.title,
c.description,
c.thumbnail,
c.intro_video_url,
c.is_active
FROM courses c
WHERE c.sub_category_id = $1
ORDER BY c.display_order ASC, c.id ASC
LIMIT $3::INT
OFFSET $2::INT
`
type GetCoursesBySubCategoryParams struct {
SubCategoryID pgtype.Int8 `json:"sub_category_id"`
Offset pgtype.Int4 `json:"offset"`
Limit pgtype.Int4 `json:"limit"`
}
type GetCoursesBySubCategoryRow struct {
TotalCount int64 `json:"total_count"`
ID int64 `json:"id"`
CategoryID int64 `json:"category_id"`
SubCategoryID pgtype.Int8 `json:"sub_category_id"`
Title string `json:"title"`
Description pgtype.Text `json:"description"`
Thumbnail pgtype.Text `json:"thumbnail"`
IntroVideoUrl pgtype.Text `json:"intro_video_url"`
IsActive bool `json:"is_active"`
}
func (q *Queries) GetCoursesBySubCategory(ctx context.Context, arg GetCoursesBySubCategoryParams) ([]GetCoursesBySubCategoryRow, error) {
rows, err := q.db.Query(ctx, GetCoursesBySubCategory, arg.SubCategoryID, arg.Offset, arg.Limit)
if err != nil {
return nil, err
}
defer rows.Close()
var items []GetCoursesBySubCategoryRow
for rows.Next() {
var i GetCoursesBySubCategoryRow
if err := rows.Scan(
&i.TotalCount,
&i.ID,
&i.CategoryID,
&i.SubCategoryID,
&i.Title,
&i.Description,
&i.Thumbnail,
&i.IntroVideoUrl,
&i.IsActive,
); err != nil {
return nil, err
}
items = append(items, i)
}
if err := rows.Err(); err != nil {
return nil, err
}
return items, nil
}
const GetHumanLanguageCourses = `-- name: GetHumanLanguageCourses :many
SELECT
COUNT(*) OVER () AS total_count,
c.id,
c.category_id,
c.sub_category_id,
c.title,
c.description,
c.thumbnail,
c.intro_video_url,
c.is_active
FROM courses c
JOIN course_categories cc ON cc.id = c.category_id
WHERE lower(trim(cc.name)) = 'human language'
ORDER BY c.display_order ASC, c.id ASC
LIMIT $2::INT
OFFSET $1::INT
`
type GetHumanLanguageCoursesParams struct {
Offset pgtype.Int4 `json:"offset"`
Limit pgtype.Int4 `json:"limit"`
}
type GetHumanLanguageCoursesRow struct {
TotalCount int64 `json:"total_count"`
ID int64 `json:"id"`
CategoryID int64 `json:"category_id"`
SubCategoryID pgtype.Int8 `json:"sub_category_id"`
Title string `json:"title"`
Description pgtype.Text `json:"description"`
Thumbnail pgtype.Text `json:"thumbnail"`
IntroVideoUrl pgtype.Text `json:"intro_video_url"`
IsActive bool `json:"is_active"`
}
func (q *Queries) GetHumanLanguageCourses(ctx context.Context, arg GetHumanLanguageCoursesParams) ([]GetHumanLanguageCoursesRow, error) {
rows, err := q.db.Query(ctx, GetHumanLanguageCourses, arg.Offset, arg.Limit)
if err != nil {
return nil, err
}
defer rows.Close()
var items []GetHumanLanguageCoursesRow
for rows.Next() {
var i GetHumanLanguageCoursesRow
if err := rows.Scan(
&i.TotalCount,
&i.ID,
&i.CategoryID,
&i.SubCategoryID,
&i.Title,
&i.Description,
&i.Thumbnail,
&i.IntroVideoUrl,
&i.IsActive,
); err != nil {
return nil, err
}
items = append(items, i)
}
if err := rows.Err(); err != nil {
return nil, err
}
return items, nil
}
const ReorderCourses = `-- name: ReorderCourses :exec
UPDATE courses
SET display_order = bulk.position
FROM (
SELECT unnest($1::BIGINT[]) AS id, unnest($2::INT[]) AS position
) AS bulk
WHERE courses.id = bulk.id
`
type ReorderCoursesParams struct {
Ids []int64 `json:"ids"`
Positions []int32 `json:"positions"`
}
func (q *Queries) ReorderCourses(ctx context.Context, arg ReorderCoursesParams) error {
_, err := q.db.Exec(ctx, ReorderCourses, arg.Ids, arg.Positions)
return err
}
const UpdateCourse = `-- name: UpdateCourse :exec
UPDATE courses
SET
title = COALESCE($1, title),
description = COALESCE($2, description),
thumbnail = COALESCE($3, thumbnail),
intro_video_url = COALESCE($4, intro_video_url),
is_active = COALESCE($5, is_active)
WHERE id = $6
`
type UpdateCourseParams struct {
Title string `json:"title"`
Description pgtype.Text `json:"description"`
Thumbnail pgtype.Text `json:"thumbnail"`
IntroVideoUrl pgtype.Text `json:"intro_video_url"`
IsActive bool `json:"is_active"`
ID int64 `json:"id"`
}
func (q *Queries) UpdateCourse(ctx context.Context, arg UpdateCourseParams) error {
_, err := q.db.Exec(ctx, UpdateCourse,
arg.Title,
arg.Description,
arg.Thumbnail,
arg.IntroVideoUrl,
arg.IsActive,
arg.ID,
)
return err
}

File diff suppressed because it is too large Load Diff

View File

@ -22,36 +22,6 @@ type ActivityLog struct {
CreatedAt pgtype.Timestamptz `json:"created_at"` CreatedAt pgtype.Timestamptz `json:"created_at"`
} }
type Course struct {
ID int64 `json:"id"`
CategoryID int64 `json:"category_id"`
Title string `json:"title"`
Description pgtype.Text `json:"description"`
IsActive bool `json:"is_active"`
Thumbnail pgtype.Text `json:"thumbnail"`
IntroVideoUrl pgtype.Text `json:"intro_video_url"`
DisplayOrder int32 `json:"display_order"`
SubCategoryID pgtype.Int8 `json:"sub_category_id"`
}
type CourseCategory struct {
ID int64 `json:"id"`
Name string `json:"name"`
IsActive bool `json:"is_active"`
CreatedAt pgtype.Timestamptz `json:"created_at"`
DisplayOrder int32 `json:"display_order"`
}
type CourseSubCategory struct {
ID int64 `json:"id"`
CategoryID int64 `json:"category_id"`
Name string `json:"name"`
Description pgtype.Text `json:"description"`
IsActive bool `json:"is_active"`
DisplayOrder int32 `json:"display_order"`
CreatedAt pgtype.Timestamptz `json:"created_at"`
}
type Device struct { type Device struct {
ID int64 `json:"id"` ID int64 `json:"id"`
UserID int64 `json:"user_id"` UserID int64 `json:"user_id"`
@ -69,47 +39,11 @@ type GlobalSetting struct {
UpdatedAt pgtype.Timestamptz `json:"updated_at"` UpdatedAt pgtype.Timestamptz `json:"updated_at"`
} }
type Level struct {
ID int64 `json:"id"`
CourseID int64 `json:"course_id"`
CefrLevel string `json:"cefr_level"`
DisplayOrder int32 `json:"display_order"`
IsActive bool `json:"is_active"`
CreatedAt pgtype.Timestamptz `json:"created_at"`
Title string `json:"title"`
Description pgtype.Text `json:"description"`
Thumbnail pgtype.Text `json:"thumbnail"`
}
type LevelToSubCourse struct { type LevelToSubCourse struct {
LevelID int64 `json:"level_id"` LevelID int64 `json:"level_id"`
SubCourseID int64 `json:"sub_course_id"` SubCourseID int64 `json:"sub_course_id"`
} }
type Module struct {
ID int64 `json:"id"`
LevelID int64 `json:"level_id"`
Title string `json:"title"`
Description pgtype.Text `json:"description"`
DisplayOrder int32 `json:"display_order"`
IsActive bool `json:"is_active"`
CreatedAt pgtype.Timestamptz `json:"created_at"`
IconUrl pgtype.Text `json:"icon_url"`
}
type ModuleCapstone struct {
ID int64 `json:"id"`
ModuleID int64 `json:"module_id"`
Title string `json:"title"`
Description pgtype.Text `json:"description"`
Tips pgtype.Text `json:"tips"`
Thumbnail pgtype.Text `json:"thumbnail"`
QuestionSetID int64 `json:"question_set_id"`
DisplayOrder int32 `json:"display_order"`
IsActive bool `json:"is_active"`
CreatedAt pgtype.Timestamptz `json:"created_at"`
}
type ModuleToSubCourse struct { type ModuleToSubCourse struct {
ModuleID int64 `json:"module_id"` ModuleID int64 `json:"module_id"`
SubCourseID int64 `json:"sub_course_id"` SubCourseID int64 `json:"sub_course_id"`
@ -171,6 +105,15 @@ type Permission struct {
CreatedAt pgtype.Timestamptz `json:"created_at"` CreatedAt pgtype.Timestamptz `json:"created_at"`
} }
type Program struct {
ID int64 `json:"id"`
Name string `json:"name"`
Description pgtype.Text `json:"description"`
Thumbnail pgtype.Text `json:"thumbnail"`
CreatedAt pgtype.Timestamptz `json:"created_at"`
UpdatedAt pgtype.Timestamptz `json:"updated_at"`
}
type Question struct { type Question struct {
ID int64 `json:"id"` ID int64 `json:"id"`
QuestionText string `json:"question_text"` QuestionText string `json:"question_text"`
@ -218,7 +161,6 @@ type QuestionSet struct {
Status string `json:"status"` Status string `json:"status"`
CreatedAt pgtype.Timestamptz `json:"created_at"` CreatedAt pgtype.Timestamptz `json:"created_at"`
UpdatedAt pgtype.Timestamptz `json:"updated_at"` UpdatedAt pgtype.Timestamptz `json:"updated_at"`
SubCourseVideoID pgtype.Int8 `json:"sub_course_video_id"`
DisplayOrder int32 `json:"display_order"` DisplayOrder int32 `json:"display_order"`
IntroVideoUrl pgtype.Text `json:"intro_video_url"` IntroVideoUrl pgtype.Text `json:"intro_video_url"`
} }
@ -315,132 +257,6 @@ type ScheduledNotification struct {
UpdatedAt pgtype.Timestamptz `json:"updated_at"` UpdatedAt pgtype.Timestamptz `json:"updated_at"`
} }
type SubCourse struct {
ID int64 `json:"id"`
CourseID int64 `json:"course_id"`
Title string `json:"title"`
Description pgtype.Text `json:"description"`
Thumbnail pgtype.Text `json:"thumbnail"`
DisplayOrder int32 `json:"display_order"`
Level string `json:"level"`
IsActive bool `json:"is_active"`
SubLevel string `json:"sub_level"`
}
type SubCoursePrerequisite struct {
ID int64 `json:"id"`
SubCourseID int64 `json:"sub_course_id"`
PrerequisiteSubCourseID int64 `json:"prerequisite_sub_course_id"`
CreatedAt pgtype.Timestamptz `json:"created_at"`
}
type SubCourseVideo struct {
ID int64 `json:"id"`
SubCourseID int64 `json:"sub_course_id"`
Title string `json:"title"`
Description pgtype.Text `json:"description"`
VideoUrl string `json:"video_url"`
Duration int32 `json:"duration"`
Resolution pgtype.Text `json:"resolution"`
IsPublished bool `json:"is_published"`
PublishDate pgtype.Timestamptz `json:"publish_date"`
Visibility pgtype.Text `json:"visibility"`
InstructorID pgtype.Text `json:"instructor_id"`
Thumbnail pgtype.Text `json:"thumbnail"`
DisplayOrder int32 `json:"display_order"`
Status string `json:"status"`
// Vimeo video ID for videos hosted on Vimeo
VimeoID pgtype.Text `json:"vimeo_id"`
// Vimeo player embed URL
VimeoEmbedUrl pgtype.Text `json:"vimeo_embed_url"`
// Vimeo iframe embed HTML code
VimeoPlayerHtml pgtype.Text `json:"vimeo_player_html"`
// Vimeo video status: pending, uploading, transcoding, available, error
VimeoStatus pgtype.Text `json:"vimeo_status"`
// Video hosting provider: DIRECT or VIMEO
VideoHostProvider pgtype.Text `json:"video_host_provider"`
}
type SubModule struct {
ID int64 `json:"id"`
ModuleID int64 `json:"module_id"`
Title string `json:"title"`
Description pgtype.Text `json:"description"`
DisplayOrder int32 `json:"display_order"`
IsActive bool `json:"is_active"`
CreatedAt pgtype.Timestamptz `json:"created_at"`
LegacySubCourseID pgtype.Int8 `json:"legacy_sub_course_id"`
Thumbnail pgtype.Text `json:"thumbnail"`
Tips pgtype.Text `json:"tips"`
}
type SubModuleCapstone struct {
ID int64 `json:"id"`
SubModuleID int64 `json:"sub_module_id"`
Title string `json:"title"`
Description pgtype.Text `json:"description"`
Tips pgtype.Text `json:"tips"`
Thumbnail pgtype.Text `json:"thumbnail"`
QuestionSetID int64 `json:"question_set_id"`
DisplayOrder int32 `json:"display_order"`
IsActive bool `json:"is_active"`
CreatedAt pgtype.Timestamptz `json:"created_at"`
InactiveSince pgtype.Timestamptz `json:"inactive_since"`
}
type SubModuleLesson struct {
ID int64 `json:"id"`
SubModuleID int64 `json:"sub_module_id"`
DisplayOrder int32 `json:"display_order"`
IsActive bool `json:"is_active"`
CreatedAt pgtype.Timestamptz `json:"created_at"`
Title string `json:"title"`
Description pgtype.Text `json:"description"`
Thumbnail pgtype.Text `json:"thumbnail"`
TeachingText pgtype.Text `json:"teaching_text"`
TeachingImageUrl pgtype.Text `json:"teaching_image_url"`
TeachingAudioUrl pgtype.Text `json:"teaching_audio_url"`
TeachingVideoUrl pgtype.Text `json:"teaching_video_url"`
InactiveSince pgtype.Timestamptz `json:"inactive_since"`
}
type SubModulePractice struct {
ID int64 `json:"id"`
SubModuleID int64 `json:"sub_module_id"`
QuestionSetID int64 `json:"question_set_id"`
IntroVideoUrl pgtype.Text `json:"intro_video_url"`
DisplayOrder int32 `json:"display_order"`
IsActive bool `json:"is_active"`
CreatedAt pgtype.Timestamptz `json:"created_at"`
Title string `json:"title"`
Description pgtype.Text `json:"description"`
Thumbnail pgtype.Text `json:"thumbnail"`
InactiveSince pgtype.Timestamptz `json:"inactive_since"`
}
type SubModuleVideo struct {
ID int64 `json:"id"`
SubModuleID int64 `json:"sub_module_id"`
Title string `json:"title"`
Description pgtype.Text `json:"description"`
VideoUrl string `json:"video_url"`
Duration pgtype.Int4 `json:"duration"`
Resolution pgtype.Text `json:"resolution"`
IsPublished bool `json:"is_published"`
PublishDate pgtype.Timestamptz `json:"publish_date"`
Visibility pgtype.Text `json:"visibility"`
InstructorID pgtype.Text `json:"instructor_id"`
Thumbnail pgtype.Text `json:"thumbnail"`
DisplayOrder int32 `json:"display_order"`
Status string `json:"status"`
VimeoID pgtype.Text `json:"vimeo_id"`
VimeoEmbedUrl pgtype.Text `json:"vimeo_embed_url"`
VimeoPlayerHtml pgtype.Text `json:"vimeo_player_html"`
VimeoStatus pgtype.Text `json:"vimeo_status"`
VideoHostProvider pgtype.Text `json:"video_host_provider"`
CreatedAt pgtype.Timestamptz `json:"created_at"`
}
type SubscriptionPlan struct { type SubscriptionPlan struct {
ID int64 `json:"id"` ID int64 `json:"id"`
Name string `json:"name"` Name string `json:"name"`
@ -540,33 +356,10 @@ type UserAudioResponse struct {
type UserPracticeProgress struct { type UserPracticeProgress struct {
ID int64 `json:"id"` ID int64 `json:"id"`
UserID int64 `json:"user_id"` UserID int64 `json:"user_id"`
SubCourseID pgtype.Int8 `json:"sub_course_id"`
QuestionSetID int64 `json:"question_set_id"` QuestionSetID int64 `json:"question_set_id"`
CompletedAt pgtype.Timestamp `json:"completed_at"`
CreatedAt pgtype.Timestamp `json:"created_at"`
UpdatedAt pgtype.Timestamp `json:"updated_at"`
}
type UserSubCourseProgress struct {
ID int64 `json:"id"`
UserID int64 `json:"user_id"`
SubCourseID int64 `json:"sub_course_id"`
Status string `json:"status"`
ProgressPercentage int16 `json:"progress_percentage"`
StartedAt pgtype.Timestamptz `json:"started_at"`
CompletedAt pgtype.Timestamptz `json:"completed_at"` CompletedAt pgtype.Timestamptz `json:"completed_at"`
CreatedAt pgtype.Timestamptz `json:"created_at"`
UpdatedAt pgtype.Timestamptz `json:"updated_at"` UpdatedAt pgtype.Timestamptz `json:"updated_at"`
} CreatedAt pgtype.Timestamptz `json:"created_at"`
type UserSubCourseVideoProgress struct {
ID int64 `json:"id"`
UserID int64 `json:"user_id"`
SubCourseID int64 `json:"sub_course_id"`
VideoID int64 `json:"video_id"`
CompletedAt pgtype.Timestamp `json:"completed_at"`
CreatedAt pgtype.Timestamp `json:"created_at"`
UpdatedAt pgtype.Timestamp `json:"updated_at"`
} }
type UserSubscription struct { type UserSubscription struct {

View File

@ -59,26 +59,16 @@ func (q *Queries) GetFirstIncompletePreviousPractice(ctx context.Context, arg Ge
const MarkPracticeCompleted = `-- name: MarkPracticeCompleted :execrows const MarkPracticeCompleted = `-- name: MarkPracticeCompleted :execrows
INSERT INTO user_practice_progress ( INSERT INTO user_practice_progress (
user_id, user_id,
sub_course_id,
question_set_id, question_set_id,
completed_at, completed_at,
updated_at updated_at
) )
SELECT VALUES (
$1::BIGINT, $1::BIGINT,
CASE $2::BIGINT,
WHEN qs.owner_type = 'SUB_COURSE' THEN qs.owner_id
WHEN qs.owner_type = 'SUB_MODULE' THEN sm.legacy_sub_course_id
ELSE NULL
END,
qs.id,
CURRENT_TIMESTAMP, CURRENT_TIMESTAMP,
CURRENT_TIMESTAMP CURRENT_TIMESTAMP
FROM question_sets qs )
LEFT JOIN sub_modules sm
ON qs.owner_type = 'SUB_MODULE'
AND qs.owner_id = sm.id
WHERE qs.id = $2::BIGINT
ON CONFLICT (user_id, question_set_id) DO UPDATE ON CONFLICT (user_id, question_set_id) DO UPDATE
SET completed_at = EXCLUDED.completed_at, SET completed_at = EXCLUDED.completed_at,
updated_at = EXCLUDED.updated_at updated_at = EXCLUDED.updated_at

162
gen/db/programs.sql.go Normal file
View File

@ -0,0 +1,162 @@
// Code generated by sqlc. DO NOT EDIT.
// versions:
// sqlc v1.30.0
// source: programs.sql
package dbgen
import (
"context"
"github.com/jackc/pgx/v5/pgtype"
)
const CreateProgram = `-- name: CreateProgram :one
INSERT INTO programs (name, description, thumbnail)
VALUES ($1, $2, $3)
RETURNING id, name, description, thumbnail, created_at, updated_at
`
type CreateProgramParams struct {
Name string `json:"name"`
Description pgtype.Text `json:"description"`
Thumbnail pgtype.Text `json:"thumbnail"`
}
func (q *Queries) CreateProgram(ctx context.Context, arg CreateProgramParams) (Program, error) {
row := q.db.QueryRow(ctx, CreateProgram, arg.Name, arg.Description, arg.Thumbnail)
var i Program
err := row.Scan(
&i.ID,
&i.Name,
&i.Description,
&i.Thumbnail,
&i.CreatedAt,
&i.UpdatedAt,
)
return i, err
}
const DeleteProgram = `-- name: DeleteProgram :exec
DELETE FROM programs
WHERE id = $1
`
func (q *Queries) DeleteProgram(ctx context.Context, id int64) error {
_, err := q.db.Exec(ctx, DeleteProgram, id)
return err
}
const GetProgramByID = `-- name: GetProgramByID :one
SELECT id, name, description, thumbnail, created_at, updated_at
FROM programs
WHERE id = $1
`
func (q *Queries) GetProgramByID(ctx context.Context, id int64) (Program, error) {
row := q.db.QueryRow(ctx, GetProgramByID, id)
var i Program
err := row.Scan(
&i.ID,
&i.Name,
&i.Description,
&i.Thumbnail,
&i.CreatedAt,
&i.UpdatedAt,
)
return i, err
}
const ListPrograms = `-- name: ListPrograms :many
SELECT
COUNT(*) OVER () AS total_count,
p.id,
p.name,
p.description,
p.thumbnail,
p.created_at,
p.updated_at
FROM programs p
ORDER BY p.created_at DESC
LIMIT $1 OFFSET $2
`
type ListProgramsParams struct {
Limit int32 `json:"limit"`
Offset int32 `json:"offset"`
}
type ListProgramsRow struct {
TotalCount int64 `json:"total_count"`
ID int64 `json:"id"`
Name string `json:"name"`
Description pgtype.Text `json:"description"`
Thumbnail pgtype.Text `json:"thumbnail"`
CreatedAt pgtype.Timestamptz `json:"created_at"`
UpdatedAt pgtype.Timestamptz `json:"updated_at"`
}
func (q *Queries) ListPrograms(ctx context.Context, arg ListProgramsParams) ([]ListProgramsRow, error) {
rows, err := q.db.Query(ctx, ListPrograms, arg.Limit, arg.Offset)
if err != nil {
return nil, err
}
defer rows.Close()
var items []ListProgramsRow
for rows.Next() {
var i ListProgramsRow
if err := rows.Scan(
&i.TotalCount,
&i.ID,
&i.Name,
&i.Description,
&i.Thumbnail,
&i.CreatedAt,
&i.UpdatedAt,
); err != nil {
return nil, err
}
items = append(items, i)
}
if err := rows.Err(); err != nil {
return nil, err
}
return items, nil
}
const UpdateProgram = `-- name: UpdateProgram :one
UPDATE programs
SET
name = COALESCE($1::varchar, name),
description = COALESCE($2::text, description),
thumbnail = COALESCE($3::text, thumbnail),
updated_at = CURRENT_TIMESTAMP
WHERE id = $4
RETURNING id, name, description, thumbnail, created_at, updated_at
`
type UpdateProgramParams struct {
Name pgtype.Text `json:"name"`
Description pgtype.Text `json:"description"`
Thumbnail pgtype.Text `json:"thumbnail"`
ID int64 `json:"id"`
}
func (q *Queries) UpdateProgram(ctx context.Context, arg UpdateProgramParams) (Program, error) {
row := q.db.QueryRow(ctx, UpdateProgram,
arg.Name,
arg.Description,
arg.Thumbnail,
arg.ID,
)
var i Program
err := row.Scan(
&i.ID,
&i.Name,
&i.Description,
&i.Thumbnail,
&i.CreatedAt,
&i.UpdatedAt,
)
return i, err
}

View File

@ -295,7 +295,7 @@ func (q *Queries) GetQuestionSetItemsPaginated(ctx context.Context, arg GetQuest
} }
const GetQuestionSetsContainingQuestion = `-- name: GetQuestionSetsContainingQuestion :many const GetQuestionSetsContainingQuestion = `-- name: GetQuestionSetsContainingQuestion :many
SELECT qs.id, qs.title, qs.description, qs.set_type, qs.owner_type, qs.owner_id, qs.banner_image, qs.persona, qs.time_limit_minutes, qs.passing_score, qs.shuffle_questions, qs.status, qs.created_at, qs.updated_at, qs.sub_course_video_id, qs.display_order, qs.intro_video_url SELECT qs.id, qs.title, qs.description, qs.set_type, qs.owner_type, qs.owner_id, qs.banner_image, qs.persona, qs.time_limit_minutes, qs.passing_score, qs.shuffle_questions, qs.status, qs.created_at, qs.updated_at, qs.display_order, qs.intro_video_url
FROM question_sets qs FROM question_sets qs
JOIN question_set_items qsi ON qsi.set_id = qs.id JOIN question_set_items qsi ON qsi.set_id = qs.id
WHERE qsi.question_id = $1 WHERE qsi.question_id = $1
@ -326,7 +326,6 @@ func (q *Queries) GetQuestionSetsContainingQuestion(ctx context.Context, questio
&i.Status, &i.Status,
&i.CreatedAt, &i.CreatedAt,
&i.UpdatedAt, &i.UpdatedAt,
&i.SubCourseVideoID,
&i.DisplayOrder, &i.DisplayOrder,
&i.IntroVideoUrl, &i.IntroVideoUrl,
); err != nil { ); err != nil {

View File

@ -64,11 +64,10 @@ INSERT INTO question_sets (
passing_score, passing_score,
shuffle_questions, shuffle_questions,
status, status,
sub_course_video_id,
intro_video_url intro_video_url
) )
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, COALESCE($10, false), COALESCE($11, 'DRAFT'), $12, $13) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, COALESCE($10, false), COALESCE($11, 'DRAFT'), $12)
RETURNING id, title, description, set_type, owner_type, owner_id, banner_image, persona, time_limit_minutes, passing_score, shuffle_questions, status, created_at, updated_at, sub_course_video_id, display_order, intro_video_url RETURNING id, title, description, set_type, owner_type, owner_id, banner_image, persona, time_limit_minutes, passing_score, shuffle_questions, status, created_at, updated_at, display_order, intro_video_url
` `
type CreateQuestionSetParams struct { type CreateQuestionSetParams struct {
@ -83,7 +82,6 @@ type CreateQuestionSetParams struct {
PassingScore pgtype.Int4 `json:"passing_score"` PassingScore pgtype.Int4 `json:"passing_score"`
Column10 interface{} `json:"column_10"` Column10 interface{} `json:"column_10"`
Column11 interface{} `json:"column_11"` Column11 interface{} `json:"column_11"`
SubCourseVideoID pgtype.Int8 `json:"sub_course_video_id"`
IntroVideoUrl pgtype.Text `json:"intro_video_url"` IntroVideoUrl pgtype.Text `json:"intro_video_url"`
} }
@ -100,7 +98,6 @@ func (q *Queries) CreateQuestionSet(ctx context.Context, arg CreateQuestionSetPa
arg.PassingScore, arg.PassingScore,
arg.Column10, arg.Column10,
arg.Column11, arg.Column11,
arg.SubCourseVideoID,
arg.IntroVideoUrl, arg.IntroVideoUrl,
) )
var i QuestionSet var i QuestionSet
@ -119,7 +116,6 @@ func (q *Queries) CreateQuestionSet(ctx context.Context, arg CreateQuestionSetPa
&i.Status, &i.Status,
&i.CreatedAt, &i.CreatedAt,
&i.UpdatedAt, &i.UpdatedAt,
&i.SubCourseVideoID,
&i.DisplayOrder, &i.DisplayOrder,
&i.IntroVideoUrl, &i.IntroVideoUrl,
) )
@ -137,7 +133,7 @@ func (q *Queries) DeleteQuestionSet(ctx context.Context, id int64) error {
} }
const GetInitialAssessmentSet = `-- name: GetInitialAssessmentSet :one const GetInitialAssessmentSet = `-- name: GetInitialAssessmentSet :one
SELECT id, title, description, set_type, owner_type, owner_id, banner_image, persona, time_limit_minutes, passing_score, shuffle_questions, status, created_at, updated_at, sub_course_video_id, display_order, intro_video_url SELECT id, title, description, set_type, owner_type, owner_id, banner_image, persona, time_limit_minutes, passing_score, shuffle_questions, status, created_at, updated_at, display_order, intro_video_url
FROM question_sets FROM question_sets
WHERE set_type = 'INITIAL_ASSESSMENT' WHERE set_type = 'INITIAL_ASSESSMENT'
AND status = 'PUBLISHED' AND status = 'PUBLISHED'
@ -163,7 +159,6 @@ func (q *Queries) GetInitialAssessmentSet(ctx context.Context) (QuestionSet, err
&i.Status, &i.Status,
&i.CreatedAt, &i.CreatedAt,
&i.UpdatedAt, &i.UpdatedAt,
&i.SubCourseVideoID,
&i.DisplayOrder, &i.DisplayOrder,
&i.IntroVideoUrl, &i.IntroVideoUrl,
) )
@ -171,7 +166,7 @@ func (q *Queries) GetInitialAssessmentSet(ctx context.Context) (QuestionSet, err
} }
const GetPublishedQuestionSetsByOwner = `-- name: GetPublishedQuestionSetsByOwner :many const GetPublishedQuestionSetsByOwner = `-- name: GetPublishedQuestionSetsByOwner :many
SELECT id, title, description, set_type, owner_type, owner_id, banner_image, persona, time_limit_minutes, passing_score, shuffle_questions, status, created_at, updated_at, sub_course_video_id, display_order, intro_video_url SELECT id, title, description, set_type, owner_type, owner_id, banner_image, persona, time_limit_minutes, passing_score, shuffle_questions, status, created_at, updated_at, display_order, intro_video_url
FROM question_sets FROM question_sets
WHERE owner_type = $1 WHERE owner_type = $1
AND owner_id = $2 AND owner_id = $2
@ -208,7 +203,6 @@ func (q *Queries) GetPublishedQuestionSetsByOwner(ctx context.Context, arg GetPu
&i.Status, &i.Status,
&i.CreatedAt, &i.CreatedAt,
&i.UpdatedAt, &i.UpdatedAt,
&i.SubCourseVideoID,
&i.DisplayOrder, &i.DisplayOrder,
&i.IntroVideoUrl, &i.IntroVideoUrl,
); err != nil { ); err != nil {
@ -223,7 +217,7 @@ func (q *Queries) GetPublishedQuestionSetsByOwner(ctx context.Context, arg GetPu
} }
const GetQuestionSetByID = `-- name: GetQuestionSetByID :one const GetQuestionSetByID = `-- name: GetQuestionSetByID :one
SELECT id, title, description, set_type, owner_type, owner_id, banner_image, persona, time_limit_minutes, passing_score, shuffle_questions, status, created_at, updated_at, sub_course_video_id, display_order, intro_video_url SELECT id, title, description, set_type, owner_type, owner_id, banner_image, persona, time_limit_minutes, passing_score, shuffle_questions, status, created_at, updated_at, display_order, intro_video_url
FROM question_sets FROM question_sets
WHERE id = $1 WHERE id = $1
` `
@ -246,7 +240,6 @@ func (q *Queries) GetQuestionSetByID(ctx context.Context, id int64) (QuestionSet
&i.Status, &i.Status,
&i.CreatedAt, &i.CreatedAt,
&i.UpdatedAt, &i.UpdatedAt,
&i.SubCourseVideoID,
&i.DisplayOrder, &i.DisplayOrder,
&i.IntroVideoUrl, &i.IntroVideoUrl,
) )
@ -254,7 +247,7 @@ func (q *Queries) GetQuestionSetByID(ctx context.Context, id int64) (QuestionSet
} }
const GetQuestionSetsByOwner = `-- name: GetQuestionSetsByOwner :many const GetQuestionSetsByOwner = `-- name: GetQuestionSetsByOwner :many
SELECT id, title, description, set_type, owner_type, owner_id, banner_image, persona, time_limit_minutes, passing_score, shuffle_questions, status, created_at, updated_at, sub_course_video_id, display_order, intro_video_url SELECT id, title, description, set_type, owner_type, owner_id, banner_image, persona, time_limit_minutes, passing_score, shuffle_questions, status, created_at, updated_at, display_order, intro_video_url
FROM question_sets FROM question_sets
WHERE owner_type = $1 WHERE owner_type = $1
AND owner_id = $2 AND owner_id = $2
@ -291,7 +284,6 @@ func (q *Queries) GetQuestionSetsByOwner(ctx context.Context, arg GetQuestionSet
&i.Status, &i.Status,
&i.CreatedAt, &i.CreatedAt,
&i.UpdatedAt, &i.UpdatedAt,
&i.SubCourseVideoID,
&i.DisplayOrder, &i.DisplayOrder,
&i.IntroVideoUrl, &i.IntroVideoUrl,
); err != nil { ); err != nil {
@ -308,7 +300,7 @@ func (q *Queries) GetQuestionSetsByOwner(ctx context.Context, arg GetQuestionSet
const GetQuestionSetsByType = `-- name: GetQuestionSetsByType :many const GetQuestionSetsByType = `-- name: GetQuestionSetsByType :many
SELECT SELECT
COUNT(*) OVER () AS total_count, COUNT(*) OVER () AS total_count,
qs.id, qs.title, qs.description, qs.set_type, qs.owner_type, qs.owner_id, qs.banner_image, qs.persona, qs.time_limit_minutes, qs.passing_score, qs.shuffle_questions, qs.status, qs.created_at, qs.updated_at, qs.sub_course_video_id, qs.display_order, qs.intro_video_url qs.id, qs.title, qs.description, qs.set_type, qs.owner_type, qs.owner_id, qs.banner_image, qs.persona, qs.time_limit_minutes, qs.passing_score, qs.shuffle_questions, qs.status, qs.created_at, qs.updated_at, qs.display_order, qs.intro_video_url
FROM question_sets qs FROM question_sets qs
WHERE set_type = $1 WHERE set_type = $1
AND status != 'ARCHIVED' AND status != 'ARCHIVED'
@ -339,7 +331,6 @@ type GetQuestionSetsByTypeRow struct {
Status string `json:"status"` Status string `json:"status"`
CreatedAt pgtype.Timestamptz `json:"created_at"` CreatedAt pgtype.Timestamptz `json:"created_at"`
UpdatedAt pgtype.Timestamptz `json:"updated_at"` UpdatedAt pgtype.Timestamptz `json:"updated_at"`
SubCourseVideoID pgtype.Int8 `json:"sub_course_video_id"`
DisplayOrder int32 `json:"display_order"` DisplayOrder int32 `json:"display_order"`
IntroVideoUrl pgtype.Text `json:"intro_video_url"` IntroVideoUrl pgtype.Text `json:"intro_video_url"`
} }
@ -369,7 +360,6 @@ func (q *Queries) GetQuestionSetsByType(ctx context.Context, arg GetQuestionSets
&i.Status, &i.Status,
&i.CreatedAt, &i.CreatedAt,
&i.UpdatedAt, &i.UpdatedAt,
&i.SubCourseVideoID,
&i.DisplayOrder, &i.DisplayOrder,
&i.IntroVideoUrl, &i.IntroVideoUrl,
); err != nil { ); err != nil {
@ -383,42 +373,6 @@ func (q *Queries) GetQuestionSetsByType(ctx context.Context, arg GetQuestionSets
return items, nil return items, nil
} }
const GetSubCourseInitialAssessmentSet = `-- name: GetSubCourseInitialAssessmentSet :one
SELECT id, title, description, set_type, owner_type, owner_id, banner_image, persona, time_limit_minutes, passing_score, shuffle_questions, status, created_at, updated_at, sub_course_video_id, display_order, intro_video_url
FROM question_sets
WHERE set_type = 'INITIAL_ASSESSMENT'
AND owner_type = 'SUB_COURSE'
AND owner_id = $1
AND status = 'PUBLISHED'
ORDER BY created_at DESC
LIMIT 1
`
func (q *Queries) GetSubCourseInitialAssessmentSet(ctx context.Context, ownerID pgtype.Int8) (QuestionSet, error) {
row := q.db.QueryRow(ctx, GetSubCourseInitialAssessmentSet, ownerID)
var i QuestionSet
err := row.Scan(
&i.ID,
&i.Title,
&i.Description,
&i.SetType,
&i.OwnerType,
&i.OwnerID,
&i.BannerImage,
&i.Persona,
&i.TimeLimitMinutes,
&i.PassingScore,
&i.ShuffleQuestions,
&i.Status,
&i.CreatedAt,
&i.UpdatedAt,
&i.SubCourseVideoID,
&i.DisplayOrder,
&i.IntroVideoUrl,
)
return i, err
}
const GetUserPersonasByQuestionSetID = `-- name: GetUserPersonasByQuestionSetID :many const GetUserPersonasByQuestionSetID = `-- name: GetUserPersonasByQuestionSetID :many
SELECT SELECT
u.id, u.id,
@ -519,9 +473,8 @@ SET
shuffle_questions = COALESCE($7, shuffle_questions), shuffle_questions = COALESCE($7, shuffle_questions),
status = COALESCE($8, status), status = COALESCE($8, status),
intro_video_url = COALESCE($9, intro_video_url), intro_video_url = COALESCE($9, intro_video_url),
sub_course_video_id = COALESCE($10, sub_course_video_id),
updated_at = CURRENT_TIMESTAMP updated_at = CURRENT_TIMESTAMP
WHERE id = $11 WHERE id = $10
` `
type UpdateQuestionSetParams struct { type UpdateQuestionSetParams struct {
@ -534,7 +487,6 @@ type UpdateQuestionSetParams struct {
ShuffleQuestions bool `json:"shuffle_questions"` ShuffleQuestions bool `json:"shuffle_questions"`
Status string `json:"status"` Status string `json:"status"`
IntroVideoUrl pgtype.Text `json:"intro_video_url"` IntroVideoUrl pgtype.Text `json:"intro_video_url"`
SubCourseVideoID pgtype.Int8 `json:"sub_course_video_id"`
ID int64 `json:"id"` ID int64 `json:"id"`
} }
@ -549,26 +501,7 @@ func (q *Queries) UpdateQuestionSet(ctx context.Context, arg UpdateQuestionSetPa
arg.ShuffleQuestions, arg.ShuffleQuestions,
arg.Status, arg.Status,
arg.IntroVideoUrl, arg.IntroVideoUrl,
arg.SubCourseVideoID,
arg.ID, arg.ID,
) )
return err return err
} }
const UpdateQuestionSetVideoLink = `-- name: UpdateQuestionSetVideoLink :exec
UPDATE question_sets
SET
sub_course_video_id = $1,
updated_at = CURRENT_TIMESTAMP
WHERE id = $2
`
type UpdateQuestionSetVideoLinkParams struct {
SubCourseVideoID pgtype.Int8 `json:"sub_course_video_id"`
ID int64 `json:"id"`
}
func (q *Queries) UpdateQuestionSetVideoLink(ctx context.Context, arg UpdateQuestionSetVideoLinkParams) error {
_, err := q.db.Exec(ctx, UpdateQuestionSetVideoLink, arg.SubCourseVideoID, arg.ID)
return err
}

View File

@ -45,6 +45,9 @@ const (
ActionIssueCreated ActivityAction = "ISSUE_CREATED" ActionIssueCreated ActivityAction = "ISSUE_CREATED"
ActionIssueStatusUpdated ActivityAction = "ISSUE_STATUS_UPDATED" ActionIssueStatusUpdated ActivityAction = "ISSUE_STATUS_UPDATED"
ActionIssueDeleted ActivityAction = "ISSUE_DELETED" ActionIssueDeleted ActivityAction = "ISSUE_DELETED"
ActionProgramCreated ActivityAction = "PROGRAM_CREATED"
ActionProgramUpdated ActivityAction = "PROGRAM_UPDATED"
ActionProgramDeleted ActivityAction = "PROGRAM_DELETED"
) )
type ResourceType string type ResourceType string
@ -62,6 +65,7 @@ const (
ResourceQuestion ResourceType = "QUESTION" ResourceQuestion ResourceType = "QUESTION"
ResourceQuestionSet ResourceType = "QUESTION_SET" ResourceQuestionSet ResourceType = "QUESTION_SET"
ResourceIssue ResourceType = "ISSUE" ResourceIssue ResourceType = "ISSUE"
ResourceProgram ResourceType = "PROGRAM"
) )
type ActivityLog struct { type ActivityLog struct {

View File

@ -1,170 +0,0 @@
package domain
import "time"
type SubCourseLevel string
const (
SubCourseLevelBeginner SubCourseLevel = "BEGINNER"
SubCourseLevelIntermediate SubCourseLevel = "INTERMEDIATE"
SubCourseLevelAdvanced SubCourseLevel = "ADVANCED"
)
type SubCourseSubLevel string
const (
SubCourseSubLevelA1 SubCourseSubLevel = "A1"
SubCourseSubLevelA2 SubCourseSubLevel = "A2"
SubCourseSubLevelA3 SubCourseSubLevel = "A3"
SubCourseSubLevelB1 SubCourseSubLevel = "B1"
SubCourseSubLevelB2 SubCourseSubLevel = "B2"
SubCourseSubLevelB3 SubCourseSubLevel = "B3"
SubCourseSubLevelC1 SubCourseSubLevel = "C1"
SubCourseSubLevelC2 SubCourseSubLevel = "C2"
SubCourseSubLevelC3 SubCourseSubLevel = "C3"
)
type ContentStatus string
const (
ContentStatusDraft ContentStatus = "DRAFT"
ContentStatusPublished ContentStatus = "PUBLISHED"
ContentStatusInactive ContentStatus = "INACTIVE"
ContentStatusArchived ContentStatus = "ARCHIVED"
)
type TreeSubCourse struct {
ID int64
Title string
Level string
SubLevel string
}
type TreeCourse struct {
ID int64
Title string
SubCourses []TreeSubCourse
}
type CourseCategory struct {
ID int64
Name string
IsActive bool
CreatedAt time.Time
}
type Course struct {
ID int64
CategoryID int64
Title string
Description *string
Thumbnail *string
IntroVideoURL *string
IsActive bool
}
type SubCourse struct {
ID int64
CourseID int64
Title string
Description *string
Thumbnail *string
DisplayOrder int32
Level string
SubLevel string
IsActive bool
}
type SubCourseVideo struct {
ID int64
SubCourseID int64
Title string
Description *string
VideoURL string
Duration int32
Resolution *string
InstructorID *string
Thumbnail *string
Visibility *string
DisplayOrder int32
IsPublished bool
PublishDate *time.Time
Status string
// Vimeo-specific fields
VimeoID *string
VimeoEmbedURL *string
VimeoPlayerHTML *string
VimeoStatus *string
}
type VideoHostProvider string
const (
VideoHostProviderDirect VideoHostProvider = "DIRECT"
VideoHostProviderVimeo VideoHostProvider = "VIMEO"
)
type VideoAccessBlock struct {
VideoID int64
Title string
DisplayOrder int32
}
// Learning Path types — full nested structure for a course
type LearningPathVideo struct {
ID int64 `json:"id"`
Title string `json:"title"`
Description *string `json:"description,omitempty"`
VideoURL string `json:"video_url"`
Duration int32 `json:"duration"`
Resolution *string `json:"resolution,omitempty"`
DisplayOrder int32 `json:"display_order"`
VimeoID *string `json:"vimeo_id,omitempty"`
VimeoEmbedURL *string `json:"vimeo_embed_url,omitempty"`
VideoHostProvider *string `json:"video_host_provider,omitempty"`
}
type LearningPathPractice struct {
ID int64 `json:"id"`
Title string `json:"title"`
Description *string `json:"description,omitempty"`
Persona *string `json:"persona,omitempty"`
Status string `json:"status"`
IntroVideoURL *string `json:"intro_video_url,omitempty"`
QuestionCount int64 `json:"question_count"`
}
type LearningPathPrerequisite struct {
SubCourseID int64 `json:"sub_course_id"`
Title string `json:"title"`
Level string `json:"level"`
SubLevel string `json:"sub_level"`
}
type LearningPathSubCourse struct {
ID int64 `json:"id"`
Title string `json:"title"`
Description *string `json:"description,omitempty"`
Thumbnail *string `json:"thumbnail,omitempty"`
Tips *string `json:"tips,omitempty"`
DisplayOrder int32 `json:"display_order"`
Level string `json:"level"`
SubLevel string `json:"sub_level"`
PrerequisiteCount int64 `json:"prerequisite_count"`
VideoCount int64 `json:"video_count"`
PracticeCount int64 `json:"practice_count"`
Prerequisites []LearningPathPrerequisite `json:"prerequisites"`
Videos []LearningPathVideo `json:"videos"`
Practices []LearningPathPractice `json:"practices"`
}
type LearningPath struct {
CourseID int64 `json:"course_id"`
CourseTitle string `json:"course_title"`
Description *string `json:"description,omitempty"`
Thumbnail *string `json:"thumbnail,omitempty"`
IntroVideoURL *string `json:"intro_video_url,omitempty"`
CategoryID int64 `json:"category_id"`
CategoryName string `json:"category_name"`
SubCourses []LearningPathSubCourse `json:"sub_courses"`
}

View File

@ -0,0 +1,25 @@
package domain
import "time"
// Program is the top-level container in the LMS hierarchy (e.g. tracks like Beginner / Intermediate / Advanced).
type Program struct {
ID int64 `json:"id"`
Name string `json:"name"`
Description *string `json:"description,omitempty"`
Thumbnail *string `json:"thumbnail,omitempty"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt *time.Time `json:"updated_at,omitempty"`
}
type CreateProgramInput struct {
Name string `json:"name" validate:"required"`
Description *string `json:"description,omitempty"`
Thumbnail *string `json:"thumbnail,omitempty"`
}
type UpdateProgramInput struct {
Name *string `json:"name,omitempty"`
Description *string `json:"description,omitempty"`
Thumbnail *string `json:"thumbnail,omitempty"`
}

View File

@ -1,84 +0,0 @@
package domain
import (
"errors"
"time"
)
var (
ErrPrerequisiteNotMet = errors.New("prerequisites not completed")
ErrProgressNotFound = errors.New("progress record not found")
ErrPrerequisiteExists = errors.New("prerequisite already exists")
ErrSelfPrerequisite = errors.New("sub-course cannot be its own prerequisite")
ErrSubCourseAlreadyStarted = errors.New("sub-course already started")
)
type SubCoursePrerequisite struct {
ID int64
SubCourseID int64
PrerequisiteSubCourseID int64
PrerequisiteTitle string
PrerequisiteLevel string
PrerequisiteDisplayOrder int32
CreatedAt time.Time
}
type SubCourseDependent struct {
ID int64
SubCourseID int64
PrerequisiteSubCourseID int64
DependentTitle string
DependentLevel string
CreatedAt time.Time
}
type ProgressStatus string
const (
ProgressStatusNotStarted ProgressStatus = "NOT_STARTED"
ProgressStatusInProgress ProgressStatus = "IN_PROGRESS"
ProgressStatusCompleted ProgressStatus = "COMPLETED"
)
type UserSubCourseProgress struct {
ID int64
UserID int64
SubCourseID int64
Status ProgressStatus
ProgressPercentage int16
StartedAt *time.Time
CompletedAt *time.Time
CreatedAt time.Time
UpdatedAt *time.Time
}
type SubCourseWithProgress struct {
SubCourseID int64
Title string
Description *string
Thumbnail *string
DisplayOrder int32
Level string
IsActive bool
ProgressStatus ProgressStatus
ProgressPercentage int16
StartedAt *time.Time
CompletedAt *time.Time
UnmetPrerequisitesCount int64
IsLocked bool
}
type UserCourseProgressItem struct {
ID int64
UserID int64
SubCourseID int64
Status ProgressStatus
ProgressPercentage int16
StartedAt *time.Time
CompletedAt *time.Time
CreatedAt time.Time
UpdatedAt *time.Time
SubCourseTitle string
SubCourseLevel string
SubCourseDisplayOrder int32
}

View File

@ -104,7 +104,6 @@ type QuestionSet struct {
PassingScore *int32 PassingScore *int32
ShuffleQuestions bool ShuffleQuestions bool
Status string Status string
SubCourseVideoID *int64
IntroVideoURL *string IntroVideoURL *string
UserPersonas []UserPersona UserPersonas []UserPersona
CreatedAt time.Time CreatedAt time.Time
@ -173,7 +172,6 @@ type CreateQuestionSetInput struct {
PassingScore *int32 PassingScore *int32
ShuffleQuestions *bool ShuffleQuestions *bool
Status *string Status *string
SubCourseVideoID *int64
IntroVideoURL *string IntroVideoURL *string
} }

View File

@ -1,216 +0,0 @@
package ports
import (
"Yimaru-Backend/internal/domain"
"context"
)
type CourseStore interface {
// Course Categories
CreateCourseCategory(
ctx context.Context,
name string,
) (domain.CourseCategory, error)
GetCourseCategoryByID(
ctx context.Context,
id int64,
) (domain.CourseCategory, error)
GetAllCourseCategories(
ctx context.Context,
limit int32,
offset int32,
) ([]domain.CourseCategory, int64, error)
UpdateCourseCategory(
ctx context.Context,
id int64,
name *string,
isActive *bool,
) error
DeleteCourseCategory(
ctx context.Context,
id int64,
) error
// Courses
CreateCourse(
ctx context.Context,
categoryID int64,
title string,
description *string,
thumbnail *string,
introVideoURL *string,
) (domain.Course, error)
GetCourseByID(
ctx context.Context,
id int64,
) (domain.Course, error)
GetCoursesByCategory(
ctx context.Context,
categoryID int64,
limit int32,
offset int32,
) ([]domain.Course, int64, error)
UpdateCourse(
ctx context.Context,
id int64,
title *string,
description *string,
thumbnail *string,
introVideoURL *string,
isActive *bool,
) error
DeleteCourse(
ctx context.Context,
id int64,
) error
// Sub-courses
CreateSubCourse(
ctx context.Context,
courseID int64,
title string,
description *string,
thumbnail *string,
displayOrder *int32,
level string,
subLevel string,
) (domain.SubCourse, error)
GetSubCourseByID(
ctx context.Context,
id int64,
) (domain.SubCourse, error)
GetSubCoursesByCourse(
ctx context.Context,
courseID int64,
) ([]domain.SubCourse, int64, error)
ListSubCoursesByCourse(
ctx context.Context,
courseID int64,
) ([]domain.SubCourse, error)
ListActiveSubCourses(
ctx context.Context,
) ([]domain.SubCourse, error)
UpdateSubCourse(
ctx context.Context,
id int64,
title *string,
description *string,
thumbnail *string,
displayOrder *int32,
level *string,
subLevel *string,
isActive *bool,
) error
DeactivateSubCourse(
ctx context.Context,
id int64,
) error
DeleteSubCourse(
ctx context.Context,
id int64,
) (domain.SubCourse, error)
// Sub-course Videos
CreateSubCourseVideo(
ctx context.Context,
subCourseID int64,
title string,
description *string,
videoURL string,
duration int32,
resolution *string,
instructorID *string,
thumbnail *string,
visibility *string,
displayOrder *int32,
status *string,
vimeoID *string,
vimeoEmbedURL *string,
vimeoPlayerHTML *string,
vimeoStatus *string,
videoHostProvider *string,
) (domain.SubCourseVideo, error)
GetSubCourseVideoByID(
ctx context.Context,
id int64,
) (domain.SubCourseVideo, error)
GetVideosBySubCourse(
ctx context.Context,
subCourseID int64,
) ([]domain.SubCourseVideo, int64, error)
GetPublishedVideosBySubCourse(
ctx context.Context,
subCourseID int64,
) ([]domain.SubCourseVideo, error)
GetFirstIncompletePreviousVideo(
ctx context.Context,
userID int64,
videoID int64,
) (*domain.VideoAccessBlock, error)
MarkVideoCompleted(
ctx context.Context,
userID int64,
videoID int64,
) error
PublishSubCourseVideo(
ctx context.Context,
videoID int64,
) error
UpdateSubCourseVideo(
ctx context.Context,
id int64,
title *string,
description *string,
videoURL *string,
duration *int32,
resolution *string,
visibility *string,
thumbnail *string,
displayOrder *int32,
status *string,
) error
ArchiveSubCourseVideo(
ctx context.Context,
id int64,
) error
DeleteSubCourseVideo(
ctx context.Context,
id int64,
) error
// Vimeo integration
UpdateVimeoStatus(ctx context.Context, videoID int64, status string) error
GetVideoByVimeoID(ctx context.Context, vimeoID string) (domain.SubCourseVideo, error)
// Learning Tree
GetFullLearningTree(ctx context.Context) ([]domain.TreeCourse, error)
// Learning Path (full nested structure for a course)
GetCourseLearningPath(ctx context.Context, courseID int64) (domain.LearningPath, error)
// Reorder (drag-and-drop support)
ReorderCourseCategories(ctx context.Context, ids []int64, positions []int32) error
ReorderCourses(ctx context.Context, ids []int64, positions []int32) error
ReorderSubCourses(ctx context.Context, ids []int64, positions []int32) error
ReorderSubCourseVideos(ctx context.Context, ids []int64, positions []int32) error
ReorderQuestionSets(ctx context.Context, ids []int64, positions []int32) error
}
type ProgressionStore interface {
// Prerequisites (admin)
AddSubCoursePrerequisite(ctx context.Context, subCourseID, prerequisiteSubCourseID int64) error
RemoveSubCoursePrerequisite(ctx context.Context, subCourseID, prerequisiteSubCourseID int64) error
GetSubCoursePrerequisites(ctx context.Context, subCourseID int64) ([]domain.SubCoursePrerequisite, error)
GetSubCourseDependents(ctx context.Context, prerequisiteSubCourseID int64) ([]domain.SubCourseDependent, error)
CountUnmetPrerequisites(ctx context.Context, subCourseID, userID int64) (int64, error)
DeleteAllPrerequisitesForSubCourse(ctx context.Context, subCourseID int64) error
// User progress
StartSubCourseProgress(ctx context.Context, userID, subCourseID int64) (domain.UserSubCourseProgress, error)
UpdateSubCourseProgress(ctx context.Context, userID, subCourseID int64, percentage int16) error
CompleteSubCourse(ctx context.Context, userID, subCourseID int64) error
RecalculateSubCourseProgress(ctx context.Context, userID, subCourseID int64) error
GetUserSubCourseProgress(ctx context.Context, userID, subCourseID int64) (domain.UserSubCourseProgress, error)
GetUserCourseProgress(ctx context.Context, userID, courseID int64) ([]domain.UserCourseProgressItem, error)
GetSubCoursesWithProgressByCourse(ctx context.Context, userID, courseID int64) ([]domain.SubCourseWithProgress, error)
}

14
internal/ports/program.go Normal file
View File

@ -0,0 +1,14 @@
package ports
import (
"Yimaru-Backend/internal/domain"
"context"
)
type ProgramStore interface {
CreateProgram(ctx context.Context, input domain.CreateProgramInput) (domain.Program, error)
GetProgramByID(ctx context.Context, id int64) (domain.Program, error)
ListPrograms(ctx context.Context, limit, offset int32) ([]domain.Program, int64, error)
UpdateProgram(ctx context.Context, id int64, input domain.UpdateProgramInput) (domain.Program, error)
DeleteProgram(ctx context.Context, id int64) error
}

View File

@ -37,7 +37,6 @@ type QuestionStore interface {
GetQuestionSetsByType(ctx context.Context, setType string, limit, offset int32) ([]domain.QuestionSet, int64, error) GetQuestionSetsByType(ctx context.Context, setType string, limit, offset int32) ([]domain.QuestionSet, int64, error)
GetPublishedQuestionSetsByOwner(ctx context.Context, ownerType string, ownerID int64) ([]domain.QuestionSet, error) GetPublishedQuestionSetsByOwner(ctx context.Context, ownerType string, ownerID int64) ([]domain.QuestionSet, error)
GetInitialAssessmentSet(ctx context.Context) (domain.QuestionSet, error) GetInitialAssessmentSet(ctx context.Context) (domain.QuestionSet, error)
GetSubCourseInitialAssessmentSet(ctx context.Context, subCourseID int64) (domain.QuestionSet, error)
GetFirstIncompletePreviousPractice(ctx context.Context, userID int64, questionSetID int64) (*domain.PracticeAccessBlock, error) GetFirstIncompletePreviousPractice(ctx context.Context, userID int64, questionSetID int64) (*domain.PracticeAccessBlock, error)
MarkPracticeCompleted(ctx context.Context, userID int64, questionSetID int64) error MarkPracticeCompleted(ctx context.Context, userID int64, questionSetID int64) error
UpdateQuestionSet(ctx context.Context, id int64, input domain.CreateQuestionSetInput) error UpdateQuestionSet(ctx context.Context, id int64, input domain.CreateQuestionSetInput) error

View File

@ -1,128 +0,0 @@
package repository
import (
dbgen "Yimaru-Backend/gen/db"
"Yimaru-Backend/internal/domain"
"context"
"github.com/jackc/pgx/v5/pgtype"
)
func NewCourseStore(s *Store) *Store { return s }
func NewProgressionStore(s *Store) *Store { return s }
func (s *Store) CreateCourseCategory(
ctx context.Context,
name string,
) (domain.CourseCategory, error) {
row, err := s.queries.CreateCourseCategory(ctx, dbgen.CreateCourseCategoryParams{
Name: name,
Column2: true,
})
if err != nil {
return domain.CourseCategory{}, err
}
return domain.CourseCategory{
ID: row.ID,
Name: row.Name,
IsActive: row.IsActive,
CreatedAt: row.CreatedAt.Time,
}, nil
}
func (s *Store) GetCourseCategoryByID(
ctx context.Context,
id int64,
) (domain.CourseCategory, error) {
row, err := s.queries.GetCourseCategoryByID(ctx, id)
if err != nil {
return domain.CourseCategory{}, err
}
return domain.CourseCategory{
ID: row.ID,
Name: row.Name,
IsActive: row.IsActive,
CreatedAt: row.CreatedAt.Time,
}, nil
}
func (s *Store) GetAllCourseCategories(
ctx context.Context,
limit int32,
offset int32,
) ([]domain.CourseCategory, int64, error) {
rows, err := s.queries.GetAllCourseCategories(ctx, dbgen.GetAllCourseCategoriesParams{
Limit: pgtype.Int4{Int32: limit},
Offset: pgtype.Int4{Int32: offset},
})
if err != nil {
return nil, 0, err
}
var (
categories []domain.CourseCategory
totalCount int64
)
for i, row := range rows {
if i == 0 {
totalCount = row.TotalCount
}
categories = append(categories, domain.CourseCategory{
ID: row.ID,
Name: row.Name,
IsActive: row.IsActive,
CreatedAt: row.CreatedAt.Time,
})
}
return categories, totalCount, nil
}
func (s *Store) UpdateCourseCategory(
ctx context.Context,
id int64,
name *string,
isActive *bool,
) error {
var (
nameVal string
isActiveVal bool
)
if name != nil {
nameVal = *name
}
if isActive != nil {
isActiveVal = *isActive
}
return s.queries.UpdateCourseCategory(ctx, dbgen.UpdateCourseCategoryParams{
Name: nameVal,
IsActive: isActiveVal,
ID: id,
})
}
func (s *Store) DeleteCourseCategory(
ctx context.Context,
id int64,
) error {
return s.queries.DeleteCourseCategory(ctx, id)
}
func (s *Store) ReorderCourseCategories(ctx context.Context, ids []int64, positions []int32) error {
return s.queries.ReorderCourseCategories(ctx, dbgen.ReorderCourseCategoriesParams{
Ids: ids,
Positions: positions,
})
}

View File

@ -1,173 +0,0 @@
package repository
import (
dbgen "Yimaru-Backend/gen/db"
"Yimaru-Backend/internal/domain"
"context"
"github.com/jackc/pgx/v5/pgtype"
)
func (s *Store) CreateCourse(
ctx context.Context,
categoryID int64,
title string,
description *string,
thumbnail *string,
introVideoURL *string,
) (domain.Course, error) {
var descVal, thumbVal, introVideoVal string
if description != nil {
descVal = *description
}
if thumbnail != nil {
thumbVal = *thumbnail
}
if introVideoURL != nil {
introVideoVal = *introVideoURL
}
row, err := s.queries.CreateCourse(ctx, dbgen.CreateCourseParams{
CategoryID: categoryID,
Title: title,
Description: pgtype.Text{String: descVal, Valid: description != nil},
Thumbnail: pgtype.Text{String: thumbVal, Valid: thumbnail != nil},
IntroVideoUrl: pgtype.Text{String: introVideoVal, Valid: introVideoURL != nil},
Column6: true,
})
if err != nil {
return domain.Course{}, err
}
return mapCourse(row), nil
}
func (s *Store) GetCourseByID(
ctx context.Context,
id int64,
) (domain.Course, error) {
row, err := s.queries.GetCourseByID(ctx, id)
if err != nil {
return domain.Course{}, err
}
return mapCourse(row), nil
}
func (s *Store) GetCoursesByCategory(
ctx context.Context,
categoryID int64,
limit int32,
offset int32,
) ([]domain.Course, int64, error) {
rows, err := s.queries.GetCoursesByCategory(ctx, dbgen.GetCoursesByCategoryParams{
CategoryID: categoryID,
Limit: pgtype.Int4{Int32: limit},
Offset: pgtype.Int4{Int32: offset},
})
if err != nil {
return nil, 0, err
}
var (
courses []domain.Course
totalCount int64
)
for i, row := range rows {
if i == 0 {
totalCount = row.TotalCount
}
courses = append(courses, domain.Course{
ID: row.ID,
CategoryID: row.CategoryID,
Title: row.Title,
Description: ptrText(row.Description),
Thumbnail: ptrText(row.Thumbnail),
IntroVideoURL: ptrText(row.IntroVideoUrl),
IsActive: row.IsActive,
})
}
return courses, totalCount, nil
}
func (s *Store) UpdateCourse(
ctx context.Context,
id int64,
title *string,
description *string,
thumbnail *string,
introVideoURL *string,
isActive *bool,
) error {
var (
titleVal string
descriptionVal string
thumbnailVal string
introVideoVal string
isActiveVal bool
)
if title != nil {
titleVal = *title
}
if description != nil {
descriptionVal = *description
}
if thumbnail != nil {
thumbnailVal = *thumbnail
}
if introVideoURL != nil {
introVideoVal = *introVideoURL
}
if isActive != nil {
isActiveVal = *isActive
}
return s.queries.UpdateCourse(ctx, dbgen.UpdateCourseParams{
Title: titleVal,
Description: pgtype.Text{String: descriptionVal, Valid: description != nil},
Thumbnail: pgtype.Text{String: thumbnailVal, Valid: thumbnail != nil},
IntroVideoUrl: pgtype.Text{String: introVideoVal, Valid: introVideoURL != nil},
IsActive: isActiveVal,
ID: id,
})
}
func (s *Store) DeleteCourse(
ctx context.Context,
id int64,
) error {
return s.queries.DeleteCourse(ctx, id)
}
func mapCourse(row dbgen.Course) domain.Course {
return domain.Course{
ID: row.ID,
CategoryID: row.CategoryID,
Title: row.Title,
Description: ptrText(row.Description),
Thumbnail: ptrText(row.Thumbnail),
IntroVideoURL: ptrText(row.IntroVideoUrl),
IsActive: row.IsActive,
}
}
func (s *Store) ReorderCourses(ctx context.Context, ids []int64, positions []int32) error {
return s.queries.ReorderCourses(ctx, dbgen.ReorderCoursesParams{
Ids: ids,
Positions: positions,
})
}
func ptrText(t pgtype.Text) *string {
if t.Valid {
return &t.String
}
return nil
}

View File

@ -0,0 +1,112 @@
package repository
import (
"context"
"errors"
dbgen "Yimaru-Backend/gen/db"
"Yimaru-Backend/internal/domain"
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgtype"
)
func programToDomain(p dbgen.Program) domain.Program {
out := domain.Program{
ID: p.ID,
Name: p.Name,
}
out.Description = fromPgText(p.Description)
out.Thumbnail = fromPgText(p.Thumbnail)
out.CreatedAt = p.CreatedAt.Time
if p.UpdatedAt.Valid {
t := p.UpdatedAt.Time
out.UpdatedAt = &t
}
return out
}
func (s *Store) CreateProgram(ctx context.Context, input domain.CreateProgramInput) (domain.Program, error) {
p, err := s.queries.CreateProgram(ctx, dbgen.CreateProgramParams{
Name: input.Name,
Description: toPgText(input.Description),
Thumbnail: toPgText(input.Thumbnail),
})
if err != nil {
return domain.Program{}, err
}
return programToDomain(p), nil
}
func (s *Store) GetProgramByID(ctx context.Context, id int64) (domain.Program, error) {
p, err := s.queries.GetProgramByID(ctx, id)
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return domain.Program{}, pgx.ErrNoRows
}
return domain.Program{}, err
}
return programToDomain(p), nil
}
func (s *Store) ListPrograms(ctx context.Context, limit, offset int32) ([]domain.Program, int64, error) {
rows, err := s.queries.ListPrograms(ctx, dbgen.ListProgramsParams{
Limit: limit,
Offset: offset,
})
if err != nil {
return nil, 0, err
}
if len(rows) == 0 {
return []domain.Program{}, 0, nil
}
var total int64
out := make([]domain.Program, 0, len(rows))
for i, r := range rows {
if i == 0 {
total = r.TotalCount
}
out = append(out, programToDomain(dbgen.Program{
ID: r.ID,
Name: r.Name,
Description: r.Description,
Thumbnail: r.Thumbnail,
CreatedAt: r.CreatedAt,
UpdatedAt: r.UpdatedAt,
}))
}
return out, total, nil
}
func optionalTextUpdate(val *string) pgtype.Text {
if val == nil {
return pgtype.Text{Valid: false}
}
return pgtype.Text{String: *val, Valid: true}
}
func (s *Store) UpdateProgram(ctx context.Context, id int64, input domain.UpdateProgramInput) (domain.Program, error) {
var nameText pgtype.Text
if input.Name != nil {
nameText = pgtype.Text{String: *input.Name, Valid: true}
} else {
nameText = pgtype.Text{Valid: false}
}
p, err := s.queries.UpdateProgram(ctx, dbgen.UpdateProgramParams{
ID: id,
Name: nameText,
Description: optionalTextUpdate(input.Description),
Thumbnail: optionalTextUpdate(input.Thumbnail),
})
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return domain.Program{}, pgx.ErrNoRows
}
return domain.Program{}, err
}
return programToDomain(p), nil
}
func (s *Store) DeleteProgram(ctx context.Context, id int64) error {
return s.queries.DeleteProgram(ctx, id)
}

View File

@ -123,7 +123,6 @@ func questionSetToDomain(qs dbgen.QuestionSet) domain.QuestionSet {
PassingScore: fromPgInt4(qs.PassingScore), PassingScore: fromPgInt4(qs.PassingScore),
ShuffleQuestions: qs.ShuffleQuestions, ShuffleQuestions: qs.ShuffleQuestions,
Status: qs.Status, Status: qs.Status,
SubCourseVideoID: fromPgInt8(qs.SubCourseVideoID),
IntroVideoURL: fromPgText(qs.IntroVideoUrl), IntroVideoURL: fromPgText(qs.IntroVideoUrl),
CreatedAt: qs.CreatedAt.Time, CreatedAt: qs.CreatedAt.Time,
UpdatedAt: timePtr(qs.UpdatedAt), UpdatedAt: timePtr(qs.UpdatedAt),
@ -542,7 +541,6 @@ func (s *Store) CreateQuestionSet(ctx context.Context, input domain.CreateQuesti
PassingScore: toPgInt4(input.PassingScore), PassingScore: toPgInt4(input.PassingScore),
Column10: shuffleQuestions, Column10: shuffleQuestions,
Column11: status, Column11: status,
SubCourseVideoID: toPgInt8(input.SubCourseVideoID),
IntroVideoUrl: toPgText(input.IntroVideoURL), IntroVideoUrl: toPgText(input.IntroVideoURL),
}) })
if err != nil { if err != nil {
@ -604,7 +602,6 @@ func (s *Store) GetQuestionSetsByType(ctx context.Context, setType string, limit
PassingScore: fromPgInt4(r.PassingScore), PassingScore: fromPgInt4(r.PassingScore),
ShuffleQuestions: r.ShuffleQuestions, ShuffleQuestions: r.ShuffleQuestions,
Status: r.Status, Status: r.Status,
SubCourseVideoID: fromPgInt8(r.SubCourseVideoID),
IntroVideoURL: fromPgText(r.IntroVideoUrl), IntroVideoURL: fromPgText(r.IntroVideoUrl),
CreatedAt: r.CreatedAt.Time, CreatedAt: r.CreatedAt.Time,
UpdatedAt: timePtr(r.UpdatedAt), UpdatedAt: timePtr(r.UpdatedAt),
@ -637,14 +634,6 @@ func (s *Store) GetInitialAssessmentSet(ctx context.Context) (domain.QuestionSet
return questionSetToDomain(qs), nil return questionSetToDomain(qs), nil
} }
func (s *Store) GetSubCourseInitialAssessmentSet(ctx context.Context, subCourseID int64) (domain.QuestionSet, error) {
qs, err := s.queries.GetSubCourseInitialAssessmentSet(ctx, pgtype.Int8{Int64: subCourseID, Valid: true})
if err != nil {
return domain.QuestionSet{}, err
}
return questionSetToDomain(qs), nil
}
func (s *Store) GetFirstIncompletePreviousPractice(ctx context.Context, userID int64, questionSetID int64) (*domain.PracticeAccessBlock, error) { func (s *Store) GetFirstIncompletePreviousPractice(ctx context.Context, userID int64, questionSetID int64) (*domain.PracticeAccessBlock, error) {
row, err := s.queries.GetFirstIncompletePreviousPractice(ctx, dbgen.GetFirstIncompletePreviousPracticeParams{ row, err := s.queries.GetFirstIncompletePreviousPractice(ctx, dbgen.GetFirstIncompletePreviousPracticeParams{
UserID: userID, UserID: userID,
@ -692,7 +681,6 @@ func (s *Store) UpdateQuestionSet(ctx context.Context, id int64, input domain.Cr
ShuffleQuestions: shuffleQuestions, ShuffleQuestions: shuffleQuestions,
Status: status, Status: status,
IntroVideoUrl: toPgText(input.IntroVideoURL), IntroVideoUrl: toPgText(input.IntroVideoURL),
SubCourseVideoID: toPgInt8(input.SubCourseVideoID),
}) })
} }

View File

@ -1,51 +0,0 @@
package course_management
import (
"Yimaru-Backend/internal/config"
"Yimaru-Backend/internal/ports"
cloudconvertservice "Yimaru-Backend/internal/services/cloudconvert"
notificationservice "Yimaru-Backend/internal/services/notification"
vimeoservice "Yimaru-Backend/internal/services/vimeo"
)
type Service struct {
userStore ports.UserStore
courseStore interface{}
progressionStore interface{}
notificationSvc *notificationservice.Service
vimeoSvc *vimeoservice.Service
cloudConvertSvc *cloudconvertservice.Service
config *config.Config
}
func NewService(
userStore ports.UserStore,
courseStore interface{},
progressionStore interface{},
notificationSvc *notificationservice.Service,
cfg *config.Config,
) *Service {
return &Service{
userStore: userStore,
courseStore: courseStore,
progressionStore: progressionStore,
notificationSvc: notificationSvc,
config: cfg,
}
}
func (s *Service) SetVimeoService(vimeoSvc *vimeoservice.Service) {
s.vimeoSvc = vimeoSvc
}
func (s *Service) HasVimeoService() bool {
return s.vimeoSvc != nil
}
func (s *Service) SetCloudConvertService(ccSvc *cloudconvertservice.Service) {
s.cloudConvertSvc = ccSvc
}
func (s *Service) HasCloudConvertService() bool {
return s.cloudConvertSvc != nil
}

View File

@ -0,0 +1,69 @@
package programs
import (
"Yimaru-Backend/internal/domain"
"Yimaru-Backend/internal/ports"
"context"
"errors"
"github.com/jackc/pgx/v5"
)
var ErrProgramNotFound = errors.New("program not found")
type Service struct {
store ports.ProgramStore
}
func NewService(store ports.ProgramStore) *Service {
return &Service{store: store}
}
func (s *Service) Create(ctx context.Context, input domain.CreateProgramInput) (domain.Program, error) {
return s.store.CreateProgram(ctx, input)
}
func (s *Service) GetByID(ctx context.Context, id int64) (domain.Program, error) {
p, err := s.store.GetProgramByID(ctx, id)
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return domain.Program{}, ErrProgramNotFound
}
return domain.Program{}, err
}
return p, nil
}
func (s *Service) List(ctx context.Context, limit, offset int32) ([]domain.Program, int64, error) {
if limit <= 0 {
limit = 20
}
if limit > 200 {
limit = 200
}
if offset < 0 {
offset = 0
}
return s.store.ListPrograms(ctx, limit, offset)
}
func (s *Service) Update(ctx context.Context, id int64, input domain.UpdateProgramInput) (domain.Program, error) {
p, err := s.store.UpdateProgram(ctx, id, input)
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return domain.Program{}, ErrProgramNotFound
}
return domain.Program{}, err
}
return p, nil
}
func (s *Service) Delete(ctx context.Context, id int64) error {
if _, err := s.store.GetProgramByID(ctx, id); err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return ErrProgramNotFound
}
return err
}
return s.store.DeleteProgram(ctx, id)
}

View File

@ -120,10 +120,6 @@ func (s *Service) GetInitialAssessmentSet(ctx context.Context) (domain.QuestionS
return s.questionStore.GetInitialAssessmentSet(ctx) return s.questionStore.GetInitialAssessmentSet(ctx)
} }
func (s *Service) GetSubCourseInitialAssessmentSet(ctx context.Context, subCourseID int64) (domain.QuestionSet, error) {
return s.questionStore.GetSubCourseInitialAssessmentSet(ctx, subCourseID)
}
func (s *Service) GetFirstIncompletePreviousPractice(ctx context.Context, userID int64, questionSetID int64) (*domain.PracticeAccessBlock, error) { func (s *Service) GetFirstIncompletePreviousPractice(ctx context.Context, userID int64, questionSetID int64) (*domain.PracticeAccessBlock, error) {
return s.questionStore.GetFirstIncompletePreviousPractice(ctx, userID, questionSetID) return s.questionStore.GetFirstIncompletePreviousPractice(ctx, userID, questionSetID)
} }

View File

@ -20,6 +20,13 @@ var AllPermissions = []domain.PermissionSeed{
{Key: "courses.delete", Name: "Delete Course", Description: "Delete a course", GroupName: "Courses"}, {Key: "courses.delete", Name: "Delete Course", Description: "Delete a course", GroupName: "Courses"},
{Key: "courses.reorder", Name: "Reorder Courses", Description: "Reorder courses", GroupName: "Courses"}, {Key: "courses.reorder", Name: "Reorder Courses", Description: "Reorder courses", GroupName: "Courses"},
// Programs (LMS top-level)
{Key: "programs.create", Name: "Create Program", Description: "Create a program", GroupName: "Programs"},
{Key: "programs.list", Name: "List Programs", Description: "List programs", GroupName: "Programs"},
{Key: "programs.get", Name: "Get Program", Description: "Get a program by ID", GroupName: "Programs"},
{Key: "programs.update", Name: "Update Program", Description: "Update a program", GroupName: "Programs"},
{Key: "programs.delete", Name: "Delete Program", Description: "Delete a program", GroupName: "Programs"},
// Course Management - Sub-courses // Course Management - Sub-courses
{Key: "subcourses.create", Name: "Create Sub-course", Description: "Create a new sub-course", GroupName: "Sub-courses"}, {Key: "subcourses.create", Name: "Create Sub-course", Description: "Create a new sub-course", GroupName: "Sub-courses"},
{Key: "subcourses.get", Name: "Get Sub-course", Description: "Get a sub-course by ID", GroupName: "Sub-courses"}, {Key: "subcourses.get", Name: "Get Sub-course", Description: "Get a sub-course by ID", GroupName: "Sub-courses"},
@ -243,6 +250,9 @@ var DefaultRolePermissions = map[string][]string{
"videos.list_by_subcourse", "videos.list_published", "videos.publish", "videos.update", "videos.delete", "videos.reorder", "videos.list_by_subcourse", "videos.list_published", "videos.publish", "videos.update", "videos.delete", "videos.reorder",
"learning_tree.get", "practices.reorder", "learning_tree.get", "practices.reorder",
// Programs
"programs.create", "programs.list", "programs.get", "programs.update", "programs.delete",
// Questions (full access) // Questions (full access)
"questions.create", "questions.list", "questions.search", "questions.get", "questions.update", "questions.delete", "questions.create", "questions.list", "questions.search", "questions.get", "questions.update", "questions.delete",
"question_sets.create", "question_sets.list", "question_sets.list_by_owner", "question_sets.get", "question_sets.update", "question_sets.delete", "question_sets.create", "question_sets.list", "question_sets.list_by_owner", "question_sets.get", "question_sets.update", "question_sets.delete",
@ -322,6 +332,8 @@ var DefaultRolePermissions = map[string][]string{
"videos.get", "videos.list_by_subcourse", "videos.list_published", "videos.get", "videos.list_by_subcourse", "videos.list_published",
"learning_tree.get", "learning_tree.get",
"programs.list", "programs.get",
// Questions (read + attempt) // Questions (read + attempt)
"questions.list", "questions.search", "questions.get", "questions.list", "questions.search", "questions.get",
"question_sets.list", "question_sets.list_by_owner", "question_sets.get", "question_sets.list", "question_sets.list_by_owner", "question_sets.get",
@ -418,6 +430,8 @@ var DefaultRolePermissions = map[string][]string{
"videos.get", "videos.list_by_subcourse", "videos.list_published", "videos.get", "videos.list_by_subcourse", "videos.list_published",
"learning_tree.get", "learning_tree.get",
"programs.list", "programs.get",
// Questions (read) // Questions (read)
"questions.list", "questions.search", "questions.get", "questions.list", "questions.search", "questions.get",
"question_sets.list", "question_sets.list_by_owner", "question_sets.get", "question_sets.list", "question_sets.list_by_owner", "question_sets.get",

View File

@ -8,10 +8,10 @@ import (
"Yimaru-Backend/internal/services/assessment" "Yimaru-Backend/internal/services/assessment"
"Yimaru-Backend/internal/services/authentication" "Yimaru-Backend/internal/services/authentication"
cloudconvertservice "Yimaru-Backend/internal/services/cloudconvert" cloudconvertservice "Yimaru-Backend/internal/services/cloudconvert"
"Yimaru-Backend/internal/services/course_management"
minioservice "Yimaru-Backend/internal/services/minio" minioservice "Yimaru-Backend/internal/services/minio"
issuereporting "Yimaru-Backend/internal/services/issue_reporting" issuereporting "Yimaru-Backend/internal/services/issue_reporting"
notificationservice "Yimaru-Backend/internal/services/notification" notificationservice "Yimaru-Backend/internal/services/notification"
"Yimaru-Backend/internal/services/programs"
"Yimaru-Backend/internal/services/questions" "Yimaru-Backend/internal/services/questions"
ratingsservice "Yimaru-Backend/internal/services/ratings" ratingsservice "Yimaru-Backend/internal/services/ratings"
rbacservice "Yimaru-Backend/internal/services/rbac" rbacservice "Yimaru-Backend/internal/services/rbac"
@ -35,13 +35,12 @@ import (
"github.com/bytedance/sonic" "github.com/bytedance/sonic"
"github.com/gofiber/fiber/v2" "github.com/gofiber/fiber/v2"
"github.com/gofiber/fiber/v2/middleware/cors" "github.com/gofiber/fiber/v2/middleware/cors"
"github.com/jackc/pgx/v5/pgtype"
) )
type App struct { type App struct {
assessmentSvc *assessment.Service assessmentSvc *assessment.Service
courseSvc *course_management.Service
questionsSvc *questions.Service questionsSvc *questions.Service
programSvc *programs.Service
subscriptionsSvc *subscriptions.Service subscriptionsSvc *subscriptions.Service
arifpaySvc *arifpay.ArifpayService arifpaySvc *arifpay.ArifpayService
issueReportingSvc *issuereporting.Service issueReportingSvc *issuereporting.Service
@ -68,13 +67,12 @@ type App struct {
analyticsDB *dbgen.Queries analyticsDB *dbgen.Queries
rbacSvc *rbacservice.Service rbacSvc *rbacservice.Service
stopPurgeWorker context.CancelFunc stopPurgeWorker context.CancelFunc
stopInactiveSubModuleContentPurge context.CancelFunc
} }
func NewApp( func NewApp(
assessmentSvc *assessment.Service, assessmentSvc *assessment.Service,
courseSvc *course_management.Service,
questionsSvc *questions.Service, questionsSvc *questions.Service,
programSvc *programs.Service,
subscriptionsSvc *subscriptions.Service, subscriptionsSvc *subscriptions.Service,
arifpaySvc *arifpay.ArifpayService, arifpaySvc *arifpay.ArifpayService,
issueReportingSvc *issuereporting.Service, issueReportingSvc *issuereporting.Service,
@ -117,8 +115,8 @@ func NewApp(
s := &App{ s := &App{
assessmentSvc: assessmentSvc, assessmentSvc: assessmentSvc,
courseSvc: courseSvc,
questionsSvc: questionsSvc, questionsSvc: questionsSvc,
programSvc: programSvc,
subscriptionsSvc: subscriptionsSvc, subscriptionsSvc: subscriptionsSvc,
arifpaySvc: arifpaySvc, arifpaySvc: arifpaySvc,
vimeoSvc: vimeoSvc, vimeoSvc: vimeoSvc,
@ -154,8 +152,6 @@ func NewApp(
func (a *App) Run() error { func (a *App) Run() error {
a.startAccountDeletionPurgeWorker() a.startAccountDeletionPurgeWorker()
defer a.stopAccountDeletionPurgeWorker() defer a.stopAccountDeletionPurgeWorker()
a.startInactiveSubModuleContentPurgeWorker()
defer a.stopInactiveSubModuleContentPurgeWorker()
return a.fiber.Listen(fmt.Sprintf(":%d", a.port)) return a.fiber.Listen(fmt.Sprintf(":%d", a.port))
} }
@ -220,86 +216,3 @@ func (a *App) runAccountDeletionPurgeOnce(ctx context.Context, batchSize int32)
a.logger.Info("account deletion purge run completed", "deleted_count", deletedCount, "batch_size", batchSize) a.logger.Info("account deletion purge run completed", "deleted_count", deletedCount, "batch_size", batchSize)
} }
} }
func (a *App) startInactiveSubModuleContentPurgeWorker() {
if a.cfg == nil || !a.cfg.InactiveSubModuleContentPurgeEnabled {
a.logger.Info("inactive submodule content purge worker disabled")
return
}
interval := a.cfg.InactiveSubModuleContentPurgeInterval
if interval <= 0 {
interval = 24 * time.Hour
}
retentionDays := a.cfg.InactiveSubModuleContentRetentionDays
if retentionDays < 1 {
retentionDays = 30
}
retention := time.Duration(retentionDays) * 24 * time.Hour
ctx, cancel := context.WithCancel(context.Background())
a.stopInactiveSubModuleContentPurge = cancel
a.logger.Info(
"starting inactive submodule content purge worker",
"interval", interval.String(),
"retention_days", retentionDays,
)
go func() {
a.runInactiveSubModuleContentPurgeOnce(ctx, retention)
ticker := time.NewTicker(interval)
defer ticker.Stop()
for {
select {
case <-ctx.Done():
a.logger.Info("inactive submodule content purge worker stopped")
return
case <-ticker.C:
a.runInactiveSubModuleContentPurgeOnce(ctx, retention)
}
}
}()
}
func (a *App) stopInactiveSubModuleContentPurgeWorker() {
if a.stopInactiveSubModuleContentPurge != nil {
a.stopInactiveSubModuleContentPurge()
}
}
func (a *App) runInactiveSubModuleContentPurgeOnce(ctx context.Context, retention time.Duration) {
cutoff := time.Now().Add(-retention)
cutoffParam := pgtype.Timestamptz{Time: cutoff, Valid: true}
nLessons, err := a.analyticsDB.PurgeInactiveSubModuleLessonsBefore(ctx, cutoffParam)
if err != nil {
a.logger.Error("purge inactive submodule lessons failed", "error", err)
return
}
nPractices, err := a.analyticsDB.PurgeInactiveSubModulePracticesBefore(ctx, cutoffParam)
if err != nil {
a.logger.Error("purge inactive submodule practices failed", "error", err)
return
}
nCapstones, err := a.analyticsDB.PurgeInactiveSubModuleCapstonesBefore(ctx, cutoffParam)
if err != nil {
a.logger.Error("purge inactive submodule capstones failed", "error", err)
return
}
if nLessons > 0 || nPractices > 0 || nCapstones > 0 {
a.logger.Info(
"inactive submodule content purge run completed",
"lessons_deleted", nLessons,
"practice_question_sets_deleted", nPractices,
"capstone_question_sets_deleted", nCapstones,
"cutoff", cutoff.UTC().Format(time.RFC3339),
)
}
}

View File

@ -10,13 +10,13 @@ import (
"Yimaru-Backend/internal/services/arifpay" "Yimaru-Backend/internal/services/arifpay"
"Yimaru-Backend/internal/services/assessment" "Yimaru-Backend/internal/services/assessment"
"Yimaru-Backend/internal/services/authentication" "Yimaru-Backend/internal/services/authentication"
course_management "Yimaru-Backend/internal/services/course_management"
cloudconvertservice "Yimaru-Backend/internal/services/cloudconvert" cloudconvertservice "Yimaru-Backend/internal/services/cloudconvert"
issuereporting "Yimaru-Backend/internal/services/issue_reporting" issuereporting "Yimaru-Backend/internal/services/issue_reporting"
minioservice "Yimaru-Backend/internal/services/minio" minioservice "Yimaru-Backend/internal/services/minio"
ratingsservice "Yimaru-Backend/internal/services/ratings" ratingsservice "Yimaru-Backend/internal/services/ratings"
rbacservice "Yimaru-Backend/internal/services/rbac" rbacservice "Yimaru-Backend/internal/services/rbac"
notificationservice "Yimaru-Backend/internal/services/notification" notificationservice "Yimaru-Backend/internal/services/notification"
"Yimaru-Backend/internal/services/programs"
"Yimaru-Backend/internal/services/questions" "Yimaru-Backend/internal/services/questions"
"Yimaru-Backend/internal/services/recommendation" "Yimaru-Backend/internal/services/recommendation"
"Yimaru-Backend/internal/services/subscriptions" "Yimaru-Backend/internal/services/subscriptions"
@ -38,8 +38,8 @@ import (
type Handler struct { type Handler struct {
assessmentSvc *assessment.Service assessmentSvc *assessment.Service
courseMgmtSvc *course_management.Service
questionsSvc *questions.Service questionsSvc *questions.Service
programSvc *programs.Service
subscriptionsSvc *subscriptions.Service subscriptionsSvc *subscriptions.Service
arifpaySvc *arifpay.ArifpayService arifpaySvc *arifpay.ArifpayService
logger *slog.Logger logger *slog.Logger
@ -66,8 +66,8 @@ type Handler struct {
func New( func New(
assessmentSvc *assessment.Service, assessmentSvc *assessment.Service,
courseMgmtSvc *course_management.Service,
questionsSvc *questions.Service, questionsSvc *questions.Service,
programSvc *programs.Service,
subscriptionsSvc *subscriptions.Service, subscriptionsSvc *subscriptions.Service,
arifpaySvc *arifpay.ArifpayService, arifpaySvc *arifpay.ArifpayService,
logger *slog.Logger, logger *slog.Logger,
@ -93,8 +93,8 @@ func New(
) *Handler { ) *Handler {
return &Handler{ return &Handler{
assessmentSvc: assessmentSvc, assessmentSvc: assessmentSvc,
courseMgmtSvc: courseMgmtSvc,
questionsSvc: questionsSvc, questionsSvc: questionsSvc,
programSvc: programSvc,
subscriptionsSvc: subscriptionsSvc, subscriptionsSvc: subscriptionsSvc,
arifpaySvc: arifpaySvc, arifpaySvc: arifpaySvc,
logger: logger, logger: logger,

File diff suppressed because it is too large Load Diff

View File

@ -1,277 +0,0 @@
package handlers
import (
"Yimaru-Backend/internal/domain"
"crypto/subtle"
"fmt"
"os"
"path/filepath"
"regexp"
"strings"
"github.com/gofiber/fiber/v2"
)
type resetAndReseedReq struct {
Confirm string `json:"confirm"`
}
type clearCourseManagementReq struct {
Confirm string `json:"confirm"`
}
func extractInsertStatement(sqlContent string, tableName string) (string, bool) {
pattern := fmt.Sprintf(`(?is)INSERT\s+INTO\s+%s\b.*?;`, regexp.QuoteMeta(tableName))
re := regexp.MustCompile(pattern)
statement := strings.TrimSpace(re.FindString(sqlContent))
if statement == "" {
return "", false
}
return statement, true
}
func resolveSeedDir(seedDir string) (string, error) {
cleanSeedDir := strings.TrimSpace(seedDir)
if cleanSeedDir == "" {
cleanSeedDir = "db/data"
}
// If absolute, use directly.
if filepath.IsAbs(cleanSeedDir) {
info, err := os.Stat(cleanSeedDir)
if err != nil {
return "", err
}
if !info.IsDir() {
return "", fmt.Errorf("seed dir is not a directory: %s", cleanSeedDir)
}
return cleanSeedDir, nil
}
candidates := make([]string, 0, 5)
// 1) Relative to current working directory.
candidates = append(candidates, filepath.Clean(cleanSeedDir))
// 2) Relative to executable directory (and parents).
if exePath, err := os.Executable(); err == nil {
exeDir := filepath.Dir(exePath)
candidates = append(candidates,
filepath.Join(exeDir, cleanSeedDir),
filepath.Join(exeDir, "..", cleanSeedDir),
filepath.Join(exeDir, "..", "..", cleanSeedDir),
)
}
for _, candidate := range candidates {
info, err := os.Stat(candidate)
if err != nil {
continue
}
if info.IsDir() {
return candidate, nil
}
}
return "", fmt.Errorf("seed directory not found (tried: %s)", strings.Join(candidates, ", "))
}
// ResetAndReseedDatabase godoc
// @Summary Reset and reseed database
// @Description Truncates course_categories, courses, and sub_courses. If seed SQL contains INSERTs for those tables (e.g. 007_course_management_seed.sql), they are replayed; otherwise tables are left empty after truncate.
// @Tags internal
// @Accept json
// @Produce json
// @Param X-Seed-Reset-Token header string true "Reset token configured in DB_RESET_RESEED_TOKEN"
// @Param body body resetAndReseedReq true "Confirmation payload"
// @Success 200 {object} domain.Response
// @Failure 400 {object} domain.ErrorResponse
// @Failure 403 {object} domain.ErrorResponse
// @Failure 500 {object} domain.ErrorResponse
// @Router /api/v1/internal/db/reset-reseed [post]
func (h *Handler) ResetAndReseedDatabase(c *fiber.Ctx) error {
if h.Cfg == nil || !h.Cfg.DBResetReseedEnabled {
return c.Status(fiber.StatusForbidden).JSON(domain.ErrorResponse{
Message: "Operation is disabled",
Error: "DB_RESET_RESEED_ENABLED must be set to true",
})
}
var req resetAndReseedReq
if err := c.BodyParser(&req); err != nil {
return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{
Message: "Invalid request body",
Error: err.Error(),
})
}
if strings.TrimSpace(req.Confirm) != "RESET_AND_RESEED" {
return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{
Message: "Confirmation required",
Error: `set confirm to "RESET_AND_RESEED"`,
})
}
expectedToken := strings.TrimSpace(h.Cfg.DBResetReseedToken)
if expectedToken != "" {
providedToken := strings.TrimSpace(c.Get("X-Seed-Reset-Token"))
if subtle.ConstantTimeCompare([]byte(providedToken), []byte(expectedToken)) != 1 {
return c.Status(fiber.StatusForbidden).JSON(domain.ErrorResponse{
Message: "Invalid reset token",
Error: "missing or invalid X-Seed-Reset-Token",
})
}
}
seedDir, err := resolveSeedDir(h.Cfg.DBSeedDir)
if err != nil {
return c.Status(fiber.StatusInternalServerError).JSON(domain.ErrorResponse{
Message: "Failed to resolve seed directory",
Error: err.Error(),
})
}
seedCandidates := []string{
filepath.Join(seedDir, "007_course_management_seed.sql"),
filepath.Join(seedDir, "001_initial_seed_data.sql"),
}
tableNames := []string{"course_categories", "courses", "sub_courses"}
statements := map[string]string{}
statementSource := map[string]string{}
for _, file := range seedCandidates {
contentBytes, readErr := os.ReadFile(file)
if readErr != nil {
continue
}
content := string(contentBytes)
for _, tableName := range tableNames {
if _, exists := statements[tableName]; exists {
continue
}
if stmt, ok := extractInsertStatement(content, tableName); ok {
statements[tableName] = stmt
statementSource[tableName] = file
}
}
}
missing := 0
for _, tableName := range tableNames {
if _, ok := statements[tableName]; !ok {
missing++
}
}
if missing != 0 && missing != len(tableNames) {
return c.Status(fiber.StatusInternalServerError).JSON(domain.ErrorResponse{
Message: "Incomplete course seed SQL",
Error: "seed files must define INSERT for all of course_categories, courses, and sub_courses, or none of them (truncate-only)",
})
}
var sqlBuilder strings.Builder
sqlBuilder.WriteString("BEGIN;\n")
sqlBuilder.WriteString("TRUNCATE TABLE sub_courses, courses, course_categories RESTART IDENTITY CASCADE;\n")
if missing == 0 {
for _, tableName := range tableNames {
sqlBuilder.WriteString("\n-- ")
sqlBuilder.WriteString(tableName)
sqlBuilder.WriteString(" from ")
sqlBuilder.WriteString(statementSource[tableName])
sqlBuilder.WriteString("\n")
sqlBuilder.WriteString(statements[tableName])
sqlBuilder.WriteString("\n")
}
}
sqlBuilder.WriteString("COMMIT;")
if _, err := h.analyticsDB.ExecRaw(c.Context(), sqlBuilder.String()); err != nil {
return c.Status(fiber.StatusInternalServerError).JSON(domain.ErrorResponse{
Message: "Failed to reset and reseed database",
Error: err.Error(),
})
}
msg := "Course management hierarchy reset and reseed completed successfully"
if missing == len(tableNames) {
msg = "Course management hierarchy truncated successfully (no INSERT seed configured; tables empty)"
}
return c.JSON(domain.Response{
Message: msg,
Data: map[string]interface{}{
"seed_dir": seedDir,
"tables": tableNames,
"sources": statementSource,
"reseeded": missing == 0,
"truncate_only": missing == len(tableNames),
},
Success: true,
StatusCode: fiber.StatusOK,
})
}
// ClearCourseManagementData godoc
// @Summary Clear course management hierarchy data only
// @Description Truncates course_categories, courses, and sub_courses (same scope as reset-reseed) without re-inserting seed SQL.
// @Tags internal
// @Accept json
// @Produce json
// @Param X-Seed-Reset-Token header string false "Optional token when DB_RESET_RESEED_TOKEN is set"
// @Param body body clearCourseManagementReq true "Confirmation payload"
// @Success 200 {object} domain.Response
// @Failure 400 {object} domain.ErrorResponse
// @Failure 403 {object} domain.ErrorResponse
// @Failure 500 {object} domain.ErrorResponse
// @Router /api/v1/internal/db/clear-course-management [post]
func (h *Handler) ClearCourseManagementData(c *fiber.Ctx) error {
if h.Cfg == nil || !h.Cfg.DBResetReseedEnabled {
return c.Status(fiber.StatusForbidden).JSON(domain.ErrorResponse{
Message: "Operation is disabled",
Error: "internal course management maintenance is disabled",
})
}
var req clearCourseManagementReq
if err := c.BodyParser(&req); err != nil {
return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{
Message: "Invalid request body",
Error: err.Error(),
})
}
if strings.TrimSpace(req.Confirm) != "CLEAR_COURSE_MANAGEMENT" {
return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{
Message: "Confirmation required",
Error: `set confirm to "CLEAR_COURSE_MANAGEMENT"`,
})
}
expectedToken := strings.TrimSpace(h.Cfg.DBResetReseedToken)
if expectedToken != "" {
providedToken := strings.TrimSpace(c.Get("X-Seed-Reset-Token"))
if subtle.ConstantTimeCompare([]byte(providedToken), []byte(expectedToken)) != 1 {
return c.Status(fiber.StatusForbidden).JSON(domain.ErrorResponse{
Message: "Invalid reset token",
Error: "missing or invalid X-Seed-Reset-Token",
})
}
}
tableNames := []string{"course_categories", "courses", "sub_courses"}
sql := `BEGIN;
TRUNCATE TABLE sub_courses, courses, course_categories RESTART IDENTITY CASCADE;
COMMIT;`
if _, err := h.analyticsDB.ExecRaw(c.Context(), sql); err != nil {
return c.Status(fiber.StatusInternalServerError).JSON(domain.ErrorResponse{
Message: "Failed to clear course management data",
Error: err.Error(),
})
}
return c.JSON(domain.Response{
Message: "Course management hierarchy cleared successfully (no re-seed)",
Data: map[string]interface{}{
"tables": tableNames,
},
Success: true,
StatusCode: fiber.StatusOK,
})
}

View File

@ -0,0 +1,227 @@
package handlers
import (
"Yimaru-Backend/internal/domain"
"Yimaru-Backend/internal/services/programs"
"context"
"errors"
"strconv"
"github.com/gofiber/fiber/v2"
)
// CreateProgram godoc
// @Summary Create program
// @Description Create a top-level LMS program
// @Tags programs
// @Accept json
// @Produce json
// @Param body body domain.CreateProgramInput true "Program"
// @Success 201 {object} domain.Response
// @Failure 400 {object} domain.ErrorResponse
// @Failure 500 {object} domain.ErrorResponse
// @Router /api/v1/programs [post]
func (h *Handler) CreateProgram(c *fiber.Ctx) error {
var req domain.CreateProgramInput
if err := c.BodyParser(&req); err != nil {
return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{
Message: "Invalid request body",
Error: err.Error(),
})
}
if valErrs, ok := h.validator.Validate(c, req); !ok {
return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{
Message: "Validation failed",
Error: firstValidationError(valErrs),
})
}
p, err := h.programSvc.Create(c.Context(), req)
if err != nil {
return c.Status(fiber.StatusInternalServerError).JSON(domain.ErrorResponse{
Message: "Failed to create program",
Error: err.Error(),
})
}
actorID := c.Locals("user_id").(int64)
actorRole := string(c.Locals("role").(domain.Role))
ip := c.IP()
ua := c.Get("User-Agent")
go h.activityLogSvc.RecordAction(context.Background(), &actorID, &actorRole, domain.ActionProgramCreated, domain.ResourceProgram, &p.ID, "Created program: "+p.Name, nil, &ip, &ua)
return c.Status(fiber.StatusCreated).JSON(domain.Response{
Message: "Program created successfully",
Data: p,
Success: true,
StatusCode: fiber.StatusCreated,
})
}
// ListPrograms godoc
// @Summary List programs
// @Description Paginated list of programs
// @Tags programs
// @Produce json
// @Param limit query int false "Page size" default(20)
// @Param offset query int false "Offset" default(0)
// @Success 200 {object} domain.Response
// @Failure 500 {object} domain.ErrorResponse
// @Router /api/v1/programs [get]
func (h *Handler) ListPrograms(c *fiber.Ctx) error {
limit, _ := strconv.Atoi(c.Query("limit", "20"))
offset, _ := strconv.Atoi(c.Query("offset", "0"))
items, total, err := h.programSvc.List(c.Context(), int32(limit), int32(offset))
if err != nil {
return c.Status(fiber.StatusInternalServerError).JSON(domain.ErrorResponse{
Message: "Failed to list programs",
Error: err.Error(),
})
}
return c.JSON(domain.Response{
Message: "Programs retrieved successfully",
Data: fiber.Map{
"programs": items,
"total_count": total,
"limit": limit,
"offset": offset,
},
Success: true,
StatusCode: fiber.StatusOK,
})
}
// GetProgram godoc
// @Summary Get program by ID
// @Tags programs
// @Produce json
// @Param id path int true "Program ID"
// @Success 200 {object} domain.Response
// @Failure 404 {object} domain.ErrorResponse
// @Router /api/v1/programs/{id} [get]
func (h *Handler) GetProgram(c *fiber.Ctx) error {
id, err := strconv.ParseInt(c.Params("id"), 10, 64)
if err != nil {
return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{
Message: "Invalid program id",
Error: err.Error(),
})
}
p, err := h.programSvc.GetByID(c.Context(), id)
if err != nil {
if errors.Is(err, programs.ErrProgramNotFound) {
return c.Status(fiber.StatusNotFound).JSON(domain.ErrorResponse{
Message: "Program not found",
Error: err.Error(),
})
}
return c.Status(fiber.StatusInternalServerError).JSON(domain.ErrorResponse{
Message: "Failed to load program",
Error: err.Error(),
})
}
return c.JSON(domain.Response{
Message: "Program retrieved successfully",
Data: p,
Success: true,
StatusCode: fiber.StatusOK,
})
}
// UpdateProgram godoc
// @Summary Update program
// @Tags programs
// @Accept json
// @Produce json
// @Param id path int true "Program ID"
// @Param body body domain.UpdateProgramInput true "Fields to update"
// @Success 200 {object} domain.Response
// @Failure 400 {object} domain.ErrorResponse
// @Failure 404 {object} domain.ErrorResponse
// @Router /api/v1/programs/{id} [put]
func (h *Handler) UpdateProgram(c *fiber.Ctx) error {
id, err := strconv.ParseInt(c.Params("id"), 10, 64)
if err != nil {
return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{
Message: "Invalid program id",
Error: err.Error(),
})
}
var req domain.UpdateProgramInput
if err := c.BodyParser(&req); err != nil {
return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{
Message: "Invalid request body",
Error: err.Error(),
})
}
p, err := h.programSvc.Update(c.Context(), id, req)
if err != nil {
if errors.Is(err, programs.ErrProgramNotFound) {
return c.Status(fiber.StatusNotFound).JSON(domain.ErrorResponse{
Message: "Program not found",
Error: err.Error(),
})
}
return c.Status(fiber.StatusInternalServerError).JSON(domain.ErrorResponse{
Message: "Failed to update program",
Error: err.Error(),
})
}
actorID := c.Locals("user_id").(int64)
actorRole := string(c.Locals("role").(domain.Role))
ip := c.IP()
ua := c.Get("User-Agent")
go h.activityLogSvc.RecordAction(context.Background(), &actorID, &actorRole, domain.ActionProgramUpdated, domain.ResourceProgram, &p.ID, "Updated program: "+p.Name, nil, &ip, &ua)
return c.JSON(domain.Response{
Message: "Program updated successfully",
Data: p,
Success: true,
StatusCode: fiber.StatusOK,
})
}
// DeleteProgram godoc
// @Summary Delete program
// @Tags programs
// @Param id path int true "Program ID"
// @Success 200 {object} domain.Response
// @Failure 404 {object} domain.ErrorResponse
// @Router /api/v1/programs/{id} [delete]
func (h *Handler) DeleteProgram(c *fiber.Ctx) error {
id, err := strconv.ParseInt(c.Params("id"), 10, 64)
if err != nil {
return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{
Message: "Invalid program id",
Error: err.Error(),
})
}
if err := h.programSvc.Delete(c.Context(), id); err != nil {
if errors.Is(err, programs.ErrProgramNotFound) {
return c.Status(fiber.StatusNotFound).JSON(domain.ErrorResponse{
Message: "Program not found",
Error: err.Error(),
})
}
return c.Status(fiber.StatusInternalServerError).JSON(domain.ErrorResponse{
Message: "Failed to delete program",
Error: err.Error(),
})
}
actorID := c.Locals("user_id").(int64)
actorRole := string(c.Locals("role").(domain.Role))
ip := c.IP()
ua := c.Get("User-Agent")
go h.activityLogSvc.RecordAction(context.Background(), &actorID, &actorRole, domain.ActionProgramDeleted, domain.ResourceProgram, &id, "Deleted program", nil, &ip, &ua)
return c.JSON(domain.Response{
Message: "Program deleted successfully",
Success: true,
StatusCode: fiber.StatusOK,
})
}
func firstValidationError(errs map[string]string) string {
for _, v := range errs {
return v
}
return "validation error"
}

View File

@ -524,7 +524,6 @@ type createQuestionSetReq struct {
PassingScore *int32 `json:"passing_score"` PassingScore *int32 `json:"passing_score"`
ShuffleQuestions *bool `json:"shuffle_questions"` ShuffleQuestions *bool `json:"shuffle_questions"`
Status *string `json:"status"` Status *string `json:"status"`
SubCourseVideoID *int64 `json:"sub_course_video_id"`
IntroVideoURL *string `json:"intro_video_url"` IntroVideoURL *string `json:"intro_video_url"`
} }
@ -541,7 +540,6 @@ type questionSetRes struct {
PassingScore *int32 `json:"passing_score,omitempty"` PassingScore *int32 `json:"passing_score,omitempty"`
ShuffleQuestions bool `json:"shuffle_questions"` ShuffleQuestions bool `json:"shuffle_questions"`
Status string `json:"status"` Status string `json:"status"`
SubCourseVideoID *int64 `json:"sub_course_video_id,omitempty"`
IntroVideoURL *string `json:"intro_video_url,omitempty"` IntroVideoURL *string `json:"intro_video_url,omitempty"`
CreatedAt string `json:"created_at"` CreatedAt string `json:"created_at"`
QuestionCount *int64 `json:"question_count,omitempty"` QuestionCount *int64 `json:"question_count,omitempty"`
@ -552,18 +550,20 @@ type listQuestionSetsRes struct {
TotalCount int64 `json:"total_count"` TotalCount int64 `json:"total_count"`
} }
func isSubCoursePractice(set domain.QuestionSet) bool { func isSequenceGatedPractice(set domain.QuestionSet) bool {
return strings.EqualFold(set.SetType, string(domain.QuestionSetTypePractice)) && if !strings.EqualFold(set.SetType, string(domain.QuestionSetTypePractice)) || set.OwnerType == nil {
set.OwnerType != nil && return false
strings.EqualFold(*set.OwnerType, "SUB_COURSE") }
ot := strings.ToUpper(strings.TrimSpace(*set.OwnerType))
return ot == "SUB_COURSE" || ot == "SUB_MODULE"
} }
func (h *Handler) enforcePracticeSequenceForStudent(c *fiber.Ctx, set domain.QuestionSet) error { func (h *Handler) enforcePracticeSequenceForStudent(c *fiber.Ctx, set domain.QuestionSet) error {
role := c.Locals("role").(domain.Role) role := c.Locals("role").(domain.Role)
if role != domain.RoleStudent || !isSubCoursePractice(set) { if role != domain.RoleStudent || !isSequenceGatedPractice(set) {
return nil return nil
} }
if !strings.EqualFold(set.Status, string(domain.ContentStatusPublished)) { if !strings.EqualFold(set.Status, "PUBLISHED") {
return fiber.NewError(fiber.StatusNotFound, "Practice not found") return fiber.NewError(fiber.StatusNotFound, "Practice not found")
} }
@ -607,10 +607,10 @@ func (h *Handler) CreateQuestionSet(c *fiber.Ctx) error {
} }
if strings.EqualFold(req.SetType, string(domain.QuestionSetTypeInitialAssessment)) { if strings.EqualFold(req.SetType, string(domain.QuestionSetTypeInitialAssessment)) {
if req.OwnerType == nil || !strings.EqualFold(*req.OwnerType, "SUB_COURSE") || req.OwnerID == nil { if req.OwnerType == nil || req.OwnerID == nil {
return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{ return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{
Message: "Invalid initial assessment ownership", Message: "Invalid initial assessment ownership",
Error: "INITIAL_ASSESSMENT question sets must include owner_type=SUB_COURSE and owner_id", Error: "INITIAL_ASSESSMENT question sets must include owner_type and owner_id",
}) })
} }
} }
@ -627,7 +627,6 @@ func (h *Handler) CreateQuestionSet(c *fiber.Ctx) error {
PassingScore: req.PassingScore, PassingScore: req.PassingScore,
ShuffleQuestions: req.ShuffleQuestions, ShuffleQuestions: req.ShuffleQuestions,
Status: req.Status, Status: req.Status,
SubCourseVideoID: req.SubCourseVideoID,
IntroVideoURL: req.IntroVideoURL, IntroVideoURL: req.IntroVideoURL,
} }
@ -661,67 +660,12 @@ func (h *Handler) CreateQuestionSet(c *fiber.Ctx) error {
PassingScore: set.PassingScore, PassingScore: set.PassingScore,
ShuffleQuestions: set.ShuffleQuestions, ShuffleQuestions: set.ShuffleQuestions,
Status: set.Status, Status: set.Status,
SubCourseVideoID: set.SubCourseVideoID,
IntroVideoURL: set.IntroVideoURL, IntroVideoURL: set.IntroVideoURL,
CreatedAt: set.CreatedAt.String(), CreatedAt: set.CreatedAt.String(),
}, },
}) })
} }
// GetSubCourseEntryAssessmentSet godoc
// @Summary Get entry assessment set for a sub-course
// @Description Returns the published INITIAL_ASSESSMENT question set for the given sub-course
// @Tags question-sets
// @Produce json
// @Param subCourseId path int true "Sub-course ID"
// @Success 200 {object} domain.Response
// @Failure 400 {object} domain.ErrorResponse
// @Failure 404 {object} domain.ErrorResponse
// @Failure 500 {object} domain.ErrorResponse
// @Router /api/v1/question-sets/sub-courses/{subCourseId}/entry-assessment [get]
func (h *Handler) GetSubCourseEntryAssessmentSet(c *fiber.Ctx) error {
subCourseIDStr := c.Params("subCourseId")
subCourseID, err := strconv.ParseInt(subCourseIDStr, 10, 64)
if err != nil {
return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{
Message: "Invalid sub-course ID",
Error: err.Error(),
})
}
set, err := h.questionsSvc.GetSubCourseInitialAssessmentSet(c.Context(), subCourseID)
if err != nil {
return c.Status(fiber.StatusNotFound).JSON(domain.ErrorResponse{
Message: "Entry assessment set not found for sub-course",
Error: err.Error(),
})
}
count, _ := h.questionsSvc.CountQuestionsInSet(c.Context(), set.ID)
return c.JSON(domain.Response{
Message: "Entry assessment set retrieved successfully",
Data: questionSetRes{
ID: set.ID,
Title: set.Title,
Description: set.Description,
SetType: set.SetType,
OwnerType: set.OwnerType,
OwnerID: set.OwnerID,
BannerImage: set.BannerImage,
Persona: set.Persona,
TimeLimitMinutes: set.TimeLimitMinutes,
PassingScore: set.PassingScore,
ShuffleQuestions: set.ShuffleQuestions,
Status: set.Status,
SubCourseVideoID: set.SubCourseVideoID,
IntroVideoURL: set.IntroVideoURL,
CreatedAt: set.CreatedAt.String(),
QuestionCount: &count,
},
})
}
// GetQuestionSetByID godoc // GetQuestionSetByID godoc
// @Summary Get question set by ID // @Summary Get question set by ID
// @Description Returns a question set with question count // @Description Returns a question set with question count
@ -778,7 +722,6 @@ func (h *Handler) GetQuestionSetByID(c *fiber.Ctx) error {
PassingScore: set.PassingScore, PassingScore: set.PassingScore,
ShuffleQuestions: set.ShuffleQuestions, ShuffleQuestions: set.ShuffleQuestions,
Status: set.Status, Status: set.Status,
SubCourseVideoID: set.SubCourseVideoID,
IntroVideoURL: set.IntroVideoURL, IntroVideoURL: set.IntroVideoURL,
CreatedAt: set.CreatedAt.String(), CreatedAt: set.CreatedAt.String(),
QuestionCount: &count, QuestionCount: &count,
@ -834,7 +777,6 @@ func (h *Handler) GetQuestionSetsByType(c *fiber.Ctx) error {
PassingScore: s.PassingScore, PassingScore: s.PassingScore,
ShuffleQuestions: s.ShuffleQuestions, ShuffleQuestions: s.ShuffleQuestions,
Status: s.Status, Status: s.Status,
SubCourseVideoID: s.SubCourseVideoID,
IntroVideoURL: s.IntroVideoURL, IntroVideoURL: s.IntroVideoURL,
CreatedAt: s.CreatedAt.String(), CreatedAt: s.CreatedAt.String(),
}) })
@ -901,7 +843,6 @@ func (h *Handler) GetQuestionSetsByOwner(c *fiber.Ctx) error {
PassingScore: s.PassingScore, PassingScore: s.PassingScore,
ShuffleQuestions: s.ShuffleQuestions, ShuffleQuestions: s.ShuffleQuestions,
Status: s.Status, Status: s.Status,
SubCourseVideoID: s.SubCourseVideoID,
IntroVideoURL: s.IntroVideoURL, IntroVideoURL: s.IntroVideoURL,
CreatedAt: s.CreatedAt.String(), CreatedAt: s.CreatedAt.String(),
}) })
@ -924,7 +865,6 @@ type updateQuestionSetReq struct {
PassingScore *int32 `json:"passing_score"` PassingScore *int32 `json:"passing_score"`
ShuffleQuestions *bool `json:"shuffle_questions"` ShuffleQuestions *bool `json:"shuffle_questions"`
Status *string `json:"status"` Status *string `json:"status"`
SubCourseVideoID *int64 `json:"sub_course_video_id"`
IntroVideoURL *string `json:"intro_video_url"` IntroVideoURL *string `json:"intro_video_url"`
} }
@ -972,7 +912,6 @@ func (h *Handler) UpdateQuestionSet(c *fiber.Ctx) error {
PassingScore: req.PassingScore, PassingScore: req.PassingScore,
ShuffleQuestions: req.ShuffleQuestions, ShuffleQuestions: req.ShuffleQuestions,
Status: req.Status, Status: req.Status,
SubCourseVideoID: req.SubCourseVideoID,
IntroVideoURL: req.IntroVideoURL, IntroVideoURL: req.IntroVideoURL,
} }
@ -1319,7 +1258,7 @@ func (h *Handler) CompletePractice(c *fiber.Ctx) error {
Error: err.Error(), Error: err.Error(),
}) })
} }
if !isSubCoursePractice(set) || !strings.EqualFold(set.Status, string(domain.ContentStatusPublished)) { if !isSequenceGatedPractice(set) || !strings.EqualFold(set.Status, "PUBLISHED") {
return c.Status(fiber.StatusNotFound).JSON(domain.ErrorResponse{ return c.Status(fiber.StatusNotFound).JSON(domain.ErrorResponse{
Message: "Practice not found", Message: "Practice not found",
}) })

View File

@ -14,8 +14,8 @@ import (
func (a *App) initAppRoutes() { func (a *App) initAppRoutes() {
h := handlers.New( h := handlers.New(
a.assessmentSvc, a.assessmentSvc,
a.courseSvc,
a.questionsSvc, a.questionsSvc,
a.programSvc,
a.subscriptionsSvc, a.subscriptionsSvc,
a.arifpaySvc, a.arifpaySvc,
a.logger, a.logger,
@ -67,6 +67,13 @@ 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.RequirePermission("programs.list"), h.ListPrograms)
groupV1.Get("/programs/:id", a.authMiddleware, 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)
// File storage (MinIO) // File storage (MinIO)
groupV1.Get("/files/url", a.authMiddleware, h.GetFileURL) groupV1.Get("/files/url", a.authMiddleware, h.GetFileURL)
groupV1.Post("/files/upload", a.authMiddleware, h.UploadMedia) groupV1.Post("/files/upload", a.authMiddleware, h.UploadMedia)
@ -78,68 +85,6 @@ func (a *App) initAppRoutes() {
groupV1.Get("/assessment/questions", h.ListAssessmentQuestions) groupV1.Get("/assessment/questions", h.ListAssessmentQuestions)
groupV1.Get("/assessment/questions/:id", h.GetAssessmentQuestionByID) groupV1.Get("/assessment/questions/:id", h.GetAssessmentQuestionByID)
// Unified Course Management (single hierarchy model)
groupV1.Get("/course-management/categories", a.authMiddleware, a.RequirePermission("learning_tree.get"), h.ListCourseCategories)
groupV1.Get("/course-management/categories/:categoryId/sub-categories", a.authMiddleware, a.RequirePermission("learning_tree.get"), h.ListCourseSubCategoriesByCategory)
groupV1.Get("/course-management/courses", a.authMiddleware, a.RequirePermission("learning_tree.get"), h.ListAllCourses)
groupV1.Get("/course-management/human-language/courses", a.authMiddleware, a.RequirePermission("learning_tree.get"), h.ListHumanLanguageCourses)
groupV1.Get("/course-management/sub-categories/:subCategoryId/courses", a.authMiddleware, a.RequirePermission("learning_tree.get"), h.ListCoursesBySubCategory)
groupV1.Get("/course-management/courses/:courseId", a.authMiddleware, a.RequirePermission("learning_tree.get"), h.GetCourseByID)
groupV1.Get("/course-management/levels", a.authMiddleware, a.RequirePermission("learning_tree.get"), h.ListAllLevels)
groupV1.Get("/course-management/courses/:courseId/levels", a.authMiddleware, a.RequirePermission("learning_tree.get"), h.ListLevelsByCourse)
groupV1.Get("/course-management/levels/:levelId", a.authMiddleware, a.RequirePermission("learning_tree.get"), h.GetLevelByID)
groupV1.Get("/course-management/modules", a.authMiddleware, a.RequirePermission("learning_tree.get"), h.ListAllModules)
groupV1.Get("/course-management/levels/:levelId/modules", a.authMiddleware, a.RequirePermission("learning_tree.get"), h.ListModulesByLevel)
groupV1.Get("/course-management/modules/:moduleId", a.authMiddleware, a.RequirePermission("learning_tree.get"), h.GetModuleByID)
groupV1.Get("/course-management/sub-modules", a.authMiddleware, a.RequirePermission("learning_tree.get"), h.ListAllSubModules)
groupV1.Get("/course-management/modules/:moduleId/sub-modules", a.authMiddleware, a.RequirePermission("learning_tree.get"), h.ListSubModulesByModule)
groupV1.Get("/course-management/sub-modules/:subModuleId", a.authMiddleware, a.RequirePermission("learning_tree.get"), h.GetSubModuleByID)
groupV1.Post("/course-management/categories", a.authMiddleware, a.RequirePermission("course_categories.create"), h.CreateCourseCategory)
groupV1.Delete("/course-management/categories/:categoryId", a.authMiddleware, a.RequirePermission("course_categories.delete"), h.DeleteCourseCategory)
groupV1.Post("/course-management/courses", a.authMiddleware, a.RequirePermission("courses.create"), h.CreateCourse)
groupV1.Put("/course-management/courses/:courseId", a.authMiddleware, a.RequirePermission("courses.update"), h.UpdateCourse)
groupV1.Delete("/course-management/courses/:courseId", a.authMiddleware, a.RequirePermission("courses.delete"), h.DeleteCourse)
groupV1.Post("/course-management/courses/:courseId/thumbnail", a.authMiddleware, a.RequirePermission("courses.upload_thumbnail"), h.UpdateCourseThumbnail)
groupV1.Get("/course-management/courses/:courseId/learning-path", a.authMiddleware, a.RequirePermission("learning_tree.get"), h.CourseLearningPath)
groupV1.Get("/course-management/hierarchy", a.authMiddleware, a.RequirePermission("learning_tree.get"), h.UnifiedHierarchy)
groupV1.Get("/course-management/human-language/hierarchy", a.authMiddleware, a.RequirePermission("learning_tree.get"), h.UnifiedHierarchy)
groupV1.Get("/course-management/courses/:courseId/hierarchy", a.authMiddleware, a.RequirePermission("learning_tree.get"), h.UnifiedHierarchyByCourse)
groupV1.Get("/course-management/sub-categories", a.authMiddleware, a.RequirePermission("learning_tree.get"), h.ListCourseSubCategories)
groupV1.Get("/course-management/human-language/sub-categories", a.authMiddleware, a.RequirePermission("learning_tree.get"), h.ListHumanLanguageCourseSubCategories)
groupV1.Post("/course-management/sub-categories", a.authMiddleware, a.RequirePermission("course_categories.create"), h.CreateCourseSubCategory)
groupV1.Delete("/course-management/sub-categories/:subCategoryId", a.authMiddleware, a.RequirePermission("course_categories.delete"), h.DeleteCourseSubCategory)
groupV1.Post("/course-management/levels", a.authMiddleware, a.RequirePermission("subcourses.create"), h.CreateLevel)
groupV1.Put("/course-management/levels/:levelId", a.authMiddleware, a.RequirePermission("subcourses.update"), h.UpdateLevel)
groupV1.Post("/course-management/modules", a.authMiddleware, a.RequirePermission("subcourses.create"), h.CreateModule)
groupV1.Put("/course-management/modules/:moduleId", a.authMiddleware, a.RequirePermission("subcourses.update"), h.UpdateModule)
groupV1.Delete("/course-management/modules/:moduleId", a.authMiddleware, a.RequirePermission("subcourses.delete"), h.DeleteModule)
groupV1.Post("/course-management/sub-modules", a.authMiddleware, a.RequirePermission("subcourses.create"), h.CreateSubModule)
groupV1.Put("/course-management/sub-modules/:subModuleId", a.authMiddleware, a.RequirePermission("subcourses.update"), h.UpdateSubModule)
groupV1.Delete("/course-management/sub-modules/:subModuleId", a.authMiddleware, a.RequirePermission("subcourses.delete"), h.DeleteSubModule)
groupV1.Post("/course-management/sub-module-videos", a.authMiddleware, a.RequirePermission("videos.create"), h.CreateSubModuleVideo)
groupV1.Put("/course-management/sub-module-videos/:videoId", a.authMiddleware, a.RequirePermission("videos.update"), h.UpdateSubModuleVideo)
groupV1.Delete("/course-management/sub-module-videos/:videoId", a.authMiddleware, a.RequirePermission("videos.delete"), h.DeleteSubModuleVideo)
groupV1.Get("/course-management/sub-modules/:subModuleId/lessons", a.authMiddleware, a.RequirePermission("learning_tree.get"), h.GetSubModuleLessons)
groupV1.Get("/course-management/sub-module-lessons/:lessonId", a.authMiddleware, a.RequirePermission("learning_tree.get"), h.GetSubModuleLessonByID)
groupV1.Put("/course-management/sub-module-lessons/:lessonId", a.authMiddleware, a.RequirePermission("subcourses.update"), h.UpdateSubModuleLesson)
groupV1.Post("/course-management/sub-module-lessons", a.authMiddleware, a.RequirePermission("subcourses.create"), h.CreateSubModuleLesson)
groupV1.Get("/course-management/sub-modules/:subModuleId/practices", a.authMiddleware, a.RequirePermission("question_sets.list"), h.GetSubModulePractices)
groupV1.Get("/course-management/practices/:practiceId/detail", a.authMiddleware, a.RequirePermission("question_sets.get"), h.GetSubModulePracticeDetail)
groupV1.Get("/course-management/practices/:practiceId", a.authMiddleware, a.RequirePermission("question_sets.get"), h.GetSubModulePracticeByID)
groupV1.Post("/course-management/sub-module-practices", a.authMiddleware, a.RequirePermission("question_sets.update"), h.CreateSubModulePractice)
groupV1.Put("/course-management/practices/:practiceId", a.authMiddleware, a.RequirePermission("question_sets.update"), h.UpdatePractice)
groupV1.Delete("/course-management/practices/:practiceId", a.authMiddleware, a.RequirePermission("question_sets.delete"), h.DeletePractice)
groupV1.Get("/course-management/sub-modules/:subModuleId/capstones", a.authMiddleware, a.RequirePermission("question_sets.list"), h.GetSubModuleCapstones)
groupV1.Get("/course-management/capstones/:capstoneId", a.authMiddleware, a.RequirePermission("question_sets.get"), h.GetSubModuleCapstoneByID)
groupV1.Post("/course-management/sub-module-capstones", a.authMiddleware, a.RequirePermission("question_sets.update"), h.CreateSubModuleCapstone)
groupV1.Put("/course-management/capstones/:capstoneId", a.authMiddleware, a.RequirePermission("question_sets.update"), h.UpdateSubModuleCapstone)
groupV1.Delete("/course-management/capstones/:capstoneId", a.authMiddleware, a.RequirePermission("question_sets.delete"), h.DeleteCapstone)
groupV1.Get("/course-management/modules/:moduleId/capstones", a.authMiddleware, a.RequirePermission("question_sets.list"), h.GetModuleCapstones)
groupV1.Get("/course-management/module-capstones/:moduleCapstoneId", a.authMiddleware, a.RequirePermission("question_sets.get"), h.GetModuleCapstoneByID)
groupV1.Post("/course-management/module-capstones", a.authMiddleware, a.RequirePermission("question_sets.update"), h.CreateModuleCapstone)
groupV1.Put("/course-management/module-capstones/:moduleCapstoneId", a.authMiddleware, a.RequirePermission("question_sets.update"), h.UpdateModuleCapstone)
groupV1.Delete("/course-management/module-capstones/:moduleCapstoneId", a.authMiddleware, a.RequirePermission("question_sets.delete"), h.DeleteModuleCapstone)
// Questions // Questions
groupV1.Post("/questions", a.authMiddleware, a.RequirePermission("questions.create"), h.CreateQuestion) groupV1.Post("/questions", a.authMiddleware, a.RequirePermission("questions.create"), h.CreateQuestion)
groupV1.Get("/questions", a.authMiddleware, a.RequirePermission("questions.list"), h.ListQuestions) groupV1.Get("/questions", a.authMiddleware, a.RequirePermission("questions.list"), h.ListQuestions)
@ -255,8 +200,6 @@ func (a *App) initAppRoutes() {
groupV1.Delete("/user/me", a.authMiddleware, a.RequirePermission("users.delete_self"), h.DeleteMyUserAccount) groupV1.Delete("/user/me", a.authMiddleware, a.RequirePermission("users.delete_self"), h.DeleteMyUserAccount)
groupV1.Post("/user/me/deletion/cancel", a.authMiddleware, a.RequirePermission("users.cancel_delete_self"), h.CancelMyUserAccountDeletion) groupV1.Post("/user/me/deletion/cancel", a.authMiddleware, a.RequirePermission("users.cancel_delete_self"), h.CancelMyUserAccountDeletion)
groupV1.Post("/internal/users/purge-due-deletions", a.authMiddleware, a.RequirePermission("users.purge_due_deletions"), h.PurgeDueDeletedUsers) groupV1.Post("/internal/users/purge-due-deletions", a.authMiddleware, a.RequirePermission("users.purge_due_deletions"), h.PurgeDueDeletedUsers)
groupV1.Post("/internal/db/reset-reseed", a.authMiddleware, a.RequirePermission("internal.db.reset_reseed"), h.ResetAndReseedDatabase)
groupV1.Post("/internal/db/clear-course-management", a.authMiddleware, a.RequirePermission("internal.db.reset_reseed"), h.ClearCourseManagementData)
groupV1.Get("/user/single/:id", a.authMiddleware, a.RequirePermission("users.get"), h.GetUserByID) groupV1.Get("/user/single/:id", a.authMiddleware, a.RequirePermission("users.get"), h.GetUserByID)
groupV1.Delete("/user/delete/:id", a.authMiddleware, a.RequirePermission("users.delete"), h.DeleteUser) groupV1.Delete("/user/delete/:id", a.authMiddleware, a.RequirePermission("users.delete"), h.DeleteUser)
groupV1.Post("/user/search", a.authMiddleware, a.RequirePermission("users.search"), h.SearchUserByNameOrPhone) groupV1.Post("/user/search", a.authMiddleware, a.RequirePermission("users.search"), h.SearchUserByNameOrPhone)
@ -343,8 +286,6 @@ func (a *App) initAppRoutes() {
teamGroup.Delete("/members/:id", a.authMiddleware, a.RequirePermission("team.members.delete"), h.DeleteTeamMember) teamGroup.Delete("/members/:id", a.authMiddleware, a.RequirePermission("team.members.delete"), h.DeleteTeamMember)
teamGroup.Post("/members/:id/change-password", a.authMiddleware, a.RequirePermission("team.members.change_password"), h.ChangeTeamMemberPassword) teamGroup.Post("/members/:id/change-password", a.authMiddleware, a.RequirePermission("team.members.change_password"), h.ChangeTeamMemberPassword)
// Legacy sub-course prerequisite/progression routes removed after hierarchy cutover.
// Ratings // Ratings
groupV1.Post("/ratings", a.authMiddleware, a.RequirePermission("ratings.submit"), h.SubmitRating) groupV1.Post("/ratings", a.authMiddleware, a.RequirePermission("ratings.submit"), h.SubmitRating)
groupV1.Get("/ratings", a.authMiddleware, a.RequirePermission("ratings.list_by_target"), h.GetRatingsByTarget) groupV1.Get("/ratings", a.authMiddleware, a.RequirePermission("ratings.list_by_target"), h.GetRatingsByTarget)