subscription management fix + duolingo hierarchy implementation

This commit is contained in:
Yared Yemane 2026-05-04 10:44:18 -07:00
parent eba2b87ed6
commit 10954d88b0
59 changed files with 7708 additions and 34 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

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

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

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

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

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

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

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

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

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

@ -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,38 +77,75 @@ 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)
@ -127,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)
@ -142,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`"
}
}
]
}
]
}
]
}