diff --git a/cmd/main.go b/cmd/main.go index fe99d3f..6e7251b 100644 --- a/cmd/main.go +++ b/cmd/main.go @@ -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, diff --git a/db/migrations/000047_lms_practices.down.sql b/db/migrations/000047_lms_practices.down.sql index 262633c..1e3d43f 100644 --- a/db/migrations/000047_lms_practices.down.sql +++ b/db/migrations/000047_lms_practices.down.sql @@ -1 +1 @@ -DROP TABLE IF EXISTS lms_practices; +DROP TABLE IF EXISTS lms_practices; \ No newline at end of file diff --git a/db/migrations/000051_exam_prep_schema.down.sql b/db/migrations/000051_exam_prep_schema.down.sql new file mode 100644 index 0000000..9162322 --- /dev/null +++ b/db/migrations/000051_exam_prep_schema.down.sql @@ -0,0 +1,2 @@ +DROP TABLE IF EXISTS exam_prep.catalog_courses; +DROP SCHEMA IF EXISTS exam_prep; diff --git a/db/migrations/000051_exam_prep_schema.up.sql b/db/migrations/000051_exam_prep_schema.up.sql new file mode 100644 index 0000000..9c57038 --- /dev/null +++ b/db/migrations/000051_exam_prep_schema.up.sql @@ -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); diff --git a/db/migrations/000052_exam_prep_units.down.sql b/db/migrations/000052_exam_prep_units.down.sql new file mode 100644 index 0000000..ba6d24c --- /dev/null +++ b/db/migrations/000052_exam_prep_units.down.sql @@ -0,0 +1 @@ +DROP TABLE IF EXISTS exam_prep.units; diff --git a/db/migrations/000052_exam_prep_units.up.sql b/db/migrations/000052_exam_prep_units.up.sql new file mode 100644 index 0000000..333cf64 --- /dev/null +++ b/db/migrations/000052_exam_prep_units.up.sql @@ -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); diff --git a/db/migrations/000053_exam_prep_unit_modules.down.sql b/db/migrations/000053_exam_prep_unit_modules.down.sql new file mode 100644 index 0000000..9e8d096 --- /dev/null +++ b/db/migrations/000053_exam_prep_unit_modules.down.sql @@ -0,0 +1 @@ +DROP TABLE IF EXISTS exam_prep.unit_modules; diff --git a/db/migrations/000053_exam_prep_unit_modules.up.sql b/db/migrations/000053_exam_prep_unit_modules.up.sql new file mode 100644 index 0000000..ac3b12d --- /dev/null +++ b/db/migrations/000053_exam_prep_unit_modules.up.sql @@ -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); diff --git a/db/migrations/000054_exam_prep_unit_module_lessons.down.sql b/db/migrations/000054_exam_prep_unit_module_lessons.down.sql new file mode 100644 index 0000000..a2b8b17 --- /dev/null +++ b/db/migrations/000054_exam_prep_unit_module_lessons.down.sql @@ -0,0 +1,2 @@ +DROP INDEX IF EXISTS uq_exam_prep_unit_module_lessons_sort; +DROP TABLE IF EXISTS exam_prep.unit_module_lessons; diff --git a/db/migrations/000054_exam_prep_unit_module_lessons.up.sql b/db/migrations/000054_exam_prep_unit_module_lessons.up.sql new file mode 100644 index 0000000..5786c35 --- /dev/null +++ b/db/migrations/000054_exam_prep_unit_module_lessons.up.sql @@ -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); diff --git a/db/migrations/000055_exam_prep_lesson_practices.down.sql b/db/migrations/000055_exam_prep_lesson_practices.down.sql new file mode 100644 index 0000000..ced0bc3 --- /dev/null +++ b/db/migrations/000055_exam_prep_lesson_practices.down.sql @@ -0,0 +1 @@ +DROP TABLE IF EXISTS exam_prep.lesson_practices; diff --git a/db/migrations/000055_exam_prep_lesson_practices.up.sql b/db/migrations/000055_exam_prep_lesson_practices.up.sql new file mode 100644 index 0000000..7f4016e --- /dev/null +++ b/db/migrations/000055_exam_prep_lesson_practices.up.sql @@ -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); diff --git a/db/query/exam_prep_catalog_courses.sql b/db/query/exam_prep_catalog_courses.sql new file mode 100644 index 0000000..aaab51f --- /dev/null +++ b/db/query/exam_prep_catalog_courses.sql @@ -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; diff --git a/db/query/exam_prep_lesson_practices.sql b/db/query/exam_prep_lesson_practices.sql new file mode 100644 index 0000000..5b58cc0 --- /dev/null +++ b/db/query/exam_prep_lesson_practices.sql @@ -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; diff --git a/db/query/exam_prep_unit_module_lessons.sql b/db/query/exam_prep_unit_module_lessons.sql new file mode 100644 index 0000000..45d6491 --- /dev/null +++ b/db/query/exam_prep_unit_module_lessons.sql @@ -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; diff --git a/db/query/exam_prep_unit_modules.sql b/db/query/exam_prep_unit_modules.sql new file mode 100644 index 0000000..8260581 --- /dev/null +++ b/db/query/exam_prep_unit_modules.sql @@ -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; diff --git a/db/query/exam_prep_units.sql b/db/query/exam_prep_units.sql new file mode 100644 index 0000000..fb77b16 --- /dev/null +++ b/db/query/exam_prep_units.sql @@ -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; diff --git a/docs/docs.go b/docs/docs.go index fcb8cf9..52e3be7 100644 --- a/docs/docs.go +++ b/docs/docs.go @@ -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": [ diff --git a/docs/swagger.json b/docs/swagger.json index 359aba3..2e9d160 100644 --- a/docs/swagger.json +++ b/docs/swagger.json @@ -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": [ diff --git a/docs/swagger.yaml b/docs/swagger.yaml index 77f3648..e28947b 100644 --- a/docs/swagger.yaml +++ b/docs/swagger.yaml @@ -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 diff --git a/gen/db/exam_prep_catalog_courses.sql.go b/gen/db/exam_prep_catalog_courses.sql.go new file mode 100644 index 0000000..44a9e92 --- /dev/null +++ b/gen/db/exam_prep_catalog_courses.sql.go @@ -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 +} diff --git a/gen/db/exam_prep_lesson_practices.sql.go b/gen/db/exam_prep_lesson_practices.sql.go new file mode 100644 index 0000000..596cc85 --- /dev/null +++ b/gen/db/exam_prep_lesson_practices.sql.go @@ -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 +} diff --git a/gen/db/exam_prep_unit_module_lessons.sql.go b/gen/db/exam_prep_unit_module_lessons.sql.go new file mode 100644 index 0000000..62f346a --- /dev/null +++ b/gen/db/exam_prep_unit_module_lessons.sql.go @@ -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 +} diff --git a/gen/db/exam_prep_unit_modules.sql.go b/gen/db/exam_prep_unit_modules.sql.go new file mode 100644 index 0000000..b78256f --- /dev/null +++ b/gen/db/exam_prep_unit_modules.sql.go @@ -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 +} diff --git a/gen/db/exam_prep_units.sql.go b/gen/db/exam_prep_units.sql.go new file mode 100644 index 0000000..5fb8a99 --- /dev/null +++ b/gen/db/exam_prep_units.sql.go @@ -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 +} diff --git a/gen/db/models.go b/gen/db/models.go index 1a2da27..e77c390 100644 --- a/gen/db/models.go +++ b/gen/db/models.go @@ -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"` diff --git a/internal/domain/exam_prep_catalog_course.go b/internal/domain/exam_prep_catalog_course.go new file mode 100644 index 0000000..2095f78 --- /dev/null +++ b/internal/domain/exam_prep_catalog_course.go @@ -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"` +} diff --git a/internal/domain/exam_prep_lesson.go b/internal/domain/exam_prep_lesson.go new file mode 100644 index 0000000..069d767 --- /dev/null +++ b/internal/domain/exam_prep_lesson.go @@ -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"` +} diff --git a/internal/domain/exam_prep_module.go b/internal/domain/exam_prep_module.go new file mode 100644 index 0000000..8923e78 --- /dev/null +++ b/internal/domain/exam_prep_module.go @@ -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"` +} diff --git a/internal/domain/exam_prep_practice.go b/internal/domain/exam_prep_practice.go new file mode 100644 index 0000000..483b84c --- /dev/null +++ b/internal/domain/exam_prep_practice.go @@ -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"` +} diff --git a/internal/domain/exam_prep_unit.go b/internal/domain/exam_prep_unit.go new file mode 100644 index 0000000..c196457 --- /dev/null +++ b/internal/domain/exam_prep_unit.go @@ -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"` +} diff --git a/internal/domain/question_type_builder.go b/internal/domain/question_type_builder.go new file mode 100644 index 0000000..84d8cad --- /dev/null +++ b/internal/domain/question_type_builder.go @@ -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 +} diff --git a/internal/domain/question_type_builder_test.go b/internal/domain/question_type_builder_test.go new file mode 100644 index 0000000..770bd29 --- /dev/null +++ b/internal/domain/question_type_builder_test.go @@ -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) + } +} diff --git a/internal/ports/exam_prep_catalog_course.go b/internal/ports/exam_prep_catalog_course.go new file mode 100644 index 0000000..bc400d0 --- /dev/null +++ b/internal/ports/exam_prep_catalog_course.go @@ -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 +} diff --git a/internal/ports/exam_prep_lesson.go b/internal/ports/exam_prep_lesson.go new file mode 100644 index 0000000..b6d432b --- /dev/null +++ b/internal/ports/exam_prep_lesson.go @@ -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 +} diff --git a/internal/ports/exam_prep_module.go b/internal/ports/exam_prep_module.go new file mode 100644 index 0000000..f73fe22 --- /dev/null +++ b/internal/ports/exam_prep_module.go @@ -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 +} diff --git a/internal/ports/exam_prep_practice.go b/internal/ports/exam_prep_practice.go new file mode 100644 index 0000000..bcfd106 --- /dev/null +++ b/internal/ports/exam_prep_practice.go @@ -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 +} diff --git a/internal/ports/exam_prep_unit.go b/internal/ports/exam_prep_unit.go new file mode 100644 index 0000000..67d3125 --- /dev/null +++ b/internal/ports/exam_prep_unit.go @@ -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 +} diff --git a/internal/repository/exam_prep_catalog_courses.go b/internal/repository/exam_prep_catalog_courses.go new file mode 100644 index 0000000..98e9383 --- /dev/null +++ b/internal/repository/exam_prep_catalog_courses.go @@ -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) +} diff --git a/internal/repository/exam_prep_lesson_practices.go b/internal/repository/exam_prep_lesson_practices.go new file mode 100644 index 0000000..cd7b644 --- /dev/null +++ b/internal/repository/exam_prep_lesson_practices.go @@ -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) +} diff --git a/internal/repository/exam_prep_reorder.go b/internal/repository/exam_prep_reorder.go new file mode 100644 index 0000000..472226b --- /dev/null +++ b/internal/repository/exam_prep_reorder.go @@ -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) +} diff --git a/internal/repository/exam_prep_unit_module_lessons.go b/internal/repository/exam_prep_unit_module_lessons.go new file mode 100644 index 0000000..3f5088f --- /dev/null +++ b/internal/repository/exam_prep_unit_module_lessons.go @@ -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) +} diff --git a/internal/repository/exam_prep_unit_modules.go b/internal/repository/exam_prep_unit_modules.go new file mode 100644 index 0000000..955c164 --- /dev/null +++ b/internal/repository/exam_prep_unit_modules.go @@ -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) +} diff --git a/internal/repository/exam_prep_units.go b/internal/repository/exam_prep_units.go new file mode 100644 index 0000000..462792c --- /dev/null +++ b/internal/repository/exam_prep_units.go @@ -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) +} diff --git a/internal/services/examprep/service.go b/internal/services/examprep/service.go new file mode 100644 index 0000000..4994802 --- /dev/null +++ b/internal/services/examprep/service.go @@ -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) +} diff --git a/internal/services/rbac/seeds.go b/internal/services/rbac/seeds.go index 71b3e46..caed007 100644 --- a/internal/services/rbac/seeds.go +++ b/internal/services/rbac/seeds.go @@ -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", diff --git a/internal/services/subscriptions/service.go b/internal/services/subscriptions/service.go index c936e57..4112828 100644 --- a/internal/services/subscriptions/service.go +++ b/internal/services/subscriptions/service.go @@ -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 diff --git a/internal/web_server/app.go b/internal/web_server/app.go index 60e07f1..74f3d70 100644 --- a/internal/web_server/app.go +++ b/internal/web_server/app.go @@ -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, diff --git a/internal/web_server/handlers/exam_prep_catalog_course_handler.go b/internal/web_server/handlers/exam_prep_catalog_course_handler.go new file mode 100644 index 0000000..01a8d5e --- /dev/null +++ b/internal/web_server/handlers/exam_prep_catalog_course_handler.go @@ -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, + }) +} diff --git a/internal/web_server/handlers/exam_prep_lesson_handler.go b/internal/web_server/handlers/exam_prep_lesson_handler.go new file mode 100644 index 0000000..4ff669b --- /dev/null +++ b/internal/web_server/handlers/exam_prep_lesson_handler.go @@ -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, + }) +} diff --git a/internal/web_server/handlers/exam_prep_module_handler.go b/internal/web_server/handlers/exam_prep_module_handler.go new file mode 100644 index 0000000..3180eae --- /dev/null +++ b/internal/web_server/handlers/exam_prep_module_handler.go @@ -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, + }) +} diff --git a/internal/web_server/handlers/exam_prep_practice_handler.go b/internal/web_server/handlers/exam_prep_practice_handler.go new file mode 100644 index 0000000..518c3e8 --- /dev/null +++ b/internal/web_server/handlers/exam_prep_practice_handler.go @@ -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, + }) +} diff --git a/internal/web_server/handlers/exam_prep_unit_handler.go b/internal/web_server/handlers/exam_prep_unit_handler.go new file mode 100644 index 0000000..81948f6 --- /dev/null +++ b/internal/web_server/handlers/exam_prep_unit_handler.go @@ -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, + }) +} diff --git a/internal/web_server/handlers/handlers.go b/internal/web_server/handlers/handlers.go index 4af6a50..02f4615 100644 --- a/internal/web_server/handlers/handlers.go +++ b/internal/web_server/handlers/handlers.go @@ -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, diff --git a/internal/web_server/handlers/question_type_builder.go b/internal/web_server/handlers/question_type_builder.go new file mode 100644 index 0000000..2856970 --- /dev/null +++ b/internal/web_server/handlers/question_type_builder.go @@ -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}, + }) +} diff --git a/internal/web_server/handlers/subscriptions.go b/internal/web_server/handlers/subscriptions.go index fff5ddd..e64c47f 100644 --- a/internal/web_server/handlers/subscriptions.go +++ b/internal/web_server/handlers/subscriptions.go @@ -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{ diff --git a/internal/web_server/middleware.go b/internal/web_server/middleware.go index 0e9dc59..2140d1a 100644 --- a/internal/web_server/middleware.go +++ b/internal/web_server/middleware.go @@ -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) diff --git a/internal/web_server/routes.go b/internal/web_server/routes.go index 6600c1d..8b608f0 100644 --- a/internal/web_server/routes.go +++ b/internal/web_server/routes.go @@ -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) diff --git a/postman/Duolingo-ExamPrep.postman_collection.json b/postman/Duolingo-ExamPrep.postman_collection.json new file mode 100644 index 0000000..13cad41 --- /dev/null +++ b/postman/Duolingo-ExamPrep.postman_collection.json @@ -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`" + } + } + ] + } + ] + } + ] +}