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

View File

@ -1 +1 @@
DROP TABLE IF EXISTS lms_practices;
DROP TABLE IF EXISTS lms_practices;

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

View File

@ -246,3 +246,95 @@ FROM
WHERE
c.program_id = $1
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
FROM otps
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
UPDATE otps

View File

@ -21,10 +21,13 @@ SELECT
q.explanation,
q.tips,
q.voice_prompt,
q.sample_answer_voice_prompt,
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
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
AND q.status != 'ARCHIVED'
ORDER BY qsi.display_order;
@ -70,9 +73,12 @@ SELECT
q.explanation,
q.tips,
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
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
AND q.status = 'PUBLISHED'
ORDER BY qsi.display_order;

View File

@ -951,6 +951,663 @@ const docTemplate = `{
"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": {
"post": {
"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": {
"post": {
"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": {
"get": {
"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}": {
"get": {
"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": {
"type": "object",
"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": {
"type": "object",
"properties": {
@ -10046,6 +10954,14 @@ const docTemplate = `{
}
}
},
"handlers.refreshFileURLReq": {
"type": "object",
"properties": {
"reference": {
"type": "string"
}
}
},
"handlers.refreshToken": {
"type": "object",
"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": {
"type": "object",
"required": [

View File

@ -943,6 +943,663 @@
"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": {
"post": {
"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": {
"post": {
"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": {
"get": {
"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}": {
"get": {
"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": {
"type": "object",
"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": {
"type": "object",
"properties": {
@ -10038,6 +10946,14 @@
}
}
},
"handlers.refreshFileURLReq": {
"type": "object",
"properties": {
"reference": {
"type": "string"
}
}
},
"handlers.refreshToken": {
"type": "object",
"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": {
"type": "object",
"required": [

View File

@ -28,6 +28,72 @@ definitions:
required:
- name
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:
properties:
description:
@ -542,6 +608,43 @@ definitions:
thumbnail:
type: string
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:
properties:
knowledge_level:
@ -1301,6 +1404,11 @@ definitions:
required:
- option_text
type: object
handlers.refreshFileURLReq:
properties:
reference:
type: string
type: object
handlers.refreshToken:
properties:
access_token:
@ -1486,6 +1594,17 @@ definitions:
title:
type: string
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:
properties:
otp:
@ -2521,6 +2640,447 @@ paths:
responses: {}
tags:
- 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:
post:
consumes:
@ -2539,6 +3099,27 @@ paths:
summary: Upload an audio file
tags:
- 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:
post:
consumes:
@ -4585,6 +5166,20 @@ paths:
summary: Submit audio answer for a question
tags:
- 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:
get:
description: Search questions by text
@ -4622,6 +5217,33 @@ paths:
summary: Search questions
tags:
- 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:
get:
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.sort_order,
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
courses c
WHERE
@ -147,15 +173,18 @@ type ListCoursesByProgramIDParams struct {
}
type ListCoursesByProgramIDRow struct {
TotalCount int64 `json:"total_count"`
ID int64 `json:"id"`
ProgramID int64 `json:"program_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"`
TotalCount int64 `json:"total_count"`
ID int64 `json:"id"`
ProgramID int64 `json:"program_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"`
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) {
@ -177,6 +206,9 @@ func (q *Queries) ListCoursesByProgramID(ctx context.Context, arg ListCoursesByP
&i.SortOrder,
&i.CreatedAt,
&i.UpdatedAt,
&i.ModuleCount,
&i.LessonCount,
&i.PracticeCount,
); err != nil {
return nil, err
}

View File

@ -7,6 +7,8 @@ package dbgen
import (
"context"
"github.com/jackc/pgx/v5/pgtype"
)
const CountCoursesInProgram = `-- name: CountCoursesInProgram :one
@ -94,6 +96,65 @@ func (q *Queries) CountModulesInCourse(ctx context.Context, courseID int64) (int
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
SELECT
count(*)::int AS n
@ -212,6 +273,122 @@ func (q *Queries) CountUserCompletedModulesInCourse(ctx context.Context, arg Cou
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
SELECT
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"`
}
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 {
Key string `json:"key"`
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
FROM otps
WHERE user_id = $1
ORDER BY created_at DESC LIMIT 1
ORDER BY id DESC
LIMIT 1
`
type GetOtpRow struct {
@ -82,6 +83,51 @@ func (q *Queries) GetOtp(ctx context.Context, userID int64) (GetOtpRow, error) {
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
UPDATE otps
SET used = TRUE, used_at = $2

View File

@ -69,27 +69,32 @@ SELECT
q.explanation,
q.tips,
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
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
AND q.status = 'PUBLISHED'
ORDER BY qsi.display_order
`
type GetPublishedQuestionsInSetRow struct {
ID int64 `json:"id"`
SetID int64 `json:"set_id"`
QuestionID int64 `json:"question_id"`
DisplayOrder int32 `json:"display_order"`
QuestionText string `json:"question_text"`
QuestionType string `json:"question_type"`
DifficultyLevel pgtype.Text `json:"difficulty_level"`
Points int32 `json:"points"`
Explanation pgtype.Text `json:"explanation"`
Tips pgtype.Text `json:"tips"`
VoicePrompt pgtype.Text `json:"voice_prompt"`
ImageUrl pgtype.Text `json:"image_url"`
ID int64 `json:"id"`
SetID int64 `json:"set_id"`
QuestionID int64 `json:"question_id"`
DisplayOrder int32 `json:"display_order"`
QuestionText string `json:"question_text"`
QuestionType string `json:"question_type"`
DifficultyLevel pgtype.Text `json:"difficulty_level"`
Points int32 `json:"points"`
Explanation pgtype.Text `json:"explanation"`
Tips pgtype.Text `json:"tips"`
VoicePrompt pgtype.Text `json:"voice_prompt"`
SampleAnswerVoicePrompt pgtype.Text `json:"sample_answer_voice_prompt"`
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) {
@ -113,7 +118,9 @@ func (q *Queries) GetPublishedQuestionsInSet(ctx context.Context, setID int64) (
&i.Explanation,
&i.Tips,
&i.VoicePrompt,
&i.SampleAnswerVoicePrompt,
&i.ImageUrl,
&i.AudioCorrectAnswerText,
); err != nil {
return nil, err
}
@ -138,29 +145,34 @@ SELECT
q.explanation,
q.tips,
q.voice_prompt,
q.sample_answer_voice_prompt,
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
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
AND q.status != 'ARCHIVED'
ORDER BY qsi.display_order
`
type GetQuestionSetItemsRow struct {
ID int64 `json:"id"`
SetID int64 `json:"set_id"`
QuestionID int64 `json:"question_id"`
DisplayOrder int32 `json:"display_order"`
QuestionText string `json:"question_text"`
QuestionType string `json:"question_type"`
DifficultyLevel pgtype.Text `json:"difficulty_level"`
Points int32 `json:"points"`
Explanation pgtype.Text `json:"explanation"`
Tips pgtype.Text `json:"tips"`
VoicePrompt pgtype.Text `json:"voice_prompt"`
ImageUrl pgtype.Text `json:"image_url"`
QuestionStatus string `json:"question_status"`
ID int64 `json:"id"`
SetID int64 `json:"set_id"`
QuestionID int64 `json:"question_id"`
DisplayOrder int32 `json:"display_order"`
QuestionText string `json:"question_text"`
QuestionType string `json:"question_type"`
DifficultyLevel pgtype.Text `json:"difficulty_level"`
Points int32 `json:"points"`
Explanation pgtype.Text `json:"explanation"`
Tips pgtype.Text `json:"tips"`
VoicePrompt pgtype.Text `json:"voice_prompt"`
SampleAnswerVoicePrompt pgtype.Text `json:"sample_answer_voice_prompt"`
ImageUrl pgtype.Text `json:"image_url"`
QuestionStatus string `json:"question_status"`
AudioCorrectAnswerText pgtype.Text `json:"audio_correct_answer_text"`
}
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.Tips,
&i.VoicePrompt,
&i.SampleAnswerVoicePrompt,
&i.ImageUrl,
&i.QuestionStatus,
&i.AudioCorrectAnswerText,
); err != nil {
return nil, err
}

View File

@ -2,8 +2,15 @@ package domain
import "time"
// DefaultCEFRCourseNames are the standard course names seeded for each program (migration 000048).
// Creating a course via the API may use any of these or a custom name.
// DefaultCEFRCoursesByProgramName maps seeded program names to default course names
// (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"}
// Course belongs to a Program.
@ -16,7 +23,12 @@ type Course struct {
SortOrder int `json:"sort_order"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt *time.Time `json:"updated_at,omitempty"`
Access *LMSEntityAccess `json:"access,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"`
}
type CreateCourseInput struct {

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
}
func (c *Client) BucketName() string {
return c.bucketName
}
func NewClient(endpoint, accessKey, secretKey string, useSSL bool, bucketName string) (*Client, error) {
mc, err := minio.New(endpoint, &minio.Options{
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
CreateOtp(ctx context.Context, otp 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})
}
// 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) {
total, err = s.queries.CountLessonsInModule(ctx, moduleID)
lessonTotal, err := s.queries.CountLessonsInModule(ctx, moduleID)
if err != nil {
return 0, 0, err
}
completed, err = s.queries.CountUserCompletedLessonsInModule(ctx, dbgen.CountUserCompletedLessonsInModuleParams{
lessonCompleted, err := s.queries.CountUserCompletedLessonsInModule(ctx, dbgen.CountUserCompletedLessonsInModuleParams{
ModuleID: moduleID,
UserID: userID,
})
if err != nil {
return 0, 0, err
}
return completed, total, nil
}
// 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)
practiceTotal, err := s.queries.CountPublishedPracticesInModule(ctx, toPgInt8(&moduleID))
if err != nil {
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,
UserID: userID,
})
if err != nil {
return 0, 0, err
}
return completed, total, nil
}
// 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)
practiceTotal, err := s.queries.CountPublishedPracticesInCourse(ctx, toPgInt8(&courseID))
if err != nil {
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,
UserID: userID,
})
if err != nil {
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
}

View File

@ -74,7 +74,7 @@ func (s *Store) ListCoursesByProgramID(ctx context.Context, programID int64, lim
if i == 0 {
total = r.TotalCount
}
out = append(out, courseToDomain(dbgen.Course{
co := courseToDomain(dbgen.Course{
ID: r.ID,
ProgramID: r.ProgramID,
Name: r.Name,
@ -83,7 +83,11 @@ func (s *Store) ListCoursesByProgramID(ctx context.Context, programID int64, lim
CreatedAt: r.CreatedAt,
UpdatedAt: r.UpdatedAt,
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
}

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) {
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
if input.Name != nil {
nameText = pgtype.Text{String: *input.Name, Valid: true}
} else {
nameText = pgtype.Text{Valid: false}
}
m, err := s.queries.UpdateModule(ctx, dbgen.UpdateModuleParams{
m, err := q.UpdateModule(ctx, dbgen.UpdateModuleParams{
ID: id,
Name: nameText,
Description: optionalTextUpdate(input.Description),
@ -112,6 +177,10 @@ func (s *Store) UpdateModule(ctx context.Context, id int64, input domain.UpdateM
}
return domain.Module{}, err
}
if err := tx.Commit(ctx); err != nil {
return domain.Module{}, err
}
return moduleToDomain(m), nil
}

View File

@ -7,8 +7,8 @@ import (
dbgen "Yimaru-Backend/gen/db"
)
// CompleteLessonForUser records lesson completion and cascades to module, course, and program when the user
// has fully completed the preceding scope. Runs in a single transaction.
// CompleteLessonForUser records lesson completion and cascades completion upward when
// both lesson and related practice requirements are satisfied.
func (s *Store) CompleteLessonForUser(ctx context.Context, userID, lessonID int64) error {
q, tx, err := s.BeginTx(ctx)
if err != nil {
@ -31,56 +31,162 @@ func (s *Store) CompleteLessonForUser(ctx context.Context, userID, lessonID int6
if err != nil {
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
}
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 {
return fmt.Errorf("commit: %w", err)
}
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
}
if !row.ExpiresAt.Valid {
return domain.Otp{}, domain.ErrOtpNotFound
}
return domain.Otp{
ID: row.ID,
UserID: row.UserID,
@ -63,6 +67,36 @@ func (s *Store) GetOtp(ctx context.Context, userID int64) (domain.Otp, error) {
ExpiresAt: row.ExpiresAt.Time,
}, 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 {
return s.queries.MarkOtpAsUsed(ctx, dbgen.MarkOtpAsUsedParams{
ID: otp.ID,

View File

@ -607,6 +607,19 @@ func (s *Store) GetQuestionSetsByType(ctx context.Context, setType string, limit
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
}
@ -724,17 +737,17 @@ func (s *Store) GetQuestionSetItems(ctx context.Context, setID int64) ([]domain.
QuestionID: r.QuestionID,
DisplayOrder: r.DisplayOrder,
},
QuestionText: r.QuestionText,
QuestionType: r.QuestionType,
DifficultyLevel: fromPgText(r.DifficultyLevel),
Points: r.Points,
Explanation: fromPgText(r.Explanation),
Tips: fromPgText(r.Tips),
VoicePrompt: fromPgText(r.VoicePrompt),
SampleAnswerVoicePrompt: nil,
ImageURL: fromPgText(r.ImageUrl),
AudioCorrectAnswerText: nil,
QuestionStatus: r.QuestionStatus,
QuestionText: r.QuestionText,
QuestionType: r.QuestionType,
DifficultyLevel: fromPgText(r.DifficultyLevel),
Points: r.Points,
Explanation: fromPgText(r.Explanation),
Tips: fromPgText(r.Tips),
VoicePrompt: fromPgText(r.VoicePrompt),
SampleAnswerVoicePrompt: fromPgText(r.SampleAnswerVoicePrompt),
ImageURL: fromPgText(r.ImageUrl),
AudioCorrectAnswerText: fromPgText(r.AudioCorrectAnswerText),
QuestionStatus: r.QuestionStatus,
}
}
return result, nil
@ -799,15 +812,17 @@ func (s *Store) GetPublishedQuestionsInSet(ctx context.Context, setID int64) ([]
QuestionID: r.QuestionID,
DisplayOrder: r.DisplayOrder,
},
QuestionText: r.QuestionText,
QuestionType: r.QuestionType,
DifficultyLevel: fromPgText(r.DifficultyLevel),
Points: r.Points,
Explanation: fromPgText(r.Explanation),
Tips: fromPgText(r.Tips),
VoicePrompt: fromPgText(r.VoicePrompt),
ImageURL: fromPgText(r.ImageUrl),
QuestionStatus: "PUBLISHED",
QuestionText: r.QuestionText,
QuestionType: r.QuestionType,
DifficultyLevel: fromPgText(r.DifficultyLevel),
Points: r.Points,
Explanation: fromPgText(r.Explanation),
Tips: fromPgText(r.Tips),
VoicePrompt: fromPgText(r.VoicePrompt),
SampleAnswerVoicePrompt: fromPgText(r.SampleAnswerVoicePrompt),
ImageURL: fromPgText(r.ImageUrl),
AudioCorrectAnswerText: fromPgText(r.AudioCorrectAnswerText),
QuestionStatus: "PUBLISHED",
}
}
return result, nil

View File

@ -95,9 +95,13 @@ func (s *Service) VerifyOtp(
return domain.LoginSuccess{}, err
}
// 1. Retrieve OTP
storedOtp, err := s.otpStore.GetOtp(ctx, user.ID)
// 1. Retrieve OTP row matching submitted code.
// 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 errors.Is(err, domain.ErrOtpNotFound) {
return domain.LoginSuccess{}, domain.ErrInvalidOtp
}
return domain.LoginSuccess{}, err
}
@ -111,12 +115,7 @@ func (s *Service) VerifyOtp(
return domain.LoginSuccess{}, domain.ErrOtpExpired
}
// 4. Invalid
if storedOtp.Otp != otpCode {
return domain.LoginSuccess{}, domain.ErrInvalidOtp
}
// 5. Mark OTP as used
// 4. Mark OTP as used
storedOtp.Used = true
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)
}
// 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.
func (s *Service) GetMyProgress(ctx context.Context, userID int64) (domain.LMSUserProgress, error) {
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)
}
func (s *Service) BucketName() string {
return s.client.BucketName()
}
// Delete removes a file from MinIO.
func (s *Service) Delete(ctx context.Context, objectKey string) error {
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.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)
{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"},
@ -280,6 +311,11 @@ var DefaultRolePermissions = map[string][]string{
// Programs
"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",
// Modules
@ -374,6 +410,11 @@ var DefaultRolePermissions = map[string][]string{
"learning_tree.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",
// Questions (read + attempt)
@ -428,6 +469,11 @@ var DefaultRolePermissions = map[string][]string{
"learning_tree.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",
// Questions (full — instructors create content)
@ -482,6 +528,11 @@ var DefaultRolePermissions = map[string][]string{
"learning_tree.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.list", "questions.search", "questions.get",

View File

@ -6,13 +6,16 @@ import (
"context"
"errors"
"time"
"github.com/jackc/pgx/v5"
)
var (
ErrPlanNotFound = errors.New("subscription plan not found")
ErrSubscriptionNotFound = errors.New("subscription not found")
ErrAlreadySubscribed = errors.New("user already has an active subscription")
ErrInvalidPlan = errors.New("invalid subscription plan")
ErrPlanNotFound = errors.New("subscription plan 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")
ErrInvalidPlan = errors.New("invalid subscription plan")
)
type Service struct {
@ -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) {
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) {
@ -105,19 +115,41 @@ func (s *Service) HasActiveSubscription(ctx context.Context, userID int64) (bool
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)
}
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)
}
// RenewSubscription extends an existing subscription
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 {
return nil, ErrSubscriptionNotFound
return nil, err
}
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

View File

@ -12,6 +12,7 @@ import (
issuereporting "Yimaru-Backend/internal/services/issue_reporting"
notificationservice "Yimaru-Backend/internal/services/notification"
"Yimaru-Backend/internal/services/courses"
"Yimaru-Backend/internal/services/examprep"
"Yimaru-Backend/internal/services/lessons"
"Yimaru-Backend/internal/services/lmsprogress"
"Yimaru-Backend/internal/services/modules"
@ -45,6 +46,7 @@ import (
type App struct {
assessmentSvc *assessment.Service
questionsSvc *questions.Service
examPrepSvc *examprep.Service
programSvc *programs.Service
courseSvc *courses.Service
moduleSvc *modules.Service
@ -82,6 +84,7 @@ type App struct {
func NewApp(
assessmentSvc *assessment.Service,
questionsSvc *questions.Service,
examPrepSvc *examprep.Service,
programSvc *programs.Service,
courseSvc *courses.Service,
moduleSvc *modules.Service,
@ -131,6 +134,7 @@ func NewApp(
s := &App{
assessmentSvc: assessmentSvc,
questionsSvc: questionsSvc,
examPrepSvc: examPrepSvc,
programSvc: programSvc,
courseSvc: courseSvc,
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"
"io"
"net/http"
"net/url"
"strconv"
"strings"
"time"
@ -31,6 +32,10 @@ type uploadMediaByURLReq struct {
Description string `json:"description"`
}
type refreshFileURLReq struct {
Reference string `json:"reference"`
}
// resolveFileURL converts a stored file path to a usable URL.
// If the path starts with "minio://", it generates a presigned URL.
// 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.
// @Summary Upload media file
// @Tags files

View File

@ -17,6 +17,7 @@ import (
rbacservice "Yimaru-Backend/internal/services/rbac"
notificationservice "Yimaru-Backend/internal/services/notification"
"Yimaru-Backend/internal/services/courses"
"Yimaru-Backend/internal/services/examprep"
"Yimaru-Backend/internal/services/lessons"
"Yimaru-Backend/internal/services/lmsprogress"
"Yimaru-Backend/internal/services/modules"
@ -44,6 +45,7 @@ import (
type Handler struct {
assessmentSvc *assessment.Service
questionsSvc *questions.Service
examPrepSvc *examprep.Service
programSvc *programs.Service
courseSvc *courses.Service
moduleSvc *modules.Service
@ -77,6 +79,7 @@ type Handler struct {
func New(
assessmentSvc *assessment.Service,
questionsSvc *questions.Service,
examPrepSvc *examprep.Service,
programSvc *programs.Service,
courseSvc *courses.Service,
moduleSvc *modules.Service,
@ -109,6 +112,7 @@ func New(
return &Handler{
assessmentSvc: assessmentSvc,
questionsSvc: questionsSvc,
examPrepSvc: examPrepSvc,
programSvc: programSvc,
courseSvc: courseSvc,
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"`
}
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 {
if !strings.EqualFold(set.SetType, string(domain.QuestionSetTypePractice)) || set.OwnerType == nil {
return false
@ -605,12 +626,19 @@ func (h *Handler) CreateQuestionSet(c *fiber.Ctx) error {
Error: err.Error(),
})
}
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{
Message: "Invalid initial assessment ownership",
Error: "INITIAL_ASSESSMENT question sets must include owner_type and owner_id",
Message: "Invalid initial assessment level",
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 {
setResponses = append(setResponses, questionSetRes{
ID: s.ID,
@ -784,6 +812,8 @@ func (h *Handler) GetQuestionSetsByType(c *fiber.Ctx) error {
return c.JSON(domain.Response{
Message: "Question sets retrieved successfully",
Success: true,
StatusCode: fiber.StatusOK,
Data: listQuestionSetsRes{
QuestionSets: setResponses,
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
if err := c.BodyParser(&req); err != nil {
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 {
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{
Title: title,
Description: req.Description,
@ -910,8 +983,8 @@ func (h *Handler) UpdateQuestionSet(c *fiber.Ctx) error {
Persona: req.Persona,
TimeLimitMinutes: req.TimeLimitMinutes,
PassingScore: req.PassingScore,
ShuffleQuestions: req.ShuffleQuestions,
Status: req.Status,
ShuffleQuestions: shuffleQuestions,
Status: status,
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{
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{
Message: "Failed to complete practice",
Error: err.Error(),

View File

@ -2,8 +2,10 @@ package handlers
import (
"Yimaru-Backend/internal/domain"
subscriptionsvc "Yimaru-Backend/internal/services/subscriptions"
"context"
"encoding/json"
"errors"
"fmt"
"strconv"
@ -512,6 +514,12 @@ func (h *Handler) CheckSubscriptionStatus(c *fiber.Ctx) error {
// @Failure 500 {object} domain.ErrorResponse
// @Router /api/v1/subscriptions/{id}/cancel [post]
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)
if err != nil {
return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{
@ -519,12 +527,25 @@ 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 {
return c.Status(fiber.StatusInternalServerError).JSON(domain.ErrorResponse{
Message: "Failed to cancel subscription",
Error: err.Error(),
})
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{
Message: "Failed to cancel subscription",
Error: err.Error(),
})
}
}
return c.JSON(domain.Response{
@ -544,6 +565,12 @@ func (h *Handler) CancelSubscription(c *fiber.Ctx) error {
// @Failure 500 {object} domain.ErrorResponse
// @Router /api/v1/subscriptions/{id}/auto-renew [put]
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)
if err != nil {
return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{
@ -559,12 +586,25 @@ 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 {
return c.Status(fiber.StatusInternalServerError).JSON(domain.ErrorResponse{
Message: "Failed to update auto-renew setting",
Error: err.Error(),
})
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{
Message: "Failed to update auto-renew setting",
Error: err.Error(),
})
}
}
return c.JSON(domain.Response{

View File

@ -171,6 +171,46 @@ func (a *App) OnlyAdminAndAbove(c *fiber.Ctx) error {
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 {
return func(c *fiber.Ctx) error {
userRole, ok := c.Locals("role").(domain.Role)

View File

@ -15,6 +15,7 @@ func (a *App) initAppRoutes() {
h := handlers.New(
a.assessmentSvc,
a.questionsSvc,
a.examPrepSvc,
a.programSvc,
a.courseSvc,
a.moduleSvc,
@ -76,43 +77,81 @@ func (a *App) initAppRoutes() {
groupV1.Post("/programs", a.authMiddleware, a.RequirePermission("programs.create"), h.CreateProgram)
groupV1.Get("/programs", a.authMiddleware, a.RequirePermission("programs.list"), h.ListPrograms)
groupV1.Put("/programs/reorder", a.authMiddleware, a.RequirePermission("programs.reorder"), h.ReorderPrograms)
groupV1.Get("/lms/progress", a.authMiddleware, a.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.Put("/programs/:id", a.authMiddleware, a.RequirePermission("programs.update"), h.UpdateProgram)
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
groupV1.Post("/programs/:id/courses", a.authMiddleware, a.RequirePermission("courses.create"), h.CreateCourse)
groupV1.Put("/programs/:id/courses/reorder", a.authMiddleware, a.RequirePermission("courses.reorder"), h.ReorderCoursesInProgram)
groupV1.Get("/programs/:id/courses", a.authMiddleware, a.RequirePermission("courses.list_by_program"), h.ListCoursesByProgram)
groupV1.Get("/courses/:id/practices", a.authMiddleware, a.RequirePermission("practices.list"), h.ListPracticesByCourse)
groupV1.Get("/courses/:id", a.authMiddleware, a.RequirePermission("courses.get"), h.GetCourse)
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.RequireActiveSubscription(), a.RequirePermission("practices.list"), h.ListPracticesByCourse)
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.Delete("/courses/:id", a.authMiddleware, a.RequirePermission("courses.delete"), h.DeleteCourse)
groupV1.Post("/courses/:courseId/modules", a.authMiddleware, a.RequirePermission("modules.create"), h.CreateModule)
groupV1.Put("/courses/:courseId/modules/reorder", a.authMiddleware, a.RequirePermission("modules.reorder"), h.ReorderModulesInCourse)
groupV1.Get("/courses/:courseId/modules", a.authMiddleware, a.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
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/:id/practices", a.authMiddleware, a.RequirePermission("practices.list"), h.ListPracticesByModule)
groupV1.Get("/modules/:id", a.authMiddleware, a.RequirePermission("modules.get"), h.GetModule)
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.RequireActiveSubscription(), a.RequirePermission("practices.list"), h.ListPracticesByModule)
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.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.Post("/lessons/:id/complete", a.authMiddleware, a.RequirePermission("lessons.complete"), h.CompleteLesson)
groupV1.Get("/lessons/:id", a.authMiddleware, a.RequirePermission("lessons.get"), h.GetLesson)
groupV1.Get("/lessons/:id/practices", a.authMiddleware, a.RequireActiveSubscription(), a.RequirePermission("practices.list"), h.ListPracticesByLesson)
groupV1.Post("/lessons/:id/complete", a.authMiddleware, a.RequireActiveSubscription(), a.RequirePermission("lessons.complete"), h.CompleteLesson)
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.Delete("/lessons/:id", a.authMiddleware, a.RequirePermission("lessons.delete"), h.DeleteLesson)
groupV1.Post("/practices", a.authMiddleware, a.RequirePermission("practices.create"), h.CreatePractice)
groupV1.Get("/practices/:id", a.authMiddleware, a.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.Delete("/practices/:id", a.authMiddleware, a.RequirePermission("practices.delete"), h.DeletePractice)
// File storage (MinIO)
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/audio", a.authMiddleware, h.UploadAudio)
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.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/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.Put("/questions/:id", a.authMiddleware, a.RequirePermission("questions.update"), h.UpdateQuestion)
groupV1.Delete("/questions/:id", a.authMiddleware, a.RequirePermission("questions.delete"), h.DeleteQuestion)
@ -141,7 +182,7 @@ func (a *App) initAppRoutes() {
// Question Set Items
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("/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.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`"
}
}
]
}
]
}
]
}