Compare commits

...

16 Commits

Author SHA1 Message Date
10954d88b0 subscription management fix + duolingo hierarchy implementation 2026-05-04 10:44:18 -07:00
eba2b87ed6 Use initial assessment description as normalized level.
Made-with: Cursor
2026-04-29 08:12:09 -07:00
60290e5c34 Swap module sort_order on conflict during update.
When updating a module sort_order to an occupied position in the same course, perform an atomic swap in a transaction instead of failing with a unique constraint error.

Made-with: Cursor
2026-04-29 03:36:45 -07:00
8430b82687 Fix partial question-set updates preserving existing values.
When PUT payload omits title, status, or shuffle_questions, reuse current persisted values so updates do not write invalid empty status values.

Made-with: Cursor
2026-04-29 03:02:13 -07:00
cdb0fa1bb3 Enforce strict initial assessment set validation.
Require INITIAL_ASSESSMENT titles to follow the Level Test A1/A2/B1/B2 format and ensure passing_score is always present on create and update.

Made-with: Cursor
2026-04-29 02:47:21 -07:00
9027b65011 Require lesson and practice completion for LMS rollups.
Update lesson and practice completion flows to cascade module/course/program progress only when both lesson completion and related published practice completion criteria are met, and align progress counters with the new rule.

Made-with: Cursor
2026-04-28 09:56:53 -07:00
8c116f4a0b GET question sets API fix 2026-04-28 09:41:09 -07:00
87bf2ed609 data loss fix 2026-04-28 09:30:48 -07:00
9cfd6c524e Allow INITIAL_ASSESSMENT question sets without owner_type and owner_id
Made-with: Cursor
2026-04-27 10:46:33 -07:00
0d02eb1a24 add MinIO media URL refresh endpoint
Add POST /api/v1/files/refresh-url to issue fresh presigned URLs from object keys, minio:// references, or stale presigned URLs so clients can refresh media links before render.

Made-with: Cursor
2026-04-27 05:25:16 -07:00
78f231f222 fix OTP verification by submitted code
Resolve false OTP already used/expired responses during registration by loading OTP rows using user_id plus submitted otp code and validating usage/expiry on the matched row.

Made-with: Cursor
2026-04-25 05:07:19 -07:00
526426d9f9 course practice count fix 2026-04-25 02:41:34 -07:00
5857fce9a0 count data for course 2026-04-25 02:36:52 -07:00
7e26f15bed early otp expiration fix 2026-04-25 00:16:05 -07:00
bc68326a66 fix: return sample_answer_voice_prompt and audio_correct_answer_text in question set items list
Extend GetQuestionSetItems and GetPublishedQuestionsInSet queries to match
paginated fields; map audio answer join in repository instead of nils.

Made-with: Cursor
2026-04-24 03:11:47 -07:00
33d34f0dd2 fix: map default CEFR courses to Beginner/Intermediate/Advanced programs
Seed A1-A2, B1-B2, and C1-C2 only on their matching programs; add migration
000050 for existing databases. Document mapping in domain.

Made-with: Cursor
2026-04-24 01:14:50 -07:00
84 changed files with 8830 additions and 196 deletions

View File

@ -24,6 +24,7 @@ import (
practicesservice "Yimaru-Backend/internal/services/practices" practicesservice "Yimaru-Backend/internal/services/practices"
programsservice "Yimaru-Backend/internal/services/programs" programsservice "Yimaru-Backend/internal/services/programs"
"Yimaru-Backend/internal/services/questions" "Yimaru-Backend/internal/services/questions"
"Yimaru-Backend/internal/services/examprep"
"Yimaru-Backend/internal/services/recommendation" "Yimaru-Backend/internal/services/recommendation"
"Yimaru-Backend/internal/services/settings" "Yimaru-Backend/internal/services/settings"
"Yimaru-Backend/internal/services/subscriptions" "Yimaru-Backend/internal/services/subscriptions"
@ -392,6 +393,7 @@ func main() {
// Questions service (unified questions system) // Questions service (unified questions system)
questionsSvc := questions.NewService(store) questionsSvc := questions.NewService(store)
examPrepSvc := examprep.NewService(store)
// LMS programs (top-level hierarchy) // LMS programs (top-level hierarchy)
programSvc := programsservice.NewService(store) programSvc := programsservice.NewService(store)
@ -451,6 +453,7 @@ func main() {
app := httpserver.NewApp( app := httpserver.NewApp(
assessmentSvc, assessmentSvc,
questionsSvc, questionsSvc,
examPrepSvc,
programSvc, programSvc,
courseSvc, courseSvc,
moduleSvc, moduleSvc,

View File

@ -1,5 +1,5 @@
-- Default CEFR-style course names per program (custom courses can still be created via the API with any name). -- Default CEFR-style courses per seeded program: Beginner→A1,A2; Intermediate→B1,B2; Advanced→C1,C2.
-- Matches hierarchy note on courses: CEFR labels A1..C2, plus ad-hoc names allowed. -- Custom courses can still be created via the API with any name.
INSERT INTO courses (program_id, name, description, thumbnail) INSERT INTO courses (program_id, name, description, thumbnail)
SELECT SELECT
p.id, p.id,
@ -7,12 +7,13 @@ SELECT
'Default CEFR level course (system seed).', 'Default CEFR level course (system seed).',
NULL NULL
FROM programs AS p FROM programs AS p
CROSS JOIN ( INNER JOIN (
VALUES VALUES
('A1'), ('Beginner', 'A1'),
('A2'), ('Beginner', 'A2'),
('B1'), ('Intermediate', 'B1'),
('B2'), ('Intermediate', 'B2'),
('C1'), ('Advanced', 'C1'),
('C2') ('Advanced', 'C2')
) AS v (name); ) AS v (program_name, name)
ON p.name = v.program_name;

View File

@ -0,0 +1 @@
-- Data cleanup is not reversed; restoring the old cross-product seed would be ambiguous.

View File

@ -0,0 +1,45 @@
-- Align default seeded courses with program: Beginner→A1,A2; Intermediate→B1,B2; Advanced→C1,C2.
-- Only touches rows with the system seed description; custom courses are unchanged.
-- Removing a course cascades to modules, lessons, and related LMS progress (see FKs on those tables).
DELETE FROM courses AS c
USING programs AS p
WHERE c.program_id = p.id
AND c.description = 'Default CEFR level course (system seed).'
AND (
(
p.name = 'Beginner'
AND c.name IN ('B1', 'B2', 'C1', 'C2')
)
OR (
p.name = 'Intermediate'
AND c.name IN ('A1', 'A2', 'C1', 'C2')
)
OR (
p.name = 'Advanced'
AND c.name IN ('A1', 'A2', 'B1', 'B2')
)
);
INSERT INTO courses (program_id, name, description, thumbnail)
SELECT
p.id,
v.name,
'Default CEFR level course (system seed).',
NULL
FROM programs AS p
INNER JOIN (
VALUES
('Beginner', 'A1'),
('Beginner', 'A2'),
('Intermediate', 'B1'),
('Intermediate', 'B2'),
('Advanced', 'C1'),
('Advanced', 'C2')
) AS v (program_name, name)
ON p.name = v.program_name
WHERE
NOT EXISTS (
SELECT 1 FROM courses AS e
WHERE e.program_id = p.id AND e.name = v.name
);

View File

@ -0,0 +1,2 @@
DROP TABLE IF EXISTS exam_prep.catalog_courses;
DROP SCHEMA IF EXISTS exam_prep;

View File

@ -0,0 +1,15 @@
-- Standalone exam-prep content hierarchy (DET, IELTS, TOEFL, etc.) — isolated from LMS Learn English tables.
CREATE SCHEMA IF NOT EXISTS exam_prep;
-- Top-level catalog "course" (e.g. Duolingo English Test, IELTS); admin-configurable labels.
CREATE TABLE exam_prep.catalog_courses (
id BIGSERIAL PRIMARY KEY,
name VARCHAR(255) NOT NULL,
description TEXT,
thumbnail TEXT,
sort_order INTEGER NOT NULL DEFAULT 0,
created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMPTZ
);
CREATE INDEX idx_exam_prep_catalog_courses_sort ON exam_prep.catalog_courses (sort_order, id);

View File

@ -0,0 +1 @@
DROP TABLE IF EXISTS exam_prep.units;

View File

@ -0,0 +1,14 @@
-- Units under an exam-prep catalog course (e.g. "Introduction to the DET English Test").
CREATE TABLE exam_prep.units (
id BIGSERIAL PRIMARY KEY,
catalog_course_id BIGINT NOT NULL REFERENCES exam_prep.catalog_courses (id) ON DELETE CASCADE,
name VARCHAR(255) NOT NULL,
description TEXT,
thumbnail TEXT,
sort_order INTEGER NOT NULL DEFAULT 0,
created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMPTZ
);
CREATE INDEX idx_exam_prep_units_catalog_course_id ON exam_prep.units (catalog_course_id);
CREATE INDEX idx_exam_prep_units_catalog_sort ON exam_prep.units (catalog_course_id, sort_order, id);

View File

@ -0,0 +1 @@
DROP TABLE IF EXISTS exam_prep.unit_modules;

View File

@ -0,0 +1,15 @@
-- Modules under an exam-prep unit (table name unit_modules avoids sqlc/LMS collision with public.modules).
CREATE TABLE exam_prep.unit_modules (
id BIGSERIAL PRIMARY KEY,
unit_id BIGINT NOT NULL REFERENCES exam_prep.units (id) ON DELETE CASCADE,
name VARCHAR(255) NOT NULL,
description TEXT,
thumbnail TEXT,
icon TEXT,
sort_order INTEGER NOT NULL DEFAULT 0,
created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMPTZ
);
CREATE INDEX idx_exam_prep_unit_modules_unit_id ON exam_prep.unit_modules (unit_id);
CREATE INDEX idx_exam_prep_unit_modules_unit_sort ON exam_prep.unit_modules (unit_id, sort_order, id);

View File

@ -0,0 +1,2 @@
DROP INDEX IF EXISTS uq_exam_prep_unit_module_lessons_sort;
DROP TABLE IF EXISTS exam_prep.unit_module_lessons;

View File

@ -0,0 +1,17 @@
-- Lessons under an exam-prep unit module (mirrors LMS lessons under modules; avoids collision with public.lessons / sqlc).
CREATE TABLE exam_prep.unit_module_lessons (
id BIGSERIAL PRIMARY KEY,
unit_module_id BIGINT NOT NULL REFERENCES exam_prep.unit_modules (id) ON DELETE CASCADE,
title VARCHAR(255) NOT NULL,
video_url TEXT,
thumbnail TEXT,
description TEXT,
sort_order INTEGER NOT NULL DEFAULT 0,
created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMPTZ
);
CREATE UNIQUE INDEX uq_exam_prep_unit_module_lessons_sort ON exam_prep.unit_module_lessons (unit_module_id, sort_order);
CREATE INDEX idx_exam_prep_unit_module_lessons_module_id ON exam_prep.unit_module_lessons (unit_module_id);
CREATE INDEX idx_exam_prep_unit_module_lessons_module_created ON exam_prep.unit_module_lessons (unit_module_id, created_at DESC);

View File

@ -0,0 +1 @@
DROP TABLE IF EXISTS exam_prep.lesson_practices;

View File

@ -0,0 +1,17 @@
-- Exam-prep practices: one row per practice, attached to an exam-prep lesson only; reuses public.question_sets / questions.
CREATE TABLE exam_prep.lesson_practices (
id BIGSERIAL PRIMARY KEY,
unit_module_lesson_id BIGINT NOT NULL REFERENCES exam_prep.unit_module_lessons (id) ON DELETE CASCADE,
title VARCHAR(255) NOT NULL,
story_description TEXT,
story_image TEXT,
persona_id BIGINT REFERENCES users (id) ON DELETE SET NULL,
question_set_id BIGINT NOT NULL REFERENCES question_sets (id) ON DELETE RESTRICT,
quick_tips TEXT,
created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMPTZ
);
CREATE INDEX idx_exam_prep_lesson_practices_lesson_id ON exam_prep.lesson_practices (unit_module_lesson_id);
CREATE INDEX idx_exam_prep_lesson_practices_question_set_id ON exam_prep.lesson_practices (question_set_id);
CREATE INDEX idx_exam_prep_lesson_practices_lesson_created ON exam_prep.lesson_practices (unit_module_lesson_id, created_at DESC);

View File

@ -0,0 +1,53 @@
-- name: ExamPrepCreateCatalogCourse :one
INSERT INTO exam_prep.catalog_courses (name, description, thumbnail, sort_order)
SELECT
$1,
$2,
$3,
coalesce((
SELECT
max(c.sort_order)
FROM exam_prep.catalog_courses AS c), 0) + 1
RETURNING
*;
-- name: ExamPrepGetCatalogCourseByID :one
SELECT *
FROM exam_prep.catalog_courses
WHERE id = $1;
-- name: ExamPrepListCatalogCourses :many
SELECT
COUNT(*) OVER () AS total_count,
c.id,
c.name,
c.description,
c.thumbnail,
c.sort_order,
c.created_at,
c.updated_at
FROM exam_prep.catalog_courses c
ORDER BY c.sort_order ASC, c.id ASC
LIMIT $1 OFFSET $2;
-- name: ExamPrepListAllCatalogCourseIDs :many
SELECT
id
FROM exam_prep.catalog_courses
ORDER BY id;
-- name: ExamPrepUpdateCatalogCourse :one
UPDATE exam_prep.catalog_courses
SET
name = coalesce(sqlc.narg('name')::varchar, name),
description = coalesce(sqlc.narg('description')::text, description),
thumbnail = coalesce(sqlc.narg('thumbnail')::text, thumbnail),
sort_order = coalesce(sqlc.narg('sort_order')::int, sort_order),
updated_at = CURRENT_TIMESTAMP
WHERE id = sqlc.arg('id')
RETURNING
*;
-- name: ExamPrepDeleteCatalogCourse :exec
DELETE FROM exam_prep.catalog_courses
WHERE id = $1;

View File

@ -0,0 +1,52 @@
-- name: ExamPrepCreateLessonPractice :one
INSERT INTO exam_prep.lesson_practices (
unit_module_lesson_id,
title,
story_description,
story_image,
persona_id,
question_set_id,
quick_tips
) VALUES ($1, $2, $3, $4, $5, $6, $7)
RETURNING *;
-- name: ExamPrepGetLessonPracticeByID :one
SELECT *
FROM exam_prep.lesson_practices
WHERE id = $1;
-- name: ExamPrepListLessonPracticesByLessonID :many
SELECT
COUNT(*) OVER () AS total_count,
p.id,
p.unit_module_lesson_id,
p.title,
p.story_description,
p.story_image,
p.persona_id,
p.question_set_id,
p.quick_tips,
p.created_at,
p.updated_at
FROM exam_prep.lesson_practices p
WHERE p.unit_module_lesson_id = $1
ORDER BY p.created_at DESC
LIMIT $2
OFFSET $3;
-- name: ExamPrepUpdateLessonPractice :one
UPDATE exam_prep.lesson_practices
SET
title = coalesce(sqlc.narg('title')::varchar, title),
story_description = coalesce(sqlc.narg('story_description')::text, story_description),
story_image = coalesce(sqlc.narg('story_image')::text, story_image),
persona_id = coalesce(sqlc.narg('persona_id')::bigint, persona_id),
question_set_id = coalesce(sqlc.narg('question_set_id')::bigint, question_set_id),
quick_tips = coalesce(sqlc.narg('quick_tips')::text, quick_tips),
updated_at = CURRENT_TIMESTAMP
WHERE id = sqlc.arg('id')
RETURNING *;
-- name: ExamPrepDeleteLessonPractice :exec
DELETE FROM exam_prep.lesson_practices
WHERE id = $1;

View File

@ -0,0 +1,68 @@
-- name: ExamPrepCreateUnitModuleLesson :one
INSERT INTO exam_prep.unit_module_lessons (unit_module_id, title, video_url, thumbnail, description, sort_order)
SELECT
$1,
$2,
$3,
$4,
$5,
coalesce((
SELECT
max(l.sort_order)
FROM exam_prep.unit_module_lessons l
WHERE
l.unit_module_id = $1), 0) + 1
RETURNING
*;
-- name: ExamPrepGetUnitModuleLessonByID :one
SELECT *
FROM exam_prep.unit_module_lessons
WHERE id = $1;
-- name: ExamPrepListUnitModuleLessonIDsByUnitModule :many
SELECT
l.id
FROM exam_prep.unit_module_lessons l
WHERE
l.unit_module_id = $1
ORDER BY
l.id;
-- name: ExamPrepListUnitModuleLessonsByUnitModuleID :many
SELECT
COUNT(*) OVER () AS total_count,
l.id,
l.unit_module_id,
l.title,
l.video_url,
l.thumbnail,
l.description,
l.sort_order,
l.created_at,
l.updated_at
FROM exam_prep.unit_module_lessons l
WHERE
l.unit_module_id = $1
ORDER BY
l.sort_order ASC,
l.id ASC
LIMIT $2
OFFSET $3;
-- name: ExamPrepUpdateUnitModuleLesson :one
UPDATE exam_prep.unit_module_lessons
SET
title = coalesce(sqlc.narg('title')::varchar, title),
video_url = coalesce(sqlc.narg('video_url')::text, video_url),
thumbnail = coalesce(sqlc.narg('thumbnail')::text, thumbnail),
description = coalesce(sqlc.narg('description')::text, description),
sort_order = coalesce(sqlc.narg('sort_order')::int, sort_order),
updated_at = CURRENT_TIMESTAMP
WHERE id = sqlc.arg('id')
RETURNING
*;
-- name: ExamPrepDeleteUnitModuleLesson :exec
DELETE FROM exam_prep.unit_module_lessons
WHERE id = $1;

View File

@ -0,0 +1,68 @@
-- name: ExamPrepCreateUnitModule :one
INSERT INTO exam_prep.unit_modules (unit_id, name, description, thumbnail, icon, sort_order)
SELECT
$1,
$2,
$3,
$4,
$5,
coalesce((
SELECT
max(m.sort_order)
FROM exam_prep.unit_modules m
WHERE
m.unit_id = $1), 0) + 1
RETURNING
*;
-- name: ExamPrepGetUnitModuleByID :one
SELECT *
FROM exam_prep.unit_modules
WHERE id = $1;
-- name: ExamPrepListUnitModuleIDsByUnit :many
SELECT
m.id
FROM exam_prep.unit_modules m
WHERE
m.unit_id = $1
ORDER BY
m.id;
-- name: ExamPrepListUnitModulesByUnit :many
SELECT
COUNT(*) OVER () AS total_count,
m.id,
m.unit_id,
m.name,
m.description,
m.thumbnail,
m.icon,
m.sort_order,
m.created_at,
m.updated_at
FROM exam_prep.unit_modules m
WHERE
m.unit_id = $1
ORDER BY
m.sort_order ASC,
m.id ASC
LIMIT $2
OFFSET $3;
-- name: ExamPrepUpdateUnitModule :one
UPDATE exam_prep.unit_modules
SET
name = coalesce(sqlc.narg('name')::varchar, name),
description = coalesce(sqlc.narg('description')::text, description),
thumbnail = coalesce(sqlc.narg('thumbnail')::text, thumbnail),
icon = coalesce(sqlc.narg('icon')::text, icon),
sort_order = coalesce(sqlc.narg('sort_order')::int, sort_order),
updated_at = CURRENT_TIMESTAMP
WHERE id = sqlc.arg('id')
RETURNING
*;
-- name: ExamPrepDeleteUnitModule :exec
DELETE FROM exam_prep.unit_modules
WHERE id = $1;

View File

@ -0,0 +1,65 @@
-- name: ExamPrepCreateUnit :one
INSERT INTO exam_prep.units (catalog_course_id, name, description, thumbnail, sort_order)
SELECT
$1,
$2,
$3,
$4,
coalesce((
SELECT
max(u.sort_order)
FROM exam_prep.units u
WHERE
u.catalog_course_id = $1), 0) + 1
RETURNING
*;
-- name: ExamPrepGetUnitByID :one
SELECT *
FROM exam_prep.units
WHERE id = $1;
-- name: ExamPrepListUnitIDsByCatalogCourse :many
SELECT
u.id
FROM exam_prep.units u
WHERE
u.catalog_course_id = $1
ORDER BY
u.id;
-- name: ExamPrepListUnitsByCatalogCourse :many
SELECT
COUNT(*) OVER () AS total_count,
u.id,
u.catalog_course_id,
u.name,
u.description,
u.thumbnail,
u.sort_order,
u.created_at,
u.updated_at
FROM exam_prep.units u
WHERE
u.catalog_course_id = $1
ORDER BY
u.sort_order ASC,
u.id ASC
LIMIT $2
OFFSET $3;
-- name: ExamPrepUpdateUnit :one
UPDATE exam_prep.units
SET
name = coalesce(sqlc.narg('name')::varchar, name),
description = coalesce(sqlc.narg('description')::text, description),
thumbnail = coalesce(sqlc.narg('thumbnail')::text, thumbnail),
sort_order = coalesce(sqlc.narg('sort_order')::int, sort_order),
updated_at = CURRENT_TIMESTAMP
WHERE id = sqlc.arg('id')
RETURNING
*;
-- name: ExamPrepDeleteUnit :exec
DELETE FROM exam_prep.units
WHERE id = $1;

View File

@ -39,7 +39,33 @@ SELECT
c.thumbnail, c.thumbnail,
c.sort_order, c.sort_order,
c.created_at, c.created_at,
c.updated_at c.updated_at,
(
SELECT
COUNT(*)::bigint
FROM
modules m
WHERE
m.course_id = c.id) AS module_count,
(
SELECT
COUNT(*)::bigint
FROM
lessons l
INNER JOIN modules m ON l.module_id = m.id
WHERE
m.course_id = c.id) AS lesson_count,
-- Practices whose parent is the course only (lms_practices.course_id). Excludes
-- practices linked via module_id or lesson_id, even for modules/lessons in this course.
(
SELECT
COUNT(*)::bigint
FROM
lms_practices p
WHERE
p.course_id = c.id
AND p.module_id IS NULL
AND p.lesson_id IS NULL) AS practice_count
FROM FROM
courses c courses c
WHERE WHERE

View File

@ -246,3 +246,95 @@ FROM
WHERE WHERE
c.program_id = $1 c.program_id = $1
AND ulp.user_id = $2; AND ulp.user_id = $2;
-- Published practices in a module (module-level and lesson-level practices should carry module_id).
-- name: CountPublishedPracticesInModule :one
SELECT
count(*)::int AS n
FROM
lms_practices lp
INNER JOIN question_sets qs ON qs.id = lp.question_set_id
WHERE
lp.module_id = $1
AND qs.set_type = 'PRACTICE'
AND qs.status = 'PUBLISHED';
-- name: CountUserCompletedPublishedPracticesInModule :one
SELECT
count(*)::int AS n
FROM
lms_practices lp
INNER JOIN question_sets qs ON qs.id = lp.question_set_id
INNER JOIN user_practice_progress upp ON upp.question_set_id = lp.question_set_id
WHERE
lp.module_id = $1
AND upp.user_id = $2
AND upp.completed_at IS NOT NULL
AND qs.set_type = 'PRACTICE'
AND qs.status = 'PUBLISHED';
-- name: CountPublishedPracticesInCourse :one
SELECT
count(*)::int AS n
FROM
lms_practices lp
INNER JOIN question_sets qs ON qs.id = lp.question_set_id
WHERE
lp.course_id = $1
AND qs.set_type = 'PRACTICE'
AND qs.status = 'PUBLISHED';
-- name: CountUserCompletedPublishedPracticesInCourse :one
SELECT
count(*)::int AS n
FROM
lms_practices lp
INNER JOIN question_sets qs ON qs.id = lp.question_set_id
INNER JOIN user_practice_progress upp ON upp.question_set_id = lp.question_set_id
WHERE
lp.course_id = $1
AND upp.user_id = $2
AND upp.completed_at IS NOT NULL
AND qs.set_type = 'PRACTICE'
AND qs.status = 'PUBLISHED';
-- name: CountPublishedPracticesInProgram :one
SELECT
count(*)::int AS n
FROM
lms_practices lp
INNER JOIN courses c ON c.id = lp.course_id
INNER JOIN question_sets qs ON qs.id = lp.question_set_id
WHERE
c.program_id = $1
AND qs.set_type = 'PRACTICE'
AND qs.status = 'PUBLISHED';
-- name: CountUserCompletedPublishedPracticesInProgram :one
SELECT
count(*)::int AS n
FROM
lms_practices lp
INNER JOIN courses c ON c.id = lp.course_id
INNER JOIN question_sets qs ON qs.id = lp.question_set_id
INNER JOIN user_practice_progress upp ON upp.question_set_id = lp.question_set_id
WHERE
c.program_id = $1
AND upp.user_id = $2
AND upp.completed_at IS NOT NULL
AND qs.set_type = 'PRACTICE'
AND qs.status = 'PUBLISHED';
-- name: GetPracticeScopeByQuestionSetID :one
SELECT
id,
course_id,
module_id,
lesson_id
FROM
lms_practices
WHERE
question_set_id = $1
ORDER BY
id DESC
LIMIT 1;

View File

@ -22,7 +22,16 @@ VALUES ($1, $2, $3, $4, $5, $6);
SELECT id, user_id, sent_to, medium, otp_for, otp, used, used_at, created_at, expires_at SELECT id, user_id, sent_to, medium, otp_for, otp, used, used_at, created_at, expires_at
FROM otps FROM otps
WHERE user_id = $1 WHERE user_id = $1
ORDER BY created_at DESC LIMIT 1; ORDER BY id DESC
LIMIT 1;
-- name: GetOtpByCode :one
SELECT id, user_id, sent_to, medium, otp_for, otp, used, used_at, created_at, expires_at
FROM otps
WHERE user_id = $1
AND otp = $2
ORDER BY id DESC
LIMIT 1;
-- name: MarkOtpAsUsed :exec -- name: MarkOtpAsUsed :exec
UPDATE otps UPDATE otps

View File

@ -21,10 +21,13 @@ SELECT
q.explanation, q.explanation,
q.tips, q.tips,
q.voice_prompt, q.voice_prompt,
q.sample_answer_voice_prompt,
q.image_url, q.image_url,
q.status as question_status q.status as question_status,
qaa.correct_answer_text AS audio_correct_answer_text
FROM question_set_items qsi FROM question_set_items qsi
JOIN questions q ON q.id = qsi.question_id JOIN questions q ON q.id = qsi.question_id
LEFT JOIN question_audio_answers qaa ON qaa.question_id = q.id
WHERE qsi.set_id = $1 WHERE qsi.set_id = $1
AND q.status != 'ARCHIVED' AND q.status != 'ARCHIVED'
ORDER BY qsi.display_order; ORDER BY qsi.display_order;
@ -70,9 +73,12 @@ SELECT
q.explanation, q.explanation,
q.tips, q.tips,
q.voice_prompt, q.voice_prompt,
q.image_url q.sample_answer_voice_prompt,
q.image_url,
qaa.correct_answer_text AS audio_correct_answer_text
FROM question_set_items qsi FROM question_set_items qsi
JOIN questions q ON q.id = qsi.question_id JOIN questions q ON q.id = qsi.question_id
LEFT JOIN question_audio_answers qaa ON qaa.question_id = q.id
WHERE qsi.set_id = $1 WHERE qsi.set_id = $1
AND q.status = 'PUBLISHED' AND q.status = 'PUBLISHED'
ORDER BY qsi.display_order; ORDER BY qsi.display_order;

View File

@ -951,6 +951,663 @@ const docTemplate = `{
"responses": {} "responses": {}
} }
}, },
"/api/v1/exam-prep/catalog-courses": {
"get": {
"produces": [
"application/json"
],
"tags": [
"exam-prep"
],
"summary": "List exam-prep catalog courses",
"parameters": [
{
"type": "integer",
"default": 20,
"description": "Page size",
"name": "limit",
"in": "query"
},
{
"type": "integer",
"default": 0,
"description": "Offset",
"name": "offset",
"in": "query"
}
],
"responses": {
"200": {
"description": "OK",
"schema": {
"$ref": "#/definitions/domain.Response"
}
}
}
},
"post": {
"description": "Top-level exam track (DET, IELTS, …) in schema exam_prep — separate from LMS programs/courses",
"consumes": [
"application/json"
],
"produces": [
"application/json"
],
"tags": [
"exam-prep"
],
"summary": "Create exam-prep catalog course",
"parameters": [
{
"description": "Catalog course",
"name": "body",
"in": "body",
"required": true,
"schema": {
"$ref": "#/definitions/domain.CreateExamPrepCatalogCourseInput"
}
}
],
"responses": {
"201": {
"description": "Created",
"schema": {
"$ref": "#/definitions/domain.Response"
}
},
"400": {
"description": "Bad Request",
"schema": {
"$ref": "#/definitions/domain.ErrorResponse"
}
}
}
}
},
"/api/v1/exam-prep/catalog-courses/reorder": {
"put": {
"consumes": [
"application/json"
],
"produces": [
"application/json"
],
"tags": [
"exam-prep"
],
"summary": "Reorder all exam-prep catalog courses",
"parameters": [
{
"description": "ordered_ids: every catalog course id exactly once",
"name": "body",
"in": "body",
"required": true,
"schema": {
"$ref": "#/definitions/domain.ReorderIDsRequest"
}
}
],
"responses": {
"200": {
"description": "OK",
"schema": {
"$ref": "#/definitions/domain.Response"
}
}
}
}
},
"/api/v1/exam-prep/catalog-courses/{catalogCourseId}/units": {
"get": {
"tags": [
"exam-prep"
],
"summary": "List exam-prep units for a catalog course",
"parameters": [
{
"type": "integer",
"description": "Catalog course ID",
"name": "catalogCourseId",
"in": "path",
"required": true
},
{
"type": "integer",
"default": 20,
"description": "Page size",
"name": "limit",
"in": "query"
},
{
"type": "integer",
"default": 0,
"description": "Offset",
"name": "offset",
"in": "query"
}
],
"responses": {
"200": {
"description": "OK",
"schema": {
"$ref": "#/definitions/domain.Response"
}
}
}
},
"post": {
"description": "Unit under a catalog course (e.g. chapter title)",
"consumes": [
"application/json"
],
"produces": [
"application/json"
],
"tags": [
"exam-prep"
],
"summary": "Create exam-prep unit",
"parameters": [
{
"type": "integer",
"description": "Catalog course ID",
"name": "catalogCourseId",
"in": "path",
"required": true
},
{
"description": "Unit",
"name": "body",
"in": "body",
"required": true,
"schema": {
"$ref": "#/definitions/domain.CreateExamPrepUnitInput"
}
}
],
"responses": {
"201": {
"description": "Created",
"schema": {
"$ref": "#/definitions/domain.Response"
}
}
}
}
},
"/api/v1/exam-prep/catalog-courses/{catalogCourseId}/units/reorder": {
"put": {
"tags": [
"exam-prep"
],
"summary": "Reorder units within a catalog course",
"parameters": [
{
"type": "integer",
"description": "Catalog course ID",
"name": "catalogCourseId",
"in": "path",
"required": true
},
{
"description": "ordered_ids: every unit id in this catalog course, new order",
"name": "body",
"in": "body",
"required": true,
"schema": {
"$ref": "#/definitions/domain.ReorderIDsRequest"
}
}
],
"responses": {}
}
},
"/api/v1/exam-prep/catalog-courses/{id}": {
"get": {
"produces": [
"application/json"
],
"tags": [
"exam-prep"
],
"summary": "Get exam-prep catalog course by ID",
"parameters": [
{
"type": "integer",
"description": "Catalog course ID",
"name": "id",
"in": "path",
"required": true
}
],
"responses": {
"200": {
"description": "OK",
"schema": {
"$ref": "#/definitions/domain.Response"
}
}
}
},
"put": {
"consumes": [
"application/json"
],
"produces": [
"application/json"
],
"tags": [
"exam-prep"
],
"summary": "Update exam-prep catalog course",
"parameters": [
{
"type": "integer",
"description": "Catalog course ID",
"name": "id",
"in": "path",
"required": true
},
{
"description": "Fields to update",
"name": "body",
"in": "body",
"required": true,
"schema": {
"$ref": "#/definitions/domain.UpdateExamPrepCatalogCourseInput"
}
}
],
"responses": {
"200": {
"description": "OK",
"schema": {
"$ref": "#/definitions/domain.Response"
}
}
}
},
"delete": {
"tags": [
"exam-prep"
],
"summary": "Delete exam-prep catalog course",
"parameters": [
{
"type": "integer",
"description": "Catalog course ID",
"name": "id",
"in": "path",
"required": true
}
],
"responses": {
"200": {
"description": "OK",
"schema": {
"$ref": "#/definitions/domain.Response"
}
}
}
}
},
"/api/v1/exam-prep/lessons/{id}": {
"get": {
"tags": [
"exam-prep"
],
"summary": "Get exam-prep lesson by ID",
"responses": {}
},
"put": {
"tags": [
"exam-prep"
],
"summary": "Update exam-prep lesson",
"responses": {}
},
"delete": {
"tags": [
"exam-prep"
],
"summary": "Delete exam-prep lesson",
"responses": {}
}
},
"/api/v1/exam-prep/lessons/{lessonId}/practices": {
"get": {
"tags": [
"exam-prep"
],
"summary": "List exam-prep practices for a lesson",
"parameters": [
{
"type": "integer",
"description": "Exam prep lesson ID",
"name": "lessonId",
"in": "path",
"required": true
},
{
"type": "integer",
"default": 20,
"description": "Page size",
"name": "limit",
"in": "query"
},
{
"type": "integer",
"default": 0,
"description": "Offset",
"name": "offset",
"in": "query"
}
],
"responses": {}
},
"post": {
"tags": [
"exam-prep"
],
"summary": "Create exam-prep practice (under a lesson; uses shared question_sets)",
"parameters": [
{
"type": "integer",
"description": "Exam prep lesson ID (unit_module_lessons.id)",
"name": "lessonId",
"in": "path",
"required": true
},
{
"description": "Practice",
"name": "body",
"in": "body",
"required": true,
"schema": {
"$ref": "#/definitions/domain.CreateExamPrepPracticeInput"
}
}
],
"responses": {}
}
},
"/api/v1/exam-prep/modules/{id}": {
"get": {
"tags": [
"exam-prep"
],
"summary": "Get exam-prep module by ID",
"responses": {}
},
"put": {
"tags": [
"exam-prep"
],
"summary": "Update exam-prep module",
"responses": {}
},
"delete": {
"tags": [
"exam-prep"
],
"summary": "Delete exam-prep module",
"responses": {}
}
},
"/api/v1/exam-prep/modules/{moduleId}/lessons": {
"get": {
"tags": [
"exam-prep"
],
"summary": "List exam-prep lessons for a unit module",
"parameters": [
{
"type": "integer",
"description": "Exam prep unit module ID",
"name": "moduleId",
"in": "path",
"required": true
},
{
"type": "integer",
"default": 20,
"description": "Page size",
"name": "limit",
"in": "query"
},
{
"type": "integer",
"default": 0,
"description": "Offset",
"name": "offset",
"in": "query"
}
],
"responses": {}
},
"post": {
"tags": [
"exam-prep"
],
"summary": "Create exam-prep lesson (under a unit module)",
"parameters": [
{
"type": "integer",
"description": "Exam prep unit module ID",
"name": "moduleId",
"in": "path",
"required": true
},
{
"description": "Lesson",
"name": "body",
"in": "body",
"required": true,
"schema": {
"$ref": "#/definitions/domain.CreateExamPrepLessonInput"
}
}
],
"responses": {}
}
},
"/api/v1/exam-prep/modules/{moduleId}/lessons/reorder": {
"put": {
"tags": [
"exam-prep"
],
"summary": "Reorder lessons within an exam-prep unit module",
"responses": {}
}
},
"/api/v1/exam-prep/practices/{id}": {
"get": {
"tags": [
"exam-prep"
],
"summary": "Get exam-prep practice by ID",
"parameters": [
{
"type": "integer",
"description": "Exam prep practice ID",
"name": "id",
"in": "path",
"required": true
}
],
"responses": {}
},
"put": {
"tags": [
"exam-prep"
],
"summary": "Update exam-prep practice",
"parameters": [
{
"type": "integer",
"description": "Exam prep practice ID",
"name": "id",
"in": "path",
"required": true
},
{
"description": "Fields to update",
"name": "body",
"in": "body",
"required": true,
"schema": {
"$ref": "#/definitions/domain.UpdateExamPrepPracticeInput"
}
}
],
"responses": {}
},
"delete": {
"tags": [
"exam-prep"
],
"summary": "Delete exam-prep practice",
"parameters": [
{
"type": "integer",
"description": "Exam prep practice ID",
"name": "id",
"in": "path",
"required": true
}
],
"responses": {}
}
},
"/api/v1/exam-prep/units/{id}": {
"get": {
"tags": [
"exam-prep"
],
"summary": "Get exam-prep unit by ID",
"parameters": [
{
"type": "integer",
"description": "Unit ID",
"name": "id",
"in": "path",
"required": true
}
],
"responses": {}
},
"put": {
"tags": [
"exam-prep"
],
"summary": "Update exam-prep unit",
"parameters": [
{
"type": "integer",
"description": "Unit ID",
"name": "id",
"in": "path",
"required": true
},
{
"description": "Fields to update",
"name": "body",
"in": "body",
"required": true,
"schema": {
"$ref": "#/definitions/domain.UpdateExamPrepUnitInput"
}
}
],
"responses": {}
},
"delete": {
"tags": [
"exam-prep"
],
"summary": "Delete exam-prep unit",
"parameters": [
{
"type": "integer",
"description": "Unit ID",
"name": "id",
"in": "path",
"required": true
}
],
"responses": {}
}
},
"/api/v1/exam-prep/units/{unitId}/modules": {
"get": {
"tags": [
"exam-prep"
],
"summary": "List exam-prep modules for a unit",
"parameters": [
{
"type": "integer",
"description": "Unit ID",
"name": "unitId",
"in": "path",
"required": true
}
],
"responses": {}
},
"post": {
"tags": [
"exam-prep"
],
"summary": "Create exam-prep module",
"parameters": [
{
"type": "integer",
"description": "Unit ID",
"name": "unitId",
"in": "path",
"required": true
},
{
"description": "Module",
"name": "body",
"in": "body",
"required": true,
"schema": {
"$ref": "#/definitions/domain.CreateExamPrepModuleInput"
}
}
],
"responses": {}
}
},
"/api/v1/exam-prep/units/{unitId}/modules/reorder": {
"put": {
"tags": [
"exam-prep"
],
"summary": "Reorder modules within a unit",
"parameters": [
{
"type": "integer",
"description": "Unit ID",
"name": "unitId",
"in": "path",
"required": true
},
{
"description": "ordered_ids",
"name": "body",
"in": "body",
"required": true,
"schema": {
"$ref": "#/definitions/domain.ReorderIDsRequest"
}
}
],
"responses": {}
}
},
"/api/v1/files/audio": { "/api/v1/files/audio": {
"post": { "post": {
"consumes": [ "consumes": [
@ -979,6 +1636,39 @@ const docTemplate = `{
} }
} }
}, },
"/api/v1/files/refresh-url": {
"post": {
"consumes": [
"application/json"
],
"produces": [
"application/json"
],
"tags": [
"files"
],
"summary": "Refresh presigned URL for a file",
"parameters": [
{
"description": "reference (object key, minio://..., or existing presigned URL)",
"name": "body",
"in": "body",
"required": true,
"schema": {
"$ref": "#/definitions/handlers.refreshFileURLReq"
}
}
],
"responses": {
"200": {
"description": "OK",
"schema": {
"$ref": "#/definitions/domain.Response"
}
}
}
}
},
"/api/v1/files/upload": { "/api/v1/files/upload": {
"post": { "post": {
"consumes": [ "consumes": [
@ -3956,6 +4646,26 @@ const docTemplate = `{
} }
} }
}, },
"/api/v1/questions/component-catalog": {
"get": {
"description": "Valid stimulus and response component kind codes for dynamic question-type definitions",
"produces": [
"application/json"
],
"tags": [
"questions"
],
"summary": "Question-type builder component catalog",
"responses": {
"200": {
"description": "OK",
"schema": {
"$ref": "#/definitions/domain.Response"
}
}
}
}
},
"/api/v1/questions/search": { "/api/v1/questions/search": {
"get": { "get": {
"description": "Search questions by text", "description": "Search questions by text",
@ -4011,6 +4721,46 @@ const docTemplate = `{
} }
} }
}, },
"/api/v1/questions/validate-question-type-definition": {
"post": {
"description": "Validates selected stimulus and response component kinds for temporary question-type definitions",
"consumes": [
"application/json"
],
"produces": [
"application/json"
],
"tags": [
"questions"
],
"summary": "Validate dynamic question-type definition",
"parameters": [
{
"description": "Stimulus and response component kinds",
"name": "body",
"in": "body",
"required": true,
"schema": {
"$ref": "#/definitions/handlers.validateQuestionTypeDefinitionReq"
}
}
],
"responses": {
"200": {
"description": "OK",
"schema": {
"$ref": "#/definitions/domain.Response"
}
},
"400": {
"description": "Bad Request",
"schema": {
"$ref": "#/definitions/domain.ErrorResponse"
}
}
}
}
},
"/api/v1/questions/{id}": { "/api/v1/questions/{id}": {
"get": { "get": {
"description": "Returns a question with its options/short answers", "description": "Returns a question with its options/short answers",
@ -8166,6 +8916,107 @@ const docTemplate = `{
} }
} }
}, },
"domain.CreateExamPrepCatalogCourseInput": {
"type": "object",
"required": [
"name"
],
"properties": {
"description": {
"type": "string"
},
"name": {
"type": "string"
},
"thumbnail": {
"type": "string"
}
}
},
"domain.CreateExamPrepLessonInput": {
"type": "object",
"required": [
"title"
],
"properties": {
"description": {
"type": "string"
},
"thumbnail": {
"type": "string"
},
"title": {
"type": "string"
},
"video_url": {
"type": "string"
}
}
},
"domain.CreateExamPrepModuleInput": {
"type": "object",
"required": [
"name"
],
"properties": {
"description": {
"type": "string"
},
"icon": {
"type": "string"
},
"name": {
"type": "string"
},
"thumbnail": {
"type": "string"
}
}
},
"domain.CreateExamPrepPracticeInput": {
"type": "object",
"required": [
"question_set_id",
"title"
],
"properties": {
"persona_id": {
"type": "integer"
},
"question_set_id": {
"type": "integer"
},
"quick_tips": {
"type": "string"
},
"story_description": {
"type": "string"
},
"story_image": {
"type": "string"
},
"title": {
"type": "string"
}
}
},
"domain.CreateExamPrepUnitInput": {
"type": "object",
"required": [
"name"
],
"properties": {
"description": {
"type": "string"
},
"name": {
"type": "string"
},
"thumbnail": {
"type": "string"
}
}
},
"domain.CreateLessonInput": { "domain.CreateLessonInput": {
"type": "object", "type": "object",
"required": [ "required": [
@ -8920,6 +9771,63 @@ const docTemplate = `{
} }
} }
}, },
"domain.UpdateExamPrepCatalogCourseInput": {
"type": "object",
"properties": {
"description": {
"type": "string"
},
"name": {
"type": "string"
},
"sort_order": {
"type": "integer"
},
"thumbnail": {
"type": "string"
}
}
},
"domain.UpdateExamPrepPracticeInput": {
"type": "object",
"properties": {
"persona_id": {
"type": "integer"
},
"question_set_id": {
"type": "integer"
},
"quick_tips": {
"type": "string"
},
"story_description": {
"type": "string"
},
"story_image": {
"type": "string"
},
"title": {
"type": "string"
}
}
},
"domain.UpdateExamPrepUnitInput": {
"type": "object",
"properties": {
"description": {
"type": "string"
},
"name": {
"type": "string"
},
"sort_order": {
"type": "integer"
},
"thumbnail": {
"type": "string"
}
}
},
"domain.UpdateKnowledgeLevelReq": { "domain.UpdateKnowledgeLevelReq": {
"type": "object", "type": "object",
"properties": { "properties": {
@ -10046,6 +10954,14 @@ const docTemplate = `{
} }
} }
}, },
"handlers.refreshFileURLReq": {
"type": "object",
"properties": {
"reference": {
"type": "string"
}
}
},
"handlers.refreshToken": { "handlers.refreshToken": {
"type": "object", "type": "object",
"required": [ "required": [
@ -10321,6 +11237,23 @@ const docTemplate = `{
} }
} }
}, },
"handlers.validateQuestionTypeDefinitionReq": {
"type": "object",
"properties": {
"response_component_kinds": {
"type": "array",
"items": {
"type": "string"
}
},
"stimulus_component_kinds": {
"type": "array",
"items": {
"type": "string"
}
}
}
},
"handlers.verifyOTPReq": { "handlers.verifyOTPReq": {
"type": "object", "type": "object",
"required": [ "required": [

View File

@ -943,6 +943,663 @@
"responses": {} "responses": {}
} }
}, },
"/api/v1/exam-prep/catalog-courses": {
"get": {
"produces": [
"application/json"
],
"tags": [
"exam-prep"
],
"summary": "List exam-prep catalog courses",
"parameters": [
{
"type": "integer",
"default": 20,
"description": "Page size",
"name": "limit",
"in": "query"
},
{
"type": "integer",
"default": 0,
"description": "Offset",
"name": "offset",
"in": "query"
}
],
"responses": {
"200": {
"description": "OK",
"schema": {
"$ref": "#/definitions/domain.Response"
}
}
}
},
"post": {
"description": "Top-level exam track (DET, IELTS, …) in schema exam_prep — separate from LMS programs/courses",
"consumes": [
"application/json"
],
"produces": [
"application/json"
],
"tags": [
"exam-prep"
],
"summary": "Create exam-prep catalog course",
"parameters": [
{
"description": "Catalog course",
"name": "body",
"in": "body",
"required": true,
"schema": {
"$ref": "#/definitions/domain.CreateExamPrepCatalogCourseInput"
}
}
],
"responses": {
"201": {
"description": "Created",
"schema": {
"$ref": "#/definitions/domain.Response"
}
},
"400": {
"description": "Bad Request",
"schema": {
"$ref": "#/definitions/domain.ErrorResponse"
}
}
}
}
},
"/api/v1/exam-prep/catalog-courses/reorder": {
"put": {
"consumes": [
"application/json"
],
"produces": [
"application/json"
],
"tags": [
"exam-prep"
],
"summary": "Reorder all exam-prep catalog courses",
"parameters": [
{
"description": "ordered_ids: every catalog course id exactly once",
"name": "body",
"in": "body",
"required": true,
"schema": {
"$ref": "#/definitions/domain.ReorderIDsRequest"
}
}
],
"responses": {
"200": {
"description": "OK",
"schema": {
"$ref": "#/definitions/domain.Response"
}
}
}
}
},
"/api/v1/exam-prep/catalog-courses/{catalogCourseId}/units": {
"get": {
"tags": [
"exam-prep"
],
"summary": "List exam-prep units for a catalog course",
"parameters": [
{
"type": "integer",
"description": "Catalog course ID",
"name": "catalogCourseId",
"in": "path",
"required": true
},
{
"type": "integer",
"default": 20,
"description": "Page size",
"name": "limit",
"in": "query"
},
{
"type": "integer",
"default": 0,
"description": "Offset",
"name": "offset",
"in": "query"
}
],
"responses": {
"200": {
"description": "OK",
"schema": {
"$ref": "#/definitions/domain.Response"
}
}
}
},
"post": {
"description": "Unit under a catalog course (e.g. chapter title)",
"consumes": [
"application/json"
],
"produces": [
"application/json"
],
"tags": [
"exam-prep"
],
"summary": "Create exam-prep unit",
"parameters": [
{
"type": "integer",
"description": "Catalog course ID",
"name": "catalogCourseId",
"in": "path",
"required": true
},
{
"description": "Unit",
"name": "body",
"in": "body",
"required": true,
"schema": {
"$ref": "#/definitions/domain.CreateExamPrepUnitInput"
}
}
],
"responses": {
"201": {
"description": "Created",
"schema": {
"$ref": "#/definitions/domain.Response"
}
}
}
}
},
"/api/v1/exam-prep/catalog-courses/{catalogCourseId}/units/reorder": {
"put": {
"tags": [
"exam-prep"
],
"summary": "Reorder units within a catalog course",
"parameters": [
{
"type": "integer",
"description": "Catalog course ID",
"name": "catalogCourseId",
"in": "path",
"required": true
},
{
"description": "ordered_ids: every unit id in this catalog course, new order",
"name": "body",
"in": "body",
"required": true,
"schema": {
"$ref": "#/definitions/domain.ReorderIDsRequest"
}
}
],
"responses": {}
}
},
"/api/v1/exam-prep/catalog-courses/{id}": {
"get": {
"produces": [
"application/json"
],
"tags": [
"exam-prep"
],
"summary": "Get exam-prep catalog course by ID",
"parameters": [
{
"type": "integer",
"description": "Catalog course ID",
"name": "id",
"in": "path",
"required": true
}
],
"responses": {
"200": {
"description": "OK",
"schema": {
"$ref": "#/definitions/domain.Response"
}
}
}
},
"put": {
"consumes": [
"application/json"
],
"produces": [
"application/json"
],
"tags": [
"exam-prep"
],
"summary": "Update exam-prep catalog course",
"parameters": [
{
"type": "integer",
"description": "Catalog course ID",
"name": "id",
"in": "path",
"required": true
},
{
"description": "Fields to update",
"name": "body",
"in": "body",
"required": true,
"schema": {
"$ref": "#/definitions/domain.UpdateExamPrepCatalogCourseInput"
}
}
],
"responses": {
"200": {
"description": "OK",
"schema": {
"$ref": "#/definitions/domain.Response"
}
}
}
},
"delete": {
"tags": [
"exam-prep"
],
"summary": "Delete exam-prep catalog course",
"parameters": [
{
"type": "integer",
"description": "Catalog course ID",
"name": "id",
"in": "path",
"required": true
}
],
"responses": {
"200": {
"description": "OK",
"schema": {
"$ref": "#/definitions/domain.Response"
}
}
}
}
},
"/api/v1/exam-prep/lessons/{id}": {
"get": {
"tags": [
"exam-prep"
],
"summary": "Get exam-prep lesson by ID",
"responses": {}
},
"put": {
"tags": [
"exam-prep"
],
"summary": "Update exam-prep lesson",
"responses": {}
},
"delete": {
"tags": [
"exam-prep"
],
"summary": "Delete exam-prep lesson",
"responses": {}
}
},
"/api/v1/exam-prep/lessons/{lessonId}/practices": {
"get": {
"tags": [
"exam-prep"
],
"summary": "List exam-prep practices for a lesson",
"parameters": [
{
"type": "integer",
"description": "Exam prep lesson ID",
"name": "lessonId",
"in": "path",
"required": true
},
{
"type": "integer",
"default": 20,
"description": "Page size",
"name": "limit",
"in": "query"
},
{
"type": "integer",
"default": 0,
"description": "Offset",
"name": "offset",
"in": "query"
}
],
"responses": {}
},
"post": {
"tags": [
"exam-prep"
],
"summary": "Create exam-prep practice (under a lesson; uses shared question_sets)",
"parameters": [
{
"type": "integer",
"description": "Exam prep lesson ID (unit_module_lessons.id)",
"name": "lessonId",
"in": "path",
"required": true
},
{
"description": "Practice",
"name": "body",
"in": "body",
"required": true,
"schema": {
"$ref": "#/definitions/domain.CreateExamPrepPracticeInput"
}
}
],
"responses": {}
}
},
"/api/v1/exam-prep/modules/{id}": {
"get": {
"tags": [
"exam-prep"
],
"summary": "Get exam-prep module by ID",
"responses": {}
},
"put": {
"tags": [
"exam-prep"
],
"summary": "Update exam-prep module",
"responses": {}
},
"delete": {
"tags": [
"exam-prep"
],
"summary": "Delete exam-prep module",
"responses": {}
}
},
"/api/v1/exam-prep/modules/{moduleId}/lessons": {
"get": {
"tags": [
"exam-prep"
],
"summary": "List exam-prep lessons for a unit module",
"parameters": [
{
"type": "integer",
"description": "Exam prep unit module ID",
"name": "moduleId",
"in": "path",
"required": true
},
{
"type": "integer",
"default": 20,
"description": "Page size",
"name": "limit",
"in": "query"
},
{
"type": "integer",
"default": 0,
"description": "Offset",
"name": "offset",
"in": "query"
}
],
"responses": {}
},
"post": {
"tags": [
"exam-prep"
],
"summary": "Create exam-prep lesson (under a unit module)",
"parameters": [
{
"type": "integer",
"description": "Exam prep unit module ID",
"name": "moduleId",
"in": "path",
"required": true
},
{
"description": "Lesson",
"name": "body",
"in": "body",
"required": true,
"schema": {
"$ref": "#/definitions/domain.CreateExamPrepLessonInput"
}
}
],
"responses": {}
}
},
"/api/v1/exam-prep/modules/{moduleId}/lessons/reorder": {
"put": {
"tags": [
"exam-prep"
],
"summary": "Reorder lessons within an exam-prep unit module",
"responses": {}
}
},
"/api/v1/exam-prep/practices/{id}": {
"get": {
"tags": [
"exam-prep"
],
"summary": "Get exam-prep practice by ID",
"parameters": [
{
"type": "integer",
"description": "Exam prep practice ID",
"name": "id",
"in": "path",
"required": true
}
],
"responses": {}
},
"put": {
"tags": [
"exam-prep"
],
"summary": "Update exam-prep practice",
"parameters": [
{
"type": "integer",
"description": "Exam prep practice ID",
"name": "id",
"in": "path",
"required": true
},
{
"description": "Fields to update",
"name": "body",
"in": "body",
"required": true,
"schema": {
"$ref": "#/definitions/domain.UpdateExamPrepPracticeInput"
}
}
],
"responses": {}
},
"delete": {
"tags": [
"exam-prep"
],
"summary": "Delete exam-prep practice",
"parameters": [
{
"type": "integer",
"description": "Exam prep practice ID",
"name": "id",
"in": "path",
"required": true
}
],
"responses": {}
}
},
"/api/v1/exam-prep/units/{id}": {
"get": {
"tags": [
"exam-prep"
],
"summary": "Get exam-prep unit by ID",
"parameters": [
{
"type": "integer",
"description": "Unit ID",
"name": "id",
"in": "path",
"required": true
}
],
"responses": {}
},
"put": {
"tags": [
"exam-prep"
],
"summary": "Update exam-prep unit",
"parameters": [
{
"type": "integer",
"description": "Unit ID",
"name": "id",
"in": "path",
"required": true
},
{
"description": "Fields to update",
"name": "body",
"in": "body",
"required": true,
"schema": {
"$ref": "#/definitions/domain.UpdateExamPrepUnitInput"
}
}
],
"responses": {}
},
"delete": {
"tags": [
"exam-prep"
],
"summary": "Delete exam-prep unit",
"parameters": [
{
"type": "integer",
"description": "Unit ID",
"name": "id",
"in": "path",
"required": true
}
],
"responses": {}
}
},
"/api/v1/exam-prep/units/{unitId}/modules": {
"get": {
"tags": [
"exam-prep"
],
"summary": "List exam-prep modules for a unit",
"parameters": [
{
"type": "integer",
"description": "Unit ID",
"name": "unitId",
"in": "path",
"required": true
}
],
"responses": {}
},
"post": {
"tags": [
"exam-prep"
],
"summary": "Create exam-prep module",
"parameters": [
{
"type": "integer",
"description": "Unit ID",
"name": "unitId",
"in": "path",
"required": true
},
{
"description": "Module",
"name": "body",
"in": "body",
"required": true,
"schema": {
"$ref": "#/definitions/domain.CreateExamPrepModuleInput"
}
}
],
"responses": {}
}
},
"/api/v1/exam-prep/units/{unitId}/modules/reorder": {
"put": {
"tags": [
"exam-prep"
],
"summary": "Reorder modules within a unit",
"parameters": [
{
"type": "integer",
"description": "Unit ID",
"name": "unitId",
"in": "path",
"required": true
},
{
"description": "ordered_ids",
"name": "body",
"in": "body",
"required": true,
"schema": {
"$ref": "#/definitions/domain.ReorderIDsRequest"
}
}
],
"responses": {}
}
},
"/api/v1/files/audio": { "/api/v1/files/audio": {
"post": { "post": {
"consumes": [ "consumes": [
@ -971,6 +1628,39 @@
} }
} }
}, },
"/api/v1/files/refresh-url": {
"post": {
"consumes": [
"application/json"
],
"produces": [
"application/json"
],
"tags": [
"files"
],
"summary": "Refresh presigned URL for a file",
"parameters": [
{
"description": "reference (object key, minio://..., or existing presigned URL)",
"name": "body",
"in": "body",
"required": true,
"schema": {
"$ref": "#/definitions/handlers.refreshFileURLReq"
}
}
],
"responses": {
"200": {
"description": "OK",
"schema": {
"$ref": "#/definitions/domain.Response"
}
}
}
}
},
"/api/v1/files/upload": { "/api/v1/files/upload": {
"post": { "post": {
"consumes": [ "consumes": [
@ -3948,6 +4638,26 @@
} }
} }
}, },
"/api/v1/questions/component-catalog": {
"get": {
"description": "Valid stimulus and response component kind codes for dynamic question-type definitions",
"produces": [
"application/json"
],
"tags": [
"questions"
],
"summary": "Question-type builder component catalog",
"responses": {
"200": {
"description": "OK",
"schema": {
"$ref": "#/definitions/domain.Response"
}
}
}
}
},
"/api/v1/questions/search": { "/api/v1/questions/search": {
"get": { "get": {
"description": "Search questions by text", "description": "Search questions by text",
@ -4003,6 +4713,46 @@
} }
} }
}, },
"/api/v1/questions/validate-question-type-definition": {
"post": {
"description": "Validates selected stimulus and response component kinds for temporary question-type definitions",
"consumes": [
"application/json"
],
"produces": [
"application/json"
],
"tags": [
"questions"
],
"summary": "Validate dynamic question-type definition",
"parameters": [
{
"description": "Stimulus and response component kinds",
"name": "body",
"in": "body",
"required": true,
"schema": {
"$ref": "#/definitions/handlers.validateQuestionTypeDefinitionReq"
}
}
],
"responses": {
"200": {
"description": "OK",
"schema": {
"$ref": "#/definitions/domain.Response"
}
},
"400": {
"description": "Bad Request",
"schema": {
"$ref": "#/definitions/domain.ErrorResponse"
}
}
}
}
},
"/api/v1/questions/{id}": { "/api/v1/questions/{id}": {
"get": { "get": {
"description": "Returns a question with its options/short answers", "description": "Returns a question with its options/short answers",
@ -8158,6 +8908,107 @@
} }
} }
}, },
"domain.CreateExamPrepCatalogCourseInput": {
"type": "object",
"required": [
"name"
],
"properties": {
"description": {
"type": "string"
},
"name": {
"type": "string"
},
"thumbnail": {
"type": "string"
}
}
},
"domain.CreateExamPrepLessonInput": {
"type": "object",
"required": [
"title"
],
"properties": {
"description": {
"type": "string"
},
"thumbnail": {
"type": "string"
},
"title": {
"type": "string"
},
"video_url": {
"type": "string"
}
}
},
"domain.CreateExamPrepModuleInput": {
"type": "object",
"required": [
"name"
],
"properties": {
"description": {
"type": "string"
},
"icon": {
"type": "string"
},
"name": {
"type": "string"
},
"thumbnail": {
"type": "string"
}
}
},
"domain.CreateExamPrepPracticeInput": {
"type": "object",
"required": [
"question_set_id",
"title"
],
"properties": {
"persona_id": {
"type": "integer"
},
"question_set_id": {
"type": "integer"
},
"quick_tips": {
"type": "string"
},
"story_description": {
"type": "string"
},
"story_image": {
"type": "string"
},
"title": {
"type": "string"
}
}
},
"domain.CreateExamPrepUnitInput": {
"type": "object",
"required": [
"name"
],
"properties": {
"description": {
"type": "string"
},
"name": {
"type": "string"
},
"thumbnail": {
"type": "string"
}
}
},
"domain.CreateLessonInput": { "domain.CreateLessonInput": {
"type": "object", "type": "object",
"required": [ "required": [
@ -8912,6 +9763,63 @@
} }
} }
}, },
"domain.UpdateExamPrepCatalogCourseInput": {
"type": "object",
"properties": {
"description": {
"type": "string"
},
"name": {
"type": "string"
},
"sort_order": {
"type": "integer"
},
"thumbnail": {
"type": "string"
}
}
},
"domain.UpdateExamPrepPracticeInput": {
"type": "object",
"properties": {
"persona_id": {
"type": "integer"
},
"question_set_id": {
"type": "integer"
},
"quick_tips": {
"type": "string"
},
"story_description": {
"type": "string"
},
"story_image": {
"type": "string"
},
"title": {
"type": "string"
}
}
},
"domain.UpdateExamPrepUnitInput": {
"type": "object",
"properties": {
"description": {
"type": "string"
},
"name": {
"type": "string"
},
"sort_order": {
"type": "integer"
},
"thumbnail": {
"type": "string"
}
}
},
"domain.UpdateKnowledgeLevelReq": { "domain.UpdateKnowledgeLevelReq": {
"type": "object", "type": "object",
"properties": { "properties": {
@ -10038,6 +10946,14 @@
} }
} }
}, },
"handlers.refreshFileURLReq": {
"type": "object",
"properties": {
"reference": {
"type": "string"
}
}
},
"handlers.refreshToken": { "handlers.refreshToken": {
"type": "object", "type": "object",
"required": [ "required": [
@ -10313,6 +11229,23 @@
} }
} }
}, },
"handlers.validateQuestionTypeDefinitionReq": {
"type": "object",
"properties": {
"response_component_kinds": {
"type": "array",
"items": {
"type": "string"
}
},
"stimulus_component_kinds": {
"type": "array",
"items": {
"type": "string"
}
}
}
},
"handlers.verifyOTPReq": { "handlers.verifyOTPReq": {
"type": "object", "type": "object",
"required": [ "required": [

View File

@ -28,6 +28,72 @@ definitions:
required: required:
- name - name
type: object type: object
domain.CreateExamPrepCatalogCourseInput:
properties:
description:
type: string
name:
type: string
thumbnail:
type: string
required:
- name
type: object
domain.CreateExamPrepLessonInput:
properties:
description:
type: string
thumbnail:
type: string
title:
type: string
video_url:
type: string
required:
- title
type: object
domain.CreateExamPrepModuleInput:
properties:
description:
type: string
icon:
type: string
name:
type: string
thumbnail:
type: string
required:
- name
type: object
domain.CreateExamPrepPracticeInput:
properties:
persona_id:
type: integer
question_set_id:
type: integer
quick_tips:
type: string
story_description:
type: string
story_image:
type: string
title:
type: string
required:
- question_set_id
- title
type: object
domain.CreateExamPrepUnitInput:
properties:
description:
type: string
name:
type: string
thumbnail:
type: string
required:
- name
type: object
domain.CreateLessonInput: domain.CreateLessonInput:
properties: properties:
description: description:
@ -542,6 +608,43 @@ definitions:
thumbnail: thumbnail:
type: string type: string
type: object type: object
domain.UpdateExamPrepCatalogCourseInput:
properties:
description:
type: string
name:
type: string
sort_order:
type: integer
thumbnail:
type: string
type: object
domain.UpdateExamPrepPracticeInput:
properties:
persona_id:
type: integer
question_set_id:
type: integer
quick_tips:
type: string
story_description:
type: string
story_image:
type: string
title:
type: string
type: object
domain.UpdateExamPrepUnitInput:
properties:
description:
type: string
name:
type: string
sort_order:
type: integer
thumbnail:
type: string
type: object
domain.UpdateKnowledgeLevelReq: domain.UpdateKnowledgeLevelReq:
properties: properties:
knowledge_level: knowledge_level:
@ -1301,6 +1404,11 @@ definitions:
required: required:
- option_text - option_text
type: object type: object
handlers.refreshFileURLReq:
properties:
reference:
type: string
type: object
handlers.refreshToken: handlers.refreshToken:
properties: properties:
access_token: access_token:
@ -1486,6 +1594,17 @@ definitions:
title: title:
type: string type: string
type: object type: object
handlers.validateQuestionTypeDefinitionReq:
properties:
response_component_kinds:
items:
type: string
type: array
stimulus_component_kinds:
items:
type: string
type: array
type: object
handlers.verifyOTPReq: handlers.verifyOTPReq:
properties: properties:
otp: otp:
@ -2521,6 +2640,447 @@ paths:
responses: {} responses: {}
tags: tags:
- practices - practices
/api/v1/exam-prep/catalog-courses:
get:
parameters:
- default: 20
description: Page size
in: query
name: limit
type: integer
- default: 0
description: Offset
in: query
name: offset
type: integer
produces:
- application/json
responses:
"200":
description: OK
schema:
$ref: '#/definitions/domain.Response'
summary: List exam-prep catalog courses
tags:
- exam-prep
post:
consumes:
- application/json
description: Top-level exam track (DET, IELTS, …) in schema exam_prep — separate
from LMS programs/courses
parameters:
- description: Catalog course
in: body
name: body
required: true
schema:
$ref: '#/definitions/domain.CreateExamPrepCatalogCourseInput'
produces:
- application/json
responses:
"201":
description: Created
schema:
$ref: '#/definitions/domain.Response'
"400":
description: Bad Request
schema:
$ref: '#/definitions/domain.ErrorResponse'
summary: Create exam-prep catalog course
tags:
- exam-prep
/api/v1/exam-prep/catalog-courses/{catalogCourseId}/units:
get:
parameters:
- description: Catalog course ID
in: path
name: catalogCourseId
required: true
type: integer
- default: 20
description: Page size
in: query
name: limit
type: integer
- default: 0
description: Offset
in: query
name: offset
type: integer
responses:
"200":
description: OK
schema:
$ref: '#/definitions/domain.Response'
summary: List exam-prep units for a catalog course
tags:
- exam-prep
post:
consumes:
- application/json
description: Unit under a catalog course (e.g. chapter title)
parameters:
- description: Catalog course ID
in: path
name: catalogCourseId
required: true
type: integer
- description: Unit
in: body
name: body
required: true
schema:
$ref: '#/definitions/domain.CreateExamPrepUnitInput'
produces:
- application/json
responses:
"201":
description: Created
schema:
$ref: '#/definitions/domain.Response'
summary: Create exam-prep unit
tags:
- exam-prep
/api/v1/exam-prep/catalog-courses/{catalogCourseId}/units/reorder:
put:
parameters:
- description: Catalog course ID
in: path
name: catalogCourseId
required: true
type: integer
- description: 'ordered_ids: every unit id in this catalog course, new order'
in: body
name: body
required: true
schema:
$ref: '#/definitions/domain.ReorderIDsRequest'
responses: {}
summary: Reorder units within a catalog course
tags:
- exam-prep
/api/v1/exam-prep/catalog-courses/{id}:
delete:
parameters:
- description: Catalog course ID
in: path
name: id
required: true
type: integer
responses:
"200":
description: OK
schema:
$ref: '#/definitions/domain.Response'
summary: Delete exam-prep catalog course
tags:
- exam-prep
get:
parameters:
- description: Catalog course ID
in: path
name: id
required: true
type: integer
produces:
- application/json
responses:
"200":
description: OK
schema:
$ref: '#/definitions/domain.Response'
summary: Get exam-prep catalog course by ID
tags:
- exam-prep
put:
consumes:
- application/json
parameters:
- description: Catalog course ID
in: path
name: id
required: true
type: integer
- description: Fields to update
in: body
name: body
required: true
schema:
$ref: '#/definitions/domain.UpdateExamPrepCatalogCourseInput'
produces:
- application/json
responses:
"200":
description: OK
schema:
$ref: '#/definitions/domain.Response'
summary: Update exam-prep catalog course
tags:
- exam-prep
/api/v1/exam-prep/catalog-courses/reorder:
put:
consumes:
- application/json
parameters:
- description: 'ordered_ids: every catalog course id exactly once'
in: body
name: body
required: true
schema:
$ref: '#/definitions/domain.ReorderIDsRequest'
produces:
- application/json
responses:
"200":
description: OK
schema:
$ref: '#/definitions/domain.Response'
summary: Reorder all exam-prep catalog courses
tags:
- exam-prep
/api/v1/exam-prep/lessons/{id}:
delete:
responses: {}
summary: Delete exam-prep lesson
tags:
- exam-prep
get:
responses: {}
summary: Get exam-prep lesson by ID
tags:
- exam-prep
put:
responses: {}
summary: Update exam-prep lesson
tags:
- exam-prep
/api/v1/exam-prep/lessons/{lessonId}/practices:
get:
parameters:
- description: Exam prep lesson ID
in: path
name: lessonId
required: true
type: integer
- default: 20
description: Page size
in: query
name: limit
type: integer
- default: 0
description: Offset
in: query
name: offset
type: integer
responses: {}
summary: List exam-prep practices for a lesson
tags:
- exam-prep
post:
parameters:
- description: Exam prep lesson ID (unit_module_lessons.id)
in: path
name: lessonId
required: true
type: integer
- description: Practice
in: body
name: body
required: true
schema:
$ref: '#/definitions/domain.CreateExamPrepPracticeInput'
responses: {}
summary: Create exam-prep practice (under a lesson; uses shared question_sets)
tags:
- exam-prep
/api/v1/exam-prep/modules/{id}:
delete:
responses: {}
summary: Delete exam-prep module
tags:
- exam-prep
get:
responses: {}
summary: Get exam-prep module by ID
tags:
- exam-prep
put:
responses: {}
summary: Update exam-prep module
tags:
- exam-prep
/api/v1/exam-prep/modules/{moduleId}/lessons:
get:
parameters:
- description: Exam prep unit module ID
in: path
name: moduleId
required: true
type: integer
- default: 20
description: Page size
in: query
name: limit
type: integer
- default: 0
description: Offset
in: query
name: offset
type: integer
responses: {}
summary: List exam-prep lessons for a unit module
tags:
- exam-prep
post:
parameters:
- description: Exam prep unit module ID
in: path
name: moduleId
required: true
type: integer
- description: Lesson
in: body
name: body
required: true
schema:
$ref: '#/definitions/domain.CreateExamPrepLessonInput'
responses: {}
summary: Create exam-prep lesson (under a unit module)
tags:
- exam-prep
/api/v1/exam-prep/modules/{moduleId}/lessons/reorder:
put:
responses: {}
summary: Reorder lessons within an exam-prep unit module
tags:
- exam-prep
/api/v1/exam-prep/practices/{id}:
delete:
parameters:
- description: Exam prep practice ID
in: path
name: id
required: true
type: integer
responses: {}
summary: Delete exam-prep practice
tags:
- exam-prep
get:
parameters:
- description: Exam prep practice ID
in: path
name: id
required: true
type: integer
responses: {}
summary: Get exam-prep practice by ID
tags:
- exam-prep
put:
parameters:
- description: Exam prep practice ID
in: path
name: id
required: true
type: integer
- description: Fields to update
in: body
name: body
required: true
schema:
$ref: '#/definitions/domain.UpdateExamPrepPracticeInput'
responses: {}
summary: Update exam-prep practice
tags:
- exam-prep
/api/v1/exam-prep/units/{id}:
delete:
parameters:
- description: Unit ID
in: path
name: id
required: true
type: integer
responses: {}
summary: Delete exam-prep unit
tags:
- exam-prep
get:
parameters:
- description: Unit ID
in: path
name: id
required: true
type: integer
responses: {}
summary: Get exam-prep unit by ID
tags:
- exam-prep
put:
parameters:
- description: Unit ID
in: path
name: id
required: true
type: integer
- description: Fields to update
in: body
name: body
required: true
schema:
$ref: '#/definitions/domain.UpdateExamPrepUnitInput'
responses: {}
summary: Update exam-prep unit
tags:
- exam-prep
/api/v1/exam-prep/units/{unitId}/modules:
get:
parameters:
- description: Unit ID
in: path
name: unitId
required: true
type: integer
responses: {}
summary: List exam-prep modules for a unit
tags:
- exam-prep
post:
parameters:
- description: Unit ID
in: path
name: unitId
required: true
type: integer
- description: Module
in: body
name: body
required: true
schema:
$ref: '#/definitions/domain.CreateExamPrepModuleInput'
responses: {}
summary: Create exam-prep module
tags:
- exam-prep
/api/v1/exam-prep/units/{unitId}/modules/reorder:
put:
parameters:
- description: Unit ID
in: path
name: unitId
required: true
type: integer
- description: ordered_ids
in: body
name: body
required: true
schema:
$ref: '#/definitions/domain.ReorderIDsRequest'
responses: {}
summary: Reorder modules within a unit
tags:
- exam-prep
/api/v1/files/audio: /api/v1/files/audio:
post: post:
consumes: consumes:
@ -2539,6 +3099,27 @@ paths:
summary: Upload an audio file summary: Upload an audio file
tags: tags:
- files - files
/api/v1/files/refresh-url:
post:
consumes:
- application/json
parameters:
- description: reference (object key, minio://..., or existing presigned URL)
in: body
name: body
required: true
schema:
$ref: '#/definitions/handlers.refreshFileURLReq'
produces:
- application/json
responses:
"200":
description: OK
schema:
$ref: '#/definitions/domain.Response'
summary: Refresh presigned URL for a file
tags:
- files
/api/v1/files/upload: /api/v1/files/upload:
post: post:
consumes: consumes:
@ -4585,6 +5166,20 @@ paths:
summary: Submit audio answer for a question summary: Submit audio answer for a question
tags: tags:
- questions - questions
/api/v1/questions/component-catalog:
get:
description: Valid stimulus and response component kind codes for dynamic question-type
definitions
produces:
- application/json
responses:
"200":
description: OK
schema:
$ref: '#/definitions/domain.Response'
summary: Question-type builder component catalog
tags:
- questions
/api/v1/questions/search: /api/v1/questions/search:
get: get:
description: Search questions by text description: Search questions by text
@ -4622,6 +5217,33 @@ paths:
summary: Search questions summary: Search questions
tags: tags:
- questions - questions
/api/v1/questions/validate-question-type-definition:
post:
consumes:
- application/json
description: Validates selected stimulus and response component kinds for temporary
question-type definitions
parameters:
- description: Stimulus and response component kinds
in: body
name: body
required: true
schema:
$ref: '#/definitions/handlers.validateQuestionTypeDefinitionReq'
produces:
- application/json
responses:
"200":
description: OK
schema:
$ref: '#/definitions/domain.Response'
"400":
description: Bad Request
schema:
$ref: '#/definitions/domain.ErrorResponse'
summary: Validate dynamic question-type definition
tags:
- questions
/api/v1/ratings: /api/v1/ratings:
get: get:
description: Returns paginated ratings for a specific target description: Returns paginated ratings for a specific target

View File

@ -0,0 +1,207 @@
// Code generated by sqlc. DO NOT EDIT.
// versions:
// sqlc v1.30.0
// source: exam_prep_catalog_courses.sql
package dbgen
import (
"context"
"github.com/jackc/pgx/v5/pgtype"
)
const ExamPrepCreateCatalogCourse = `-- name: ExamPrepCreateCatalogCourse :one
INSERT INTO exam_prep.catalog_courses (name, description, thumbnail, sort_order)
SELECT
$1,
$2,
$3,
coalesce((
SELECT
max(c.sort_order)
FROM exam_prep.catalog_courses AS c), 0) + 1
RETURNING
id, name, description, thumbnail, sort_order, created_at, updated_at
`
type ExamPrepCreateCatalogCourseParams struct {
Name string `json:"name"`
Description pgtype.Text `json:"description"`
Thumbnail pgtype.Text `json:"thumbnail"`
}
func (q *Queries) ExamPrepCreateCatalogCourse(ctx context.Context, arg ExamPrepCreateCatalogCourseParams) (ExamPrepCatalogCourse, error) {
row := q.db.QueryRow(ctx, ExamPrepCreateCatalogCourse, arg.Name, arg.Description, arg.Thumbnail)
var i ExamPrepCatalogCourse
err := row.Scan(
&i.ID,
&i.Name,
&i.Description,
&i.Thumbnail,
&i.SortOrder,
&i.CreatedAt,
&i.UpdatedAt,
)
return i, err
}
const ExamPrepDeleteCatalogCourse = `-- name: ExamPrepDeleteCatalogCourse :exec
DELETE FROM exam_prep.catalog_courses
WHERE id = $1
`
func (q *Queries) ExamPrepDeleteCatalogCourse(ctx context.Context, id int64) error {
_, err := q.db.Exec(ctx, ExamPrepDeleteCatalogCourse, id)
return err
}
const ExamPrepGetCatalogCourseByID = `-- name: ExamPrepGetCatalogCourseByID :one
SELECT id, name, description, thumbnail, sort_order, created_at, updated_at
FROM exam_prep.catalog_courses
WHERE id = $1
`
func (q *Queries) ExamPrepGetCatalogCourseByID(ctx context.Context, id int64) (ExamPrepCatalogCourse, error) {
row := q.db.QueryRow(ctx, ExamPrepGetCatalogCourseByID, id)
var i ExamPrepCatalogCourse
err := row.Scan(
&i.ID,
&i.Name,
&i.Description,
&i.Thumbnail,
&i.SortOrder,
&i.CreatedAt,
&i.UpdatedAt,
)
return i, err
}
const ExamPrepListAllCatalogCourseIDs = `-- name: ExamPrepListAllCatalogCourseIDs :many
SELECT
id
FROM exam_prep.catalog_courses
ORDER BY id
`
func (q *Queries) ExamPrepListAllCatalogCourseIDs(ctx context.Context) ([]int64, error) {
rows, err := q.db.Query(ctx, ExamPrepListAllCatalogCourseIDs)
if err != nil {
return nil, err
}
defer rows.Close()
var items []int64
for rows.Next() {
var id int64
if err := rows.Scan(&id); err != nil {
return nil, err
}
items = append(items, id)
}
if err := rows.Err(); err != nil {
return nil, err
}
return items, nil
}
const ExamPrepListCatalogCourses = `-- name: ExamPrepListCatalogCourses :many
SELECT
COUNT(*) OVER () AS total_count,
c.id,
c.name,
c.description,
c.thumbnail,
c.sort_order,
c.created_at,
c.updated_at
FROM exam_prep.catalog_courses c
ORDER BY c.sort_order ASC, c.id ASC
LIMIT $1 OFFSET $2
`
type ExamPrepListCatalogCoursesParams struct {
Limit int32 `json:"limit"`
Offset int32 `json:"offset"`
}
type ExamPrepListCatalogCoursesRow struct {
TotalCount int64 `json:"total_count"`
ID int64 `json:"id"`
Name string `json:"name"`
Description pgtype.Text `json:"description"`
Thumbnail pgtype.Text `json:"thumbnail"`
SortOrder int32 `json:"sort_order"`
CreatedAt pgtype.Timestamptz `json:"created_at"`
UpdatedAt pgtype.Timestamptz `json:"updated_at"`
}
func (q *Queries) ExamPrepListCatalogCourses(ctx context.Context, arg ExamPrepListCatalogCoursesParams) ([]ExamPrepListCatalogCoursesRow, error) {
rows, err := q.db.Query(ctx, ExamPrepListCatalogCourses, arg.Limit, arg.Offset)
if err != nil {
return nil, err
}
defer rows.Close()
var items []ExamPrepListCatalogCoursesRow
for rows.Next() {
var i ExamPrepListCatalogCoursesRow
if err := rows.Scan(
&i.TotalCount,
&i.ID,
&i.Name,
&i.Description,
&i.Thumbnail,
&i.SortOrder,
&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 ExamPrepUpdateCatalogCourse = `-- name: ExamPrepUpdateCatalogCourse :one
UPDATE exam_prep.catalog_courses
SET
name = coalesce($1::varchar, name),
description = coalesce($2::text, description),
thumbnail = coalesce($3::text, thumbnail),
sort_order = coalesce($4::int, sort_order),
updated_at = CURRENT_TIMESTAMP
WHERE id = $5
RETURNING
id, name, description, thumbnail, sort_order, created_at, updated_at
`
type ExamPrepUpdateCatalogCourseParams struct {
Name pgtype.Text `json:"name"`
Description pgtype.Text `json:"description"`
Thumbnail pgtype.Text `json:"thumbnail"`
SortOrder pgtype.Int4 `json:"sort_order"`
ID int64 `json:"id"`
}
func (q *Queries) ExamPrepUpdateCatalogCourse(ctx context.Context, arg ExamPrepUpdateCatalogCourseParams) (ExamPrepCatalogCourse, error) {
row := q.db.QueryRow(ctx, ExamPrepUpdateCatalogCourse,
arg.Name,
arg.Description,
arg.Thumbnail,
arg.SortOrder,
arg.ID,
)
var i ExamPrepCatalogCourse
err := row.Scan(
&i.ID,
&i.Name,
&i.Description,
&i.Thumbnail,
&i.SortOrder,
&i.CreatedAt,
&i.UpdatedAt,
)
return i, err
}

View File

@ -0,0 +1,217 @@
// Code generated by sqlc. DO NOT EDIT.
// versions:
// sqlc v1.30.0
// source: exam_prep_lesson_practices.sql
package dbgen
import (
"context"
"github.com/jackc/pgx/v5/pgtype"
)
const ExamPrepCreateLessonPractice = `-- name: ExamPrepCreateLessonPractice :one
INSERT INTO exam_prep.lesson_practices (
unit_module_lesson_id,
title,
story_description,
story_image,
persona_id,
question_set_id,
quick_tips
) VALUES ($1, $2, $3, $4, $5, $6, $7)
RETURNING id, unit_module_lesson_id, title, story_description, story_image, persona_id, question_set_id, quick_tips, created_at, updated_at
`
type ExamPrepCreateLessonPracticeParams struct {
UnitModuleLessonID int64 `json:"unit_module_lesson_id"`
Title string `json:"title"`
StoryDescription pgtype.Text `json:"story_description"`
StoryImage pgtype.Text `json:"story_image"`
PersonaID pgtype.Int8 `json:"persona_id"`
QuestionSetID int64 `json:"question_set_id"`
QuickTips pgtype.Text `json:"quick_tips"`
}
func (q *Queries) ExamPrepCreateLessonPractice(ctx context.Context, arg ExamPrepCreateLessonPracticeParams) (ExamPrepLessonPractice, error) {
row := q.db.QueryRow(ctx, ExamPrepCreateLessonPractice,
arg.UnitModuleLessonID,
arg.Title,
arg.StoryDescription,
arg.StoryImage,
arg.PersonaID,
arg.QuestionSetID,
arg.QuickTips,
)
var i ExamPrepLessonPractice
err := row.Scan(
&i.ID,
&i.UnitModuleLessonID,
&i.Title,
&i.StoryDescription,
&i.StoryImage,
&i.PersonaID,
&i.QuestionSetID,
&i.QuickTips,
&i.CreatedAt,
&i.UpdatedAt,
)
return i, err
}
const ExamPrepDeleteLessonPractice = `-- name: ExamPrepDeleteLessonPractice :exec
DELETE FROM exam_prep.lesson_practices
WHERE id = $1
`
func (q *Queries) ExamPrepDeleteLessonPractice(ctx context.Context, id int64) error {
_, err := q.db.Exec(ctx, ExamPrepDeleteLessonPractice, id)
return err
}
const ExamPrepGetLessonPracticeByID = `-- name: ExamPrepGetLessonPracticeByID :one
SELECT id, unit_module_lesson_id, title, story_description, story_image, persona_id, question_set_id, quick_tips, created_at, updated_at
FROM exam_prep.lesson_practices
WHERE id = $1
`
func (q *Queries) ExamPrepGetLessonPracticeByID(ctx context.Context, id int64) (ExamPrepLessonPractice, error) {
row := q.db.QueryRow(ctx, ExamPrepGetLessonPracticeByID, id)
var i ExamPrepLessonPractice
err := row.Scan(
&i.ID,
&i.UnitModuleLessonID,
&i.Title,
&i.StoryDescription,
&i.StoryImage,
&i.PersonaID,
&i.QuestionSetID,
&i.QuickTips,
&i.CreatedAt,
&i.UpdatedAt,
)
return i, err
}
const ExamPrepListLessonPracticesByLessonID = `-- name: ExamPrepListLessonPracticesByLessonID :many
SELECT
COUNT(*) OVER () AS total_count,
p.id,
p.unit_module_lesson_id,
p.title,
p.story_description,
p.story_image,
p.persona_id,
p.question_set_id,
p.quick_tips,
p.created_at,
p.updated_at
FROM exam_prep.lesson_practices p
WHERE p.unit_module_lesson_id = $1
ORDER BY p.created_at DESC
LIMIT $2
OFFSET $3
`
type ExamPrepListLessonPracticesByLessonIDParams struct {
UnitModuleLessonID int64 `json:"unit_module_lesson_id"`
Limit int32 `json:"limit"`
Offset int32 `json:"offset"`
}
type ExamPrepListLessonPracticesByLessonIDRow struct {
TotalCount int64 `json:"total_count"`
ID int64 `json:"id"`
UnitModuleLessonID int64 `json:"unit_module_lesson_id"`
Title string `json:"title"`
StoryDescription pgtype.Text `json:"story_description"`
StoryImage pgtype.Text `json:"story_image"`
PersonaID pgtype.Int8 `json:"persona_id"`
QuestionSetID int64 `json:"question_set_id"`
QuickTips pgtype.Text `json:"quick_tips"`
CreatedAt pgtype.Timestamptz `json:"created_at"`
UpdatedAt pgtype.Timestamptz `json:"updated_at"`
}
func (q *Queries) ExamPrepListLessonPracticesByLessonID(ctx context.Context, arg ExamPrepListLessonPracticesByLessonIDParams) ([]ExamPrepListLessonPracticesByLessonIDRow, error) {
rows, err := q.db.Query(ctx, ExamPrepListLessonPracticesByLessonID, arg.UnitModuleLessonID, arg.Limit, arg.Offset)
if err != nil {
return nil, err
}
defer rows.Close()
var items []ExamPrepListLessonPracticesByLessonIDRow
for rows.Next() {
var i ExamPrepListLessonPracticesByLessonIDRow
if err := rows.Scan(
&i.TotalCount,
&i.ID,
&i.UnitModuleLessonID,
&i.Title,
&i.StoryDescription,
&i.StoryImage,
&i.PersonaID,
&i.QuestionSetID,
&i.QuickTips,
&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 ExamPrepUpdateLessonPractice = `-- name: ExamPrepUpdateLessonPractice :one
UPDATE exam_prep.lesson_practices
SET
title = coalesce($1::varchar, title),
story_description = coalesce($2::text, story_description),
story_image = coalesce($3::text, story_image),
persona_id = coalesce($4::bigint, persona_id),
question_set_id = coalesce($5::bigint, question_set_id),
quick_tips = coalesce($6::text, quick_tips),
updated_at = CURRENT_TIMESTAMP
WHERE id = $7
RETURNING id, unit_module_lesson_id, title, story_description, story_image, persona_id, question_set_id, quick_tips, created_at, updated_at
`
type ExamPrepUpdateLessonPracticeParams struct {
Title pgtype.Text `json:"title"`
StoryDescription pgtype.Text `json:"story_description"`
StoryImage pgtype.Text `json:"story_image"`
PersonaID pgtype.Int8 `json:"persona_id"`
QuestionSetID pgtype.Int8 `json:"question_set_id"`
QuickTips pgtype.Text `json:"quick_tips"`
ID int64 `json:"id"`
}
func (q *Queries) ExamPrepUpdateLessonPractice(ctx context.Context, arg ExamPrepUpdateLessonPracticeParams) (ExamPrepLessonPractice, error) {
row := q.db.QueryRow(ctx, ExamPrepUpdateLessonPractice,
arg.Title,
arg.StoryDescription,
arg.StoryImage,
arg.PersonaID,
arg.QuestionSetID,
arg.QuickTips,
arg.ID,
)
var i ExamPrepLessonPractice
err := row.Scan(
&i.ID,
&i.UnitModuleLessonID,
&i.Title,
&i.StoryDescription,
&i.StoryImage,
&i.PersonaID,
&i.QuestionSetID,
&i.QuickTips,
&i.CreatedAt,
&i.UpdatedAt,
)
return i, err
}

View File

@ -0,0 +1,243 @@
// Code generated by sqlc. DO NOT EDIT.
// versions:
// sqlc v1.30.0
// source: exam_prep_unit_module_lessons.sql
package dbgen
import (
"context"
"github.com/jackc/pgx/v5/pgtype"
)
const ExamPrepCreateUnitModuleLesson = `-- name: ExamPrepCreateUnitModuleLesson :one
INSERT INTO exam_prep.unit_module_lessons (unit_module_id, title, video_url, thumbnail, description, sort_order)
SELECT
$1,
$2,
$3,
$4,
$5,
coalesce((
SELECT
max(l.sort_order)
FROM exam_prep.unit_module_lessons l
WHERE
l.unit_module_id = $1), 0) + 1
RETURNING
id, unit_module_id, title, video_url, thumbnail, description, sort_order, created_at, updated_at
`
type ExamPrepCreateUnitModuleLessonParams struct {
UnitModuleID int64 `json:"unit_module_id"`
Title string `json:"title"`
VideoUrl pgtype.Text `json:"video_url"`
Thumbnail pgtype.Text `json:"thumbnail"`
Description pgtype.Text `json:"description"`
}
func (q *Queries) ExamPrepCreateUnitModuleLesson(ctx context.Context, arg ExamPrepCreateUnitModuleLessonParams) (ExamPrepUnitModuleLesson, error) {
row := q.db.QueryRow(ctx, ExamPrepCreateUnitModuleLesson,
arg.UnitModuleID,
arg.Title,
arg.VideoUrl,
arg.Thumbnail,
arg.Description,
)
var i ExamPrepUnitModuleLesson
err := row.Scan(
&i.ID,
&i.UnitModuleID,
&i.Title,
&i.VideoUrl,
&i.Thumbnail,
&i.Description,
&i.SortOrder,
&i.CreatedAt,
&i.UpdatedAt,
)
return i, err
}
const ExamPrepDeleteUnitModuleLesson = `-- name: ExamPrepDeleteUnitModuleLesson :exec
DELETE FROM exam_prep.unit_module_lessons
WHERE id = $1
`
func (q *Queries) ExamPrepDeleteUnitModuleLesson(ctx context.Context, id int64) error {
_, err := q.db.Exec(ctx, ExamPrepDeleteUnitModuleLesson, id)
return err
}
const ExamPrepGetUnitModuleLessonByID = `-- name: ExamPrepGetUnitModuleLessonByID :one
SELECT id, unit_module_id, title, video_url, thumbnail, description, sort_order, created_at, updated_at
FROM exam_prep.unit_module_lessons
WHERE id = $1
`
func (q *Queries) ExamPrepGetUnitModuleLessonByID(ctx context.Context, id int64) (ExamPrepUnitModuleLesson, error) {
row := q.db.QueryRow(ctx, ExamPrepGetUnitModuleLessonByID, id)
var i ExamPrepUnitModuleLesson
err := row.Scan(
&i.ID,
&i.UnitModuleID,
&i.Title,
&i.VideoUrl,
&i.Thumbnail,
&i.Description,
&i.SortOrder,
&i.CreatedAt,
&i.UpdatedAt,
)
return i, err
}
const ExamPrepListUnitModuleLessonIDsByUnitModule = `-- name: ExamPrepListUnitModuleLessonIDsByUnitModule :many
SELECT
l.id
FROM exam_prep.unit_module_lessons l
WHERE
l.unit_module_id = $1
ORDER BY
l.id
`
func (q *Queries) ExamPrepListUnitModuleLessonIDsByUnitModule(ctx context.Context, unitModuleID int64) ([]int64, error) {
rows, err := q.db.Query(ctx, ExamPrepListUnitModuleLessonIDsByUnitModule, unitModuleID)
if err != nil {
return nil, err
}
defer rows.Close()
var items []int64
for rows.Next() {
var id int64
if err := rows.Scan(&id); err != nil {
return nil, err
}
items = append(items, id)
}
if err := rows.Err(); err != nil {
return nil, err
}
return items, nil
}
const ExamPrepListUnitModuleLessonsByUnitModuleID = `-- name: ExamPrepListUnitModuleLessonsByUnitModuleID :many
SELECT
COUNT(*) OVER () AS total_count,
l.id,
l.unit_module_id,
l.title,
l.video_url,
l.thumbnail,
l.description,
l.sort_order,
l.created_at,
l.updated_at
FROM exam_prep.unit_module_lessons l
WHERE
l.unit_module_id = $1
ORDER BY
l.sort_order ASC,
l.id ASC
LIMIT $2
OFFSET $3
`
type ExamPrepListUnitModuleLessonsByUnitModuleIDParams struct {
UnitModuleID int64 `json:"unit_module_id"`
Limit int32 `json:"limit"`
Offset int32 `json:"offset"`
}
type ExamPrepListUnitModuleLessonsByUnitModuleIDRow struct {
TotalCount int64 `json:"total_count"`
ID int64 `json:"id"`
UnitModuleID int64 `json:"unit_module_id"`
Title string `json:"title"`
VideoUrl pgtype.Text `json:"video_url"`
Thumbnail pgtype.Text `json:"thumbnail"`
Description pgtype.Text `json:"description"`
SortOrder int32 `json:"sort_order"`
CreatedAt pgtype.Timestamptz `json:"created_at"`
UpdatedAt pgtype.Timestamptz `json:"updated_at"`
}
func (q *Queries) ExamPrepListUnitModuleLessonsByUnitModuleID(ctx context.Context, arg ExamPrepListUnitModuleLessonsByUnitModuleIDParams) ([]ExamPrepListUnitModuleLessonsByUnitModuleIDRow, error) {
rows, err := q.db.Query(ctx, ExamPrepListUnitModuleLessonsByUnitModuleID, arg.UnitModuleID, arg.Limit, arg.Offset)
if err != nil {
return nil, err
}
defer rows.Close()
var items []ExamPrepListUnitModuleLessonsByUnitModuleIDRow
for rows.Next() {
var i ExamPrepListUnitModuleLessonsByUnitModuleIDRow
if err := rows.Scan(
&i.TotalCount,
&i.ID,
&i.UnitModuleID,
&i.Title,
&i.VideoUrl,
&i.Thumbnail,
&i.Description,
&i.SortOrder,
&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 ExamPrepUpdateUnitModuleLesson = `-- name: ExamPrepUpdateUnitModuleLesson :one
UPDATE exam_prep.unit_module_lessons
SET
title = coalesce($1::varchar, title),
video_url = coalesce($2::text, video_url),
thumbnail = coalesce($3::text, thumbnail),
description = coalesce($4::text, description),
sort_order = coalesce($5::int, sort_order),
updated_at = CURRENT_TIMESTAMP
WHERE id = $6
RETURNING
id, unit_module_id, title, video_url, thumbnail, description, sort_order, created_at, updated_at
`
type ExamPrepUpdateUnitModuleLessonParams struct {
Title pgtype.Text `json:"title"`
VideoUrl pgtype.Text `json:"video_url"`
Thumbnail pgtype.Text `json:"thumbnail"`
Description pgtype.Text `json:"description"`
SortOrder pgtype.Int4 `json:"sort_order"`
ID int64 `json:"id"`
}
func (q *Queries) ExamPrepUpdateUnitModuleLesson(ctx context.Context, arg ExamPrepUpdateUnitModuleLessonParams) (ExamPrepUnitModuleLesson, error) {
row := q.db.QueryRow(ctx, ExamPrepUpdateUnitModuleLesson,
arg.Title,
arg.VideoUrl,
arg.Thumbnail,
arg.Description,
arg.SortOrder,
arg.ID,
)
var i ExamPrepUnitModuleLesson
err := row.Scan(
&i.ID,
&i.UnitModuleID,
&i.Title,
&i.VideoUrl,
&i.Thumbnail,
&i.Description,
&i.SortOrder,
&i.CreatedAt,
&i.UpdatedAt,
)
return i, err
}

View File

@ -0,0 +1,243 @@
// Code generated by sqlc. DO NOT EDIT.
// versions:
// sqlc v1.30.0
// source: exam_prep_unit_modules.sql
package dbgen
import (
"context"
"github.com/jackc/pgx/v5/pgtype"
)
const ExamPrepCreateUnitModule = `-- name: ExamPrepCreateUnitModule :one
INSERT INTO exam_prep.unit_modules (unit_id, name, description, thumbnail, icon, sort_order)
SELECT
$1,
$2,
$3,
$4,
$5,
coalesce((
SELECT
max(m.sort_order)
FROM exam_prep.unit_modules m
WHERE
m.unit_id = $1), 0) + 1
RETURNING
id, unit_id, name, description, thumbnail, icon, sort_order, created_at, updated_at
`
type ExamPrepCreateUnitModuleParams struct {
UnitID int64 `json:"unit_id"`
Name string `json:"name"`
Description pgtype.Text `json:"description"`
Thumbnail pgtype.Text `json:"thumbnail"`
Icon pgtype.Text `json:"icon"`
}
func (q *Queries) ExamPrepCreateUnitModule(ctx context.Context, arg ExamPrepCreateUnitModuleParams) (ExamPrepUnitModule, error) {
row := q.db.QueryRow(ctx, ExamPrepCreateUnitModule,
arg.UnitID,
arg.Name,
arg.Description,
arg.Thumbnail,
arg.Icon,
)
var i ExamPrepUnitModule
err := row.Scan(
&i.ID,
&i.UnitID,
&i.Name,
&i.Description,
&i.Thumbnail,
&i.Icon,
&i.SortOrder,
&i.CreatedAt,
&i.UpdatedAt,
)
return i, err
}
const ExamPrepDeleteUnitModule = `-- name: ExamPrepDeleteUnitModule :exec
DELETE FROM exam_prep.unit_modules
WHERE id = $1
`
func (q *Queries) ExamPrepDeleteUnitModule(ctx context.Context, id int64) error {
_, err := q.db.Exec(ctx, ExamPrepDeleteUnitModule, id)
return err
}
const ExamPrepGetUnitModuleByID = `-- name: ExamPrepGetUnitModuleByID :one
SELECT id, unit_id, name, description, thumbnail, icon, sort_order, created_at, updated_at
FROM exam_prep.unit_modules
WHERE id = $1
`
func (q *Queries) ExamPrepGetUnitModuleByID(ctx context.Context, id int64) (ExamPrepUnitModule, error) {
row := q.db.QueryRow(ctx, ExamPrepGetUnitModuleByID, id)
var i ExamPrepUnitModule
err := row.Scan(
&i.ID,
&i.UnitID,
&i.Name,
&i.Description,
&i.Thumbnail,
&i.Icon,
&i.SortOrder,
&i.CreatedAt,
&i.UpdatedAt,
)
return i, err
}
const ExamPrepListUnitModuleIDsByUnit = `-- name: ExamPrepListUnitModuleIDsByUnit :many
SELECT
m.id
FROM exam_prep.unit_modules m
WHERE
m.unit_id = $1
ORDER BY
m.id
`
func (q *Queries) ExamPrepListUnitModuleIDsByUnit(ctx context.Context, unitID int64) ([]int64, error) {
rows, err := q.db.Query(ctx, ExamPrepListUnitModuleIDsByUnit, unitID)
if err != nil {
return nil, err
}
defer rows.Close()
var items []int64
for rows.Next() {
var id int64
if err := rows.Scan(&id); err != nil {
return nil, err
}
items = append(items, id)
}
if err := rows.Err(); err != nil {
return nil, err
}
return items, nil
}
const ExamPrepListUnitModulesByUnit = `-- name: ExamPrepListUnitModulesByUnit :many
SELECT
COUNT(*) OVER () AS total_count,
m.id,
m.unit_id,
m.name,
m.description,
m.thumbnail,
m.icon,
m.sort_order,
m.created_at,
m.updated_at
FROM exam_prep.unit_modules m
WHERE
m.unit_id = $1
ORDER BY
m.sort_order ASC,
m.id ASC
LIMIT $2
OFFSET $3
`
type ExamPrepListUnitModulesByUnitParams struct {
UnitID int64 `json:"unit_id"`
Limit int32 `json:"limit"`
Offset int32 `json:"offset"`
}
type ExamPrepListUnitModulesByUnitRow struct {
TotalCount int64 `json:"total_count"`
ID int64 `json:"id"`
UnitID int64 `json:"unit_id"`
Name string `json:"name"`
Description pgtype.Text `json:"description"`
Thumbnail pgtype.Text `json:"thumbnail"`
Icon pgtype.Text `json:"icon"`
SortOrder int32 `json:"sort_order"`
CreatedAt pgtype.Timestamptz `json:"created_at"`
UpdatedAt pgtype.Timestamptz `json:"updated_at"`
}
func (q *Queries) ExamPrepListUnitModulesByUnit(ctx context.Context, arg ExamPrepListUnitModulesByUnitParams) ([]ExamPrepListUnitModulesByUnitRow, error) {
rows, err := q.db.Query(ctx, ExamPrepListUnitModulesByUnit, arg.UnitID, arg.Limit, arg.Offset)
if err != nil {
return nil, err
}
defer rows.Close()
var items []ExamPrepListUnitModulesByUnitRow
for rows.Next() {
var i ExamPrepListUnitModulesByUnitRow
if err := rows.Scan(
&i.TotalCount,
&i.ID,
&i.UnitID,
&i.Name,
&i.Description,
&i.Thumbnail,
&i.Icon,
&i.SortOrder,
&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 ExamPrepUpdateUnitModule = `-- name: ExamPrepUpdateUnitModule :one
UPDATE exam_prep.unit_modules
SET
name = coalesce($1::varchar, name),
description = coalesce($2::text, description),
thumbnail = coalesce($3::text, thumbnail),
icon = coalesce($4::text, icon),
sort_order = coalesce($5::int, sort_order),
updated_at = CURRENT_TIMESTAMP
WHERE id = $6
RETURNING
id, unit_id, name, description, thumbnail, icon, sort_order, created_at, updated_at
`
type ExamPrepUpdateUnitModuleParams struct {
Name pgtype.Text `json:"name"`
Description pgtype.Text `json:"description"`
Thumbnail pgtype.Text `json:"thumbnail"`
Icon pgtype.Text `json:"icon"`
SortOrder pgtype.Int4 `json:"sort_order"`
ID int64 `json:"id"`
}
func (q *Queries) ExamPrepUpdateUnitModule(ctx context.Context, arg ExamPrepUpdateUnitModuleParams) (ExamPrepUnitModule, error) {
row := q.db.QueryRow(ctx, ExamPrepUpdateUnitModule,
arg.Name,
arg.Description,
arg.Thumbnail,
arg.Icon,
arg.SortOrder,
arg.ID,
)
var i ExamPrepUnitModule
err := row.Scan(
&i.ID,
&i.UnitID,
&i.Name,
&i.Description,
&i.Thumbnail,
&i.Icon,
&i.SortOrder,
&i.CreatedAt,
&i.UpdatedAt,
)
return i, err
}

View File

@ -0,0 +1,231 @@
// Code generated by sqlc. DO NOT EDIT.
// versions:
// sqlc v1.30.0
// source: exam_prep_units.sql
package dbgen
import (
"context"
"github.com/jackc/pgx/v5/pgtype"
)
const ExamPrepCreateUnit = `-- name: ExamPrepCreateUnit :one
INSERT INTO exam_prep.units (catalog_course_id, name, description, thumbnail, sort_order)
SELECT
$1,
$2,
$3,
$4,
coalesce((
SELECT
max(u.sort_order)
FROM exam_prep.units u
WHERE
u.catalog_course_id = $1), 0) + 1
RETURNING
id, catalog_course_id, name, description, thumbnail, sort_order, created_at, updated_at
`
type ExamPrepCreateUnitParams struct {
CatalogCourseID int64 `json:"catalog_course_id"`
Name string `json:"name"`
Description pgtype.Text `json:"description"`
Thumbnail pgtype.Text `json:"thumbnail"`
}
func (q *Queries) ExamPrepCreateUnit(ctx context.Context, arg ExamPrepCreateUnitParams) (ExamPrepUnit, error) {
row := q.db.QueryRow(ctx, ExamPrepCreateUnit,
arg.CatalogCourseID,
arg.Name,
arg.Description,
arg.Thumbnail,
)
var i ExamPrepUnit
err := row.Scan(
&i.ID,
&i.CatalogCourseID,
&i.Name,
&i.Description,
&i.Thumbnail,
&i.SortOrder,
&i.CreatedAt,
&i.UpdatedAt,
)
return i, err
}
const ExamPrepDeleteUnit = `-- name: ExamPrepDeleteUnit :exec
DELETE FROM exam_prep.units
WHERE id = $1
`
func (q *Queries) ExamPrepDeleteUnit(ctx context.Context, id int64) error {
_, err := q.db.Exec(ctx, ExamPrepDeleteUnit, id)
return err
}
const ExamPrepGetUnitByID = `-- name: ExamPrepGetUnitByID :one
SELECT id, catalog_course_id, name, description, thumbnail, sort_order, created_at, updated_at
FROM exam_prep.units
WHERE id = $1
`
func (q *Queries) ExamPrepGetUnitByID(ctx context.Context, id int64) (ExamPrepUnit, error) {
row := q.db.QueryRow(ctx, ExamPrepGetUnitByID, id)
var i ExamPrepUnit
err := row.Scan(
&i.ID,
&i.CatalogCourseID,
&i.Name,
&i.Description,
&i.Thumbnail,
&i.SortOrder,
&i.CreatedAt,
&i.UpdatedAt,
)
return i, err
}
const ExamPrepListUnitIDsByCatalogCourse = `-- name: ExamPrepListUnitIDsByCatalogCourse :many
SELECT
u.id
FROM exam_prep.units u
WHERE
u.catalog_course_id = $1
ORDER BY
u.id
`
func (q *Queries) ExamPrepListUnitIDsByCatalogCourse(ctx context.Context, catalogCourseID int64) ([]int64, error) {
rows, err := q.db.Query(ctx, ExamPrepListUnitIDsByCatalogCourse, catalogCourseID)
if err != nil {
return nil, err
}
defer rows.Close()
var items []int64
for rows.Next() {
var id int64
if err := rows.Scan(&id); err != nil {
return nil, err
}
items = append(items, id)
}
if err := rows.Err(); err != nil {
return nil, err
}
return items, nil
}
const ExamPrepListUnitsByCatalogCourse = `-- name: ExamPrepListUnitsByCatalogCourse :many
SELECT
COUNT(*) OVER () AS total_count,
u.id,
u.catalog_course_id,
u.name,
u.description,
u.thumbnail,
u.sort_order,
u.created_at,
u.updated_at
FROM exam_prep.units u
WHERE
u.catalog_course_id = $1
ORDER BY
u.sort_order ASC,
u.id ASC
LIMIT $2
OFFSET $3
`
type ExamPrepListUnitsByCatalogCourseParams struct {
CatalogCourseID int64 `json:"catalog_course_id"`
Limit int32 `json:"limit"`
Offset int32 `json:"offset"`
}
type ExamPrepListUnitsByCatalogCourseRow struct {
TotalCount int64 `json:"total_count"`
ID int64 `json:"id"`
CatalogCourseID int64 `json:"catalog_course_id"`
Name string `json:"name"`
Description pgtype.Text `json:"description"`
Thumbnail pgtype.Text `json:"thumbnail"`
SortOrder int32 `json:"sort_order"`
CreatedAt pgtype.Timestamptz `json:"created_at"`
UpdatedAt pgtype.Timestamptz `json:"updated_at"`
}
func (q *Queries) ExamPrepListUnitsByCatalogCourse(ctx context.Context, arg ExamPrepListUnitsByCatalogCourseParams) ([]ExamPrepListUnitsByCatalogCourseRow, error) {
rows, err := q.db.Query(ctx, ExamPrepListUnitsByCatalogCourse, arg.CatalogCourseID, arg.Limit, arg.Offset)
if err != nil {
return nil, err
}
defer rows.Close()
var items []ExamPrepListUnitsByCatalogCourseRow
for rows.Next() {
var i ExamPrepListUnitsByCatalogCourseRow
if err := rows.Scan(
&i.TotalCount,
&i.ID,
&i.CatalogCourseID,
&i.Name,
&i.Description,
&i.Thumbnail,
&i.SortOrder,
&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 ExamPrepUpdateUnit = `-- name: ExamPrepUpdateUnit :one
UPDATE exam_prep.units
SET
name = coalesce($1::varchar, name),
description = coalesce($2::text, description),
thumbnail = coalesce($3::text, thumbnail),
sort_order = coalesce($4::int, sort_order),
updated_at = CURRENT_TIMESTAMP
WHERE id = $5
RETURNING
id, catalog_course_id, name, description, thumbnail, sort_order, created_at, updated_at
`
type ExamPrepUpdateUnitParams struct {
Name pgtype.Text `json:"name"`
Description pgtype.Text `json:"description"`
Thumbnail pgtype.Text `json:"thumbnail"`
SortOrder pgtype.Int4 `json:"sort_order"`
ID int64 `json:"id"`
}
func (q *Queries) ExamPrepUpdateUnit(ctx context.Context, arg ExamPrepUpdateUnitParams) (ExamPrepUnit, error) {
row := q.db.QueryRow(ctx, ExamPrepUpdateUnit,
arg.Name,
arg.Description,
arg.Thumbnail,
arg.SortOrder,
arg.ID,
)
var i ExamPrepUnit
err := row.Scan(
&i.ID,
&i.CatalogCourseID,
&i.Name,
&i.Description,
&i.Thumbnail,
&i.SortOrder,
&i.CreatedAt,
&i.UpdatedAt,
)
return i, err
}

View File

@ -129,7 +129,33 @@ SELECT
c.thumbnail, c.thumbnail,
c.sort_order, c.sort_order,
c.created_at, c.created_at,
c.updated_at c.updated_at,
(
SELECT
COUNT(*)::bigint
FROM
modules m
WHERE
m.course_id = c.id) AS module_count,
(
SELECT
COUNT(*)::bigint
FROM
lessons l
INNER JOIN modules m ON l.module_id = m.id
WHERE
m.course_id = c.id) AS lesson_count,
-- Practices whose parent is the course only (lms_practices.course_id). Excludes
-- practices linked via module_id or lesson_id, even for modules/lessons in this course.
(
SELECT
COUNT(*)::bigint
FROM
lms_practices p
WHERE
p.course_id = c.id
AND p.module_id IS NULL
AND p.lesson_id IS NULL) AS practice_count
FROM FROM
courses c courses c
WHERE WHERE
@ -156,6 +182,9 @@ type ListCoursesByProgramIDRow struct {
SortOrder int32 `json:"sort_order"` SortOrder int32 `json:"sort_order"`
CreatedAt pgtype.Timestamptz `json:"created_at"` CreatedAt pgtype.Timestamptz `json:"created_at"`
UpdatedAt pgtype.Timestamptz `json:"updated_at"` UpdatedAt pgtype.Timestamptz `json:"updated_at"`
ModuleCount int64 `json:"module_count"`
LessonCount int64 `json:"lesson_count"`
PracticeCount int64 `json:"practice_count"`
} }
func (q *Queries) ListCoursesByProgramID(ctx context.Context, arg ListCoursesByProgramIDParams) ([]ListCoursesByProgramIDRow, error) { func (q *Queries) ListCoursesByProgramID(ctx context.Context, arg ListCoursesByProgramIDParams) ([]ListCoursesByProgramIDRow, error) {
@ -177,6 +206,9 @@ func (q *Queries) ListCoursesByProgramID(ctx context.Context, arg ListCoursesByP
&i.SortOrder, &i.SortOrder,
&i.CreatedAt, &i.CreatedAt,
&i.UpdatedAt, &i.UpdatedAt,
&i.ModuleCount,
&i.LessonCount,
&i.PracticeCount,
); err != nil { ); err != nil {
return nil, err return nil, err
} }

View File

@ -7,6 +7,8 @@ package dbgen
import ( import (
"context" "context"
"github.com/jackc/pgx/v5/pgtype"
) )
const CountCoursesInProgram = `-- name: CountCoursesInProgram :one const CountCoursesInProgram = `-- name: CountCoursesInProgram :one
@ -94,6 +96,65 @@ func (q *Queries) CountModulesInCourse(ctx context.Context, courseID int64) (int
return n, err return n, err
} }
const CountPublishedPracticesInCourse = `-- name: CountPublishedPracticesInCourse :one
SELECT
count(*)::int AS n
FROM
lms_practices lp
INNER JOIN question_sets qs ON qs.id = lp.question_set_id
WHERE
lp.course_id = $1
AND qs.set_type = 'PRACTICE'
AND qs.status = 'PUBLISHED'
`
func (q *Queries) CountPublishedPracticesInCourse(ctx context.Context, courseID pgtype.Int8) (int32, error) {
row := q.db.QueryRow(ctx, CountPublishedPracticesInCourse, courseID)
var n int32
err := row.Scan(&n)
return n, err
}
const CountPublishedPracticesInModule = `-- name: CountPublishedPracticesInModule :one
SELECT
count(*)::int AS n
FROM
lms_practices lp
INNER JOIN question_sets qs ON qs.id = lp.question_set_id
WHERE
lp.module_id = $1
AND qs.set_type = 'PRACTICE'
AND qs.status = 'PUBLISHED'
`
// Published practices in a module (module-level and lesson-level practices should carry module_id).
func (q *Queries) CountPublishedPracticesInModule(ctx context.Context, moduleID pgtype.Int8) (int32, error) {
row := q.db.QueryRow(ctx, CountPublishedPracticesInModule, moduleID)
var n int32
err := row.Scan(&n)
return n, err
}
const CountPublishedPracticesInProgram = `-- name: CountPublishedPracticesInProgram :one
SELECT
count(*)::int AS n
FROM
lms_practices lp
INNER JOIN courses c ON c.id = lp.course_id
INNER JOIN question_sets qs ON qs.id = lp.question_set_id
WHERE
c.program_id = $1
AND qs.set_type = 'PRACTICE'
AND qs.status = 'PUBLISHED'
`
func (q *Queries) CountPublishedPracticesInProgram(ctx context.Context, programID int64) (int32, error) {
row := q.db.QueryRow(ctx, CountPublishedPracticesInProgram, programID)
var n int32
err := row.Scan(&n)
return n, err
}
const CountUserCompletedCoursesInProgram = `-- name: CountUserCompletedCoursesInProgram :one const CountUserCompletedCoursesInProgram = `-- name: CountUserCompletedCoursesInProgram :one
SELECT SELECT
count(*)::int AS n count(*)::int AS n
@ -212,6 +273,122 @@ func (q *Queries) CountUserCompletedModulesInCourse(ctx context.Context, arg Cou
return n, err return n, err
} }
const CountUserCompletedPublishedPracticesInCourse = `-- name: CountUserCompletedPublishedPracticesInCourse :one
SELECT
count(*)::int AS n
FROM
lms_practices lp
INNER JOIN question_sets qs ON qs.id = lp.question_set_id
INNER JOIN user_practice_progress upp ON upp.question_set_id = lp.question_set_id
WHERE
lp.course_id = $1
AND upp.user_id = $2
AND upp.completed_at IS NOT NULL
AND qs.set_type = 'PRACTICE'
AND qs.status = 'PUBLISHED'
`
type CountUserCompletedPublishedPracticesInCourseParams struct {
CourseID pgtype.Int8 `json:"course_id"`
UserID int64 `json:"user_id"`
}
func (q *Queries) CountUserCompletedPublishedPracticesInCourse(ctx context.Context, arg CountUserCompletedPublishedPracticesInCourseParams) (int32, error) {
row := q.db.QueryRow(ctx, CountUserCompletedPublishedPracticesInCourse, arg.CourseID, arg.UserID)
var n int32
err := row.Scan(&n)
return n, err
}
const CountUserCompletedPublishedPracticesInModule = `-- name: CountUserCompletedPublishedPracticesInModule :one
SELECT
count(*)::int AS n
FROM
lms_practices lp
INNER JOIN question_sets qs ON qs.id = lp.question_set_id
INNER JOIN user_practice_progress upp ON upp.question_set_id = lp.question_set_id
WHERE
lp.module_id = $1
AND upp.user_id = $2
AND upp.completed_at IS NOT NULL
AND qs.set_type = 'PRACTICE'
AND qs.status = 'PUBLISHED'
`
type CountUserCompletedPublishedPracticesInModuleParams struct {
ModuleID pgtype.Int8 `json:"module_id"`
UserID int64 `json:"user_id"`
}
func (q *Queries) CountUserCompletedPublishedPracticesInModule(ctx context.Context, arg CountUserCompletedPublishedPracticesInModuleParams) (int32, error) {
row := q.db.QueryRow(ctx, CountUserCompletedPublishedPracticesInModule, arg.ModuleID, arg.UserID)
var n int32
err := row.Scan(&n)
return n, err
}
const CountUserCompletedPublishedPracticesInProgram = `-- name: CountUserCompletedPublishedPracticesInProgram :one
SELECT
count(*)::int AS n
FROM
lms_practices lp
INNER JOIN courses c ON c.id = lp.course_id
INNER JOIN question_sets qs ON qs.id = lp.question_set_id
INNER JOIN user_practice_progress upp ON upp.question_set_id = lp.question_set_id
WHERE
c.program_id = $1
AND upp.user_id = $2
AND upp.completed_at IS NOT NULL
AND qs.set_type = 'PRACTICE'
AND qs.status = 'PUBLISHED'
`
type CountUserCompletedPublishedPracticesInProgramParams struct {
ProgramID int64 `json:"program_id"`
UserID int64 `json:"user_id"`
}
func (q *Queries) CountUserCompletedPublishedPracticesInProgram(ctx context.Context, arg CountUserCompletedPublishedPracticesInProgramParams) (int32, error) {
row := q.db.QueryRow(ctx, CountUserCompletedPublishedPracticesInProgram, arg.ProgramID, arg.UserID)
var n int32
err := row.Scan(&n)
return n, err
}
const GetPracticeScopeByQuestionSetID = `-- name: GetPracticeScopeByQuestionSetID :one
SELECT
id,
course_id,
module_id,
lesson_id
FROM
lms_practices
WHERE
question_set_id = $1
ORDER BY
id DESC
LIMIT 1
`
type GetPracticeScopeByQuestionSetIDRow struct {
ID int64 `json:"id"`
CourseID pgtype.Int8 `json:"course_id"`
ModuleID pgtype.Int8 `json:"module_id"`
LessonID pgtype.Int8 `json:"lesson_id"`
}
func (q *Queries) GetPracticeScopeByQuestionSetID(ctx context.Context, questionSetID int64) (GetPracticeScopeByQuestionSetIDRow, error) {
row := q.db.QueryRow(ctx, GetPracticeScopeByQuestionSetID, questionSetID)
var i GetPracticeScopeByQuestionSetIDRow
err := row.Scan(
&i.ID,
&i.CourseID,
&i.ModuleID,
&i.LessonID,
)
return i, err
}
const GetPreviousCourseInProgram = `-- name: GetPreviousCourseInProgram :one const GetPreviousCourseInProgram = `-- name: GetPreviousCourseInProgram :one
SELECT SELECT
c2.id, c2.program_id, c2.name, c2.description, c2.thumbnail, c2.created_at, c2.updated_at, c2.sort_order c2.id, c2.program_id, c2.name, c2.description, c2.thumbnail, c2.created_at, c2.updated_at, c2.sort_order

View File

@ -43,6 +43,64 @@ type Device struct {
CreatedAt pgtype.Timestamptz `json:"created_at"` CreatedAt pgtype.Timestamptz `json:"created_at"`
} }
type ExamPrepCatalogCourse struct {
ID int64 `json:"id"`
Name string `json:"name"`
Description pgtype.Text `json:"description"`
Thumbnail pgtype.Text `json:"thumbnail"`
SortOrder int32 `json:"sort_order"`
CreatedAt pgtype.Timestamptz `json:"created_at"`
UpdatedAt pgtype.Timestamptz `json:"updated_at"`
}
type ExamPrepLessonPractice struct {
ID int64 `json:"id"`
UnitModuleLessonID int64 `json:"unit_module_lesson_id"`
Title string `json:"title"`
StoryDescription pgtype.Text `json:"story_description"`
StoryImage pgtype.Text `json:"story_image"`
PersonaID pgtype.Int8 `json:"persona_id"`
QuestionSetID int64 `json:"question_set_id"`
QuickTips pgtype.Text `json:"quick_tips"`
CreatedAt pgtype.Timestamptz `json:"created_at"`
UpdatedAt pgtype.Timestamptz `json:"updated_at"`
}
type ExamPrepUnit struct {
ID int64 `json:"id"`
CatalogCourseID int64 `json:"catalog_course_id"`
Name string `json:"name"`
Description pgtype.Text `json:"description"`
Thumbnail pgtype.Text `json:"thumbnail"`
SortOrder int32 `json:"sort_order"`
CreatedAt pgtype.Timestamptz `json:"created_at"`
UpdatedAt pgtype.Timestamptz `json:"updated_at"`
}
type ExamPrepUnitModule struct {
ID int64 `json:"id"`
UnitID int64 `json:"unit_id"`
Name string `json:"name"`
Description pgtype.Text `json:"description"`
Thumbnail pgtype.Text `json:"thumbnail"`
Icon pgtype.Text `json:"icon"`
SortOrder int32 `json:"sort_order"`
CreatedAt pgtype.Timestamptz `json:"created_at"`
UpdatedAt pgtype.Timestamptz `json:"updated_at"`
}
type ExamPrepUnitModuleLesson struct {
ID int64 `json:"id"`
UnitModuleID int64 `json:"unit_module_id"`
Title string `json:"title"`
VideoUrl pgtype.Text `json:"video_url"`
Thumbnail pgtype.Text `json:"thumbnail"`
Description pgtype.Text `json:"description"`
SortOrder int32 `json:"sort_order"`
CreatedAt pgtype.Timestamptz `json:"created_at"`
UpdatedAt pgtype.Timestamptz `json:"updated_at"`
}
type GlobalSetting struct { type GlobalSetting struct {
Key string `json:"key"` Key string `json:"key"`
Value string `json:"value"` Value string `json:"value"`

View File

@ -48,7 +48,8 @@ const GetOtp = `-- name: GetOtp :one
SELECT id, user_id, sent_to, medium, otp_for, otp, used, used_at, created_at, expires_at SELECT id, user_id, sent_to, medium, otp_for, otp, used, used_at, created_at, expires_at
FROM otps FROM otps
WHERE user_id = $1 WHERE user_id = $1
ORDER BY created_at DESC LIMIT 1 ORDER BY id DESC
LIMIT 1
` `
type GetOtpRow struct { type GetOtpRow struct {
@ -82,6 +83,51 @@ func (q *Queries) GetOtp(ctx context.Context, userID int64) (GetOtpRow, error) {
return i, err return i, err
} }
const GetOtpByCode = `-- name: GetOtpByCode :one
SELECT id, user_id, sent_to, medium, otp_for, otp, used, used_at, created_at, expires_at
FROM otps
WHERE user_id = $1
AND otp = $2
ORDER BY id DESC
LIMIT 1
`
type GetOtpByCodeParams struct {
UserID int64 `json:"user_id"`
Otp string `json:"otp"`
}
type GetOtpByCodeRow struct {
ID int64 `json:"id"`
UserID int64 `json:"user_id"`
SentTo string `json:"sent_to"`
Medium string `json:"medium"`
OtpFor string `json:"otp_for"`
Otp string `json:"otp"`
Used bool `json:"used"`
UsedAt pgtype.Timestamptz `json:"used_at"`
CreatedAt pgtype.Timestamptz `json:"created_at"`
ExpiresAt pgtype.Timestamptz `json:"expires_at"`
}
func (q *Queries) GetOtpByCode(ctx context.Context, arg GetOtpByCodeParams) (GetOtpByCodeRow, error) {
row := q.db.QueryRow(ctx, GetOtpByCode, arg.UserID, arg.Otp)
var i GetOtpByCodeRow
err := row.Scan(
&i.ID,
&i.UserID,
&i.SentTo,
&i.Medium,
&i.OtpFor,
&i.Otp,
&i.Used,
&i.UsedAt,
&i.CreatedAt,
&i.ExpiresAt,
)
return i, err
}
const MarkOtpAsUsed = `-- name: MarkOtpAsUsed :exec const MarkOtpAsUsed = `-- name: MarkOtpAsUsed :exec
UPDATE otps UPDATE otps
SET used = TRUE, used_at = $2 SET used = TRUE, used_at = $2

View File

@ -69,9 +69,12 @@ SELECT
q.explanation, q.explanation,
q.tips, q.tips,
q.voice_prompt, q.voice_prompt,
q.image_url q.sample_answer_voice_prompt,
q.image_url,
qaa.correct_answer_text AS audio_correct_answer_text
FROM question_set_items qsi FROM question_set_items qsi
JOIN questions q ON q.id = qsi.question_id JOIN questions q ON q.id = qsi.question_id
LEFT JOIN question_audio_answers qaa ON qaa.question_id = q.id
WHERE qsi.set_id = $1 WHERE qsi.set_id = $1
AND q.status = 'PUBLISHED' AND q.status = 'PUBLISHED'
ORDER BY qsi.display_order ORDER BY qsi.display_order
@ -89,7 +92,9 @@ type GetPublishedQuestionsInSetRow struct {
Explanation pgtype.Text `json:"explanation"` Explanation pgtype.Text `json:"explanation"`
Tips pgtype.Text `json:"tips"` Tips pgtype.Text `json:"tips"`
VoicePrompt pgtype.Text `json:"voice_prompt"` VoicePrompt pgtype.Text `json:"voice_prompt"`
SampleAnswerVoicePrompt pgtype.Text `json:"sample_answer_voice_prompt"`
ImageUrl pgtype.Text `json:"image_url"` ImageUrl pgtype.Text `json:"image_url"`
AudioCorrectAnswerText pgtype.Text `json:"audio_correct_answer_text"`
} }
func (q *Queries) GetPublishedQuestionsInSet(ctx context.Context, setID int64) ([]GetPublishedQuestionsInSetRow, error) { func (q *Queries) GetPublishedQuestionsInSet(ctx context.Context, setID int64) ([]GetPublishedQuestionsInSetRow, error) {
@ -113,7 +118,9 @@ func (q *Queries) GetPublishedQuestionsInSet(ctx context.Context, setID int64) (
&i.Explanation, &i.Explanation,
&i.Tips, &i.Tips,
&i.VoicePrompt, &i.VoicePrompt,
&i.SampleAnswerVoicePrompt,
&i.ImageUrl, &i.ImageUrl,
&i.AudioCorrectAnswerText,
); err != nil { ); err != nil {
return nil, err return nil, err
} }
@ -138,10 +145,13 @@ SELECT
q.explanation, q.explanation,
q.tips, q.tips,
q.voice_prompt, q.voice_prompt,
q.sample_answer_voice_prompt,
q.image_url, q.image_url,
q.status as question_status q.status as question_status,
qaa.correct_answer_text AS audio_correct_answer_text
FROM question_set_items qsi FROM question_set_items qsi
JOIN questions q ON q.id = qsi.question_id JOIN questions q ON q.id = qsi.question_id
LEFT JOIN question_audio_answers qaa ON qaa.question_id = q.id
WHERE qsi.set_id = $1 WHERE qsi.set_id = $1
AND q.status != 'ARCHIVED' AND q.status != 'ARCHIVED'
ORDER BY qsi.display_order ORDER BY qsi.display_order
@ -159,8 +169,10 @@ type GetQuestionSetItemsRow struct {
Explanation pgtype.Text `json:"explanation"` Explanation pgtype.Text `json:"explanation"`
Tips pgtype.Text `json:"tips"` Tips pgtype.Text `json:"tips"`
VoicePrompt pgtype.Text `json:"voice_prompt"` VoicePrompt pgtype.Text `json:"voice_prompt"`
SampleAnswerVoicePrompt pgtype.Text `json:"sample_answer_voice_prompt"`
ImageUrl pgtype.Text `json:"image_url"` ImageUrl pgtype.Text `json:"image_url"`
QuestionStatus string `json:"question_status"` QuestionStatus string `json:"question_status"`
AudioCorrectAnswerText pgtype.Text `json:"audio_correct_answer_text"`
} }
func (q *Queries) GetQuestionSetItems(ctx context.Context, setID int64) ([]GetQuestionSetItemsRow, error) { func (q *Queries) GetQuestionSetItems(ctx context.Context, setID int64) ([]GetQuestionSetItemsRow, error) {
@ -184,8 +196,10 @@ func (q *Queries) GetQuestionSetItems(ctx context.Context, setID int64) ([]GetQu
&i.Explanation, &i.Explanation,
&i.Tips, &i.Tips,
&i.VoicePrompt, &i.VoicePrompt,
&i.SampleAnswerVoicePrompt,
&i.ImageUrl, &i.ImageUrl,
&i.QuestionStatus, &i.QuestionStatus,
&i.AudioCorrectAnswerText,
); err != nil { ); err != nil {
return nil, err return nil, err
} }

View File

@ -2,8 +2,15 @@ package domain
import "time" import "time"
// DefaultCEFRCourseNames are the standard course names seeded for each program (migration 000048). // DefaultCEFRCoursesByProgramName maps seeded program names to default course names
// Creating a course via the API may use any of these or a custom name. // (migrations 000048 and 000050). The API may still create courses with any name.
var DefaultCEFRCoursesByProgramName = map[string][]string{
"Beginner": {"A1", "A2"},
"Intermediate": {"B1", "B2"},
"Advanced": {"C1", "C2"},
}
// DefaultCEFRCourseNames is every CEFR label used in DefaultCEFRCoursesByProgramName.
var DefaultCEFRCourseNames = []string{"A1", "A2", "B1", "B2", "C1", "C2"} var DefaultCEFRCourseNames = []string{"A1", "A2", "B1", "B2", "C1", "C2"}
// Course belongs to a Program. // Course belongs to a Program.
@ -16,6 +23,11 @@ type Course struct {
SortOrder int `json:"sort_order"` SortOrder int `json:"sort_order"`
CreatedAt time.Time `json:"created_at"` CreatedAt time.Time `json:"created_at"`
UpdatedAt *time.Time `json:"updated_at,omitempty"` UpdatedAt *time.Time `json:"updated_at,omitempty"`
// Populated on list-by-program. Practice count: lms_practices rows with course_id = course only
// (not practices attached to a module or lesson under this course).
ModuleCount int `json:"module_count"`
LessonCount int `json:"lesson_count"`
PracticeCount int `json:"practice_count"`
Access *LMSEntityAccess `json:"access,omitempty"` Access *LMSEntityAccess `json:"access,omitempty"`
} }

View File

@ -0,0 +1,27 @@
package domain
import "time"
// ExamPrepCatalogCourse is a top-level exam-prep track (e.g. DET, IELTS) in schema exam_prep — separate from LMS Learn English courses.
type ExamPrepCatalogCourse struct {
ID int64 `json:"id"`
Name string `json:"name"`
Description *string `json:"description,omitempty"`
Thumbnail *string `json:"thumbnail,omitempty"`
SortOrder int `json:"sort_order"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt *time.Time `json:"updated_at,omitempty"`
}
type CreateExamPrepCatalogCourseInput struct {
Name string `json:"name" validate:"required"`
Description *string `json:"description,omitempty"`
Thumbnail *string `json:"thumbnail,omitempty"`
}
type UpdateExamPrepCatalogCourseInput struct {
Name *string `json:"name,omitempty"`
Description *string `json:"description,omitempty"`
Thumbnail *string `json:"thumbnail,omitempty"`
SortOrder *int `json:"sort_order,omitempty"`
}

View File

@ -0,0 +1,31 @@
package domain
import "time"
// ExamPrepLesson is a video lesson under an exam-prep unit module (exam_prep.unit_module_lessons).
type ExamPrepLesson struct {
ID int64 `json:"id"`
UnitModuleID int64 `json:"unit_module_id"`
Title string `json:"title"`
VideoURL *string `json:"video_url,omitempty"`
Thumbnail *string `json:"thumbnail,omitempty"`
Description *string `json:"description,omitempty"`
SortOrder int `json:"sort_order"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt *time.Time `json:"updated_at,omitempty"`
}
type CreateExamPrepLessonInput struct {
Title string `json:"title" validate:"required"`
VideoURL *string `json:"video_url,omitempty"`
Thumbnail *string `json:"thumbnail,omitempty"`
Description *string `json:"description,omitempty"`
}
type UpdateExamPrepLessonInput struct {
Title *string `json:"title,omitempty"`
VideoURL *string `json:"video_url,omitempty"`
Thumbnail *string `json:"thumbnail,omitempty"`
Description *string `json:"description,omitempty"`
SortOrder *int `json:"sort_order,omitempty"`
}

View File

@ -0,0 +1,31 @@
package domain
import "time"
// ExamPrepModule is a module under an exam-prep unit (stored in exam_prep.unit_modules).
type ExamPrepModule struct {
ID int64 `json:"id"`
UnitID int64 `json:"unit_id"`
Name string `json:"name"`
Description *string `json:"description,omitempty"`
Thumbnail *string `json:"thumbnail,omitempty"`
Icon *string `json:"icon,omitempty"`
SortOrder int `json:"sort_order"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt *time.Time `json:"updated_at,omitempty"`
}
type CreateExamPrepModuleInput struct {
Name string `json:"name" validate:"required"`
Description *string `json:"description,omitempty"`
Thumbnail *string `json:"thumbnail,omitempty"`
Icon *string `json:"icon,omitempty"`
}
type UpdateExamPrepModuleInput struct {
Name *string `json:"name,omitempty"`
Description *string `json:"description,omitempty"`
Thumbnail *string `json:"thumbnail,omitempty"`
Icon *string `json:"icon,omitempty"`
SortOrder *int `json:"sort_order,omitempty"`
}

View File

@ -0,0 +1,36 @@
package domain
import "time"
// ExamPrepPractice is question-set content tied to an exam-prep lesson; uses shared question_sets / questions.
type ExamPrepPractice struct {
ID int64 `json:"id"`
LessonID int64 `json:"lesson_id"` // exam_prep.unit_module_lessons.id
Title string `json:"title"`
StoryDescription *string `json:"story_description,omitempty"`
StoryImage *string `json:"story_image,omitempty"`
PersonaID *int64 `json:"persona_id,omitempty"`
QuestionSetID int64 `json:"question_set_id"`
QuickTips *string `json:"quick_tips,omitempty"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt *time.Time `json:"updated_at,omitempty"`
}
// CreateExamPrepPracticeInput is the body for POST .../exam-prep/lessons/{lessonId}/practices (lesson from path).
type CreateExamPrepPracticeInput struct {
Title string `json:"title" validate:"required"`
StoryDescription *string `json:"story_description,omitempty"`
StoryImage *string `json:"story_image,omitempty"`
PersonaID *int64 `json:"persona_id,omitempty"`
QuestionSetID int64 `json:"question_set_id" validate:"required,gt=0"`
QuickTips *string `json:"quick_tips,omitempty"`
}
type UpdateExamPrepPracticeInput struct {
Title *string `json:"title,omitempty"`
StoryDescription *string `json:"story_description,omitempty"`
StoryImage *string `json:"story_image,omitempty"`
PersonaID *int64 `json:"persona_id,omitempty"`
QuestionSetID *int64 `json:"question_set_id,omitempty"`
QuickTips *string `json:"quick_tips,omitempty"`
}

View File

@ -0,0 +1,28 @@
package domain
import "time"
// ExamPrepUnit is a chapter-like grouping under an exam-prep catalog course (schema exam_prep.units).
type ExamPrepUnit struct {
ID int64 `json:"id"`
CatalogCourseID int64 `json:"catalog_course_id"`
Name string `json:"name"`
Description *string `json:"description,omitempty"`
Thumbnail *string `json:"thumbnail,omitempty"`
SortOrder int `json:"sort_order"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt *time.Time `json:"updated_at,omitempty"`
}
type CreateExamPrepUnitInput struct {
Name string `json:"name" validate:"required"`
Description *string `json:"description,omitempty"`
Thumbnail *string `json:"thumbnail,omitempty"`
}
type UpdateExamPrepUnitInput struct {
Name *string `json:"name,omitempty"`
Description *string `json:"description,omitempty"`
Thumbnail *string `json:"thumbnail,omitempty"`
SortOrder *int `json:"sort_order,omitempty"`
}

View File

@ -0,0 +1,223 @@
package domain
import (
"fmt"
"sort"
"strings"
)
// Stimulus-side components for the question-type builder (Section A — question input types).
type StimulusComponentKind string
const (
StimulusPrepTime StimulusComponentKind = "PREP_TIME"
StimulusInstruction StimulusComponentKind = "INSTRUCTION"
StimulusAudioClip StimulusComponentKind = "AUDIO_CLIP"
StimulusTextPassage StimulusComponentKind = "TEXT_PASSAGE"
StimulusImage StimulusComponentKind = "IMAGE"
StimulusMatchingInputs StimulusComponentKind = "MATCHING_INPUTS"
StimulusSelectMissingWords StimulusComponentKind = "SELECT_MISSING_WORDS"
StimulusTable StimulusComponentKind = "TABLE"
StimulusFlowChart StimulusComponentKind = "FLOW_CHART"
)
// Response-side components for the question-type builder (Section B — answer types).
type ResponseComponentKind string
const (
ResponseAudioResponse ResponseComponentKind = "AUDIO_RESPONSE"
ResponseTextInput ResponseComponentKind = "TEXT_INPUT"
ResponseShortAnswer ResponseComponentKind = "SHORT_ANSWER"
ResponseMultipleChoice ResponseComponentKind = "MULTIPLE_CHOICE"
ResponseAnswerTimer ResponseComponentKind = "ANSWER_TIMER"
ResponseSelectMissingWords ResponseComponentKind = "SELECT_MISSING_WORDS"
ResponsePDFUpload ResponseComponentKind = "PDF_UPLOAD"
ResponseMatchingAnswer ResponseComponentKind = "MATCHING_ANSWER"
ResponseLabelSelection ResponseComponentKind = "LABEL_SELECTION"
)
var (
stimulusCatalog = []StimulusComponentKind{
StimulusPrepTime,
StimulusInstruction,
StimulusAudioClip,
StimulusTextPassage,
StimulusImage,
StimulusMatchingInputs,
StimulusSelectMissingWords,
StimulusTable,
StimulusFlowChart,
}
stimulusSet map[string]struct{}
responseCatalog = []ResponseComponentKind{
ResponseAudioResponse,
ResponseTextInput,
ResponseShortAnswer,
ResponseMultipleChoice,
ResponseAnswerTimer,
ResponseSelectMissingWords,
ResponsePDFUpload,
ResponseMatchingAnswer,
ResponseLabelSelection,
}
responseSet map[string]struct{}
// responseKindsAuxiliary are allowed but cannot be the only selected answer kinds.
responseKindsAuxiliary = map[string]struct{}{
string(ResponseAnswerTimer): {},
}
)
func init() {
stimulusSet = make(map[string]struct{}, len(stimulusCatalog))
for _, k := range stimulusCatalog {
stimulusSet[string(k)] = struct{}{}
}
responseSet = make(map[string]struct{}, len(responseCatalog))
for _, k := range responseCatalog {
responseSet[string(k)] = struct{}{}
}
}
// StimulusComponentCatalog returns all valid stimulus component kind strings (stable order).
func StimulusComponentCatalog() []string {
out := make([]string, len(stimulusCatalog))
for i, k := range stimulusCatalog {
out[i] = string(k)
}
return out
}
// ResponseComponentCatalog returns all valid response component kind strings (stable order).
func ResponseComponentCatalog() []string {
out := make([]string, len(responseCatalog))
for i, k := range responseCatalog {
out[i] = string(k)
}
return out
}
// IsValidStimulusComponentKind reports whether s is a known stimulus component kind.
func IsValidStimulusComponentKind(s string) bool {
_, ok := stimulusSet[s]
return ok
}
// IsValidResponseComponentKind reports whether s is a known response component kind.
func IsValidResponseComponentKind(s string) bool {
_, ok := responseSet[s]
return ok
}
// ValidateDynamicQuestionTypeDefinition checks stimulus and response component selections for a
// temporary dynamic question-type definition. Empty or duplicate entries, unknown kinds, and
// disallowed combinations return a non-nil error.
func ValidateDynamicQuestionTypeDefinition(stimulusKinds, responseKinds []string) error {
var errs []string
stimulus := normalizeKindList(stimulusKinds)
response := normalizeKindList(responseKinds)
if len(stimulus) == 0 {
errs = append(errs, "at least one stimulus (question input) component is required")
}
if len(response) == 0 {
errs = append(errs, "at least one response component is required")
}
for i, k := range stimulus {
if k == "" {
errs = append(errs, fmt.Sprintf("stimulus [%d]: empty component kind", i))
continue
}
if !IsValidStimulusComponentKind(k) {
errs = append(errs, fmt.Sprintf("stimulus: unknown component kind %q", k))
}
}
for i, k := range response {
if k == "" {
errs = append(errs, fmt.Sprintf("response [%d]: empty component kind", i))
continue
}
if !IsValidResponseComponentKind(k) {
errs = append(errs, fmt.Sprintf("response: unknown component kind %q", k))
}
}
if dup := findDuplicates(stimulus); len(dup) > 0 {
errs = append(errs, fmt.Sprintf("stimulus: duplicate component kinds: %s", strings.Join(dup, ", ")))
}
if dup := findDuplicates(response); len(dup) > 0 {
errs = append(errs, fmt.Sprintf("response: duplicate component kinds: %s", strings.Join(dup, ", ")))
}
countPrep := 0
for _, k := range stimulus {
if k == string(StimulusPrepTime) {
countPrep++
}
}
if countPrep > 1 {
errs = append(errs, "stimulus: at most one PREP_TIME is allowed")
}
countAnsTimer := 0
for _, k := range response {
if k == string(ResponseAnswerTimer) {
countAnsTimer++
}
}
if countAnsTimer > 1 {
errs = append(errs, "response: at most one ANSWER_TIMER is allowed")
}
if len(response) > 0 && onlyAuxiliaryResponseKinds(response) {
errs = append(errs, "response: at least one non-timer answer component is required (ANSWER_TIMER alone is not sufficient)")
}
if len(errs) == 0 {
return nil
}
return fmt.Errorf("%s", strings.Join(errs, "; "))
}
func normalizeKindList(in []string) []string {
var out []string
for _, s := range in {
t := strings.TrimSpace(s)
if t == "" {
continue
}
out = append(out, t)
}
return out
}
func findDuplicates(kinds []string) []string {
seen := make(map[string]int)
for _, k := range kinds {
seen[k]++
}
var dups []string
for k, n := range seen {
if n > 1 {
dups = append(dups, k)
}
}
sort.Strings(dups)
return dups
}
func onlyAuxiliaryResponseKinds(response []string) bool {
if len(response) == 0 {
return false
}
for _, k := range response {
if _, aux := responseKindsAuxiliary[k]; !aux {
return false
}
}
return true
}

View File

@ -0,0 +1,66 @@
package domain
import (
"strings"
"testing"
)
func TestValidateDynamicQuestionTypeDefinition_valid(t *testing.T) {
err := ValidateDynamicQuestionTypeDefinition(
[]string{"INSTRUCTION", "IMAGE"},
[]string{"AUDIO_RESPONSE"},
)
if err != nil {
t.Fatalf("expected nil, got %v", err)
}
}
func TestValidateDynamicQuestionTypeDefinition_unknownStimulus(t *testing.T) {
err := ValidateDynamicQuestionTypeDefinition(
[]string{"NOT_A_KIND"},
[]string{"SHORT_ANSWER"},
)
if err == nil || !strings.Contains(err.Error(), "unknown") {
t.Fatalf("expected unknown stimulus error, got %v", err)
}
}
func TestValidateDynamicQuestionTypeDefinition_emptyResponse(t *testing.T) {
err := ValidateDynamicQuestionTypeDefinition(
[]string{"INSTRUCTION"},
nil,
)
if err == nil || !strings.Contains(err.Error(), "at least one response") {
t.Fatalf("expected empty response error, got %v", err)
}
}
func TestValidateDynamicQuestionTypeDefinition_duplicateStimulus(t *testing.T) {
err := ValidateDynamicQuestionTypeDefinition(
[]string{"IMAGE", "IMAGE"},
[]string{"MULTIPLE_CHOICE"},
)
if err == nil || !strings.Contains(err.Error(), "duplicate") {
t.Fatalf("expected duplicate error, got %v", err)
}
}
func TestValidateDynamicQuestionTypeDefinition_timerOnlyResponse(t *testing.T) {
err := ValidateDynamicQuestionTypeDefinition(
[]string{"TEXT_PASSAGE"},
[]string{"ANSWER_TIMER"},
)
if err == nil || !strings.Contains(err.Error(), "ANSWER_TIMER alone") {
t.Fatalf("expected auxiliary-only error, got %v", err)
}
}
func TestValidateDynamicQuestionTypeDefinition_twoPrepTimes(t *testing.T) {
err := ValidateDynamicQuestionTypeDefinition(
[]string{"PREP_TIME", "PREP_TIME", "INSTRUCTION"},
[]string{"TEXT_INPUT"},
)
if err == nil || !strings.Contains(err.Error(), "PREP_TIME") {
t.Fatalf("expected at most one PREP_TIME, got %v", err)
}
}

View File

@ -14,6 +14,10 @@ type Client struct {
bucketName string bucketName string
} }
func (c *Client) BucketName() string {
return c.bucketName
}
func NewClient(endpoint, accessKey, secretKey string, useSSL bool, bucketName string) (*Client, error) { func NewClient(endpoint, accessKey, secretKey string, useSSL bool, bucketName string) (*Client, error) {
mc, err := minio.New(endpoint, &minio.Options{ mc, err := minio.New(endpoint, &minio.Options{
Creds: credentials.NewStaticV4(accessKey, secretKey, ""), Creds: credentials.NewStaticV4(accessKey, secretKey, ""),

View File

@ -0,0 +1,17 @@
package ports
import (
"Yimaru-Backend/internal/domain"
"context"
)
// ExamPrepCatalogCourseStore persists exam_prep.catalog_courses (DET / IELTS / … tracks).
type ExamPrepCatalogCourseStore interface {
CreateExamPrepCatalogCourse(ctx context.Context, input domain.CreateExamPrepCatalogCourseInput) (domain.ExamPrepCatalogCourse, error)
GetExamPrepCatalogCourseByID(ctx context.Context, id int64) (domain.ExamPrepCatalogCourse, error)
ListExamPrepCatalogCourses(ctx context.Context, limit, offset int32) ([]domain.ExamPrepCatalogCourse, int64, error)
ListAllExamPrepCatalogCourseIDs(ctx context.Context) ([]int64, error)
UpdateExamPrepCatalogCourse(ctx context.Context, id int64, input domain.UpdateExamPrepCatalogCourseInput) (domain.ExamPrepCatalogCourse, error)
DeleteExamPrepCatalogCourse(ctx context.Context, id int64) error
ReorderExamPrepCatalogCourses(ctx context.Context, orderedIDs []int64) error
}

View File

@ -0,0 +1,17 @@
package ports
import (
"Yimaru-Backend/internal/domain"
"context"
)
// ExamPrepLessonStore persists exam_prep.unit_module_lessons.
type ExamPrepLessonStore interface {
CreateExamPrepUnitModuleLesson(ctx context.Context, unitModuleID int64, input domain.CreateExamPrepLessonInput) (domain.ExamPrepLesson, error)
GetExamPrepUnitModuleLessonByID(ctx context.Context, id int64) (domain.ExamPrepLesson, error)
ListExamPrepUnitModuleLessonsByUnitModuleID(ctx context.Context, unitModuleID int64, limit, offset int32) ([]domain.ExamPrepLesson, int64, error)
ListExamPrepUnitModuleLessonIDsByUnitModule(ctx context.Context, unitModuleID int64) ([]int64, error)
UpdateExamPrepUnitModuleLesson(ctx context.Context, id int64, input domain.UpdateExamPrepLessonInput) (domain.ExamPrepLesson, error)
DeleteExamPrepUnitModuleLesson(ctx context.Context, id int64) error
ReorderExamPrepUnitModuleLessonsInUnitModule(ctx context.Context, unitModuleID int64, orderedIDs []int64) error
}

View File

@ -0,0 +1,17 @@
package ports
import (
"Yimaru-Backend/internal/domain"
"context"
)
// ExamPrepModuleStore persists exam_prep.unit_modules.
type ExamPrepModuleStore interface {
CreateExamPrepUnitModule(ctx context.Context, unitID int64, input domain.CreateExamPrepModuleInput) (domain.ExamPrepModule, error)
GetExamPrepUnitModuleByID(ctx context.Context, id int64) (domain.ExamPrepModule, error)
ListExamPrepUnitModulesByUnit(ctx context.Context, unitID int64, limit, offset int32) ([]domain.ExamPrepModule, int64, error)
ListExamPrepUnitModuleIDsByUnit(ctx context.Context, unitID int64) ([]int64, error)
UpdateExamPrepUnitModule(ctx context.Context, id int64, input domain.UpdateExamPrepModuleInput) (domain.ExamPrepModule, error)
DeleteExamPrepUnitModule(ctx context.Context, id int64) error
ReorderExamPrepUnitModulesInUnit(ctx context.Context, unitID int64, orderedIDs []int64) error
}

View File

@ -0,0 +1,15 @@
package ports
import (
"Yimaru-Backend/internal/domain"
"context"
)
// ExamPrepPracticeStore persists exam_prep.lesson_practices.
type ExamPrepPracticeStore interface {
CreateExamPrepLessonPractice(ctx context.Context, lessonID int64, in domain.CreateExamPrepPracticeInput) (domain.ExamPrepPractice, error)
GetExamPrepLessonPracticeByID(ctx context.Context, id int64) (domain.ExamPrepPractice, error)
ListExamPrepLessonPracticesByLessonID(ctx context.Context, lessonID int64, limit, offset int32) ([]domain.ExamPrepPractice, int64, error)
UpdateExamPrepLessonPractice(ctx context.Context, id int64, input domain.UpdateExamPrepPracticeInput) (domain.ExamPrepPractice, error)
DeleteExamPrepLessonPractice(ctx context.Context, id int64) error
}

View File

@ -0,0 +1,17 @@
package ports
import (
"Yimaru-Backend/internal/domain"
"context"
)
// ExamPrepUnitStore persists exam_prep.units.
type ExamPrepUnitStore interface {
CreateExamPrepUnit(ctx context.Context, catalogCourseID int64, input domain.CreateExamPrepUnitInput) (domain.ExamPrepUnit, error)
GetExamPrepUnitByID(ctx context.Context, id int64) (domain.ExamPrepUnit, error)
ListExamPrepUnitsByCatalogCourse(ctx context.Context, catalogCourseID int64, limit, offset int32) ([]domain.ExamPrepUnit, int64, error)
ListExamPrepUnitIDsByCatalogCourse(ctx context.Context, catalogCourseID int64) ([]int64, error)
UpdateExamPrepUnit(ctx context.Context, id int64, input domain.UpdateExamPrepUnitInput) (domain.ExamPrepUnit, error)
DeleteExamPrepUnit(ctx context.Context, id int64) error
ReorderExamPrepUnitsInCatalogCourse(ctx context.Context, catalogCourseID int64, orderedIDs []int64) error
}

View File

@ -94,4 +94,5 @@ type OtpStore interface {
MarkOtpAsUsed(ctx context.Context, otp domain.Otp) error MarkOtpAsUsed(ctx context.Context, otp domain.Otp) error
CreateOtp(ctx context.Context, otp domain.Otp) error CreateOtp(ctx context.Context, otp domain.Otp) error
GetOtp(ctx context.Context, userID int64) (domain.Otp, error) GetOtp(ctx context.Context, userID int64) (domain.Otp, error)
GetOtpByCode(ctx context.Context, userID int64, otpCode string) (domain.Otp, error)
} }

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 examPrepCatalogCourseToDomain(c dbgen.ExamPrepCatalogCourse) domain.ExamPrepCatalogCourse {
out := domain.ExamPrepCatalogCourse{
ID: c.ID,
Name: c.Name,
SortOrder: int(c.SortOrder),
}
out.Description = fromPgText(c.Description)
out.Thumbnail = fromPgText(c.Thumbnail)
out.CreatedAt = c.CreatedAt.Time
if c.UpdatedAt.Valid {
t := c.UpdatedAt.Time
out.UpdatedAt = &t
}
return out
}
func (s *Store) CreateExamPrepCatalogCourse(ctx context.Context, input domain.CreateExamPrepCatalogCourseInput) (domain.ExamPrepCatalogCourse, error) {
c, err := s.queries.ExamPrepCreateCatalogCourse(ctx, dbgen.ExamPrepCreateCatalogCourseParams{
Name: input.Name,
Description: toPgText(input.Description),
Thumbnail: toPgText(input.Thumbnail),
})
if err != nil {
return domain.ExamPrepCatalogCourse{}, err
}
return examPrepCatalogCourseToDomain(c), nil
}
func (s *Store) GetExamPrepCatalogCourseByID(ctx context.Context, id int64) (domain.ExamPrepCatalogCourse, error) {
c, err := s.queries.ExamPrepGetCatalogCourseByID(ctx, id)
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return domain.ExamPrepCatalogCourse{}, pgx.ErrNoRows
}
return domain.ExamPrepCatalogCourse{}, err
}
return examPrepCatalogCourseToDomain(c), nil
}
func (s *Store) ListExamPrepCatalogCourses(ctx context.Context, limit, offset int32) ([]domain.ExamPrepCatalogCourse, int64, error) {
rows, err := s.queries.ExamPrepListCatalogCourses(ctx, dbgen.ExamPrepListCatalogCoursesParams{
Limit: limit,
Offset: offset,
})
if err != nil {
return nil, 0, err
}
if len(rows) == 0 {
return []domain.ExamPrepCatalogCourse{}, 0, nil
}
var total int64
out := make([]domain.ExamPrepCatalogCourse, 0, len(rows))
for i, r := range rows {
if i == 0 {
total = r.TotalCount
}
out = append(out, examPrepCatalogCourseToDomain(dbgen.ExamPrepCatalogCourse{
ID: r.ID,
Name: r.Name,
Description: r.Description,
Thumbnail: r.Thumbnail,
SortOrder: r.SortOrder,
CreatedAt: r.CreatedAt,
UpdatedAt: r.UpdatedAt,
}))
}
return out, total, nil
}
func (s *Store) ListAllExamPrepCatalogCourseIDs(ctx context.Context) ([]int64, error) {
return s.queries.ExamPrepListAllCatalogCourseIDs(ctx)
}
func (s *Store) UpdateExamPrepCatalogCourse(ctx context.Context, id int64, input domain.UpdateExamPrepCatalogCourseInput) (domain.ExamPrepCatalogCourse, error) {
var nameText pgtype.Text
if input.Name != nil {
nameText = pgtype.Text{String: *input.Name, Valid: true}
} else {
nameText = pgtype.Text{Valid: false}
}
c, err := s.queries.ExamPrepUpdateCatalogCourse(ctx, dbgen.ExamPrepUpdateCatalogCourseParams{
ID: id,
Name: nameText,
Description: optionalTextUpdate(input.Description),
Thumbnail: optionalTextUpdate(input.Thumbnail),
SortOrder: optionalInt4Update(input.SortOrder),
})
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return domain.ExamPrepCatalogCourse{}, pgx.ErrNoRows
}
return domain.ExamPrepCatalogCourse{}, err
}
return examPrepCatalogCourseToDomain(c), nil
}
func (s *Store) DeleteExamPrepCatalogCourse(ctx context.Context, id int64) error {
return s.queries.ExamPrepDeleteCatalogCourse(ctx, id)
}

View File

@ -0,0 +1,126 @@
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 examPrepPracticeFromListRow(r dbgen.ExamPrepListLessonPracticesByLessonIDRow) domain.ExamPrepPractice {
return examPrepPracticeToDomain(dbgen.ExamPrepLessonPractice{
ID: r.ID,
UnitModuleLessonID: r.UnitModuleLessonID,
Title: r.Title,
StoryDescription: r.StoryDescription,
StoryImage: r.StoryImage,
PersonaID: r.PersonaID,
QuestionSetID: r.QuestionSetID,
QuickTips: r.QuickTips,
CreatedAt: r.CreatedAt,
UpdatedAt: r.UpdatedAt,
})
}
func examPrepPracticeToDomain(p dbgen.ExamPrepLessonPractice) domain.ExamPrepPractice {
out := domain.ExamPrepPractice{
ID: p.ID,
LessonID: p.UnitModuleLessonID,
Title: p.Title,
QuestionSetID: p.QuestionSetID,
}
out.StoryDescription = fromPgText(p.StoryDescription)
out.StoryImage = fromPgText(p.StoryImage)
out.QuickTips = fromPgText(p.QuickTips)
out.PersonaID = fromPgInt8ID(p.PersonaID)
out.CreatedAt = p.CreatedAt.Time
if p.UpdatedAt.Valid {
t := p.UpdatedAt.Time
out.UpdatedAt = &t
}
return out
}
func (s *Store) CreateExamPrepLessonPractice(ctx context.Context, lessonID int64, in domain.CreateExamPrepPracticeInput) (domain.ExamPrepPractice, error) {
p, err := s.queries.ExamPrepCreateLessonPractice(ctx, dbgen.ExamPrepCreateLessonPracticeParams{
UnitModuleLessonID: lessonID,
Title: in.Title,
StoryDescription: toPgText(in.StoryDescription),
StoryImage: toPgText(in.StoryImage),
PersonaID: int64PtrToPg8(in.PersonaID),
QuestionSetID: in.QuestionSetID,
QuickTips: toPgText(in.QuickTips),
})
if err != nil {
return domain.ExamPrepPractice{}, err
}
return examPrepPracticeToDomain(p), nil
}
func (s *Store) GetExamPrepLessonPracticeByID(ctx context.Context, id int64) (domain.ExamPrepPractice, error) {
p, err := s.queries.ExamPrepGetLessonPracticeByID(ctx, id)
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return domain.ExamPrepPractice{}, pgx.ErrNoRows
}
return domain.ExamPrepPractice{}, err
}
return examPrepPracticeToDomain(p), nil
}
func (s *Store) ListExamPrepLessonPracticesByLessonID(ctx context.Context, lessonID int64, limit, offset int32) ([]domain.ExamPrepPractice, int64, error) {
rows, err := s.queries.ExamPrepListLessonPracticesByLessonID(ctx, dbgen.ExamPrepListLessonPracticesByLessonIDParams{
UnitModuleLessonID: lessonID,
Limit: limit,
Offset: offset,
})
if err != nil {
return nil, 0, err
}
if len(rows) == 0 {
return []domain.ExamPrepPractice{}, 0, nil
}
var total int64
out := make([]domain.ExamPrepPractice, 0, len(rows))
for i, r := range rows {
if i == 0 {
total = r.TotalCount
}
out = append(out, examPrepPracticeFromListRow(r))
}
return out, total, nil
}
func (s *Store) UpdateExamPrepLessonPractice(ctx context.Context, id int64, input domain.UpdateExamPrepPracticeInput) (domain.ExamPrepPractice, error) {
var titleText pgtype.Text
if input.Title != nil {
titleText = pgtype.Text{String: *input.Title, Valid: true}
} else {
titleText = pgtype.Text{Valid: false}
}
qs := optionalInt8UpdateID(input.QuestionSetID)
p, err := s.queries.ExamPrepUpdateLessonPractice(ctx, dbgen.ExamPrepUpdateLessonPracticeParams{
ID: id,
Title: titleText,
StoryDescription: optionalTextUpdate(input.StoryDescription),
StoryImage: optionalTextUpdate(input.StoryImage),
PersonaID: optionalInt8UpdateID(input.PersonaID),
QuestionSetID: qs,
QuickTips: optionalTextUpdate(input.QuickTips),
})
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return domain.ExamPrepPractice{}, pgx.ErrNoRows
}
return domain.ExamPrepPractice{}, err
}
return examPrepPracticeToDomain(p), nil
}
func (s *Store) DeleteExamPrepLessonPractice(ctx context.Context, id int64) error {
return s.queries.ExamPrepDeleteLessonPractice(ctx, id)
}

View File

@ -0,0 +1,113 @@
package repository
import (
"context"
"fmt"
)
const lessonReorderSortBump int32 = 1_000_000
// ReorderExamPrepCatalogCourses sets sort_order to 1..n for all catalog course rows (transactional).
func (s *Store) ReorderExamPrepCatalogCourses(ctx context.Context, orderedIDs []int64) error {
tx, err := s.conn.Begin(ctx)
if err != nil {
return err
}
defer func() { _ = tx.Rollback(ctx) }()
for i, id := range orderedIDs {
tag, err := tx.Exec(ctx, `
UPDATE exam_prep.catalog_courses
SET sort_order = $1,
updated_at = CURRENT_TIMESTAMP
WHERE id = $2`, int32(i+1), id)
if err != nil {
return err
}
if tag.RowsAffected() == 0 {
return fmt.Errorf("exam prep catalog course id %d not found", id)
}
}
return tx.Commit(ctx)
}
// ReorderExamPrepUnitsInCatalogCourse sets sort_order to 1..n for units under catalogCourseID.
func (s *Store) ReorderExamPrepUnitsInCatalogCourse(ctx context.Context, catalogCourseID int64, orderedIDs []int64) error {
tx, err := s.conn.Begin(ctx)
if err != nil {
return err
}
defer func() { _ = tx.Rollback(ctx) }()
for i, id := range orderedIDs {
tag, err := tx.Exec(ctx, `
UPDATE exam_prep.units
SET sort_order = $1,
updated_at = CURRENT_TIMESTAMP
WHERE id = $2
AND catalog_course_id = $3`, int32(i+1), id, catalogCourseID)
if err != nil {
return err
}
if tag.RowsAffected() == 0 {
return fmt.Errorf("unit id %d not in catalog course %d", id, catalogCourseID)
}
}
return tx.Commit(ctx)
}
// ReorderExamPrepUnitModulesInUnit sets sort_order to 1..n for modules under unitID.
func (s *Store) ReorderExamPrepUnitModulesInUnit(ctx context.Context, unitID int64, orderedIDs []int64) error {
tx, err := s.conn.Begin(ctx)
if err != nil {
return err
}
defer func() { _ = tx.Rollback(ctx) }()
for i, id := range orderedIDs {
tag, err := tx.Exec(ctx, `
UPDATE exam_prep.unit_modules
SET sort_order = $1,
updated_at = CURRENT_TIMESTAMP
WHERE id = $2
AND unit_id = $3`, int32(i+1), id, unitID)
if err != nil {
return err
}
if tag.RowsAffected() == 0 {
return fmt.Errorf("module id %d not in unit %d", id, unitID)
}
}
return tx.Commit(ctx)
}
// ReorderExamPrepUnitModuleLessonsInUnitModule sets sort_order to 1..n under unitModuleID.
// Uses an intermediate bump so UNIQUE (unit_module_id, sort_order) is never violated mid-reorder.
func (s *Store) ReorderExamPrepUnitModuleLessonsInUnitModule(ctx context.Context, unitModuleID int64, orderedIDs []int64) error {
tx, err := s.conn.Begin(ctx)
if err != nil {
return err
}
defer func() { _ = tx.Rollback(ctx) }()
if _, err := tx.Exec(ctx, `
UPDATE exam_prep.unit_module_lessons
SET sort_order = sort_order + $1,
updated_at = CURRENT_TIMESTAMP
WHERE unit_module_id = $2`, lessonReorderSortBump, unitModuleID); err != nil {
return err
}
for i, id := range orderedIDs {
tag, err := tx.Exec(ctx, `
UPDATE exam_prep.unit_module_lessons
SET sort_order = $1,
updated_at = CURRENT_TIMESTAMP
WHERE id = $2
AND unit_module_id = $3`, int32(i+1), id, unitModuleID)
if err != nil {
return err
}
if tag.RowsAffected() == 0 {
return fmt.Errorf("lesson id %d not in exam prep module %d", id, unitModuleID)
}
}
return tx.Commit(ctx)
}

View File

@ -0,0 +1,120 @@
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 examPrepLessonToDomain(l dbgen.ExamPrepUnitModuleLesson) domain.ExamPrepLesson {
out := domain.ExamPrepLesson{
ID: l.ID,
UnitModuleID: l.UnitModuleID,
Title: l.Title,
SortOrder: int(l.SortOrder),
}
out.VideoURL = fromPgText(l.VideoUrl)
out.Thumbnail = fromPgText(l.Thumbnail)
out.Description = fromPgText(l.Description)
out.CreatedAt = l.CreatedAt.Time
if l.UpdatedAt.Valid {
t := l.UpdatedAt.Time
out.UpdatedAt = &t
}
return out
}
func (s *Store) CreateExamPrepUnitModuleLesson(ctx context.Context, unitModuleID int64, input domain.CreateExamPrepLessonInput) (domain.ExamPrepLesson, error) {
l, err := s.queries.ExamPrepCreateUnitModuleLesson(ctx, dbgen.ExamPrepCreateUnitModuleLessonParams{
UnitModuleID: unitModuleID,
Title: input.Title,
VideoUrl: toPgText(input.VideoURL),
Thumbnail: toPgText(input.Thumbnail),
Description: toPgText(input.Description),
})
if err != nil {
return domain.ExamPrepLesson{}, err
}
return examPrepLessonToDomain(l), nil
}
func (s *Store) GetExamPrepUnitModuleLessonByID(ctx context.Context, id int64) (domain.ExamPrepLesson, error) {
l, err := s.queries.ExamPrepGetUnitModuleLessonByID(ctx, id)
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return domain.ExamPrepLesson{}, pgx.ErrNoRows
}
return domain.ExamPrepLesson{}, err
}
return examPrepLessonToDomain(l), nil
}
func (s *Store) ListExamPrepUnitModuleLessonsByUnitModuleID(ctx context.Context, unitModuleID int64, limit, offset int32) ([]domain.ExamPrepLesson, int64, error) {
rows, err := s.queries.ExamPrepListUnitModuleLessonsByUnitModuleID(ctx, dbgen.ExamPrepListUnitModuleLessonsByUnitModuleIDParams{
UnitModuleID: unitModuleID,
Limit: limit,
Offset: offset,
})
if err != nil {
return nil, 0, err
}
if len(rows) == 0 {
return []domain.ExamPrepLesson{}, 0, nil
}
var total int64
out := make([]domain.ExamPrepLesson, 0, len(rows))
for i, r := range rows {
if i == 0 {
total = r.TotalCount
}
out = append(out, examPrepLessonToDomain(dbgen.ExamPrepUnitModuleLesson{
ID: r.ID,
UnitModuleID: r.UnitModuleID,
Title: r.Title,
VideoUrl: r.VideoUrl,
Thumbnail: r.Thumbnail,
Description: r.Description,
SortOrder: r.SortOrder,
CreatedAt: r.CreatedAt,
UpdatedAt: r.UpdatedAt,
}))
}
return out, total, nil
}
func (s *Store) ListExamPrepUnitModuleLessonIDsByUnitModule(ctx context.Context, unitModuleID int64) ([]int64, error) {
return s.queries.ExamPrepListUnitModuleLessonIDsByUnitModule(ctx, unitModuleID)
}
func (s *Store) UpdateExamPrepUnitModuleLesson(ctx context.Context, id int64, input domain.UpdateExamPrepLessonInput) (domain.ExamPrepLesson, error) {
var titleText pgtype.Text
if input.Title != nil {
titleText = pgtype.Text{String: *input.Title, Valid: true}
} else {
titleText = pgtype.Text{Valid: false}
}
l, err := s.queries.ExamPrepUpdateUnitModuleLesson(ctx, dbgen.ExamPrepUpdateUnitModuleLessonParams{
ID: id,
Title: titleText,
VideoUrl: optionalTextUpdate(input.VideoURL),
Thumbnail: optionalTextUpdate(input.Thumbnail),
Description: optionalTextUpdate(input.Description),
SortOrder: optionalInt4Update(input.SortOrder),
})
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return domain.ExamPrepLesson{}, pgx.ErrNoRows
}
return domain.ExamPrepLesson{}, err
}
return examPrepLessonToDomain(l), nil
}
func (s *Store) DeleteExamPrepUnitModuleLesson(ctx context.Context, id int64) error {
return s.queries.ExamPrepDeleteUnitModuleLesson(ctx, id)
}

View File

@ -0,0 +1,120 @@
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 examPrepModuleToDomain(m dbgen.ExamPrepUnitModule) domain.ExamPrepModule {
out := domain.ExamPrepModule{
ID: m.ID,
UnitID: m.UnitID,
Name: m.Name,
SortOrder: int(m.SortOrder),
}
out.Description = fromPgText(m.Description)
out.Thumbnail = fromPgText(m.Thumbnail)
out.Icon = fromPgText(m.Icon)
out.CreatedAt = m.CreatedAt.Time
if m.UpdatedAt.Valid {
t := m.UpdatedAt.Time
out.UpdatedAt = &t
}
return out
}
func (s *Store) CreateExamPrepUnitModule(ctx context.Context, unitID int64, input domain.CreateExamPrepModuleInput) (domain.ExamPrepModule, error) {
m, err := s.queries.ExamPrepCreateUnitModule(ctx, dbgen.ExamPrepCreateUnitModuleParams{
UnitID: unitID,
Name: input.Name,
Description: toPgText(input.Description),
Thumbnail: toPgText(input.Thumbnail),
Icon: toPgText(input.Icon),
})
if err != nil {
return domain.ExamPrepModule{}, err
}
return examPrepModuleToDomain(m), nil
}
func (s *Store) GetExamPrepUnitModuleByID(ctx context.Context, id int64) (domain.ExamPrepModule, error) {
m, err := s.queries.ExamPrepGetUnitModuleByID(ctx, id)
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return domain.ExamPrepModule{}, pgx.ErrNoRows
}
return domain.ExamPrepModule{}, err
}
return examPrepModuleToDomain(m), nil
}
func (s *Store) ListExamPrepUnitModulesByUnit(ctx context.Context, unitID int64, limit, offset int32) ([]domain.ExamPrepModule, int64, error) {
rows, err := s.queries.ExamPrepListUnitModulesByUnit(ctx, dbgen.ExamPrepListUnitModulesByUnitParams{
UnitID: unitID,
Limit: limit,
Offset: offset,
})
if err != nil {
return nil, 0, err
}
if len(rows) == 0 {
return []domain.ExamPrepModule{}, 0, nil
}
var total int64
out := make([]domain.ExamPrepModule, 0, len(rows))
for i, r := range rows {
if i == 0 {
total = r.TotalCount
}
out = append(out, examPrepModuleToDomain(dbgen.ExamPrepUnitModule{
ID: r.ID,
UnitID: r.UnitID,
Name: r.Name,
Description: r.Description,
Thumbnail: r.Thumbnail,
Icon: r.Icon,
SortOrder: r.SortOrder,
CreatedAt: r.CreatedAt,
UpdatedAt: r.UpdatedAt,
}))
}
return out, total, nil
}
func (s *Store) ListExamPrepUnitModuleIDsByUnit(ctx context.Context, unitID int64) ([]int64, error) {
return s.queries.ExamPrepListUnitModuleIDsByUnit(ctx, unitID)
}
func (s *Store) UpdateExamPrepUnitModule(ctx context.Context, id int64, input domain.UpdateExamPrepModuleInput) (domain.ExamPrepModule, error) {
var nameText pgtype.Text
if input.Name != nil {
nameText = pgtype.Text{String: *input.Name, Valid: true}
} else {
nameText = pgtype.Text{Valid: false}
}
m, err := s.queries.ExamPrepUpdateUnitModule(ctx, dbgen.ExamPrepUpdateUnitModuleParams{
ID: id,
Name: nameText,
Description: optionalTextUpdate(input.Description),
Thumbnail: optionalTextUpdate(input.Thumbnail),
Icon: optionalTextUpdate(input.Icon),
SortOrder: optionalInt4Update(input.SortOrder),
})
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return domain.ExamPrepModule{}, pgx.ErrNoRows
}
return domain.ExamPrepModule{}, err
}
return examPrepModuleToDomain(m), nil
}
func (s *Store) DeleteExamPrepUnitModule(ctx context.Context, id int64) error {
return s.queries.ExamPrepDeleteUnitModule(ctx, id)
}

View File

@ -0,0 +1,116 @@
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 examPrepUnitToDomain(u dbgen.ExamPrepUnit) domain.ExamPrepUnit {
out := domain.ExamPrepUnit{
ID: u.ID,
CatalogCourseID: u.CatalogCourseID,
Name: u.Name,
SortOrder: int(u.SortOrder),
}
out.Description = fromPgText(u.Description)
out.Thumbnail = fromPgText(u.Thumbnail)
out.CreatedAt = u.CreatedAt.Time
if u.UpdatedAt.Valid {
t := u.UpdatedAt.Time
out.UpdatedAt = &t
}
return out
}
func (s *Store) CreateExamPrepUnit(ctx context.Context, catalogCourseID int64, input domain.CreateExamPrepUnitInput) (domain.ExamPrepUnit, error) {
u, err := s.queries.ExamPrepCreateUnit(ctx, dbgen.ExamPrepCreateUnitParams{
CatalogCourseID: catalogCourseID,
Name: input.Name,
Description: toPgText(input.Description),
Thumbnail: toPgText(input.Thumbnail),
})
if err != nil {
return domain.ExamPrepUnit{}, err
}
return examPrepUnitToDomain(u), nil
}
func (s *Store) GetExamPrepUnitByID(ctx context.Context, id int64) (domain.ExamPrepUnit, error) {
u, err := s.queries.ExamPrepGetUnitByID(ctx, id)
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return domain.ExamPrepUnit{}, pgx.ErrNoRows
}
return domain.ExamPrepUnit{}, err
}
return examPrepUnitToDomain(u), nil
}
func (s *Store) ListExamPrepUnitsByCatalogCourse(ctx context.Context, catalogCourseID int64, limit, offset int32) ([]domain.ExamPrepUnit, int64, error) {
rows, err := s.queries.ExamPrepListUnitsByCatalogCourse(ctx, dbgen.ExamPrepListUnitsByCatalogCourseParams{
CatalogCourseID: catalogCourseID,
Limit: limit,
Offset: offset,
})
if err != nil {
return nil, 0, err
}
if len(rows) == 0 {
return []domain.ExamPrepUnit{}, 0, nil
}
var total int64
out := make([]domain.ExamPrepUnit, 0, len(rows))
for i, r := range rows {
if i == 0 {
total = r.TotalCount
}
out = append(out, examPrepUnitToDomain(dbgen.ExamPrepUnit{
ID: r.ID,
CatalogCourseID: r.CatalogCourseID,
Name: r.Name,
Description: r.Description,
Thumbnail: r.Thumbnail,
SortOrder: r.SortOrder,
CreatedAt: r.CreatedAt,
UpdatedAt: r.UpdatedAt,
}))
}
return out, total, nil
}
func (s *Store) ListExamPrepUnitIDsByCatalogCourse(ctx context.Context, catalogCourseID int64) ([]int64, error) {
return s.queries.ExamPrepListUnitIDsByCatalogCourse(ctx, catalogCourseID)
}
func (s *Store) UpdateExamPrepUnit(ctx context.Context, id int64, input domain.UpdateExamPrepUnitInput) (domain.ExamPrepUnit, error) {
var nameText pgtype.Text
if input.Name != nil {
nameText = pgtype.Text{String: *input.Name, Valid: true}
} else {
nameText = pgtype.Text{Valid: false}
}
u, err := s.queries.ExamPrepUpdateUnit(ctx, dbgen.ExamPrepUpdateUnitParams{
ID: id,
Name: nameText,
Description: optionalTextUpdate(input.Description),
Thumbnail: optionalTextUpdate(input.Thumbnail),
SortOrder: optionalInt4Update(input.SortOrder),
})
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return domain.ExamPrepUnit{}, pgx.ErrNoRows
}
return domain.ExamPrepUnit{}, err
}
return examPrepUnitToDomain(u), nil
}
func (s *Store) DeleteExamPrepUnit(ctx context.Context, id int64) error {
return s.queries.ExamPrepDeleteUnit(ctx, id)
}

View File

@ -38,50 +38,89 @@ func (s *Store) LmsUserHasLessonProgress(ctx context.Context, userID, lessonID i
return s.queries.UserHasLessonProgress(ctx, dbgen.UserHasLessonProgressParams{UserID: userID, LessonID: lessonID}) return s.queries.UserHasLessonProgress(ctx, dbgen.UserHasLessonProgressParams{UserID: userID, LessonID: lessonID})
} }
// LmsUserLessonProgressInModule returns completed and total lesson counts in a module (for progress UI). // LmsUserLessonProgressInModule returns combined completed/total counts for lessons + published practices in a module.
func (s *Store) LmsUserLessonProgressInModule(ctx context.Context, userID, moduleID int64) (completed, total int32, err error) { func (s *Store) LmsUserLessonProgressInModule(ctx context.Context, userID, moduleID int64) (completed, total int32, err error) {
total, err = s.queries.CountLessonsInModule(ctx, moduleID) lessonTotal, err := s.queries.CountLessonsInModule(ctx, moduleID)
if err != nil { if err != nil {
return 0, 0, err return 0, 0, err
} }
completed, err = s.queries.CountUserCompletedLessonsInModule(ctx, dbgen.CountUserCompletedLessonsInModuleParams{ lessonCompleted, err := s.queries.CountUserCompletedLessonsInModule(ctx, dbgen.CountUserCompletedLessonsInModuleParams{
ModuleID: moduleID, ModuleID: moduleID,
UserID: userID, UserID: userID,
}) })
if err != nil { if err != nil {
return 0, 0, err return 0, 0, err
} }
return completed, total, nil practiceTotal, err := s.queries.CountPublishedPracticesInModule(ctx, toPgInt8(&moduleID))
}
// LmsUserLessonProgressInCourse returns completed and total lesson counts in a course (all modules).
func (s *Store) LmsUserLessonProgressInCourse(ctx context.Context, userID, courseID int64) (completed, total int32, err error) {
total, err = s.queries.CountLessonsInCourse(ctx, courseID)
if err != nil { if err != nil {
return 0, 0, err return 0, 0, err
} }
completed, err = s.queries.CountUserCompletedLessonsInCourse(ctx, dbgen.CountUserCompletedLessonsInCourseParams{ practiceCompleted, err := s.queries.CountUserCompletedPublishedPracticesInModule(ctx, dbgen.CountUserCompletedPublishedPracticesInModuleParams{
ModuleID: toPgInt8(&moduleID),
UserID: userID,
})
if err != nil {
return 0, 0, err
}
total = lessonTotal + practiceTotal
completed = lessonCompleted + practiceCompleted
return completed, total, nil
}
// LmsUserLessonProgressInCourse returns combined completed/total counts for lessons + published practices in a course.
func (s *Store) LmsUserLessonProgressInCourse(ctx context.Context, userID, courseID int64) (completed, total int32, err error) {
lessonTotal, err := s.queries.CountLessonsInCourse(ctx, courseID)
if err != nil {
return 0, 0, err
}
lessonCompleted, err := s.queries.CountUserCompletedLessonsInCourse(ctx, dbgen.CountUserCompletedLessonsInCourseParams{
CourseID: courseID, CourseID: courseID,
UserID: userID, UserID: userID,
}) })
if err != nil { if err != nil {
return 0, 0, err return 0, 0, err
} }
return completed, total, nil practiceTotal, err := s.queries.CountPublishedPracticesInCourse(ctx, toPgInt8(&courseID))
}
// LmsUserLessonProgressInProgram returns completed and total lesson counts in a program (all courses).
func (s *Store) LmsUserLessonProgressInProgram(ctx context.Context, userID, programID int64) (completed, total int32, err error) {
total, err = s.queries.CountLessonsInProgram(ctx, programID)
if err != nil { if err != nil {
return 0, 0, err return 0, 0, err
} }
completed, err = s.queries.CountUserCompletedLessonsInProgram(ctx, dbgen.CountUserCompletedLessonsInProgramParams{ practiceCompleted, err := s.queries.CountUserCompletedPublishedPracticesInCourse(ctx, dbgen.CountUserCompletedPublishedPracticesInCourseParams{
CourseID: toPgInt8(&courseID),
UserID: userID,
})
if err != nil {
return 0, 0, err
}
total = lessonTotal + practiceTotal
completed = lessonCompleted + practiceCompleted
return completed, total, nil
}
// LmsUserLessonProgressInProgram returns combined completed/total counts for lessons + published practices in a program.
func (s *Store) LmsUserLessonProgressInProgram(ctx context.Context, userID, programID int64) (completed, total int32, err error) {
lessonTotal, err := s.queries.CountLessonsInProgram(ctx, programID)
if err != nil {
return 0, 0, err
}
lessonCompleted, err := s.queries.CountUserCompletedLessonsInProgram(ctx, dbgen.CountUserCompletedLessonsInProgramParams{
ProgramID: programID, ProgramID: programID,
UserID: userID, UserID: userID,
}) })
if err != nil { if err != nil {
return 0, 0, err return 0, 0, err
} }
practiceTotal, err := s.queries.CountPublishedPracticesInProgram(ctx, programID)
if err != nil {
return 0, 0, err
}
practiceCompleted, err := s.queries.CountUserCompletedPublishedPracticesInProgram(ctx, dbgen.CountUserCompletedPublishedPracticesInProgramParams{
ProgramID: programID,
UserID: userID,
})
if err != nil {
return 0, 0, err
}
total = lessonTotal + practiceTotal
completed = lessonCompleted + practiceCompleted
return completed, total, nil return completed, total, nil
} }

View File

@ -74,7 +74,7 @@ func (s *Store) ListCoursesByProgramID(ctx context.Context, programID int64, lim
if i == 0 { if i == 0 {
total = r.TotalCount total = r.TotalCount
} }
out = append(out, courseToDomain(dbgen.Course{ co := courseToDomain(dbgen.Course{
ID: r.ID, ID: r.ID,
ProgramID: r.ProgramID, ProgramID: r.ProgramID,
Name: r.Name, Name: r.Name,
@ -83,7 +83,11 @@ func (s *Store) ListCoursesByProgramID(ctx context.Context, programID int64, lim
CreatedAt: r.CreatedAt, CreatedAt: r.CreatedAt,
UpdatedAt: r.UpdatedAt, UpdatedAt: r.UpdatedAt,
SortOrder: r.SortOrder, SortOrder: r.SortOrder,
})) })
co.ModuleCount = int(r.ModuleCount)
co.LessonCount = int(r.LessonCount)
co.PracticeCount = int(r.PracticeCount)
out = append(out, co)
} }
return out, total, nil return out, total, nil
} }

View File

@ -93,13 +93,78 @@ func (s *Store) ListModulesByProgramAndCourse(ctx context.Context, programID, co
} }
func (s *Store) UpdateModule(ctx context.Context, id int64, input domain.UpdateModuleInput) (domain.Module, error) { func (s *Store) UpdateModule(ctx context.Context, id int64, input domain.UpdateModuleInput) (domain.Module, error) {
q, tx, err := s.BeginTx(ctx)
if err != nil {
return domain.Module{}, err
}
defer tx.Rollback(ctx)
var current dbgen.Module
err = tx.QueryRow(ctx, `
SELECT id, program_id, course_id, name, description, icon, sort_order, created_at, updated_at
FROM modules
WHERE id = $1
FOR UPDATE
`, id).Scan(
&current.ID,
&current.ProgramID,
&current.CourseID,
&current.Name,
&current.Description,
&current.Icon,
&current.SortOrder,
&current.CreatedAt,
&current.UpdatedAt,
)
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return domain.Module{}, pgx.ErrNoRows
}
return domain.Module{}, err
}
if input.SortOrder != nil {
targetSort := int32(*input.SortOrder)
if targetSort != current.SortOrder {
var conflictID int64
err = tx.QueryRow(ctx, `
SELECT id
FROM modules
WHERE course_id = $1
AND sort_order = $2
AND id <> $3
FOR UPDATE
`, current.CourseID, targetSort, id).Scan(&conflictID)
if err != nil && !errors.Is(err, pgx.ErrNoRows) {
return domain.Module{}, err
}
if err == nil {
var tempSort int32
if err := tx.QueryRow(ctx, `
SELECT COALESCE(MIN(sort_order), 0) - 1
FROM modules
WHERE course_id = $1
`, current.CourseID).Scan(&tempSort); err != nil {
return domain.Module{}, err
}
if _, err := tx.Exec(ctx, `UPDATE modules SET sort_order = $1 WHERE id = $2`, tempSort, id); err != nil {
return domain.Module{}, err
}
if _, err := tx.Exec(ctx, `UPDATE modules SET sort_order = $1 WHERE id = $2`, current.SortOrder, conflictID); err != nil {
return domain.Module{}, err
}
}
}
}
var nameText pgtype.Text var nameText pgtype.Text
if input.Name != nil { if input.Name != nil {
nameText = pgtype.Text{String: *input.Name, Valid: true} nameText = pgtype.Text{String: *input.Name, Valid: true}
} else { } else {
nameText = pgtype.Text{Valid: false} nameText = pgtype.Text{Valid: false}
} }
m, err := s.queries.UpdateModule(ctx, dbgen.UpdateModuleParams{ m, err := q.UpdateModule(ctx, dbgen.UpdateModuleParams{
ID: id, ID: id,
Name: nameText, Name: nameText,
Description: optionalTextUpdate(input.Description), Description: optionalTextUpdate(input.Description),
@ -112,6 +177,10 @@ func (s *Store) UpdateModule(ctx context.Context, id int64, input domain.UpdateM
} }
return domain.Module{}, err return domain.Module{}, err
} }
if err := tx.Commit(ctx); err != nil {
return domain.Module{}, err
}
return moduleToDomain(m), nil return moduleToDomain(m), nil
} }

View File

@ -7,8 +7,8 @@ import (
dbgen "Yimaru-Backend/gen/db" dbgen "Yimaru-Backend/gen/db"
) )
// CompleteLessonForUser records lesson completion and cascades to module, course, and program when the user // CompleteLessonForUser records lesson completion and cascades completion upward when
// has fully completed the preceding scope. Runs in a single transaction. // both lesson and related practice requirements are satisfied.
func (s *Store) CompleteLessonForUser(ctx context.Context, userID, lessonID int64) error { func (s *Store) CompleteLessonForUser(ctx context.Context, userID, lessonID int64) error {
q, tx, err := s.BeginTx(ctx) q, tx, err := s.BeginTx(ctx)
if err != nil { if err != nil {
@ -31,56 +31,162 @@ func (s *Store) CompleteLessonForUser(ctx context.Context, userID, lessonID int6
if err != nil { if err != nil {
return err return err
} }
nLess, err := q.CountLessonsInModule(ctx, lesson.ModuleID)
if err != nil { if err := s.cascadeLMSCompletion(ctx, q, userID, mod.ID, crs.ID, crs.ProgramID); err != nil {
return err return err
} }
nDoneLess, err := q.CountUserCompletedLessonsInModule(ctx, dbgen.CountUserCompletedLessonsInModuleParams{
ModuleID: lesson.ModuleID,
UserID: userID,
})
if err != nil {
return err
}
if nLess > 0 && nDoneLess >= nLess {
if err := q.InsertUserModuleProgress(ctx, dbgen.InsertUserModuleProgressParams{UserID: userID, ModuleID: mod.ID}); err != nil {
return err
}
nMods, err := q.CountModulesInCourse(ctx, mod.CourseID)
if err != nil {
return err
}
nDoneMods, err := q.CountUserCompletedModulesInCourse(ctx, dbgen.CountUserCompletedModulesInCourseParams{
CourseID: mod.CourseID,
UserID: userID,
})
if err != nil {
return err
}
if nMods > 0 && nDoneMods >= nMods {
if err := q.InsertUserCourseProgress(ctx, dbgen.InsertUserCourseProgressParams{UserID: userID, CourseID: crs.ID}); err != nil {
return err
}
nCr, err := q.CountCoursesInProgram(ctx, crs.ProgramID)
if err != nil {
return err
}
nCrDone, err := q.CountUserCompletedCoursesInProgram(ctx, dbgen.CountUserCompletedCoursesInProgramParams{
ProgramID: crs.ProgramID,
UserID: userID,
})
if err != nil {
return err
}
if nCr > 0 && nCrDone >= nCr {
if err := q.InsertUserProgramProgress(ctx, dbgen.InsertUserProgramProgressParams{UserID: userID, ProgramID: crs.ProgramID}); err != nil {
return err
}
}
}
}
if err := tx.Commit(ctx); err != nil { if err := tx.Commit(ctx); err != nil {
return fmt.Errorf("commit: %w", err) return fmt.Errorf("commit: %w", err)
} }
return nil return nil
} }
// CompletePracticeForUser records practice completion and cascades completion upward when
// both lesson and related practice requirements are satisfied.
func (s *Store) CompletePracticeForUser(ctx context.Context, userID, questionSetID int64) error {
q, tx, err := s.BeginTx(ctx)
if err != nil {
return fmt.Errorf("begin tx: %w", err)
}
defer func() { _ = tx.Rollback(ctx) }()
if _, err := q.MarkPracticeCompleted(ctx, dbgen.MarkPracticeCompletedParams{
UserID: userID,
QuestionSetID: questionSetID,
}); err != nil {
return err
}
scope, err := q.GetPracticeScopeByQuestionSetID(ctx, questionSetID)
if err != nil {
return err
}
if !scope.ModuleID.Valid {
return fmt.Errorf("practice %d is not linked to a module", questionSetID)
}
mod, err := q.GetModuleByID(ctx, scope.ModuleID.Int64)
if err != nil {
return err
}
crs, err := q.GetCourseByID(ctx, mod.CourseID)
if err != nil {
return err
}
if err := s.cascadeLMSCompletion(ctx, q, userID, mod.ID, crs.ID, crs.ProgramID); err != nil {
return err
}
if err := tx.Commit(ctx); err != nil {
return fmt.Errorf("commit: %w", err)
}
return nil
}
func (s *Store) cascadeLMSCompletion(ctx context.Context, q *dbgen.Queries, userID, moduleID, courseID, programID int64) error {
moduleLessonsTotal, err := q.CountLessonsInModule(ctx, moduleID)
if err != nil {
return err
}
moduleLessonsDone, err := q.CountUserCompletedLessonsInModule(ctx, dbgen.CountUserCompletedLessonsInModuleParams{
ModuleID: moduleID,
UserID: userID,
})
if err != nil {
return err
}
modulePracticesTotal, err := q.CountPublishedPracticesInModule(ctx, toPgInt8(&moduleID))
if err != nil {
return err
}
modulePracticesDone, err := q.CountUserCompletedPublishedPracticesInModule(ctx, dbgen.CountUserCompletedPublishedPracticesInModuleParams{
ModuleID: toPgInt8(&moduleID),
UserID: userID,
})
if err != nil {
return err
}
moduleLessonsComplete := moduleLessonsTotal > 0 && moduleLessonsDone >= moduleLessonsTotal
modulePracticesComplete := modulePracticesDone >= modulePracticesTotal
if !moduleLessonsComplete || !modulePracticesComplete {
return nil
}
if err := q.InsertUserModuleProgress(ctx, dbgen.InsertUserModuleProgressParams{UserID: userID, ModuleID: moduleID}); err != nil {
return err
}
nMods, err := q.CountModulesInCourse(ctx, courseID)
if err != nil {
return err
}
nDoneMods, err := q.CountUserCompletedModulesInCourse(ctx, dbgen.CountUserCompletedModulesInCourseParams{
CourseID: courseID,
UserID: userID,
})
if err != nil {
return err
}
coursePracticesTotal, err := q.CountPublishedPracticesInCourse(ctx, toPgInt8(&courseID))
if err != nil {
return err
}
coursePracticesDone, err := q.CountUserCompletedPublishedPracticesInCourse(ctx, dbgen.CountUserCompletedPublishedPracticesInCourseParams{
CourseID: toPgInt8(&courseID),
UserID: userID,
})
if err != nil {
return err
}
courseModulesComplete := nMods > 0 && nDoneMods >= nMods
coursePracticesComplete := coursePracticesDone >= coursePracticesTotal
if !courseModulesComplete || !coursePracticesComplete {
return nil
}
if err := q.InsertUserCourseProgress(ctx, dbgen.InsertUserCourseProgressParams{UserID: userID, CourseID: courseID}); err != nil {
return err
}
nCr, err := q.CountCoursesInProgram(ctx, programID)
if err != nil {
return err
}
nCrDone, err := q.CountUserCompletedCoursesInProgram(ctx, dbgen.CountUserCompletedCoursesInProgramParams{
ProgramID: programID,
UserID: userID,
})
if err != nil {
return err
}
programPracticesTotal, err := q.CountPublishedPracticesInProgram(ctx, programID)
if err != nil {
return err
}
programPracticesDone, err := q.CountUserCompletedPublishedPracticesInProgram(ctx, dbgen.CountUserCompletedPublishedPracticesInProgramParams{
ProgramID: programID,
UserID: userID,
})
if err != nil {
return err
}
programCoursesComplete := nCr > 0 && nCrDone >= nCr
programPracticesComplete := programPracticesDone >= programPracticesTotal
if !programCoursesComplete || !programPracticesComplete {
return nil
}
if err := q.InsertUserProgramProgress(ctx, dbgen.InsertUserProgramProgressParams{UserID: userID, ProgramID: programID}); err != nil {
return err
}
return nil
}

View File

@ -50,6 +50,10 @@ func (s *Store) GetOtp(ctx context.Context, userID int64) (domain.Otp, error) {
} }
return domain.Otp{}, err return domain.Otp{}, err
} }
if !row.ExpiresAt.Valid {
return domain.Otp{}, domain.ErrOtpNotFound
}
return domain.Otp{ return domain.Otp{
ID: row.ID, ID: row.ID,
UserID: row.UserID, UserID: row.UserID,
@ -63,6 +67,36 @@ func (s *Store) GetOtp(ctx context.Context, userID int64) (domain.Otp, error) {
ExpiresAt: row.ExpiresAt.Time, ExpiresAt: row.ExpiresAt.Time,
}, nil }, nil
} }
func (s *Store) GetOtpByCode(ctx context.Context, userID int64, otpCode string) (domain.Otp, error) {
row, err := s.queries.GetOtpByCode(ctx, dbgen.GetOtpByCodeParams{
UserID: userID,
Otp: otpCode,
})
if err != nil {
if err == sql.ErrNoRows {
return domain.Otp{}, domain.ErrOtpNotFound
}
return domain.Otp{}, err
}
if !row.ExpiresAt.Valid {
return domain.Otp{}, domain.ErrOtpNotFound
}
return domain.Otp{
ID: row.ID,
UserID: row.UserID,
SentTo: row.SentTo,
Medium: domain.OtpMedium(row.Medium),
For: domain.OtpFor(row.OtpFor),
Otp: row.Otp,
Used: row.Used,
UsedAt: row.UsedAt.Time,
CreatedAt: row.CreatedAt.Time,
ExpiresAt: row.ExpiresAt.Time,
}, nil
}
func (s *Store) MarkOtpAsUsed(ctx context.Context, otp domain.Otp) error { func (s *Store) MarkOtpAsUsed(ctx context.Context, otp domain.Otp) error {
return s.queries.MarkOtpAsUsed(ctx, dbgen.MarkOtpAsUsedParams{ return s.queries.MarkOtpAsUsed(ctx, dbgen.MarkOtpAsUsedParams{
ID: otp.ID, ID: otp.ID,

View File

@ -607,6 +607,19 @@ func (s *Store) GetQuestionSetsByType(ctx context.Context, setType string, limit
UpdatedAt: timePtr(r.UpdatedAt), UpdatedAt: timePtr(r.UpdatedAt),
} }
} }
// COUNT(*) OVER() only appears when at least one row is returned.
// For out-of-range offsets, fetch total count explicitly so pagination metadata stays correct.
if len(rows) == 0 {
err = s.conn.QueryRow(
ctx,
`SELECT COUNT(*) FROM question_sets WHERE set_type = $1 AND status != 'ARCHIVED'`,
setType,
).Scan(&totalCount)
if err != nil {
return nil, 0, err
}
}
return result, totalCount, nil return result, totalCount, nil
} }
@ -731,9 +744,9 @@ func (s *Store) GetQuestionSetItems(ctx context.Context, setID int64) ([]domain.
Explanation: fromPgText(r.Explanation), Explanation: fromPgText(r.Explanation),
Tips: fromPgText(r.Tips), Tips: fromPgText(r.Tips),
VoicePrompt: fromPgText(r.VoicePrompt), VoicePrompt: fromPgText(r.VoicePrompt),
SampleAnswerVoicePrompt: nil, SampleAnswerVoicePrompt: fromPgText(r.SampleAnswerVoicePrompt),
ImageURL: fromPgText(r.ImageUrl), ImageURL: fromPgText(r.ImageUrl),
AudioCorrectAnswerText: nil, AudioCorrectAnswerText: fromPgText(r.AudioCorrectAnswerText),
QuestionStatus: r.QuestionStatus, QuestionStatus: r.QuestionStatus,
} }
} }
@ -806,7 +819,9 @@ func (s *Store) GetPublishedQuestionsInSet(ctx context.Context, setID int64) ([]
Explanation: fromPgText(r.Explanation), Explanation: fromPgText(r.Explanation),
Tips: fromPgText(r.Tips), Tips: fromPgText(r.Tips),
VoicePrompt: fromPgText(r.VoicePrompt), VoicePrompt: fromPgText(r.VoicePrompt),
SampleAnswerVoicePrompt: fromPgText(r.SampleAnswerVoicePrompt),
ImageURL: fromPgText(r.ImageUrl), ImageURL: fromPgText(r.ImageUrl),
AudioCorrectAnswerText: fromPgText(r.AudioCorrectAnswerText),
QuestionStatus: "PUBLISHED", QuestionStatus: "PUBLISHED",
} }
} }

View File

@ -95,9 +95,13 @@ func (s *Service) VerifyOtp(
return domain.LoginSuccess{}, err return domain.LoginSuccess{}, err
} }
// 1. Retrieve OTP // 1. Retrieve OTP row matching submitted code.
storedOtp, err := s.otpStore.GetOtp(ctx, user.ID) // This avoids false positives when another OTP row exists for the same user.
storedOtp, err := s.otpStore.GetOtpByCode(ctx, user.ID, otpCode)
if err != nil { if err != nil {
if errors.Is(err, domain.ErrOtpNotFound) {
return domain.LoginSuccess{}, domain.ErrInvalidOtp
}
return domain.LoginSuccess{}, err return domain.LoginSuccess{}, err
} }
@ -111,12 +115,7 @@ func (s *Service) VerifyOtp(
return domain.LoginSuccess{}, domain.ErrOtpExpired return domain.LoginSuccess{}, domain.ErrOtpExpired
} }
// 4. Invalid // 4. Mark OTP as used
if storedOtp.Otp != otpCode {
return domain.LoginSuccess{}, domain.ErrInvalidOtp
}
// 5. Mark OTP as used
storedOtp.Used = true storedOtp.Used = true
storedOtp.UsedAt = timePtr(time.Now()) storedOtp.UsedAt = timePtr(time.Now())

View File

@ -0,0 +1,407 @@
package examprep
import (
"Yimaru-Backend/internal/domain"
"Yimaru-Backend/internal/ports"
"context"
"errors"
"github.com/jackc/pgx/v5"
)
var ErrCatalogCourseNotFound = errors.New("exam prep catalog course not found")
var ErrUnitNotFound = errors.New("exam prep unit not found")
var ErrModuleNotFound = errors.New("exam prep module not found")
var ErrLessonNotFound = errors.New("exam prep lesson not found")
var ErrPracticeNotFound = errors.New("exam prep practice not found")
// examPrepStore is implemented by *repository.Store (catalog courses, units, modules, lessons, practices).
type examPrepStore interface {
ports.ExamPrepCatalogCourseStore
ports.ExamPrepUnitStore
ports.ExamPrepModuleStore
ports.ExamPrepLessonStore
ports.ExamPrepPracticeStore
}
type Service struct {
store examPrepStore
}
func NewService(store examPrepStore) *Service {
return &Service{store: store}
}
func (s *Service) CreateCatalogCourse(ctx context.Context, input domain.CreateExamPrepCatalogCourseInput) (domain.ExamPrepCatalogCourse, error) {
return s.store.CreateExamPrepCatalogCourse(ctx, input)
}
func (s *Service) GetCatalogCourseByID(ctx context.Context, id int64) (domain.ExamPrepCatalogCourse, error) {
c, err := s.store.GetExamPrepCatalogCourseByID(ctx, id)
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return domain.ExamPrepCatalogCourse{}, ErrCatalogCourseNotFound
}
return domain.ExamPrepCatalogCourse{}, err
}
return c, nil
}
func (s *Service) ListCatalogCourses(ctx context.Context, limit, offset int32) ([]domain.ExamPrepCatalogCourse, int64, error) {
if limit <= 0 {
limit = 20
}
if limit > 200 {
limit = 200
}
if offset < 0 {
offset = 0
}
return s.store.ListExamPrepCatalogCourses(ctx, limit, offset)
}
func (s *Service) UpdateCatalogCourse(ctx context.Context, id int64, input domain.UpdateExamPrepCatalogCourseInput) (domain.ExamPrepCatalogCourse, error) {
c, err := s.store.UpdateExamPrepCatalogCourse(ctx, id, input)
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return domain.ExamPrepCatalogCourse{}, ErrCatalogCourseNotFound
}
return domain.ExamPrepCatalogCourse{}, err
}
return c, nil
}
func (s *Service) DeleteCatalogCourse(ctx context.Context, id int64) error {
if _, err := s.store.GetExamPrepCatalogCourseByID(ctx, id); err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return ErrCatalogCourseNotFound
}
return err
}
return s.store.DeleteExamPrepCatalogCourse(ctx, id)
}
func (s *Service) ReorderCatalogCourses(ctx context.Context, ordered []int64) error {
expected, err := s.store.ListAllExamPrepCatalogCourseIDs(ctx)
if err != nil {
return err
}
if err := domain.ValidateReorderPermutation(ordered, expected); err != nil {
return err
}
if len(ordered) == 0 {
return nil
}
return s.store.ReorderExamPrepCatalogCourses(ctx, ordered)
}
func (s *Service) ensureCatalogCourse(ctx context.Context, catalogCourseID int64) error {
if _, err := s.store.GetExamPrepCatalogCourseByID(ctx, catalogCourseID); err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return ErrCatalogCourseNotFound
}
return err
}
return nil
}
func (s *Service) CreateUnit(ctx context.Context, catalogCourseID int64, input domain.CreateExamPrepUnitInput) (domain.ExamPrepUnit, error) {
if err := s.ensureCatalogCourse(ctx, catalogCourseID); err != nil {
return domain.ExamPrepUnit{}, err
}
return s.store.CreateExamPrepUnit(ctx, catalogCourseID, input)
}
func (s *Service) ListUnitsByCatalogCourse(ctx context.Context, catalogCourseID int64, limit, offset int32) ([]domain.ExamPrepUnit, int64, error) {
if err := s.ensureCatalogCourse(ctx, catalogCourseID); err != nil {
return nil, 0, err
}
if limit <= 0 {
limit = 20
}
if limit > 200 {
limit = 200
}
if offset < 0 {
offset = 0
}
return s.store.ListExamPrepUnitsByCatalogCourse(ctx, catalogCourseID, limit, offset)
}
func (s *Service) GetUnitByID(ctx context.Context, id int64) (domain.ExamPrepUnit, error) {
u, err := s.store.GetExamPrepUnitByID(ctx, id)
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return domain.ExamPrepUnit{}, ErrUnitNotFound
}
return domain.ExamPrepUnit{}, err
}
return u, nil
}
func (s *Service) UpdateUnit(ctx context.Context, id int64, input domain.UpdateExamPrepUnitInput) (domain.ExamPrepUnit, error) {
u, err := s.store.UpdateExamPrepUnit(ctx, id, input)
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return domain.ExamPrepUnit{}, ErrUnitNotFound
}
return domain.ExamPrepUnit{}, err
}
return u, nil
}
func (s *Service) DeleteUnit(ctx context.Context, id int64) error {
if _, err := s.store.GetExamPrepUnitByID(ctx, id); err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return ErrUnitNotFound
}
return err
}
return s.store.DeleteExamPrepUnit(ctx, id)
}
func (s *Service) ReorderUnitsInCatalogCourse(ctx context.Context, catalogCourseID int64, ordered []int64) error {
if err := s.ensureCatalogCourse(ctx, catalogCourseID); err != nil {
return err
}
expected, err := s.store.ListExamPrepUnitIDsByCatalogCourse(ctx, catalogCourseID)
if err != nil {
return err
}
if err := domain.ValidateReorderPermutation(ordered, expected); err != nil {
return err
}
if len(ordered) == 0 {
return nil
}
return s.store.ReorderExamPrepUnitsInCatalogCourse(ctx, catalogCourseID, ordered)
}
func (s *Service) ensureUnit(ctx context.Context, unitID int64) error {
if _, err := s.store.GetExamPrepUnitByID(ctx, unitID); err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return ErrUnitNotFound
}
return err
}
return nil
}
func (s *Service) CreateModule(ctx context.Context, unitID int64, input domain.CreateExamPrepModuleInput) (domain.ExamPrepModule, error) {
if err := s.ensureUnit(ctx, unitID); err != nil {
return domain.ExamPrepModule{}, err
}
return s.store.CreateExamPrepUnitModule(ctx, unitID, input)
}
func (s *Service) ListModulesByUnit(ctx context.Context, unitID int64, limit, offset int32) ([]domain.ExamPrepModule, int64, error) {
if err := s.ensureUnit(ctx, unitID); err != nil {
return nil, 0, err
}
if limit <= 0 {
limit = 20
}
if limit > 200 {
limit = 200
}
if offset < 0 {
offset = 0
}
return s.store.ListExamPrepUnitModulesByUnit(ctx, unitID, limit, offset)
}
func (s *Service) GetModuleByID(ctx context.Context, id int64) (domain.ExamPrepModule, error) {
m, err := s.store.GetExamPrepUnitModuleByID(ctx, id)
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return domain.ExamPrepModule{}, ErrModuleNotFound
}
return domain.ExamPrepModule{}, err
}
return m, nil
}
func (s *Service) UpdateModule(ctx context.Context, id int64, input domain.UpdateExamPrepModuleInput) (domain.ExamPrepModule, error) {
m, err := s.store.UpdateExamPrepUnitModule(ctx, id, input)
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return domain.ExamPrepModule{}, ErrModuleNotFound
}
return domain.ExamPrepModule{}, err
}
return m, nil
}
func (s *Service) DeleteModule(ctx context.Context, id int64) error {
if _, err := s.store.GetExamPrepUnitModuleByID(ctx, id); err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return ErrModuleNotFound
}
return err
}
return s.store.DeleteExamPrepUnitModule(ctx, id)
}
func (s *Service) ReorderModulesInUnit(ctx context.Context, unitID int64, ordered []int64) error {
if err := s.ensureUnit(ctx, unitID); err != nil {
return err
}
expected, err := s.store.ListExamPrepUnitModuleIDsByUnit(ctx, unitID)
if err != nil {
return err
}
if err := domain.ValidateReorderPermutation(ordered, expected); err != nil {
return err
}
if len(ordered) == 0 {
return nil
}
return s.store.ReorderExamPrepUnitModulesInUnit(ctx, unitID, ordered)
}
func (s *Service) ensureModule(ctx context.Context, unitModuleID int64) error {
if _, err := s.store.GetExamPrepUnitModuleByID(ctx, unitModuleID); err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return ErrModuleNotFound
}
return err
}
return nil
}
func (s *Service) CreateLesson(ctx context.Context, unitModuleID int64, input domain.CreateExamPrepLessonInput) (domain.ExamPrepLesson, error) {
if err := s.ensureModule(ctx, unitModuleID); err != nil {
return domain.ExamPrepLesson{}, err
}
return s.store.CreateExamPrepUnitModuleLesson(ctx, unitModuleID, input)
}
func (s *Service) ListLessonsByUnitModule(ctx context.Context, unitModuleID int64, limit, offset int32) ([]domain.ExamPrepLesson, int64, error) {
if err := s.ensureModule(ctx, unitModuleID); err != nil {
return nil, 0, err
}
if limit <= 0 {
limit = 20
}
if limit > 200 {
limit = 200
}
if offset < 0 {
offset = 0
}
return s.store.ListExamPrepUnitModuleLessonsByUnitModuleID(ctx, unitModuleID, limit, offset)
}
func (s *Service) GetLessonByID(ctx context.Context, id int64) (domain.ExamPrepLesson, error) {
l, err := s.store.GetExamPrepUnitModuleLessonByID(ctx, id)
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return domain.ExamPrepLesson{}, ErrLessonNotFound
}
return domain.ExamPrepLesson{}, err
}
return l, nil
}
func (s *Service) UpdateLesson(ctx context.Context, id int64, input domain.UpdateExamPrepLessonInput) (domain.ExamPrepLesson, error) {
l, err := s.store.UpdateExamPrepUnitModuleLesson(ctx, id, input)
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return domain.ExamPrepLesson{}, ErrLessonNotFound
}
return domain.ExamPrepLesson{}, err
}
return l, nil
}
func (s *Service) DeleteLesson(ctx context.Context, id int64) error {
if _, err := s.store.GetExamPrepUnitModuleLessonByID(ctx, id); err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return ErrLessonNotFound
}
return err
}
return s.store.DeleteExamPrepUnitModuleLesson(ctx, id)
}
func (s *Service) ReorderLessonsInUnitModule(ctx context.Context, unitModuleID int64, ordered []int64) error {
if err := s.ensureModule(ctx, unitModuleID); err != nil {
return err
}
expected, err := s.store.ListExamPrepUnitModuleLessonIDsByUnitModule(ctx, unitModuleID)
if err != nil {
return err
}
if err := domain.ValidateReorderPermutation(ordered, expected); err != nil {
return err
}
if len(ordered) == 0 {
return nil
}
return s.store.ReorderExamPrepUnitModuleLessonsInUnitModule(ctx, unitModuleID, ordered)
}
func (s *Service) ensureLesson(ctx context.Context, lessonID int64) error {
if _, err := s.store.GetExamPrepUnitModuleLessonByID(ctx, lessonID); err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return ErrLessonNotFound
}
return err
}
return nil
}
func (s *Service) CreateExamPrepPractice(ctx context.Context, lessonID int64, input domain.CreateExamPrepPracticeInput) (domain.ExamPrepPractice, error) {
if err := s.ensureLesson(ctx, lessonID); err != nil {
return domain.ExamPrepPractice{}, err
}
return s.store.CreateExamPrepLessonPractice(ctx, lessonID, input)
}
func (s *Service) ListExamPrepPracticesByLesson(ctx context.Context, lessonID int64, limit, offset int32) ([]domain.ExamPrepPractice, int64, error) {
if err := s.ensureLesson(ctx, lessonID); err != nil {
return nil, 0, err
}
if limit <= 0 {
limit = 20
}
if limit > 200 {
limit = 200
}
if offset < 0 {
offset = 0
}
return s.store.ListExamPrepLessonPracticesByLessonID(ctx, lessonID, limit, offset)
}
func (s *Service) GetExamPrepPracticeByID(ctx context.Context, id int64) (domain.ExamPrepPractice, error) {
p, err := s.store.GetExamPrepLessonPracticeByID(ctx, id)
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return domain.ExamPrepPractice{}, ErrPracticeNotFound
}
return domain.ExamPrepPractice{}, err
}
return p, nil
}
func (s *Service) UpdateExamPrepPractice(ctx context.Context, id int64, input domain.UpdateExamPrepPracticeInput) (domain.ExamPrepPractice, error) {
p, err := s.store.UpdateExamPrepLessonPractice(ctx, id, input)
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return domain.ExamPrepPractice{}, ErrPracticeNotFound
}
return domain.ExamPrepPractice{}, err
}
return p, nil
}
func (s *Service) DeleteExamPrepPractice(ctx context.Context, id int64) error {
if _, err := s.store.GetExamPrepLessonPracticeByID(ctx, id); err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return ErrPracticeNotFound
}
return err
}
return s.store.DeleteExamPrepLessonPractice(ctx, id)
}

View File

@ -31,6 +31,11 @@ func (s *Service) CompleteLessonForUser(ctx context.Context, userID, lessonID in
return s.store.CompleteLessonForUser(ctx, userID, lessonID) return s.store.CompleteLessonForUser(ctx, userID, lessonID)
} }
// CompletePracticeForUser records practice completion and rolls up to module, course, and program when applicable.
func (s *Service) CompletePracticeForUser(ctx context.Context, userID, questionSetID int64) error {
return s.store.CompletePracticeForUser(ctx, userID, questionSetID)
}
// GetMyProgress returns completed lesson, module, course, and program IDs for the user. // GetMyProgress returns completed lesson, module, course, and program IDs for the user.
func (s *Service) GetMyProgress(ctx context.Context, userID int64) (domain.LMSUserProgress, error) { func (s *Service) GetMyProgress(ctx context.Context, userID int64) (domain.LMSUserProgress, error) {
return s.store.GetLMSUserProgressSnapshot(ctx, userID) return s.store.GetLMSUserProgressSnapshot(ctx, userID)

View File

@ -66,6 +66,10 @@ func (s *Service) GetURL(ctx context.Context, objectKey string, expiry time.Dura
return s.client.GetFileURL(ctx, objectKey, expiry) return s.client.GetFileURL(ctx, objectKey, expiry)
} }
func (s *Service) BucketName() string {
return s.client.BucketName()
}
// Delete removes a file from MinIO. // Delete removes a file from MinIO.
func (s *Service) Delete(ctx context.Context, objectKey string) error { func (s *Service) Delete(ctx context.Context, objectKey string) error {
s.logger.Info("Deleting file from MinIO", zap.String("object_key", objectKey)) s.logger.Info("Deleting file from MinIO", zap.String("object_key", objectKey))

View File

@ -29,6 +29,37 @@ var AllPermissions = []domain.PermissionSeed{
{Key: "programs.delete", Name: "Delete Program", Description: "Delete a program", GroupName: "Programs"}, {Key: "programs.delete", Name: "Delete Program", Description: "Delete a program", GroupName: "Programs"},
{Key: "programs.reorder", Name: "Reorder Programs", Description: "Set program order for the learning path (batch)", GroupName: "Programs"}, {Key: "programs.reorder", Name: "Reorder Programs", Description: "Set program order for the learning path (batch)", GroupName: "Programs"},
// Exam prep (schema exam_prep — DET / IELTS / TOEFL tracks; separate from LMS Learn English)
{Key: "exam_prep.catalog_courses.create", Name: "Create Exam Prep Catalog Course", Description: "Create a top-level exam prep catalog entry", GroupName: "Exam Prep"},
{Key: "exam_prep.catalog_courses.list", Name: "List Exam Prep Catalog Courses", Description: "List exam prep catalog courses", GroupName: "Exam Prep"},
{Key: "exam_prep.catalog_courses.get", Name: "Get Exam Prep Catalog Course", Description: "Get an exam prep catalog course by ID", GroupName: "Exam Prep"},
{Key: "exam_prep.catalog_courses.update", Name: "Update Exam Prep Catalog Course", Description: "Update an exam prep catalog course", GroupName: "Exam Prep"},
{Key: "exam_prep.catalog_courses.delete", Name: "Delete Exam Prep Catalog Course", Description: "Delete an exam prep catalog course", GroupName: "Exam Prep"},
{Key: "exam_prep.catalog_courses.reorder", Name: "Reorder Exam Prep Catalog Courses", Description: "Set global order of exam prep catalog courses", GroupName: "Exam Prep"},
{Key: "exam_prep.units.create", Name: "Create Exam Prep Unit", Description: "Create a unit under a catalog course", GroupName: "Exam Prep"},
{Key: "exam_prep.units.list", Name: "List Exam Prep Units", Description: "List units under a catalog course", GroupName: "Exam Prep"},
{Key: "exam_prep.units.get", Name: "Get Exam Prep Unit", Description: "Get an exam prep unit by ID", GroupName: "Exam Prep"},
{Key: "exam_prep.units.update", Name: "Update Exam Prep Unit", Description: "Update an exam prep unit", GroupName: "Exam Prep"},
{Key: "exam_prep.units.delete", Name: "Delete Exam Prep Unit", Description: "Delete an exam prep unit", GroupName: "Exam Prep"},
{Key: "exam_prep.units.reorder", Name: "Reorder Exam Prep Units", Description: "Reorder units within a catalog course", GroupName: "Exam Prep"},
{Key: "exam_prep.modules.create", Name: "Create Exam Prep Module", Description: "Create a module under an exam prep unit", GroupName: "Exam Prep"},
{Key: "exam_prep.modules.list", Name: "List Exam Prep Modules", Description: "List modules under a unit", GroupName: "Exam Prep"},
{Key: "exam_prep.modules.get", Name: "Get Exam Prep Module", Description: "Get an exam prep module by ID", GroupName: "Exam Prep"},
{Key: "exam_prep.modules.update", Name: "Update Exam Prep Module", Description: "Update an exam prep module", GroupName: "Exam Prep"},
{Key: "exam_prep.modules.delete", Name: "Delete Exam Prep Module", Description: "Delete an exam prep module", GroupName: "Exam Prep"},
{Key: "exam_prep.modules.reorder", Name: "Reorder Exam Prep Modules", Description: "Reorder modules within a unit", GroupName: "Exam Prep"},
{Key: "exam_prep.lessons.create", Name: "Create Exam Prep Lesson", Description: "Create a lesson under an exam prep unit module", GroupName: "Exam Prep"},
{Key: "exam_prep.lessons.list_by_module", Name: "List Exam Prep Lessons by Module", Description: "List lessons under an exam prep unit module", GroupName: "Exam Prep"},
{Key: "exam_prep.lessons.get", Name: "Get Exam Prep Lesson", Description: "Get an exam prep lesson by ID", GroupName: "Exam Prep"},
{Key: "exam_prep.lessons.update", Name: "Update Exam Prep Lesson", Description: "Update an exam prep lesson", GroupName: "Exam Prep"},
{Key: "exam_prep.lessons.delete", Name: "Delete Exam Prep Lesson", Description: "Delete an exam prep lesson", GroupName: "Exam Prep"},
{Key: "exam_prep.lessons.reorder", Name: "Reorder Exam Prep Lessons", Description: "Reorder lessons within an exam prep unit module", GroupName: "Exam Prep"},
{Key: "exam_prep.practices.create", Name: "Create Exam Prep Practice", Description: "Create a practice under an exam prep lesson (references question_sets)", GroupName: "Exam Prep"},
{Key: "exam_prep.practices.list_by_lesson", Name: "List Exam Prep Practices by Lesson", Description: "List practices for an exam prep lesson", GroupName: "Exam Prep"},
{Key: "exam_prep.practices.get", Name: "Get Exam Prep Practice", Description: "Get an exam prep practice by ID", GroupName: "Exam Prep"},
{Key: "exam_prep.practices.update", Name: "Update Exam Prep Practice", Description: "Update an exam prep practice", GroupName: "Exam Prep"},
{Key: "exam_prep.practices.delete", Name: "Delete Exam Prep Practice", Description: "Delete an exam prep practice", GroupName: "Exam Prep"},
// Modules (LMS, under a course) // Modules (LMS, under a course)
{Key: "modules.create", Name: "Create Module", Description: "Create a module in a course", GroupName: "Modules"}, {Key: "modules.create", Name: "Create Module", Description: "Create a module in a course", GroupName: "Modules"},
{Key: "modules.get", Name: "Get Module", Description: "Get a module by ID", GroupName: "Modules"}, {Key: "modules.get", Name: "Get Module", Description: "Get a module by ID", GroupName: "Modules"},
@ -280,6 +311,11 @@ var DefaultRolePermissions = map[string][]string{
// Programs // Programs
"programs.create", "programs.list", "programs.get", "programs.update", "programs.delete", "programs.reorder", "programs.create", "programs.list", "programs.get", "programs.update", "programs.delete", "programs.reorder",
"exam_prep.catalog_courses.create", "exam_prep.catalog_courses.list", "exam_prep.catalog_courses.get", "exam_prep.catalog_courses.update", "exam_prep.catalog_courses.delete", "exam_prep.catalog_courses.reorder",
"exam_prep.units.create", "exam_prep.units.list", "exam_prep.units.get", "exam_prep.units.update", "exam_prep.units.delete", "exam_prep.units.reorder",
"exam_prep.modules.create", "exam_prep.modules.list", "exam_prep.modules.get", "exam_prep.modules.update", "exam_prep.modules.delete", "exam_prep.modules.reorder",
"exam_prep.lessons.create", "exam_prep.lessons.list_by_module", "exam_prep.lessons.get", "exam_prep.lessons.update", "exam_prep.lessons.delete", "exam_prep.lessons.reorder",
"exam_prep.practices.create", "exam_prep.practices.list_by_lesson", "exam_prep.practices.get", "exam_prep.practices.update", "exam_prep.practices.delete",
"lms.get_my_progress", "lms.get_my_progress",
// Modules // Modules
@ -374,6 +410,11 @@ var DefaultRolePermissions = map[string][]string{
"learning_tree.get", "learning_tree.get",
"programs.list", "programs.get", "programs.list", "programs.get",
"exam_prep.catalog_courses.list", "exam_prep.catalog_courses.get",
"exam_prep.units.list", "exam_prep.units.get",
"exam_prep.modules.list", "exam_prep.modules.get",
"exam_prep.lessons.list_by_module", "exam_prep.lessons.get",
"exam_prep.practices.list_by_lesson", "exam_prep.practices.get",
"lms.get_my_progress", "lms.get_my_progress",
// Questions (read + attempt) // Questions (read + attempt)
@ -428,6 +469,11 @@ var DefaultRolePermissions = map[string][]string{
"learning_tree.get", "learning_tree.get",
"programs.list", "programs.get", "programs.list", "programs.get",
"exam_prep.catalog_courses.list", "exam_prep.catalog_courses.get",
"exam_prep.units.list", "exam_prep.units.get",
"exam_prep.modules.list", "exam_prep.modules.get",
"exam_prep.lessons.list_by_module", "exam_prep.lessons.get",
"exam_prep.practices.list_by_lesson", "exam_prep.practices.get",
"lms.get_my_progress", "lms.get_my_progress",
// Questions (full — instructors create content) // Questions (full — instructors create content)
@ -482,6 +528,11 @@ var DefaultRolePermissions = map[string][]string{
"learning_tree.get", "learning_tree.get",
"programs.list", "programs.get", "programs.list", "programs.get",
"exam_prep.catalog_courses.list", "exam_prep.catalog_courses.get",
"exam_prep.units.list", "exam_prep.units.get",
"exam_prep.modules.list", "exam_prep.modules.get",
"exam_prep.lessons.list_by_module", "exam_prep.lessons.get",
"exam_prep.practices.list_by_lesson", "exam_prep.practices.get",
// Questions (read) // Questions (read)
"questions.list", "questions.search", "questions.get", "questions.list", "questions.search", "questions.get",

View File

@ -6,11 +6,14 @@ import (
"context" "context"
"errors" "errors"
"time" "time"
"github.com/jackc/pgx/v5"
) )
var ( var (
ErrPlanNotFound = errors.New("subscription plan not found") ErrPlanNotFound = errors.New("subscription plan not found")
ErrSubscriptionNotFound = errors.New("subscription not found") ErrSubscriptionNotFound = errors.New("subscription not found")
ErrSubscriptionNotOwned = errors.New("subscription does not belong to this user")
ErrAlreadySubscribed = errors.New("user already has an active subscription") ErrAlreadySubscribed = errors.New("user already has an active subscription")
ErrInvalidPlan = errors.New("invalid subscription plan") ErrInvalidPlan = errors.New("invalid subscription plan")
) )
@ -90,7 +93,14 @@ func (s *Service) Subscribe(ctx context.Context, userID, planID int64, paymentRe
} }
func (s *Service) GetSubscriptionByID(ctx context.Context, id int64) (*domain.UserSubscription, error) { func (s *Service) GetSubscriptionByID(ctx context.Context, id int64) (*domain.UserSubscription, error) {
return s.store.GetUserSubscriptionByID(ctx, id) sub, err := s.store.GetUserSubscriptionByID(ctx, id)
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return nil, ErrSubscriptionNotFound
}
return nil, err
}
return sub, nil
} }
func (s *Service) GetActiveSubscription(ctx context.Context, userID int64) (*domain.UserSubscription, error) { func (s *Service) GetActiveSubscription(ctx context.Context, userID int64) (*domain.UserSubscription, error) {
@ -105,19 +115,41 @@ func (s *Service) HasActiveSubscription(ctx context.Context, userID int64) (bool
return s.store.HasActiveSubscription(ctx, userID) return s.store.HasActiveSubscription(ctx, userID)
} }
func (s *Service) CancelSubscription(ctx context.Context, subscriptionID int64) error { func (s *Service) subscriptionOwnedBy(ctx context.Context, subscriptionID, userID int64) error {
sub, err := s.store.GetUserSubscriptionByID(ctx, subscriptionID)
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return ErrSubscriptionNotFound
}
return err
}
if sub.UserID != userID {
return ErrSubscriptionNotOwned
}
return nil
}
// CancelSubscriptionForUser cancels only if the subscription row belongs to userID.
func (s *Service) CancelSubscriptionForUser(ctx context.Context, subscriptionID, userID int64) error {
if err := s.subscriptionOwnedBy(ctx, subscriptionID, userID); err != nil {
return err
}
return s.store.CancelUserSubscription(ctx, subscriptionID) return s.store.CancelUserSubscription(ctx, subscriptionID)
} }
func (s *Service) SetAutoRenew(ctx context.Context, subscriptionID int64, autoRenew bool) error { // SetAutoRenewForUser updates auto-renew only if the subscription belongs to userID.
func (s *Service) SetAutoRenewForUser(ctx context.Context, subscriptionID, userID int64, autoRenew bool) error {
if err := s.subscriptionOwnedBy(ctx, subscriptionID, userID); err != nil {
return err
}
return s.store.UpdateAutoRenew(ctx, subscriptionID, autoRenew) return s.store.UpdateAutoRenew(ctx, subscriptionID, autoRenew)
} }
// RenewSubscription extends an existing subscription // RenewSubscription extends an existing subscription
func (s *Service) RenewSubscription(ctx context.Context, subscriptionID int64) (*domain.UserSubscription, error) { func (s *Service) RenewSubscription(ctx context.Context, subscriptionID int64) (*domain.UserSubscription, error) {
sub, err := s.store.GetUserSubscriptionByID(ctx, subscriptionID) sub, err := s.GetSubscriptionByID(ctx, subscriptionID)
if err != nil { if err != nil {
return nil, ErrSubscriptionNotFound return nil, err
} }
plan, err := s.store.GetSubscriptionPlanByID(ctx, sub.PlanID) plan, err := s.store.GetSubscriptionPlanByID(ctx, sub.PlanID)
@ -145,7 +177,7 @@ func (s *Service) RenewSubscription(ctx context.Context, subscriptionID int64) (
} }
} }
return s.store.GetUserSubscriptionByID(ctx, subscriptionID) return s.GetSubscriptionByID(ctx, subscriptionID)
} }
// Helper functions // Helper functions

View File

@ -12,6 +12,7 @@ import (
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/courses" "Yimaru-Backend/internal/services/courses"
"Yimaru-Backend/internal/services/examprep"
"Yimaru-Backend/internal/services/lessons" "Yimaru-Backend/internal/services/lessons"
"Yimaru-Backend/internal/services/lmsprogress" "Yimaru-Backend/internal/services/lmsprogress"
"Yimaru-Backend/internal/services/modules" "Yimaru-Backend/internal/services/modules"
@ -45,6 +46,7 @@ import (
type App struct { type App struct {
assessmentSvc *assessment.Service assessmentSvc *assessment.Service
questionsSvc *questions.Service questionsSvc *questions.Service
examPrepSvc *examprep.Service
programSvc *programs.Service programSvc *programs.Service
courseSvc *courses.Service courseSvc *courses.Service
moduleSvc *modules.Service moduleSvc *modules.Service
@ -82,6 +84,7 @@ type App struct {
func NewApp( func NewApp(
assessmentSvc *assessment.Service, assessmentSvc *assessment.Service,
questionsSvc *questions.Service, questionsSvc *questions.Service,
examPrepSvc *examprep.Service,
programSvc *programs.Service, programSvc *programs.Service,
courseSvc *courses.Service, courseSvc *courses.Service,
moduleSvc *modules.Service, moduleSvc *modules.Service,
@ -131,6 +134,7 @@ func NewApp(
s := &App{ s := &App{
assessmentSvc: assessmentSvc, assessmentSvc: assessmentSvc,
questionsSvc: questionsSvc, questionsSvc: questionsSvc,
examPrepSvc: examPrepSvc,
programSvc: programSvc, programSvc: programSvc,
courseSvc: courseSvc, courseSvc: courseSvc,
moduleSvc: moduleSvc, moduleSvc: moduleSvc,

View File

@ -0,0 +1,235 @@
package handlers
import (
"Yimaru-Backend/internal/domain"
"Yimaru-Backend/internal/services/examprep"
"errors"
"strconv"
"github.com/gofiber/fiber/v2"
)
// CreateExamPrepCatalogCourse godoc
// @Summary Create exam-prep catalog course
// @Description Top-level exam track (DET, IELTS, …) in schema exam_prep — separate from LMS programs/courses
// @Tags exam-prep
// @Accept json
// @Produce json
// @Param body body domain.CreateExamPrepCatalogCourseInput true "Catalog course"
// @Success 201 {object} domain.Response
// @Failure 400 {object} domain.ErrorResponse
// @Router /api/v1/exam-prep/catalog-courses [post]
func (h *Handler) CreateExamPrepCatalogCourse(c *fiber.Ctx) error {
var req domain.CreateExamPrepCatalogCourseInput
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),
})
}
out, err := h.examPrepSvc.CreateCatalogCourse(c.Context(), req)
if err != nil {
return c.Status(fiber.StatusInternalServerError).JSON(domain.ErrorResponse{
Message: "Failed to create catalog course",
Error: err.Error(),
})
}
return c.Status(fiber.StatusCreated).JSON(domain.Response{
Message: "Catalog course created successfully",
Data: out,
Success: true,
StatusCode: fiber.StatusCreated,
})
}
// ListExamPrepCatalogCourses godoc
// @Summary List exam-prep catalog courses
// @Tags exam-prep
// @Produce json
// @Param limit query int false "Page size" default(20)
// @Param offset query int false "Offset" default(0)
// @Success 200 {object} domain.Response
// @Router /api/v1/exam-prep/catalog-courses [get]
func (h *Handler) ListExamPrepCatalogCourses(c *fiber.Ctx) error {
limit, _ := strconv.Atoi(c.Query("limit", "20"))
offset, _ := strconv.Atoi(c.Query("offset", "0"))
items, total, err := h.examPrepSvc.ListCatalogCourses(c.Context(), int32(limit), int32(offset))
if err != nil {
return c.Status(fiber.StatusInternalServerError).JSON(domain.ErrorResponse{
Message: "Failed to list catalog courses",
Error: err.Error(),
})
}
return c.JSON(domain.Response{
Message: "Catalog courses retrieved successfully",
Data: fiber.Map{
"catalog_courses": items,
"total_count": total,
"limit": limit,
"offset": offset,
},
Success: true,
StatusCode: fiber.StatusOK,
})
}
// ReorderExamPrepCatalogCourses godoc
// @Summary Reorder all exam-prep catalog courses
// @Tags exam-prep
// @Accept json
// @Produce json
// @Param body body domain.ReorderIDsRequest true "ordered_ids: every catalog course id exactly once"
// @Success 200 {object} domain.Response
// @Router /api/v1/exam-prep/catalog-courses/reorder [put]
func (h *Handler) ReorderExamPrepCatalogCourses(c *fiber.Ctx) error {
var req domain.ReorderIDsRequest
if err := c.BodyParser(&req); err != nil {
return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{
Message: "Invalid request body",
Error: err.Error(),
})
}
if req.OrderedIDs == nil {
return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{
Message: "ordered_ids is required (use an empty array if there are no catalog courses)",
Error: "missing ordered_ids",
})
}
if err := h.examPrepSvc.ReorderCatalogCourses(c.Context(), req.OrderedIDs); err != nil {
if errors.Is(err, domain.ErrReorderInvalidIDSet) {
return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{
Message: err.Error(),
Error: "INVALID_REORDER",
})
}
return c.Status(fiber.StatusInternalServerError).JSON(domain.ErrorResponse{
Message: "Failed to reorder catalog courses",
Error: err.Error(),
})
}
return c.JSON(domain.Response{
Message: "Catalog courses reordered successfully",
Success: true,
StatusCode: fiber.StatusOK,
})
}
// GetExamPrepCatalogCourseByID godoc
// @Summary Get exam-prep catalog course by ID
// @Tags exam-prep
// @Produce json
// @Param id path int true "Catalog course ID"
// @Success 200 {object} domain.Response
// @Router /api/v1/exam-prep/catalog-courses/{id} [get]
func (h *Handler) GetExamPrepCatalogCourseByID(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 catalog course id",
Error: err.Error(),
})
}
out, err := h.examPrepSvc.GetCatalogCourseByID(c.Context(), id)
if err != nil {
if errors.Is(err, examprep.ErrCatalogCourseNotFound) {
return c.Status(fiber.StatusNotFound).JSON(domain.ErrorResponse{
Message: "Catalog course not found",
Error: err.Error(),
})
}
return c.Status(fiber.StatusInternalServerError).JSON(domain.ErrorResponse{
Message: "Failed to get catalog course",
Error: err.Error(),
})
}
return c.JSON(domain.Response{
Message: "Catalog course retrieved successfully",
Data: out,
Success: true,
StatusCode: fiber.StatusOK,
})
}
// UpdateExamPrepCatalogCourse godoc
// @Summary Update exam-prep catalog course
// @Tags exam-prep
// @Accept json
// @Produce json
// @Param id path int true "Catalog course ID"
// @Param body body domain.UpdateExamPrepCatalogCourseInput true "Fields to update"
// @Success 200 {object} domain.Response
// @Router /api/v1/exam-prep/catalog-courses/{id} [put]
func (h *Handler) UpdateExamPrepCatalogCourse(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 catalog course id",
Error: err.Error(),
})
}
var req domain.UpdateExamPrepCatalogCourseInput
if err := c.BodyParser(&req); err != nil {
return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{
Message: "Invalid request body",
Error: err.Error(),
})
}
out, err := h.examPrepSvc.UpdateCatalogCourse(c.Context(), id, req)
if err != nil {
if errors.Is(err, examprep.ErrCatalogCourseNotFound) {
return c.Status(fiber.StatusNotFound).JSON(domain.ErrorResponse{
Message: "Catalog course not found",
Error: err.Error(),
})
}
return c.Status(fiber.StatusInternalServerError).JSON(domain.ErrorResponse{
Message: "Failed to update catalog course",
Error: err.Error(),
})
}
return c.JSON(domain.Response{
Message: "Catalog course updated successfully",
Data: out,
Success: true,
StatusCode: fiber.StatusOK,
})
}
// DeleteExamPrepCatalogCourse godoc
// @Summary Delete exam-prep catalog course
// @Tags exam-prep
// @Param id path int true "Catalog course ID"
// @Success 200 {object} domain.Response
// @Router /api/v1/exam-prep/catalog-courses/{id} [delete]
func (h *Handler) DeleteExamPrepCatalogCourse(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 catalog course id",
Error: err.Error(),
})
}
if err := h.examPrepSvc.DeleteCatalogCourse(c.Context(), id); err != nil {
if errors.Is(err, examprep.ErrCatalogCourseNotFound) {
return c.Status(fiber.StatusNotFound).JSON(domain.ErrorResponse{
Message: "Catalog course not found",
Error: err.Error(),
})
}
return c.Status(fiber.StatusInternalServerError).JSON(domain.ErrorResponse{
Message: "Failed to delete catalog course",
Error: err.Error(),
})
}
return c.JSON(domain.Response{
Message: "Catalog course deleted successfully",
Success: true,
StatusCode: fiber.StatusOK,
})
}

View File

@ -0,0 +1,255 @@
package handlers
import (
"Yimaru-Backend/internal/domain"
"Yimaru-Backend/internal/services/examprep"
"errors"
"strconv"
"github.com/gofiber/fiber/v2"
)
// CreateExamPrepLesson godoc
// @Summary Create exam-prep lesson (under a unit module)
// @Tags exam-prep
// @Param moduleId path int true "Exam prep unit module ID"
// @Param body body domain.CreateExamPrepLessonInput true "Lesson"
// @Router /api/v1/exam-prep/modules/{moduleId}/lessons [post]
func (h *Handler) CreateExamPrepLesson(c *fiber.Ctx) error {
moduleID, err := strconv.ParseInt(c.Params("moduleId"), 10, 64)
if err != nil {
return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{
Message: "Invalid module id",
Error: err.Error(),
})
}
var req domain.CreateExamPrepLessonInput
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),
})
}
les, err := h.examPrepSvc.CreateLesson(c.Context(), moduleID, req)
if err != nil {
if errors.Is(err, examprep.ErrModuleNotFound) {
return c.Status(fiber.StatusNotFound).JSON(domain.ErrorResponse{
Message: "Module not found",
Error: err.Error(),
})
}
return c.Status(fiber.StatusInternalServerError).JSON(domain.ErrorResponse{
Message: "Failed to create lesson",
Error: err.Error(),
})
}
return c.Status(fiber.StatusCreated).JSON(domain.Response{
Message: "Lesson created successfully",
Data: les,
Success: true,
StatusCode: fiber.StatusCreated,
})
}
// ListExamPrepLessonsByUnitModule godoc
// @Summary List exam-prep lessons for a unit module
// @Tags exam-prep
// @Param moduleId path int true "Exam prep unit module ID"
// @Param limit query int false "Page size" default(20)
// @Param offset query int false "Offset" default(0)
// @Router /api/v1/exam-prep/modules/{moduleId}/lessons [get]
func (h *Handler) ListExamPrepLessonsByUnitModule(c *fiber.Ctx) error {
moduleID, err := strconv.ParseInt(c.Params("moduleId"), 10, 64)
if err != nil {
return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{
Message: "Invalid module id",
Error: err.Error(),
})
}
limit, _ := strconv.Atoi(c.Query("limit", "20"))
offset, _ := strconv.Atoi(c.Query("offset", "0"))
items, total, err := h.examPrepSvc.ListLessonsByUnitModule(c.Context(), moduleID, int32(limit), int32(offset))
if err != nil {
if errors.Is(err, examprep.ErrModuleNotFound) {
return c.Status(fiber.StatusNotFound).JSON(domain.ErrorResponse{
Message: "Module not found",
Error: err.Error(),
})
}
return c.Status(fiber.StatusInternalServerError).JSON(domain.ErrorResponse{
Message: "Failed to list lessons",
Error: err.Error(),
})
}
return c.JSON(domain.Response{
Message: "Lessons retrieved successfully",
Data: fiber.Map{
"lessons": items,
"total_count": total,
"limit": limit,
"offset": offset,
},
Success: true,
StatusCode: fiber.StatusOK,
})
}
// ReorderExamPrepLessonsInUnitModule godoc
// @Summary Reorder lessons within an exam-prep unit module
// @Tags exam-prep
// @Router /api/v1/exam-prep/modules/{moduleId}/lessons/reorder [put]
func (h *Handler) ReorderExamPrepLessonsInUnitModule(c *fiber.Ctx) error {
moduleID, err := strconv.ParseInt(c.Params("moduleId"), 10, 64)
if err != nil {
return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{
Message: "Invalid module id",
Error: err.Error(),
})
}
var req domain.ReorderIDsRequest
if err := c.BodyParser(&req); err != nil {
return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{
Message: "Invalid request body",
Error: err.Error(),
})
}
if req.OrderedIDs == nil {
return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{
Message: "ordered_ids is required (use an empty array if there are no lessons)",
Error: "missing ordered_ids",
})
}
if err := h.examPrepSvc.ReorderLessonsInUnitModule(c.Context(), moduleID, req.OrderedIDs); err != nil {
if errors.Is(err, examprep.ErrModuleNotFound) {
return c.Status(fiber.StatusNotFound).JSON(domain.ErrorResponse{
Message: "Module not found",
Error: err.Error(),
})
}
if errors.Is(err, domain.ErrReorderInvalidIDSet) {
return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{
Message: err.Error(),
Error: "INVALID_REORDER",
})
}
return c.Status(fiber.StatusInternalServerError).JSON(domain.ErrorResponse{
Message: "Failed to reorder lessons",
Error: err.Error(),
})
}
return c.JSON(domain.Response{
Message: "Lessons reordered successfully",
Success: true,
StatusCode: fiber.StatusOK,
})
}
// GetExamPrepLessonByID godoc
// @Summary Get exam-prep lesson by ID
// @Tags exam-prep
// @Router /api/v1/exam-prep/lessons/{id} [get]
func (h *Handler) GetExamPrepLessonByID(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 lesson id",
Error: err.Error(),
})
}
les, err := h.examPrepSvc.GetLessonByID(c.Context(), id)
if err != nil {
if errors.Is(err, examprep.ErrLessonNotFound) {
return c.Status(fiber.StatusNotFound).JSON(domain.ErrorResponse{
Message: "Lesson not found",
Error: err.Error(),
})
}
return c.Status(fiber.StatusInternalServerError).JSON(domain.ErrorResponse{
Message: "Failed to get lesson",
Error: err.Error(),
})
}
return c.JSON(domain.Response{
Message: "Lesson retrieved successfully",
Data: les,
Success: true,
StatusCode: fiber.StatusOK,
})
}
// UpdateExamPrepLesson godoc
// @Summary Update exam-prep lesson
// @Tags exam-prep
// @Router /api/v1/exam-prep/lessons/{id} [put]
func (h *Handler) UpdateExamPrepLesson(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 lesson id",
Error: err.Error(),
})
}
var req domain.UpdateExamPrepLessonInput
if err := c.BodyParser(&req); err != nil {
return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{
Message: "Invalid request body",
Error: err.Error(),
})
}
les, err := h.examPrepSvc.UpdateLesson(c.Context(), id, req)
if err != nil {
if errors.Is(err, examprep.ErrLessonNotFound) {
return c.Status(fiber.StatusNotFound).JSON(domain.ErrorResponse{
Message: "Lesson not found",
Error: err.Error(),
})
}
return c.Status(fiber.StatusInternalServerError).JSON(domain.ErrorResponse{
Message: "Failed to update lesson",
Error: err.Error(),
})
}
return c.JSON(domain.Response{
Message: "Lesson updated successfully",
Data: les,
Success: true,
StatusCode: fiber.StatusOK,
})
}
// DeleteExamPrepLesson godoc
// @Summary Delete exam-prep lesson
// @Tags exam-prep
// @Router /api/v1/exam-prep/lessons/{id} [delete]
func (h *Handler) DeleteExamPrepLesson(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 lesson id",
Error: err.Error(),
})
}
if err := h.examPrepSvc.DeleteLesson(c.Context(), id); err != nil {
if errors.Is(err, examprep.ErrLessonNotFound) {
return c.Status(fiber.StatusNotFound).JSON(domain.ErrorResponse{
Message: "Lesson not found",
Error: err.Error(),
})
}
return c.Status(fiber.StatusInternalServerError).JSON(domain.ErrorResponse{
Message: "Failed to delete lesson",
Error: err.Error(),
})
}
return c.JSON(domain.Response{
Message: "Lesson deleted successfully",
Success: true,
StatusCode: fiber.StatusOK,
})
}

View File

@ -0,0 +1,255 @@
package handlers
import (
"Yimaru-Backend/internal/domain"
"Yimaru-Backend/internal/services/examprep"
"errors"
"strconv"
"github.com/gofiber/fiber/v2"
)
// CreateExamPrepModule godoc
// @Summary Create exam-prep module
// @Tags exam-prep
// @Param unitId path int true "Unit ID"
// @Param body body domain.CreateExamPrepModuleInput true "Module"
// @Router /api/v1/exam-prep/units/{unitId}/modules [post]
func (h *Handler) CreateExamPrepModule(c *fiber.Ctx) error {
unitID, err := strconv.ParseInt(c.Params("unitId"), 10, 64)
if err != nil {
return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{
Message: "Invalid unit id",
Error: err.Error(),
})
}
var req domain.CreateExamPrepModuleInput
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),
})
}
out, err := h.examPrepSvc.CreateModule(c.Context(), unitID, req)
if err != nil {
if errors.Is(err, examprep.ErrUnitNotFound) {
return c.Status(fiber.StatusNotFound).JSON(domain.ErrorResponse{
Message: "Unit not found",
Error: err.Error(),
})
}
return c.Status(fiber.StatusInternalServerError).JSON(domain.ErrorResponse{
Message: "Failed to create module",
Error: err.Error(),
})
}
return c.Status(fiber.StatusCreated).JSON(domain.Response{
Message: "Module created successfully",
Data: out,
Success: true,
StatusCode: fiber.StatusCreated,
})
}
// ListExamPrepModulesByUnit godoc
// @Summary List exam-prep modules for a unit
// @Tags exam-prep
// @Param unitId path int true "Unit ID"
// @Router /api/v1/exam-prep/units/{unitId}/modules [get]
func (h *Handler) ListExamPrepModulesByUnit(c *fiber.Ctx) error {
unitID, err := strconv.ParseInt(c.Params("unitId"), 10, 64)
if err != nil {
return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{
Message: "Invalid unit id",
Error: err.Error(),
})
}
limit, _ := strconv.Atoi(c.Query("limit", "20"))
offset, _ := strconv.Atoi(c.Query("offset", "0"))
items, total, err := h.examPrepSvc.ListModulesByUnit(c.Context(), unitID, int32(limit), int32(offset))
if err != nil {
if errors.Is(err, examprep.ErrUnitNotFound) {
return c.Status(fiber.StatusNotFound).JSON(domain.ErrorResponse{
Message: "Unit not found",
Error: err.Error(),
})
}
return c.Status(fiber.StatusInternalServerError).JSON(domain.ErrorResponse{
Message: "Failed to list modules",
Error: err.Error(),
})
}
return c.JSON(domain.Response{
Message: "Modules retrieved successfully",
Data: fiber.Map{
"modules": items,
"total_count": total,
"limit": limit,
"offset": offset,
},
Success: true,
StatusCode: fiber.StatusOK,
})
}
// ReorderExamPrepModulesInUnit godoc
// @Summary Reorder modules within a unit
// @Tags exam-prep
// @Param unitId path int true "Unit ID"
// @Param body body domain.ReorderIDsRequest true "ordered_ids"
// @Router /api/v1/exam-prep/units/{unitId}/modules/reorder [put]
func (h *Handler) ReorderExamPrepModulesInUnit(c *fiber.Ctx) error {
unitID, err := strconv.ParseInt(c.Params("unitId"), 10, 64)
if err != nil {
return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{
Message: "Invalid unit id",
Error: err.Error(),
})
}
var req domain.ReorderIDsRequest
if err := c.BodyParser(&req); err != nil {
return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{
Message: "Invalid request body",
Error: err.Error(),
})
}
if req.OrderedIDs == nil {
return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{
Message: "ordered_ids is required (use an empty array if there are no modules)",
Error: "missing ordered_ids",
})
}
if err := h.examPrepSvc.ReorderModulesInUnit(c.Context(), unitID, req.OrderedIDs); err != nil {
if errors.Is(err, examprep.ErrUnitNotFound) {
return c.Status(fiber.StatusNotFound).JSON(domain.ErrorResponse{
Message: "Unit not found",
Error: err.Error(),
})
}
if errors.Is(err, domain.ErrReorderInvalidIDSet) {
return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{
Message: err.Error(),
Error: "INVALID_REORDER",
})
}
return c.Status(fiber.StatusInternalServerError).JSON(domain.ErrorResponse{
Message: "Failed to reorder modules",
Error: err.Error(),
})
}
return c.JSON(domain.Response{
Message: "Modules reordered successfully",
Success: true,
StatusCode: fiber.StatusOK,
})
}
// GetExamPrepModuleByID godoc
// @Summary Get exam-prep module by ID
// @Tags exam-prep
// @Router /api/v1/exam-prep/modules/{id} [get]
func (h *Handler) GetExamPrepModuleByID(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 module id",
Error: err.Error(),
})
}
out, err := h.examPrepSvc.GetModuleByID(c.Context(), id)
if err != nil {
if errors.Is(err, examprep.ErrModuleNotFound) {
return c.Status(fiber.StatusNotFound).JSON(domain.ErrorResponse{
Message: "Module not found",
Error: err.Error(),
})
}
return c.Status(fiber.StatusInternalServerError).JSON(domain.ErrorResponse{
Message: "Failed to get module",
Error: err.Error(),
})
}
return c.JSON(domain.Response{
Message: "Module retrieved successfully",
Data: out,
Success: true,
StatusCode: fiber.StatusOK,
})
}
// UpdateExamPrepModule godoc
// @Summary Update exam-prep module
// @Tags exam-prep
// @Router /api/v1/exam-prep/modules/{id} [put]
func (h *Handler) UpdateExamPrepModule(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 module id",
Error: err.Error(),
})
}
var req domain.UpdateExamPrepModuleInput
if err := c.BodyParser(&req); err != nil {
return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{
Message: "Invalid request body",
Error: err.Error(),
})
}
out, err := h.examPrepSvc.UpdateModule(c.Context(), id, req)
if err != nil {
if errors.Is(err, examprep.ErrModuleNotFound) {
return c.Status(fiber.StatusNotFound).JSON(domain.ErrorResponse{
Message: "Module not found",
Error: err.Error(),
})
}
return c.Status(fiber.StatusInternalServerError).JSON(domain.ErrorResponse{
Message: "Failed to update module",
Error: err.Error(),
})
}
return c.JSON(domain.Response{
Message: "Module updated successfully",
Data: out,
Success: true,
StatusCode: fiber.StatusOK,
})
}
// DeleteExamPrepModule godoc
// @Summary Delete exam-prep module
// @Tags exam-prep
// @Router /api/v1/exam-prep/modules/{id} [delete]
func (h *Handler) DeleteExamPrepModule(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 module id",
Error: err.Error(),
})
}
if err := h.examPrepSvc.DeleteModule(c.Context(), id); err != nil {
if errors.Is(err, examprep.ErrModuleNotFound) {
return c.Status(fiber.StatusNotFound).JSON(domain.ErrorResponse{
Message: "Module not found",
Error: err.Error(),
})
}
return c.Status(fiber.StatusInternalServerError).JSON(domain.ErrorResponse{
Message: "Failed to delete module",
Error: err.Error(),
})
}
return c.JSON(domain.Response{
Message: "Module deleted successfully",
Success: true,
StatusCode: fiber.StatusOK,
})
}

View File

@ -0,0 +1,209 @@
package handlers
import (
"Yimaru-Backend/internal/domain"
"Yimaru-Backend/internal/services/examprep"
"errors"
"strconv"
"github.com/gofiber/fiber/v2"
)
// CreateExamPrepPractice godoc
// @Summary Create exam-prep practice (under a lesson; uses shared question_sets)
// @Tags exam-prep
// @Param lessonId path int true "Exam prep lesson ID (unit_module_lessons.id)"
// @Param body body domain.CreateExamPrepPracticeInput true "Practice"
// @Router /api/v1/exam-prep/lessons/{lessonId}/practices [post]
func (h *Handler) CreateExamPrepPractice(c *fiber.Ctx) error {
lessonID, err := strconv.ParseInt(c.Params("lessonId"), 10, 64)
if err != nil {
return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{
Message: "Invalid lesson id",
Error: err.Error(),
})
}
var req domain.CreateExamPrepPracticeInput
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.examPrepSvc.CreateExamPrepPractice(c.Context(), lessonID, req)
if err != nil {
if errors.Is(err, examprep.ErrLessonNotFound) {
return c.Status(fiber.StatusNotFound).JSON(domain.ErrorResponse{
Message: "Lesson not found",
Error: err.Error(),
})
}
return c.Status(fiber.StatusInternalServerError).JSON(domain.ErrorResponse{
Message: "Failed to create practice",
Error: err.Error(),
})
}
return c.Status(fiber.StatusCreated).JSON(domain.Response{
Message: "Practice created successfully",
Data: p,
Success: true,
StatusCode: fiber.StatusCreated,
})
}
// ListExamPrepPracticesByLesson godoc
// @Summary List exam-prep practices for a lesson
// @Tags exam-prep
// @Param lessonId path int true "Exam prep lesson ID"
// @Param limit query int false "Page size" default(20)
// @Param offset query int false "Offset" default(0)
// @Router /api/v1/exam-prep/lessons/{lessonId}/practices [get]
func (h *Handler) ListExamPrepPracticesByLesson(c *fiber.Ctx) error {
lessonID, err := strconv.ParseInt(c.Params("lessonId"), 10, 64)
if err != nil {
return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{
Message: "Invalid lesson id",
Error: err.Error(),
})
}
limit, _ := strconv.Atoi(c.Query("limit", "20"))
offset, _ := strconv.Atoi(c.Query("offset", "0"))
items, total, err := h.examPrepSvc.ListExamPrepPracticesByLesson(c.Context(), lessonID, int32(limit), int32(offset))
if err != nil {
if errors.Is(err, examprep.ErrLessonNotFound) {
return c.Status(fiber.StatusNotFound).JSON(domain.ErrorResponse{
Message: "Lesson not found",
Error: err.Error(),
})
}
return c.Status(fiber.StatusInternalServerError).JSON(domain.ErrorResponse{
Message: "Failed to list practices",
Error: err.Error(),
})
}
return c.JSON(domain.Response{
Message: "Practices retrieved successfully",
Data: fiber.Map{
"practices": items,
"total_count": total,
"limit": limit,
"offset": offset,
},
Success: true,
StatusCode: fiber.StatusOK,
})
}
// GetExamPrepPracticeByID godoc
// @Summary Get exam-prep practice by ID
// @Tags exam-prep
// @Param id path int true "Exam prep practice ID"
// @Router /api/v1/exam-prep/practices/{id} [get]
func (h *Handler) GetExamPrepPracticeByID(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 practice id",
Error: err.Error(),
})
}
p, err := h.examPrepSvc.GetExamPrepPracticeByID(c.Context(), id)
if err != nil {
if errors.Is(err, examprep.ErrPracticeNotFound) {
return c.Status(fiber.StatusNotFound).JSON(domain.ErrorResponse{
Message: "Practice not found",
Error: err.Error(),
})
}
return c.Status(fiber.StatusInternalServerError).JSON(domain.ErrorResponse{
Message: "Failed to load practice",
Error: err.Error(),
})
}
return c.JSON(domain.Response{
Message: "Practice retrieved successfully",
Data: p,
Success: true,
StatusCode: fiber.StatusOK,
})
}
// UpdateExamPrepPractice godoc
// @Summary Update exam-prep practice
// @Tags exam-prep
// @Param id path int true "Exam prep practice ID"
// @Param body body domain.UpdateExamPrepPracticeInput true "Fields to update"
// @Router /api/v1/exam-prep/practices/{id} [put]
func (h *Handler) UpdateExamPrepPractice(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 practice id",
Error: err.Error(),
})
}
var req domain.UpdateExamPrepPracticeInput
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.examPrepSvc.UpdateExamPrepPractice(c.Context(), id, req)
if err != nil {
if errors.Is(err, examprep.ErrPracticeNotFound) {
return c.Status(fiber.StatusNotFound).JSON(domain.ErrorResponse{
Message: "Practice not found",
Error: err.Error(),
})
}
return c.Status(fiber.StatusInternalServerError).JSON(domain.ErrorResponse{
Message: "Failed to update practice",
Error: err.Error(),
})
}
return c.JSON(domain.Response{
Message: "Practice updated successfully",
Data: p,
Success: true,
StatusCode: fiber.StatusOK,
})
}
// DeleteExamPrepPractice godoc
// @Summary Delete exam-prep practice
// @Tags exam-prep
// @Param id path int true "Exam prep practice ID"
// @Router /api/v1/exam-prep/practices/{id} [delete]
func (h *Handler) DeleteExamPrepPractice(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 practice id",
Error: err.Error(),
})
}
if err := h.examPrepSvc.DeleteExamPrepPractice(c.Context(), id); err != nil {
if errors.Is(err, examprep.ErrPracticeNotFound) {
return c.Status(fiber.StatusNotFound).JSON(domain.ErrorResponse{
Message: "Practice not found",
Error: err.Error(),
})
}
return c.Status(fiber.StatusInternalServerError).JSON(domain.ErrorResponse{
Message: "Failed to delete practice",
Error: err.Error(),
})
}
return c.JSON(domain.Response{
Message: "Practice deleted successfully",
Success: true,
StatusCode: fiber.StatusOK,
})
}

View File

@ -0,0 +1,266 @@
package handlers
import (
"Yimaru-Backend/internal/domain"
"Yimaru-Backend/internal/services/examprep"
"errors"
"strconv"
"github.com/gofiber/fiber/v2"
)
// CreateExamPrepUnit godoc
// @Summary Create exam-prep unit
// @Description Unit under a catalog course (e.g. chapter title)
// @Tags exam-prep
// @Accept json
// @Produce json
// @Param catalogCourseId path int true "Catalog course ID"
// @Param body body domain.CreateExamPrepUnitInput true "Unit"
// @Success 201 {object} domain.Response
// @Router /api/v1/exam-prep/catalog-courses/{catalogCourseId}/units [post]
func (h *Handler) CreateExamPrepUnit(c *fiber.Ctx) error {
catalogCourseID, err := strconv.ParseInt(c.Params("catalogCourseId"), 10, 64)
if err != nil {
return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{
Message: "Invalid catalog course id",
Error: err.Error(),
})
}
var req domain.CreateExamPrepUnitInput
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),
})
}
out, err := h.examPrepSvc.CreateUnit(c.Context(), catalogCourseID, req)
if err != nil {
if errors.Is(err, examprep.ErrCatalogCourseNotFound) {
return c.Status(fiber.StatusNotFound).JSON(domain.ErrorResponse{
Message: "Catalog course not found",
Error: err.Error(),
})
}
return c.Status(fiber.StatusInternalServerError).JSON(domain.ErrorResponse{
Message: "Failed to create unit",
Error: err.Error(),
})
}
return c.Status(fiber.StatusCreated).JSON(domain.Response{
Message: "Unit created successfully",
Data: out,
Success: true,
StatusCode: fiber.StatusCreated,
})
}
// ListExamPrepUnitsByCatalogCourse godoc
// @Summary List exam-prep units for a catalog course
// @Tags exam-prep
// @Param catalogCourseId path int true "Catalog course ID"
// @Param limit query int false "Page size" default(20)
// @Param offset query int false "Offset" default(0)
// @Success 200 {object} domain.Response
// @Router /api/v1/exam-prep/catalog-courses/{catalogCourseId}/units [get]
func (h *Handler) ListExamPrepUnitsByCatalogCourse(c *fiber.Ctx) error {
catalogCourseID, err := strconv.ParseInt(c.Params("catalogCourseId"), 10, 64)
if err != nil {
return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{
Message: "Invalid catalog course id",
Error: err.Error(),
})
}
limit, _ := strconv.Atoi(c.Query("limit", "20"))
offset, _ := strconv.Atoi(c.Query("offset", "0"))
items, total, err := h.examPrepSvc.ListUnitsByCatalogCourse(c.Context(), catalogCourseID, int32(limit), int32(offset))
if err != nil {
if errors.Is(err, examprep.ErrCatalogCourseNotFound) {
return c.Status(fiber.StatusNotFound).JSON(domain.ErrorResponse{
Message: "Catalog course not found",
Error: err.Error(),
})
}
return c.Status(fiber.StatusInternalServerError).JSON(domain.ErrorResponse{
Message: "Failed to list units",
Error: err.Error(),
})
}
return c.JSON(domain.Response{
Message: "Units retrieved successfully",
Data: fiber.Map{
"units": items,
"total_count": total,
"limit": limit,
"offset": offset,
},
Success: true,
StatusCode: fiber.StatusOK,
})
}
// ReorderExamPrepUnitsInCatalogCourse godoc
// @Summary Reorder units within a catalog course
// @Tags exam-prep
// @Param catalogCourseId path int true "Catalog course ID"
// @Param body body domain.ReorderIDsRequest true "ordered_ids: every unit id in this catalog course, new order"
// @Router /api/v1/exam-prep/catalog-courses/{catalogCourseId}/units/reorder [put]
func (h *Handler) ReorderExamPrepUnitsInCatalogCourse(c *fiber.Ctx) error {
catalogCourseID, err := strconv.ParseInt(c.Params("catalogCourseId"), 10, 64)
if err != nil {
return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{
Message: "Invalid catalog course id",
Error: err.Error(),
})
}
var req domain.ReorderIDsRequest
if err := c.BodyParser(&req); err != nil {
return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{
Message: "Invalid request body",
Error: err.Error(),
})
}
if req.OrderedIDs == nil {
return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{
Message: "ordered_ids is required (use an empty array if there are no units)",
Error: "missing ordered_ids",
})
}
if err := h.examPrepSvc.ReorderUnitsInCatalogCourse(c.Context(), catalogCourseID, req.OrderedIDs); err != nil {
if errors.Is(err, examprep.ErrCatalogCourseNotFound) {
return c.Status(fiber.StatusNotFound).JSON(domain.ErrorResponse{
Message: "Catalog course not found",
Error: err.Error(),
})
}
if errors.Is(err, domain.ErrReorderInvalidIDSet) {
return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{
Message: err.Error(),
Error: "INVALID_REORDER",
})
}
return c.Status(fiber.StatusInternalServerError).JSON(domain.ErrorResponse{
Message: "Failed to reorder units",
Error: err.Error(),
})
}
return c.JSON(domain.Response{
Message: "Units reordered successfully",
Success: true,
StatusCode: fiber.StatusOK,
})
}
// GetExamPrepUnitByID godoc
// @Summary Get exam-prep unit by ID
// @Tags exam-prep
// @Param id path int true "Unit ID"
// @Router /api/v1/exam-prep/units/{id} [get]
func (h *Handler) GetExamPrepUnitByID(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 unit id",
Error: err.Error(),
})
}
out, err := h.examPrepSvc.GetUnitByID(c.Context(), id)
if err != nil {
if errors.Is(err, examprep.ErrUnitNotFound) {
return c.Status(fiber.StatusNotFound).JSON(domain.ErrorResponse{
Message: "Unit not found",
Error: err.Error(),
})
}
return c.Status(fiber.StatusInternalServerError).JSON(domain.ErrorResponse{
Message: "Failed to get unit",
Error: err.Error(),
})
}
return c.JSON(domain.Response{
Message: "Unit retrieved successfully",
Data: out,
Success: true,
StatusCode: fiber.StatusOK,
})
}
// UpdateExamPrepUnit godoc
// @Summary Update exam-prep unit
// @Tags exam-prep
// @Param id path int true "Unit ID"
// @Param body body domain.UpdateExamPrepUnitInput true "Fields to update"
// @Router /api/v1/exam-prep/units/{id} [put]
func (h *Handler) UpdateExamPrepUnit(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 unit id",
Error: err.Error(),
})
}
var req domain.UpdateExamPrepUnitInput
if err := c.BodyParser(&req); err != nil {
return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{
Message: "Invalid request body",
Error: err.Error(),
})
}
out, err := h.examPrepSvc.UpdateUnit(c.Context(), id, req)
if err != nil {
if errors.Is(err, examprep.ErrUnitNotFound) {
return c.Status(fiber.StatusNotFound).JSON(domain.ErrorResponse{
Message: "Unit not found",
Error: err.Error(),
})
}
return c.Status(fiber.StatusInternalServerError).JSON(domain.ErrorResponse{
Message: "Failed to update unit",
Error: err.Error(),
})
}
return c.JSON(domain.Response{
Message: "Unit updated successfully",
Data: out,
Success: true,
StatusCode: fiber.StatusOK,
})
}
// DeleteExamPrepUnit godoc
// @Summary Delete exam-prep unit
// @Tags exam-prep
// @Param id path int true "Unit ID"
// @Router /api/v1/exam-prep/units/{id} [delete]
func (h *Handler) DeleteExamPrepUnit(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 unit id",
Error: err.Error(),
})
}
if err := h.examPrepSvc.DeleteUnit(c.Context(), id); err != nil {
if errors.Is(err, examprep.ErrUnitNotFound) {
return c.Status(fiber.StatusNotFound).JSON(domain.ErrorResponse{
Message: "Unit not found",
Error: err.Error(),
})
}
return c.Status(fiber.StatusInternalServerError).JSON(domain.ErrorResponse{
Message: "Failed to delete unit",
Error: err.Error(),
})
}
return c.JSON(domain.Response{
Message: "Unit deleted successfully",
Success: true,
StatusCode: fiber.StatusOK,
})
}

View File

@ -7,6 +7,7 @@ import (
"fmt" "fmt"
"io" "io"
"net/http" "net/http"
"net/url"
"strconv" "strconv"
"strings" "strings"
"time" "time"
@ -31,6 +32,10 @@ type uploadMediaByURLReq struct {
Description string `json:"description"` Description string `json:"description"`
} }
type refreshFileURLReq struct {
Reference string `json:"reference"`
}
// resolveFileURL converts a stored file path to a usable URL. // resolveFileURL converts a stored file path to a usable URL.
// If the path starts with "minio://", it generates a presigned URL. // If the path starts with "minio://", it generates a presigned URL.
// Otherwise it returns the path as-is (e.g. "/static/..."). // Otherwise it returns the path as-is (e.g. "/static/...").
@ -82,6 +87,93 @@ func (h *Handler) GetFileURL(c *fiber.Ctx) error {
}) })
} }
func (h *Handler) extractObjectKeyFromReference(reference string) (string, error) {
ref := strings.TrimSpace(reference)
if ref == "" {
return "", fmt.Errorf("reference is required")
}
if strings.HasPrefix(ref, "minio://") {
key := strings.TrimPrefix(ref, "minio://")
if key == "" {
return "", fmt.Errorf("invalid minio reference")
}
return key, nil
}
u, err := url.Parse(ref)
if err == nil && u.Scheme != "" && u.Host != "" {
path := strings.TrimPrefix(u.Path, "/")
if path == "" {
return "", fmt.Errorf("invalid file URL")
}
bucket := strings.TrimSpace(h.minioSvc.BucketName())
if bucket != "" {
prefix := bucket + "/"
if strings.HasPrefix(path, prefix) {
path = strings.TrimPrefix(path, prefix)
}
}
if path == "" {
return "", fmt.Errorf("invalid file URL")
}
return path, nil
}
return ref, nil
}
// RefreshFileURL generates a new presigned URL from an object key, minio:// URI, or stale presigned URL.
// @Summary Refresh presigned URL for a file
// @Tags files
// @Accept json
// @Produce json
// @Param body body refreshFileURLReq true "reference (object key, minio://..., or existing presigned URL)"
// @Success 200 {object} domain.Response
// @Router /api/v1/files/refresh-url [post]
func (h *Handler) RefreshFileURL(c *fiber.Ctx) error {
if h.minioSvc == nil {
return c.Status(fiber.StatusServiceUnavailable).JSON(domain.ErrorResponse{
Message: "File storage service is not available",
})
}
var req refreshFileURLReq
if err := c.BodyParser(&req); err != nil {
return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{
Message: "Invalid request body",
Error: err.Error(),
})
}
objectKey, err := h.extractObjectKeyFromReference(req.Reference)
if err != nil {
return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{
Message: "Invalid file reference",
Error: err.Error(),
})
}
freshURL, err := h.minioSvc.GetURL(c.Context(), objectKey, 1*time.Hour)
if err != nil {
return c.Status(fiber.StatusInternalServerError).JSON(domain.ErrorResponse{
Message: "Failed to refresh file URL",
Error: err.Error(),
})
}
return c.Status(fiber.StatusOK).JSON(domain.Response{
Message: "File URL refreshed",
Data: map[string]interface{}{
"object_key": objectKey,
"url": freshURL,
"expires_in": int((1 * time.Hour).Seconds()),
},
Success: true,
StatusCode: fiber.StatusOK,
})
}
// UploadMedia uploads an image/audio/video file and returns its URL and key. // UploadMedia uploads an image/audio/video file and returns its URL and key.
// @Summary Upload media file // @Summary Upload media file
// @Tags files // @Tags files

View File

@ -17,6 +17,7 @@ import (
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/courses" "Yimaru-Backend/internal/services/courses"
"Yimaru-Backend/internal/services/examprep"
"Yimaru-Backend/internal/services/lessons" "Yimaru-Backend/internal/services/lessons"
"Yimaru-Backend/internal/services/lmsprogress" "Yimaru-Backend/internal/services/lmsprogress"
"Yimaru-Backend/internal/services/modules" "Yimaru-Backend/internal/services/modules"
@ -44,6 +45,7 @@ import (
type Handler struct { type Handler struct {
assessmentSvc *assessment.Service assessmentSvc *assessment.Service
questionsSvc *questions.Service questionsSvc *questions.Service
examPrepSvc *examprep.Service
programSvc *programs.Service programSvc *programs.Service
courseSvc *courses.Service courseSvc *courses.Service
moduleSvc *modules.Service moduleSvc *modules.Service
@ -77,6 +79,7 @@ type Handler struct {
func New( func New(
assessmentSvc *assessment.Service, assessmentSvc *assessment.Service,
questionsSvc *questions.Service, questionsSvc *questions.Service,
examPrepSvc *examprep.Service,
programSvc *programs.Service, programSvc *programs.Service,
courseSvc *courses.Service, courseSvc *courses.Service,
moduleSvc *modules.Service, moduleSvc *modules.Service,
@ -109,6 +112,7 @@ func New(
return &Handler{ return &Handler{
assessmentSvc: assessmentSvc, assessmentSvc: assessmentSvc,
questionsSvc: questionsSvc, questionsSvc: questionsSvc,
examPrepSvc: examPrepSvc,
programSvc: programSvc, programSvc: programSvc,
courseSvc: courseSvc, courseSvc: courseSvc,
moduleSvc: moduleSvc, moduleSvc: moduleSvc,

View File

@ -0,0 +1,66 @@
package handlers
import (
"Yimaru-Backend/internal/domain"
"github.com/gofiber/fiber/v2"
)
type componentCatalogRes struct {
StimulusKinds []string `json:"stimulus_component_kinds"`
ResponseKinds []string `json:"response_component_kinds"`
}
type validateQuestionTypeDefinitionReq struct {
StimulusComponentKinds []string `json:"stimulus_component_kinds"`
ResponseComponentKinds []string `json:"response_component_kinds"`
}
// GetQuestionTypeComponentCatalog godoc
// @Summary Question-type builder component catalog
// @Description Valid stimulus and response component kind codes for dynamic question-type definitions
// @Tags questions
// @Produce json
// @Success 200 {object} domain.Response
// @Router /api/v1/questions/component-catalog [get]
func (h *Handler) GetQuestionTypeComponentCatalog(c *fiber.Ctx) error {
return c.JSON(domain.Response{
Message: "Component catalog",
Data: componentCatalogRes{
StimulusKinds: domain.StimulusComponentCatalog(),
ResponseKinds: domain.ResponseComponentCatalog(),
},
})
}
// ValidateQuestionTypeDefinition godoc
// @Summary Validate dynamic question-type definition
// @Description Validates selected stimulus and response component kinds for temporary question-type definitions
// @Tags questions
// @Accept json
// @Produce json
// @Param body body validateQuestionTypeDefinitionReq true "Stimulus and response component kinds"
// @Success 200 {object} domain.Response
// @Failure 400 {object} domain.ErrorResponse
// @Router /api/v1/questions/validate-question-type-definition [post]
func (h *Handler) ValidateQuestionTypeDefinition(c *fiber.Ctx) error {
var req validateQuestionTypeDefinitionReq
if err := c.BodyParser(&req); err != nil {
return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{
Message: "Invalid request body",
Error: err.Error(),
})
}
if err := domain.ValidateDynamicQuestionTypeDefinition(req.StimulusComponentKinds, req.ResponseComponentKinds); err != nil {
return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{
Message: "Invalid question type definition",
Error: err.Error(),
})
}
return c.JSON(domain.Response{
Message: "Question type definition is valid",
Data: fiber.Map{"valid": true},
})
}

View File

@ -550,6 +550,27 @@ type listQuestionSetsRes struct {
TotalCount int64 `json:"total_count"` TotalCount int64 `json:"total_count"`
} }
var validInitialAssessmentLevels = map[string]struct{}{
"A1": {},
"A2": {},
"B1": {},
"B2": {},
}
func normalizeInitialAssessmentLevel(description *string) (string, error) {
if description == nil {
return "", fmt.Errorf("description is required and must be one of: A1, A2, B1, B2")
}
level := strings.ToUpper(strings.TrimSpace(*description))
if level == "" {
return "", fmt.Errorf("description is required and must be one of: A1, A2, B1, B2")
}
if _, ok := validInitialAssessmentLevels[level]; !ok {
return "", fmt.Errorf("description must be one of: A1, A2, B1, B2")
}
return level, nil
}
func isSequenceGatedPractice(set domain.QuestionSet) bool { func isSequenceGatedPractice(set domain.QuestionSet) bool {
if !strings.EqualFold(set.SetType, string(domain.QuestionSetTypePractice)) || set.OwnerType == nil { if !strings.EqualFold(set.SetType, string(domain.QuestionSetTypePractice)) || set.OwnerType == nil {
return false return false
@ -605,12 +626,19 @@ func (h *Handler) CreateQuestionSet(c *fiber.Ctx) error {
Error: err.Error(), Error: err.Error(),
}) })
} }
if strings.EqualFold(req.SetType, string(domain.QuestionSetTypeInitialAssessment)) { if strings.EqualFold(req.SetType, string(domain.QuestionSetTypeInitialAssessment)) {
if req.OwnerType == nil || req.OwnerID == nil { normalizedLevel, err := normalizeInitialAssessmentLevel(req.Description)
if err != 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 level",
Error: "INITIAL_ASSESSMENT question sets must include owner_type and owner_id", Error: err.Error(),
})
}
req.Description = &normalizedLevel
if req.PassingScore == nil {
return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{
Message: "Invalid initial assessment set",
Error: "passing_score is required for INITIAL_ASSESSMENT question sets",
}) })
} }
} }
@ -762,7 +790,7 @@ func (h *Handler) GetQuestionSetsByType(c *fiber.Ctx) error {
}) })
} }
var setResponses []questionSetRes setResponses := make([]questionSetRes, 0, len(sets))
for _, s := range sets { for _, s := range sets {
setResponses = append(setResponses, questionSetRes{ setResponses = append(setResponses, questionSetRes{
ID: s.ID, ID: s.ID,
@ -784,6 +812,8 @@ func (h *Handler) GetQuestionSetsByType(c *fiber.Ctx) error {
return c.JSON(domain.Response{ return c.JSON(domain.Response{
Message: "Question sets retrieved successfully", Message: "Question sets retrieved successfully",
Success: true,
StatusCode: fiber.StatusOK,
Data: listQuestionSetsRes{ Data: listQuestionSetsRes{
QuestionSets: setResponses, QuestionSets: setResponses,
TotalCount: totalCount, TotalCount: totalCount,
@ -890,6 +920,14 @@ func (h *Handler) UpdateQuestionSet(c *fiber.Ctx) error {
}) })
} }
existingSet, err := h.questionsSvc.GetQuestionSetByID(c.Context(), id)
if err != nil {
return c.Status(fiber.StatusNotFound).JSON(domain.ErrorResponse{
Message: "Question set not found",
Error: err.Error(),
})
}
var req updateQuestionSetReq var req updateQuestionSetReq
if err := c.BodyParser(&req); err != nil { if err := c.BodyParser(&req); err != nil {
return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{ return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{
@ -898,11 +936,46 @@ func (h *Handler) UpdateQuestionSet(c *fiber.Ctx) error {
}) })
} }
title := "" title := existingSet.Title
if req.Title != nil { if req.Title != nil {
title = *req.Title title = *req.Title
} }
if strings.EqualFold(existingSet.SetType, string(domain.QuestionSetTypeInitialAssessment)) {
effectiveDescription := existingSet.Description
if req.Description != nil {
effectiveDescription = req.Description
}
normalizedLevel, err := normalizeInitialAssessmentLevel(effectiveDescription)
if err != nil {
return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{
Message: "Invalid initial assessment level",
Error: err.Error(),
})
}
req.Description = &normalizedLevel
effectivePassingScore := existingSet.PassingScore
if req.PassingScore != nil {
effectivePassingScore = req.PassingScore
}
if effectivePassingScore == nil {
return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{
Message: "Invalid initial assessment set",
Error: "passing_score is required for INITIAL_ASSESSMENT question sets",
})
}
}
status := req.Status
if status == nil {
status = &existingSet.Status
}
shuffleQuestions := req.ShuffleQuestions
if shuffleQuestions == nil {
shuffleQuestions = &existingSet.ShuffleQuestions
}
input := domain.CreateQuestionSetInput{ input := domain.CreateQuestionSetInput{
Title: title, Title: title,
Description: req.Description, Description: req.Description,
@ -910,8 +983,8 @@ func (h *Handler) UpdateQuestionSet(c *fiber.Ctx) error {
Persona: req.Persona, Persona: req.Persona,
TimeLimitMinutes: req.TimeLimitMinutes, TimeLimitMinutes: req.TimeLimitMinutes,
PassingScore: req.PassingScore, PassingScore: req.PassingScore,
ShuffleQuestions: req.ShuffleQuestions, ShuffleQuestions: shuffleQuestions,
Status: req.Status, Status: status,
IntroVideoURL: req.IntroVideoURL, IntroVideoURL: req.IntroVideoURL,
} }
@ -1127,11 +1200,64 @@ func (h *Handler) GetQuestionsInSet(c *fiber.Ctx) error {
}) })
} }
itemResponses := questionSetItemsToRes(items) questionResponses := make([]questionRes, 0, len(items))
for _, item := range items {
question, err := h.questionsSvc.GetQuestionWithDetails(c.Context(), item.QuestionID)
if err != nil {
return c.Status(fiber.StatusInternalServerError).JSON(domain.ErrorResponse{
Message: "Failed to get question details",
Error: err.Error(),
})
}
options := make([]optionRes, 0, len(question.Options))
for _, opt := range question.Options {
options = append(options, optionRes{
ID: opt.ID,
OptionText: opt.OptionText,
OptionOrder: opt.OptionOrder,
IsCorrect: opt.IsCorrect,
})
}
shortAnswers := make([]shortAnswerRes, 0, len(question.ShortAnswers))
for _, sa := range question.ShortAnswers {
shortAnswers = append(shortAnswers, shortAnswerRes{
ID: sa.ID,
AcceptableAnswer: sa.AcceptableAnswer,
MatchType: sa.MatchType,
})
}
var audioCorrectAnswerText *string
if question.AudioAnswer != nil {
audioCorrectAnswerText = &question.AudioAnswer.CorrectAnswerText
}
questionResponses = append(questionResponses, questionRes{
ID: question.ID,
QuestionText: question.QuestionText,
QuestionType: question.QuestionType,
DifficultyLevel: question.DifficultyLevel,
Points: question.Points,
Explanation: question.Explanation,
Tips: question.Tips,
VoicePrompt: question.VoicePrompt,
SampleAnswerVoicePrompt: question.SampleAnswerVoicePrompt,
ImageURL: question.ImageURL,
Status: question.Status,
CreatedAt: question.CreatedAt.String(),
Options: options,
ShortAnswers: shortAnswers,
AudioCorrectAnswerText: audioCorrectAnswerText,
})
}
return c.JSON(domain.Response{ return c.JSON(domain.Response{
Message: "Questions retrieved successfully", Message: "Questions retrieved successfully",
Data: itemResponses, Success: true,
StatusCode: fiber.StatusOK,
Data: questionResponses,
}) })
} }
@ -1271,7 +1397,7 @@ func (h *Handler) CompletePractice(c *fiber.Ctx) error {
}) })
} }
if err := h.questionsSvc.MarkPracticeCompleted(c.Context(), userID, set.ID); err != nil { if err := h.lmsProgressSvc.CompletePracticeForUser(c.Context(), userID, set.ID); err != nil {
return c.Status(fiber.StatusInternalServerError).JSON(domain.ErrorResponse{ return c.Status(fiber.StatusInternalServerError).JSON(domain.ErrorResponse{
Message: "Failed to complete practice", Message: "Failed to complete practice",
Error: err.Error(), Error: err.Error(),

View File

@ -2,8 +2,10 @@ package handlers
import ( import (
"Yimaru-Backend/internal/domain" "Yimaru-Backend/internal/domain"
subscriptionsvc "Yimaru-Backend/internal/services/subscriptions"
"context" "context"
"encoding/json" "encoding/json"
"errors"
"fmt" "fmt"
"strconv" "strconv"
@ -512,6 +514,12 @@ func (h *Handler) CheckSubscriptionStatus(c *fiber.Ctx) error {
// @Failure 500 {object} domain.ErrorResponse // @Failure 500 {object} domain.ErrorResponse
// @Router /api/v1/subscriptions/{id}/cancel [post] // @Router /api/v1/subscriptions/{id}/cancel [post]
func (h *Handler) CancelSubscription(c *fiber.Ctx) error { func (h *Handler) CancelSubscription(c *fiber.Ctx) error {
userID, ok := c.Locals("user_id").(int64)
if !ok {
return c.Status(fiber.StatusUnauthorized).JSON(domain.ErrorResponse{
Message: "Unauthorized",
})
}
id, err := strconv.ParseInt(c.Params("id"), 10, 64) id, err := strconv.ParseInt(c.Params("id"), 10, 64)
if err != nil { if err != nil {
return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{ return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{
@ -519,13 +527,26 @@ func (h *Handler) CancelSubscription(c *fiber.Ctx) error {
}) })
} }
err = h.subscriptionsSvc.CancelSubscription(c.Context(), id) err = h.subscriptionsSvc.CancelSubscriptionForUser(c.Context(), id, userID)
if err != nil { if err != nil {
switch {
case errors.Is(err, subscriptionsvc.ErrSubscriptionNotFound):
return c.Status(fiber.StatusNotFound).JSON(domain.ErrorResponse{
Message: "Subscription not found",
Error: err.Error(),
})
case errors.Is(err, subscriptionsvc.ErrSubscriptionNotOwned):
return c.Status(fiber.StatusForbidden).JSON(domain.ErrorResponse{
Message: "You do not have access to this subscription",
Error: err.Error(),
})
default:
return c.Status(fiber.StatusInternalServerError).JSON(domain.ErrorResponse{ return c.Status(fiber.StatusInternalServerError).JSON(domain.ErrorResponse{
Message: "Failed to cancel subscription", Message: "Failed to cancel subscription",
Error: err.Error(), Error: err.Error(),
}) })
} }
}
return c.JSON(domain.Response{ return c.JSON(domain.Response{
Message: "Subscription cancelled successfully", Message: "Subscription cancelled successfully",
@ -544,6 +565,12 @@ func (h *Handler) CancelSubscription(c *fiber.Ctx) error {
// @Failure 500 {object} domain.ErrorResponse // @Failure 500 {object} domain.ErrorResponse
// @Router /api/v1/subscriptions/{id}/auto-renew [put] // @Router /api/v1/subscriptions/{id}/auto-renew [put]
func (h *Handler) SetAutoRenew(c *fiber.Ctx) error { func (h *Handler) SetAutoRenew(c *fiber.Ctx) error {
userID, ok := c.Locals("user_id").(int64)
if !ok {
return c.Status(fiber.StatusUnauthorized).JSON(domain.ErrorResponse{
Message: "Unauthorized",
})
}
id, err := strconv.ParseInt(c.Params("id"), 10, 64) id, err := strconv.ParseInt(c.Params("id"), 10, 64)
if err != nil { if err != nil {
return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{ return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{
@ -559,13 +586,26 @@ func (h *Handler) SetAutoRenew(c *fiber.Ctx) error {
}) })
} }
err = h.subscriptionsSvc.SetAutoRenew(c.Context(), id, req.AutoRenew) err = h.subscriptionsSvc.SetAutoRenewForUser(c.Context(), id, userID, req.AutoRenew)
if err != nil { if err != nil {
switch {
case errors.Is(err, subscriptionsvc.ErrSubscriptionNotFound):
return c.Status(fiber.StatusNotFound).JSON(domain.ErrorResponse{
Message: "Subscription not found",
Error: err.Error(),
})
case errors.Is(err, subscriptionsvc.ErrSubscriptionNotOwned):
return c.Status(fiber.StatusForbidden).JSON(domain.ErrorResponse{
Message: "You do not have access to this subscription",
Error: err.Error(),
})
default:
return c.Status(fiber.StatusInternalServerError).JSON(domain.ErrorResponse{ return c.Status(fiber.StatusInternalServerError).JSON(domain.ErrorResponse{
Message: "Failed to update auto-renew setting", Message: "Failed to update auto-renew setting",
Error: err.Error(), Error: err.Error(),
}) })
} }
}
return c.JSON(domain.Response{ return c.JSON(domain.Response{
Message: "Auto-renew setting updated successfully", Message: "Auto-renew setting updated successfully",

View File

@ -171,6 +171,46 @@ func (a *App) OnlyAdminAndAbove(c *fiber.Ctx) error {
return c.Next() return c.Next()
} }
// RequireActiveSubscription enforces an active subscription for learner accounts.
// Staff roles (SUPER_ADMIN, ADMIN, INSTRUCTOR, SUPPORT) bypass this check.
// Use after authMiddleware on routes that deliver paid learning content.
func (a *App) RequireActiveSubscription() fiber.Handler {
return func(c *fiber.Ctx) error {
role, ok := c.Locals("role").(domain.Role)
if !ok {
return fiber.NewError(fiber.StatusForbidden, "Role not found in context")
}
switch role {
case domain.RoleSuperAdmin, domain.RoleAdmin, domain.RoleInstructor, domain.RoleSupport:
return c.Next()
case domain.RoleStudent:
userID, ok := c.Locals("user_id").(int64)
if !ok || userID == 0 {
return fiber.NewError(fiber.StatusUnauthorized, "Unauthorized")
}
active, err := a.subscriptionsSvc.HasActiveSubscription(c.Context(), userID)
if err != nil {
a.mongoLoggerSvc.Error("subscription check failed",
zap.Int64("userID", userID),
zap.String("path", c.Path()),
zap.Error(err),
zap.Time("timestamp", time.Now()),
)
return fiber.NewError(fiber.StatusInternalServerError, "Failed to verify subscription")
}
if !active {
return c.Status(fiber.StatusForbidden).JSON(domain.ErrorResponse{
Message: "Active subscription required to access this content",
Error: "subscription_required",
})
}
return c.Next()
default:
return c.Next()
}
}
}
func (a *App) RequirePermission(permKey string) fiber.Handler { func (a *App) RequirePermission(permKey string) fiber.Handler {
return func(c *fiber.Ctx) error { return func(c *fiber.Ctx) error {
userRole, ok := c.Locals("role").(domain.Role) userRole, ok := c.Locals("role").(domain.Role)

View File

@ -15,6 +15,7 @@ func (a *App) initAppRoutes() {
h := handlers.New( h := handlers.New(
a.assessmentSvc, a.assessmentSvc,
a.questionsSvc, a.questionsSvc,
a.examPrepSvc,
a.programSvc, a.programSvc,
a.courseSvc, a.courseSvc,
a.moduleSvc, a.moduleSvc,
@ -76,43 +77,81 @@ func (a *App) initAppRoutes() {
groupV1.Post("/programs", a.authMiddleware, a.RequirePermission("programs.create"), h.CreateProgram) 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", a.authMiddleware, a.RequirePermission("programs.list"), h.ListPrograms)
groupV1.Put("/programs/reorder", a.authMiddleware, a.RequirePermission("programs.reorder"), h.ReorderPrograms) groupV1.Put("/programs/reorder", a.authMiddleware, a.RequirePermission("programs.reorder"), h.ReorderPrograms)
groupV1.Get("/lms/progress", a.authMiddleware, a.RequirePermission("lms.get_my_progress"), h.GetMyLMSProgress) groupV1.Get("/lms/progress", a.authMiddleware, a.RequireActiveSubscription(), a.RequirePermission("lms.get_my_progress"), h.GetMyLMSProgress)
groupV1.Get("/programs/:id", a.authMiddleware, a.RequirePermission("programs.get"), h.GetProgram) 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.Put("/programs/:id", a.authMiddleware, a.RequirePermission("programs.update"), h.UpdateProgram)
groupV1.Delete("/programs/:id", a.authMiddleware, a.RequirePermission("programs.delete"), h.DeleteProgram) groupV1.Delete("/programs/:id", a.authMiddleware, a.RequirePermission("programs.delete"), h.DeleteProgram)
// Exam prep (schema exam_prep — separate from LMS Learn English). Students need an active subscription.
examPrep := groupV1.Group("/exam-prep", a.authMiddleware, a.RequireActiveSubscription())
examPrep.Post("/catalog-courses", a.RequirePermission("exam_prep.catalog_courses.create"), h.CreateExamPrepCatalogCourse)
examPrep.Get("/catalog-courses", a.RequirePermission("exam_prep.catalog_courses.list"), h.ListExamPrepCatalogCourses)
examPrep.Put("/catalog-courses/reorder", a.RequirePermission("exam_prep.catalog_courses.reorder"), h.ReorderExamPrepCatalogCourses)
examPrep.Get("/catalog-courses/:id", a.RequirePermission("exam_prep.catalog_courses.get"), h.GetExamPrepCatalogCourseByID)
examPrep.Put("/catalog-courses/:id", a.RequirePermission("exam_prep.catalog_courses.update"), h.UpdateExamPrepCatalogCourse)
examPrep.Delete("/catalog-courses/:id", a.RequirePermission("exam_prep.catalog_courses.delete"), h.DeleteExamPrepCatalogCourse)
examPrep.Post("/catalog-courses/:catalogCourseId/units", a.RequirePermission("exam_prep.units.create"), h.CreateExamPrepUnit)
examPrep.Get("/catalog-courses/:catalogCourseId/units", a.RequirePermission("exam_prep.units.list"), h.ListExamPrepUnitsByCatalogCourse)
examPrep.Put("/catalog-courses/:catalogCourseId/units/reorder", a.RequirePermission("exam_prep.units.reorder"), h.ReorderExamPrepUnitsInCatalogCourse)
examPrep.Post("/units/:unitId/modules", a.RequirePermission("exam_prep.modules.create"), h.CreateExamPrepModule)
examPrep.Get("/units/:unitId/modules", a.RequirePermission("exam_prep.modules.list"), h.ListExamPrepModulesByUnit)
examPrep.Put("/units/:unitId/modules/reorder", a.RequirePermission("exam_prep.modules.reorder"), h.ReorderExamPrepModulesInUnit)
examPrep.Post("/modules/:moduleId/lessons", a.RequirePermission("exam_prep.lessons.create"), h.CreateExamPrepLesson)
examPrep.Get("/modules/:moduleId/lessons", a.RequirePermission("exam_prep.lessons.list_by_module"), h.ListExamPrepLessonsByUnitModule)
examPrep.Put("/modules/:moduleId/lessons/reorder", a.RequirePermission("exam_prep.lessons.reorder"), h.ReorderExamPrepLessonsInUnitModule)
examPrep.Post("/lessons/:lessonId/practices", a.RequirePermission("exam_prep.practices.create"), h.CreateExamPrepPractice)
examPrep.Get("/lessons/:lessonId/practices", a.RequirePermission("exam_prep.practices.list_by_lesson"), h.ListExamPrepPracticesByLesson)
examPrep.Get("/practices/:id", a.RequirePermission("exam_prep.practices.get"), h.GetExamPrepPracticeByID)
examPrep.Put("/practices/:id", a.RequirePermission("exam_prep.practices.update"), h.UpdateExamPrepPractice)
examPrep.Delete("/practices/:id", a.RequirePermission("exam_prep.practices.delete"), h.DeleteExamPrepPractice)
examPrep.Get("/lessons/:id", a.RequirePermission("exam_prep.lessons.get"), h.GetExamPrepLessonByID)
examPrep.Put("/lessons/:id", a.RequirePermission("exam_prep.lessons.update"), h.UpdateExamPrepLesson)
examPrep.Delete("/lessons/:id", a.RequirePermission("exam_prep.lessons.delete"), h.DeleteExamPrepLesson)
examPrep.Get("/modules/:id", a.RequirePermission("exam_prep.modules.get"), h.GetExamPrepModuleByID)
examPrep.Put("/modules/:id", a.RequirePermission("exam_prep.modules.update"), h.UpdateExamPrepModule)
examPrep.Delete("/modules/:id", a.RequirePermission("exam_prep.modules.delete"), h.DeleteExamPrepModule)
examPrep.Get("/units/:id", a.RequirePermission("exam_prep.units.get"), h.GetExamPrepUnitByID)
examPrep.Put("/units/:id", a.RequirePermission("exam_prep.units.update"), h.UpdateExamPrepUnit)
examPrep.Delete("/units/:id", a.RequirePermission("exam_prep.units.delete"), h.DeleteExamPrepUnit)
// Courses // Courses
groupV1.Post("/programs/:id/courses", a.authMiddleware, a.RequirePermission("courses.create"), h.CreateCourse) groupV1.Post("/programs/:id/courses", a.authMiddleware, a.RequirePermission("courses.create"), h.CreateCourse)
groupV1.Put("/programs/:id/courses/reorder", a.authMiddleware, a.RequirePermission("courses.reorder"), h.ReorderCoursesInProgram) groupV1.Put("/programs/:id/courses/reorder", a.authMiddleware, a.RequirePermission("courses.reorder"), h.ReorderCoursesInProgram)
groupV1.Get("/programs/:id/courses", a.authMiddleware, a.RequirePermission("courses.list_by_program"), h.ListCoursesByProgram) groupV1.Get("/programs/:id/courses", a.authMiddleware, a.RequireActiveSubscription(), a.RequirePermission("courses.list_by_program"), h.ListCoursesByProgram)
groupV1.Get("/courses/:id/practices", a.authMiddleware, a.RequirePermission("practices.list"), h.ListPracticesByCourse) groupV1.Get("/courses/:id/practices", a.authMiddleware, a.RequireActiveSubscription(), a.RequirePermission("practices.list"), h.ListPracticesByCourse)
groupV1.Get("/courses/:id", a.authMiddleware, a.RequirePermission("courses.get"), h.GetCourse) groupV1.Get("/courses/:id", a.authMiddleware, a.RequireActiveSubscription(), a.RequirePermission("courses.get"), h.GetCourse)
groupV1.Put("/courses/:id", a.authMiddleware, a.RequirePermission("courses.update"), h.UpdateCourse) groupV1.Put("/courses/:id", a.authMiddleware, a.RequirePermission("courses.update"), h.UpdateCourse)
groupV1.Delete("/courses/:id", a.authMiddleware, a.RequirePermission("courses.delete"), h.DeleteCourse) groupV1.Delete("/courses/:id", a.authMiddleware, a.RequirePermission("courses.delete"), h.DeleteCourse)
groupV1.Post("/courses/:courseId/modules", a.authMiddleware, a.RequirePermission("modules.create"), h.CreateModule) groupV1.Post("/courses/:courseId/modules", a.authMiddleware, a.RequirePermission("modules.create"), h.CreateModule)
groupV1.Put("/courses/:courseId/modules/reorder", a.authMiddleware, a.RequirePermission("modules.reorder"), h.ReorderModulesInCourse) groupV1.Put("/courses/:courseId/modules/reorder", a.authMiddleware, a.RequirePermission("modules.reorder"), h.ReorderModulesInCourse)
groupV1.Get("/courses/:courseId/modules", a.authMiddleware, a.RequirePermission("modules.list_by_course"), h.ListModulesByCourse) groupV1.Get("/courses/:courseId/modules", a.authMiddleware, a.RequireActiveSubscription(), a.RequirePermission("modules.list_by_course"), h.ListModulesByCourse)
// /modules/:moduleId/lessons before /modules/:id; /modules/:id/practices before /modules/:id // /modules/:moduleId/lessons before /modules/:id; /modules/:id/practices before /modules/:id
groupV1.Post("/modules/:moduleId/lessons", a.authMiddleware, a.RequirePermission("lessons.create"), h.CreateLesson) groupV1.Post("/modules/:moduleId/lessons", a.authMiddleware, a.RequirePermission("lessons.create"), h.CreateLesson)
groupV1.Get("/modules/:moduleId/lessons", a.authMiddleware, a.RequirePermission("lessons.list_by_module"), h.ListLessonsByModule) groupV1.Get("/modules/:moduleId/lessons", a.authMiddleware, a.RequireActiveSubscription(), a.RequirePermission("lessons.list_by_module"), h.ListLessonsByModule)
groupV1.Get("/modules/:id/practices", a.authMiddleware, a.RequirePermission("practices.list"), h.ListPracticesByModule) groupV1.Get("/modules/:id/practices", a.authMiddleware, a.RequireActiveSubscription(), a.RequirePermission("practices.list"), h.ListPracticesByModule)
groupV1.Get("/modules/:id", a.authMiddleware, a.RequirePermission("modules.get"), h.GetModule) groupV1.Get("/modules/:id", a.authMiddleware, a.RequireActiveSubscription(), a.RequirePermission("modules.get"), h.GetModule)
groupV1.Put("/modules/:id", a.authMiddleware, a.RequirePermission("modules.update"), h.UpdateModule) groupV1.Put("/modules/:id", a.authMiddleware, a.RequirePermission("modules.update"), h.UpdateModule)
groupV1.Delete("/modules/:id", a.authMiddleware, a.RequirePermission("modules.delete"), h.DeleteModule) groupV1.Delete("/modules/:id", a.authMiddleware, a.RequirePermission("modules.delete"), h.DeleteModule)
groupV1.Get("/lessons/:id/practices", a.authMiddleware, a.RequirePermission("practices.list"), h.ListPracticesByLesson) groupV1.Get("/lessons/:id/practices", a.authMiddleware, a.RequireActiveSubscription(), a.RequirePermission("practices.list"), h.ListPracticesByLesson)
groupV1.Post("/lessons/:id/complete", a.authMiddleware, a.RequirePermission("lessons.complete"), h.CompleteLesson) groupV1.Post("/lessons/:id/complete", a.authMiddleware, a.RequireActiveSubscription(), a.RequirePermission("lessons.complete"), h.CompleteLesson)
groupV1.Get("/lessons/:id", a.authMiddleware, a.RequirePermission("lessons.get"), h.GetLesson) groupV1.Get("/lessons/:id", a.authMiddleware, a.RequireActiveSubscription(), a.RequirePermission("lessons.get"), h.GetLesson)
groupV1.Put("/lessons/:id", a.authMiddleware, a.RequirePermission("lessons.update"), h.UpdateLesson) groupV1.Put("/lessons/:id", a.authMiddleware, a.RequirePermission("lessons.update"), h.UpdateLesson)
groupV1.Delete("/lessons/:id", a.authMiddleware, a.RequirePermission("lessons.delete"), h.DeleteLesson) groupV1.Delete("/lessons/:id", a.authMiddleware, a.RequirePermission("lessons.delete"), h.DeleteLesson)
groupV1.Post("/practices", a.authMiddleware, a.RequirePermission("practices.create"), h.CreatePractice) groupV1.Post("/practices", a.authMiddleware, a.RequirePermission("practices.create"), h.CreatePractice)
groupV1.Get("/practices/:id", a.authMiddleware, a.RequirePermission("practices.get"), h.GetPractice) groupV1.Get("/practices/:id", a.authMiddleware, a.RequireActiveSubscription(), a.RequirePermission("practices.get"), h.GetPractice)
groupV1.Put("/practices/:id", a.authMiddleware, a.RequirePermission("practices.update"), h.UpdatePractice) groupV1.Put("/practices/:id", a.authMiddleware, a.RequirePermission("practices.update"), h.UpdatePractice)
groupV1.Delete("/practices/:id", a.authMiddleware, a.RequirePermission("practices.delete"), h.DeletePractice) groupV1.Delete("/practices/:id", a.authMiddleware, a.RequirePermission("practices.delete"), h.DeletePractice)
// 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/refresh-url", a.authMiddleware, h.RefreshFileURL)
groupV1.Post("/files/upload", a.authMiddleware, h.UploadMedia) groupV1.Post("/files/upload", a.authMiddleware, h.UploadMedia)
groupV1.Post("/files/audio", a.authMiddleware, h.UploadAudio) groupV1.Post("/files/audio", a.authMiddleware, h.UploadAudio)
groupV1.Post("/questions/audio-answer", a.authMiddleware, h.SubmitAudioAnswer) groupV1.Post("/questions/audio-answer", a.authMiddleware, h.SubmitAudioAnswer)
@ -126,6 +165,8 @@ func (a *App) initAppRoutes() {
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)
groupV1.Get("/questions/search", a.authMiddleware, a.RequirePermission("questions.search"), h.SearchQuestions) groupV1.Get("/questions/search", a.authMiddleware, a.RequirePermission("questions.search"), h.SearchQuestions)
groupV1.Get("/questions/component-catalog", a.authMiddleware, a.RequirePermission("questions.list"), h.GetQuestionTypeComponentCatalog)
groupV1.Post("/questions/validate-question-type-definition", a.authMiddleware, a.RequirePermission("questions.create"), h.ValidateQuestionTypeDefinition)
groupV1.Get("/questions/:id", a.authMiddleware, a.RequirePermission("questions.get"), h.GetQuestionByID) groupV1.Get("/questions/:id", a.authMiddleware, a.RequirePermission("questions.get"), h.GetQuestionByID)
groupV1.Put("/questions/:id", a.authMiddleware, a.RequirePermission("questions.update"), h.UpdateQuestion) groupV1.Put("/questions/:id", a.authMiddleware, a.RequirePermission("questions.update"), h.UpdateQuestion)
groupV1.Delete("/questions/:id", a.authMiddleware, a.RequirePermission("questions.delete"), h.DeleteQuestion) groupV1.Delete("/questions/:id", a.authMiddleware, a.RequirePermission("questions.delete"), h.DeleteQuestion)
@ -141,7 +182,7 @@ func (a *App) initAppRoutes() {
// Question Set Items // Question Set Items
groupV1.Post("/question-sets/:setId/questions", a.authMiddleware, a.RequirePermission("question_set_items.add"), h.AddQuestionToSet) groupV1.Post("/question-sets/:setId/questions", a.authMiddleware, a.RequirePermission("question_set_items.add"), h.AddQuestionToSet)
groupV1.Get("/question-sets/:setId/questions", a.authMiddleware, a.RequirePermission("question_set_items.list"), h.GetQuestionsInSet) groupV1.Get("/question-sets/:setId/questions", a.authMiddleware, a.RequirePermission("question_set_items.list"), h.GetQuestionsInSet)
groupV1.Get("/practices/:practiceId/questions", a.authMiddleware, a.RequirePermission("question_set_items.list"), h.GetQuestionsByPractice) groupV1.Get("/practices/:practiceId/questions", a.authMiddleware, a.RequireActiveSubscription(), a.RequirePermission("question_set_items.list"), h.GetQuestionsByPractice)
groupV1.Delete("/question-sets/:setId/questions/:questionId", a.authMiddleware, a.RequirePermission("question_set_items.remove"), h.RemoveQuestionFromSet) groupV1.Delete("/question-sets/:setId/questions/:questionId", a.authMiddleware, a.RequirePermission("question_set_items.remove"), h.RemoveQuestionFromSet)
groupV1.Put("/question-sets/:setId/questions/:questionId/order", a.authMiddleware, a.RequirePermission("question_set_items.update_order"), h.UpdateQuestionOrderInSet) groupV1.Put("/question-sets/:setId/questions/:questionId/order", a.authMiddleware, a.RequirePermission("question_set_items.update_order"), h.UpdateQuestionOrderInSet)

View File

@ -0,0 +1,457 @@
{
"info": {
"_postman_id": "f7c2e4a1-8b3d-4e9f-a2c6-11dd99ee5501",
"name": "Yimaru Exam Prep (Duolingo)",
"description": "Exam-prep tree API (`/api/v1/exam-prep/...`): catalog courses → units → modules → lessons → practices. Requires Bearer token.\n\n**Courses** = `catalog-courses` in the backend. Set collection variables before chaining requests.",
"schema": "https://schema.getpostman.com/json/collection/v2.1.0/collection.json"
},
"auth": {
"type": "bearer",
"bearer": [
{
"key": "token",
"value": "{{accessToken}}",
"type": "string"
}
]
},
"variable": [
{
"key": "baseUrl",
"value": "http://localhost:8080"
},
{
"key": "accessToken",
"value": ""
},
{
"key": "catalogCourseId",
"value": "1"
},
{
"key": "unitId",
"value": "1"
},
{
"key": "moduleId",
"value": "1"
},
{
"key": "lessonId",
"value": "1"
},
{
"key": "practiceId",
"value": "1"
}
],
"item": [
{
"name": "Duolingo",
"item": [
{
"name": "Courses",
"description": "Backend route group: **`catalog-courses`** (`exam_prep.catalog_courses.*`)",
"item": [
{
"name": "Create catalog course",
"request": {
"method": "POST",
"header": [
{
"key": "Content-Type",
"value": "application/json"
}
],
"body": {
"mode": "raw",
"raw": "{\n \"name\": \"IELTS Prep\",\n \"description\": \"Optional description\",\n \"thumbnail\": null\n}"
},
"url": "{{baseUrl}}/api/v1/exam-prep/catalog-courses",
"description": "Permission: `exam_prep.catalog_courses.create`"
}
},
{
"name": "List catalog courses",
"request": {
"method": "GET",
"url": "{{baseUrl}}/api/v1/exam-prep/catalog-courses?limit=20&offset=0",
"description": "Permission: `exam_prep.catalog_courses.list`"
}
},
{
"name": "Reorder catalog courses",
"request": {
"method": "PUT",
"header": [
{
"key": "Content-Type",
"value": "application/json"
}
],
"body": {
"mode": "raw",
"raw": "{\n \"ordered_ids\": [1, 2, 3]\n}"
},
"url": "{{baseUrl}}/api/v1/exam-prep/catalog-courses/reorder",
"description": "Permission: `exam_prep.catalog_courses.reorder`. Must include every id in scope exactly once."
}
},
{
"name": "Get catalog course by ID",
"request": {
"method": "GET",
"url": "{{baseUrl}}/api/v1/exam-prep/catalog-courses/{{catalogCourseId}}",
"description": "Permission: `exam_prep.catalog_courses.get`"
}
},
{
"name": "Update catalog course",
"request": {
"method": "PUT",
"header": [
{
"key": "Content-Type",
"value": "application/json"
}
],
"body": {
"mode": "raw",
"raw": "{\n \"name\": \"Updated name\",\n \"description\": null,\n \"thumbnail\": null,\n \"sort_order\": 1\n}"
},
"url": "{{baseUrl}}/api/v1/exam-prep/catalog-courses/{{catalogCourseId}}",
"description": "Permission: `exam_prep.catalog_courses.update`"
}
},
{
"name": "Delete catalog course",
"request": {
"method": "DELETE",
"url": "{{baseUrl}}/api/v1/exam-prep/catalog-courses/{{catalogCourseId}}",
"description": "Permission: `exam_prep.catalog_courses.delete`"
}
}
]
},
{
"name": "Units",
"description": "Nested under catalog course (`exam_prep.units.*`)",
"item": [
{
"name": "Create unit",
"request": {
"method": "POST",
"header": [
{
"key": "Content-Type",
"value": "application/json"
}
],
"body": {
"mode": "raw",
"raw": "{\n \"name\": \"Grammar foundations\",\n \"description\": null,\n \"thumbnail\": null\n}"
},
"url": "{{baseUrl}}/api/v1/exam-prep/catalog-courses/{{catalogCourseId}}/units",
"description": "Permission: `exam_prep.units.create`"
}
},
{
"name": "List units by catalog course",
"request": {
"method": "GET",
"url": "{{baseUrl}}/api/v1/exam-prep/catalog-courses/{{catalogCourseId}}/units?limit=20&offset=0",
"description": "Permission: `exam_prep.units.list`"
}
},
{
"name": "Reorder units in catalog course",
"request": {
"method": "PUT",
"header": [
{
"key": "Content-Type",
"value": "application/json"
}
],
"body": {
"mode": "raw",
"raw": "{\n \"ordered_ids\": [1, 2, 3]\n}"
},
"url": "{{baseUrl}}/api/v1/exam-prep/catalog-courses/{{catalogCourseId}}/units/reorder",
"description": "Permission: `exam_prep.units.reorder`"
}
},
{
"name": "Get unit by ID",
"request": {
"method": "GET",
"url": "{{baseUrl}}/api/v1/exam-prep/units/{{unitId}}",
"description": "Permission: `exam_prep.units.get`"
}
},
{
"name": "Update unit",
"request": {
"method": "PUT",
"header": [
{
"key": "Content-Type",
"value": "application/json"
}
],
"body": {
"mode": "raw",
"raw": "{\n \"name\": \"Updated unit\",\n \"description\": null,\n \"thumbnail\": null,\n \"sort_order\": 1\n}"
},
"url": "{{baseUrl}}/api/v1/exam-prep/units/{{unitId}}",
"description": "Permission: `exam_prep.units.update`"
}
},
{
"name": "Delete unit",
"request": {
"method": "DELETE",
"url": "{{baseUrl}}/api/v1/exam-prep/units/{{unitId}}",
"description": "Permission: `exam_prep.units.delete`"
}
}
]
},
{
"name": "Modules",
"description": "Exam-prep **`unit_modules`** (`exam_prep.modules.*`)",
"item": [
{
"name": "Create module",
"request": {
"method": "POST",
"header": [
{
"key": "Content-Type",
"value": "application/json"
}
],
"body": {
"mode": "raw",
"raw": "{\n \"name\": \"Present tense\",\n \"description\": null,\n \"thumbnail\": null,\n \"icon\": null\n}"
},
"url": "{{baseUrl}}/api/v1/exam-prep/units/{{unitId}}/modules",
"description": "Permission: `exam_prep.modules.create`"
}
},
{
"name": "List modules by unit",
"request": {
"method": "GET",
"url": "{{baseUrl}}/api/v1/exam-prep/units/{{unitId}}/modules?limit=20&offset=0",
"description": "Permission: `exam_prep.modules.list`"
}
},
{
"name": "Reorder modules in unit",
"request": {
"method": "PUT",
"header": [
{
"key": "Content-Type",
"value": "application/json"
}
],
"body": {
"mode": "raw",
"raw": "{\n \"ordered_ids\": [1, 2, 3]\n}"
},
"url": "{{baseUrl}}/api/v1/exam-prep/units/{{unitId}}/modules/reorder",
"description": "Permission: `exam_prep.modules.reorder`"
}
},
{
"name": "Get module by ID",
"request": {
"method": "GET",
"url": "{{baseUrl}}/api/v1/exam-prep/modules/{{moduleId}}",
"description": "Permission: `exam_prep.modules.get`"
}
},
{
"name": "Update module",
"request": {
"method": "PUT",
"header": [
{
"key": "Content-Type",
"value": "application/json"
}
],
"body": {
"mode": "raw",
"raw": "{\n \"name\": \"Updated module\",\n \"description\": null,\n \"thumbnail\": null,\n \"icon\": null,\n \"sort_order\": 1\n}"
},
"url": "{{baseUrl}}/api/v1/exam-prep/modules/{{moduleId}}",
"description": "Permission: `exam_prep.modules.update`"
}
},
{
"name": "Delete module",
"request": {
"method": "DELETE",
"url": "{{baseUrl}}/api/v1/exam-prep/modules/{{moduleId}}",
"description": "Permission: `exam_prep.modules.delete`"
}
}
]
},
{
"name": "Lessons",
"description": "`exam_prep.lessons.*`",
"item": [
{
"name": "Create lesson",
"request": {
"method": "POST",
"header": [
{
"key": "Content-Type",
"value": "application/json"
}
],
"body": {
"mode": "raw",
"raw": "{\n \"title\": \"Intro video\",\n \"video_url\": \"https://example.com/video\",\n \"thumbnail\": null,\n \"description\": null\n}"
},
"url": "{{baseUrl}}/api/v1/exam-prep/modules/{{moduleId}}/lessons",
"description": "Permission: `exam_prep.lessons.create`"
}
},
{
"name": "List lessons by module",
"request": {
"method": "GET",
"url": "{{baseUrl}}/api/v1/exam-prep/modules/{{moduleId}}/lessons?limit=20&offset=0",
"description": "Permission: `exam_prep.lessons.list_by_module`"
}
},
{
"name": "Reorder lessons in module",
"request": {
"method": "PUT",
"header": [
{
"key": "Content-Type",
"value": "application/json"
}
],
"body": {
"mode": "raw",
"raw": "{\n \"ordered_ids\": [1, 2, 3]\n}"
},
"url": "{{baseUrl}}/api/v1/exam-prep/modules/{{moduleId}}/lessons/reorder",
"description": "Permission: `exam_prep.lessons.reorder`"
}
},
{
"name": "Get lesson by ID",
"request": {
"method": "GET",
"url": "{{baseUrl}}/api/v1/exam-prep/lessons/{{lessonId}}",
"description": "Permission: `exam_prep.lessons.get`"
}
},
{
"name": "Update lesson",
"request": {
"method": "PUT",
"header": [
{
"key": "Content-Type",
"value": "application/json"
}
],
"body": {
"mode": "raw",
"raw": "{\n \"title\": \"Updated lesson\",\n \"video_url\": null,\n \"thumbnail\": null,\n \"description\": null,\n \"sort_order\": 1\n}"
},
"url": "{{baseUrl}}/api/v1/exam-prep/lessons/{{lessonId}}",
"description": "Permission: `exam_prep.lessons.update`"
}
},
{
"name": "Delete lesson",
"request": {
"method": "DELETE",
"url": "{{baseUrl}}/api/v1/exam-prep/lessons/{{lessonId}}",
"description": "Permission: `exam_prep.lessons.delete`"
}
}
]
},
{
"name": "Practices",
"description": "Tied to lesson; **`question_set_id`** references shared `question_sets`. `exam_prep.practices.*`",
"item": [
{
"name": "Create practice",
"request": {
"method": "POST",
"header": [
{
"key": "Content-Type",
"value": "application/json"
}
],
"body": {
"mode": "raw",
"raw": "{\n \"title\": \"Drill: articles\",\n \"story_description\": null,\n \"story_image\": null,\n \"persona_id\": null,\n \"question_set_id\": 1,\n \"quick_tips\": null\n}"
},
"url": "{{baseUrl}}/api/v1/exam-prep/lessons/{{lessonId}}/practices",
"description": "Permission: `exam_prep.practices.create`"
}
},
{
"name": "List practices by lesson",
"request": {
"method": "GET",
"url": "{{baseUrl}}/api/v1/exam-prep/lessons/{{lessonId}}/practices?limit=20&offset=0",
"description": "Permission: `exam_prep.practices.list_by_lesson`"
}
},
{
"name": "Get practice by ID",
"request": {
"method": "GET",
"url": "{{baseUrl}}/api/v1/exam-prep/practices/{{practiceId}}",
"description": "Permission: `exam_prep.practices.get`"
}
},
{
"name": "Update practice",
"request": {
"method": "PUT",
"header": [
{
"key": "Content-Type",
"value": "application/json"
}
],
"body": {
"mode": "raw",
"raw": "{\n \"title\": \"Updated practice\",\n \"story_description\": null,\n \"story_image\": null,\n \"persona_id\": null,\n \"question_set_id\": 1,\n \"quick_tips\": null\n}"
},
"url": "{{baseUrl}}/api/v1/exam-prep/practices/{{practiceId}}",
"description": "Permission: `exam_prep.practices.update`. Omit fields you do not change."
}
},
{
"name": "Delete practice",
"request": {
"method": "DELETE",
"url": "{{baseUrl}}/api/v1/exam-prep/practices/{{practiceId}}",
"description": "Permission: `exam_prep.practices.delete`"
}
}
]
}
]
}
]
}