Compare commits
16 Commits
5b53929d92
...
10954d88b0
| Author | SHA1 | Date | |
|---|---|---|---|
| 10954d88b0 | |||
| eba2b87ed6 | |||
| 60290e5c34 | |||
| 8430b82687 | |||
| cdb0fa1bb3 | |||
| 9027b65011 | |||
| 8c116f4a0b | |||
| 87bf2ed609 | |||
| 9cfd6c524e | |||
| 0d02eb1a24 | |||
| 78f231f222 | |||
| 526426d9f9 | |||
| 5857fce9a0 | |||
| 7e26f15bed | |||
| bc68326a66 | |||
| 33d34f0dd2 |
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -1 +1 @@
|
|||
DROP TABLE IF EXISTS lms_practices;
|
||||
DROP TABLE IF EXISTS lms_practices;
|
||||
|
|
@ -1,5 +1,5 @@
|
|||
-- Default CEFR-style course names per program (custom courses can still be created via the API with any name).
|
||||
-- Matches hierarchy note on courses: CEFR labels A1..C2, plus ad-hoc names allowed.
|
||||
-- Default CEFR-style courses per seeded program: Beginner→A1,A2; Intermediate→B1,B2; Advanced→C1,C2.
|
||||
-- Custom courses can still be created via the API with any name.
|
||||
INSERT INTO courses (program_id, name, description, thumbnail)
|
||||
SELECT
|
||||
p.id,
|
||||
|
|
@ -7,12 +7,13 @@ SELECT
|
|||
'Default CEFR level course (system seed).',
|
||||
NULL
|
||||
FROM programs AS p
|
||||
CROSS JOIN (
|
||||
INNER JOIN (
|
||||
VALUES
|
||||
('A1'),
|
||||
('A2'),
|
||||
('B1'),
|
||||
('B2'),
|
||||
('C1'),
|
||||
('C2')
|
||||
) AS v (name);
|
||||
('Beginner', 'A1'),
|
||||
('Beginner', 'A2'),
|
||||
('Intermediate', 'B1'),
|
||||
('Intermediate', 'B2'),
|
||||
('Advanced', 'C1'),
|
||||
('Advanced', 'C2')
|
||||
) AS v (program_name, name)
|
||||
ON p.name = v.program_name;
|
||||
|
|
|
|||
|
|
@ -0,0 +1 @@
|
|||
-- Data cleanup is not reversed; restoring the old cross-product seed would be ambiguous.
|
||||
45
db/migrations/000050_default_courses_per_program.up.sql
Normal file
45
db/migrations/000050_default_courses_per_program.up.sql
Normal file
|
|
@ -0,0 +1,45 @@
|
|||
-- Align default seeded courses with program: Beginner→A1,A2; Intermediate→B1,B2; Advanced→C1,C2.
|
||||
-- Only touches rows with the system seed description; custom courses are unchanged.
|
||||
-- Removing a course cascades to modules, lessons, and related LMS progress (see FKs on those tables).
|
||||
|
||||
DELETE FROM courses AS c
|
||||
USING programs AS p
|
||||
WHERE c.program_id = p.id
|
||||
AND c.description = 'Default CEFR level course (system seed).'
|
||||
AND (
|
||||
(
|
||||
p.name = 'Beginner'
|
||||
AND c.name IN ('B1', 'B2', 'C1', 'C2')
|
||||
)
|
||||
OR (
|
||||
p.name = 'Intermediate'
|
||||
AND c.name IN ('A1', 'A2', 'C1', 'C2')
|
||||
)
|
||||
OR (
|
||||
p.name = 'Advanced'
|
||||
AND c.name IN ('A1', 'A2', 'B1', 'B2')
|
||||
)
|
||||
);
|
||||
|
||||
INSERT INTO courses (program_id, name, description, thumbnail)
|
||||
SELECT
|
||||
p.id,
|
||||
v.name,
|
||||
'Default CEFR level course (system seed).',
|
||||
NULL
|
||||
FROM programs AS p
|
||||
INNER JOIN (
|
||||
VALUES
|
||||
('Beginner', 'A1'),
|
||||
('Beginner', 'A2'),
|
||||
('Intermediate', 'B1'),
|
||||
('Intermediate', 'B2'),
|
||||
('Advanced', 'C1'),
|
||||
('Advanced', 'C2')
|
||||
) AS v (program_name, name)
|
||||
ON p.name = v.program_name
|
||||
WHERE
|
||||
NOT EXISTS (
|
||||
SELECT 1 FROM courses AS e
|
||||
WHERE e.program_id = p.id AND e.name = v.name
|
||||
);
|
||||
2
db/migrations/000051_exam_prep_schema.down.sql
Normal file
2
db/migrations/000051_exam_prep_schema.down.sql
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
DROP TABLE IF EXISTS exam_prep.catalog_courses;
|
||||
DROP SCHEMA IF EXISTS exam_prep;
|
||||
15
db/migrations/000051_exam_prep_schema.up.sql
Normal file
15
db/migrations/000051_exam_prep_schema.up.sql
Normal file
|
|
@ -0,0 +1,15 @@
|
|||
-- Standalone exam-prep content hierarchy (DET, IELTS, TOEFL, etc.) — isolated from LMS Learn English tables.
|
||||
CREATE SCHEMA IF NOT EXISTS exam_prep;
|
||||
|
||||
-- Top-level catalog "course" (e.g. Duolingo English Test, IELTS); admin-configurable labels.
|
||||
CREATE TABLE exam_prep.catalog_courses (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
name VARCHAR(255) NOT NULL,
|
||||
description TEXT,
|
||||
thumbnail TEXT,
|
||||
sort_order INTEGER NOT NULL DEFAULT 0,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TIMESTAMPTZ
|
||||
);
|
||||
|
||||
CREATE INDEX idx_exam_prep_catalog_courses_sort ON exam_prep.catalog_courses (sort_order, id);
|
||||
1
db/migrations/000052_exam_prep_units.down.sql
Normal file
1
db/migrations/000052_exam_prep_units.down.sql
Normal file
|
|
@ -0,0 +1 @@
|
|||
DROP TABLE IF EXISTS exam_prep.units;
|
||||
14
db/migrations/000052_exam_prep_units.up.sql
Normal file
14
db/migrations/000052_exam_prep_units.up.sql
Normal file
|
|
@ -0,0 +1,14 @@
|
|||
-- Units under an exam-prep catalog course (e.g. "Introduction to the DET English Test").
|
||||
CREATE TABLE exam_prep.units (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
catalog_course_id BIGINT NOT NULL REFERENCES exam_prep.catalog_courses (id) ON DELETE CASCADE,
|
||||
name VARCHAR(255) NOT NULL,
|
||||
description TEXT,
|
||||
thumbnail TEXT,
|
||||
sort_order INTEGER NOT NULL DEFAULT 0,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TIMESTAMPTZ
|
||||
);
|
||||
|
||||
CREATE INDEX idx_exam_prep_units_catalog_course_id ON exam_prep.units (catalog_course_id);
|
||||
CREATE INDEX idx_exam_prep_units_catalog_sort ON exam_prep.units (catalog_course_id, sort_order, id);
|
||||
1
db/migrations/000053_exam_prep_unit_modules.down.sql
Normal file
1
db/migrations/000053_exam_prep_unit_modules.down.sql
Normal file
|
|
@ -0,0 +1 @@
|
|||
DROP TABLE IF EXISTS exam_prep.unit_modules;
|
||||
15
db/migrations/000053_exam_prep_unit_modules.up.sql
Normal file
15
db/migrations/000053_exam_prep_unit_modules.up.sql
Normal file
|
|
@ -0,0 +1,15 @@
|
|||
-- Modules under an exam-prep unit (table name unit_modules avoids sqlc/LMS collision with public.modules).
|
||||
CREATE TABLE exam_prep.unit_modules (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
unit_id BIGINT NOT NULL REFERENCES exam_prep.units (id) ON DELETE CASCADE,
|
||||
name VARCHAR(255) NOT NULL,
|
||||
description TEXT,
|
||||
thumbnail TEXT,
|
||||
icon TEXT,
|
||||
sort_order INTEGER NOT NULL DEFAULT 0,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TIMESTAMPTZ
|
||||
);
|
||||
|
||||
CREATE INDEX idx_exam_prep_unit_modules_unit_id ON exam_prep.unit_modules (unit_id);
|
||||
CREATE INDEX idx_exam_prep_unit_modules_unit_sort ON exam_prep.unit_modules (unit_id, sort_order, id);
|
||||
|
|
@ -0,0 +1,2 @@
|
|||
DROP INDEX IF EXISTS uq_exam_prep_unit_module_lessons_sort;
|
||||
DROP TABLE IF EXISTS exam_prep.unit_module_lessons;
|
||||
17
db/migrations/000054_exam_prep_unit_module_lessons.up.sql
Normal file
17
db/migrations/000054_exam_prep_unit_module_lessons.up.sql
Normal file
|
|
@ -0,0 +1,17 @@
|
|||
-- Lessons under an exam-prep unit module (mirrors LMS lessons under modules; avoids collision with public.lessons / sqlc).
|
||||
CREATE TABLE exam_prep.unit_module_lessons (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
unit_module_id BIGINT NOT NULL REFERENCES exam_prep.unit_modules (id) ON DELETE CASCADE,
|
||||
title VARCHAR(255) NOT NULL,
|
||||
video_url TEXT,
|
||||
thumbnail TEXT,
|
||||
description TEXT,
|
||||
sort_order INTEGER NOT NULL DEFAULT 0,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TIMESTAMPTZ
|
||||
);
|
||||
|
||||
CREATE UNIQUE INDEX uq_exam_prep_unit_module_lessons_sort ON exam_prep.unit_module_lessons (unit_module_id, sort_order);
|
||||
|
||||
CREATE INDEX idx_exam_prep_unit_module_lessons_module_id ON exam_prep.unit_module_lessons (unit_module_id);
|
||||
CREATE INDEX idx_exam_prep_unit_module_lessons_module_created ON exam_prep.unit_module_lessons (unit_module_id, created_at DESC);
|
||||
1
db/migrations/000055_exam_prep_lesson_practices.down.sql
Normal file
1
db/migrations/000055_exam_prep_lesson_practices.down.sql
Normal file
|
|
@ -0,0 +1 @@
|
|||
DROP TABLE IF EXISTS exam_prep.lesson_practices;
|
||||
17
db/migrations/000055_exam_prep_lesson_practices.up.sql
Normal file
17
db/migrations/000055_exam_prep_lesson_practices.up.sql
Normal file
|
|
@ -0,0 +1,17 @@
|
|||
-- Exam-prep practices: one row per practice, attached to an exam-prep lesson only; reuses public.question_sets / questions.
|
||||
CREATE TABLE exam_prep.lesson_practices (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
unit_module_lesson_id BIGINT NOT NULL REFERENCES exam_prep.unit_module_lessons (id) ON DELETE CASCADE,
|
||||
title VARCHAR(255) NOT NULL,
|
||||
story_description TEXT,
|
||||
story_image TEXT,
|
||||
persona_id BIGINT REFERENCES users (id) ON DELETE SET NULL,
|
||||
question_set_id BIGINT NOT NULL REFERENCES question_sets (id) ON DELETE RESTRICT,
|
||||
quick_tips TEXT,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TIMESTAMPTZ
|
||||
);
|
||||
|
||||
CREATE INDEX idx_exam_prep_lesson_practices_lesson_id ON exam_prep.lesson_practices (unit_module_lesson_id);
|
||||
CREATE INDEX idx_exam_prep_lesson_practices_question_set_id ON exam_prep.lesson_practices (question_set_id);
|
||||
CREATE INDEX idx_exam_prep_lesson_practices_lesson_created ON exam_prep.lesson_practices (unit_module_lesson_id, created_at DESC);
|
||||
53
db/query/exam_prep_catalog_courses.sql
Normal file
53
db/query/exam_prep_catalog_courses.sql
Normal file
|
|
@ -0,0 +1,53 @@
|
|||
-- name: ExamPrepCreateCatalogCourse :one
|
||||
INSERT INTO exam_prep.catalog_courses (name, description, thumbnail, sort_order)
|
||||
SELECT
|
||||
$1,
|
||||
$2,
|
||||
$3,
|
||||
coalesce((
|
||||
SELECT
|
||||
max(c.sort_order)
|
||||
FROM exam_prep.catalog_courses AS c), 0) + 1
|
||||
RETURNING
|
||||
*;
|
||||
|
||||
-- name: ExamPrepGetCatalogCourseByID :one
|
||||
SELECT *
|
||||
FROM exam_prep.catalog_courses
|
||||
WHERE id = $1;
|
||||
|
||||
-- name: ExamPrepListCatalogCourses :many
|
||||
SELECT
|
||||
COUNT(*) OVER () AS total_count,
|
||||
c.id,
|
||||
c.name,
|
||||
c.description,
|
||||
c.thumbnail,
|
||||
c.sort_order,
|
||||
c.created_at,
|
||||
c.updated_at
|
||||
FROM exam_prep.catalog_courses c
|
||||
ORDER BY c.sort_order ASC, c.id ASC
|
||||
LIMIT $1 OFFSET $2;
|
||||
|
||||
-- name: ExamPrepListAllCatalogCourseIDs :many
|
||||
SELECT
|
||||
id
|
||||
FROM exam_prep.catalog_courses
|
||||
ORDER BY id;
|
||||
|
||||
-- name: ExamPrepUpdateCatalogCourse :one
|
||||
UPDATE exam_prep.catalog_courses
|
||||
SET
|
||||
name = coalesce(sqlc.narg('name')::varchar, name),
|
||||
description = coalesce(sqlc.narg('description')::text, description),
|
||||
thumbnail = coalesce(sqlc.narg('thumbnail')::text, thumbnail),
|
||||
sort_order = coalesce(sqlc.narg('sort_order')::int, sort_order),
|
||||
updated_at = CURRENT_TIMESTAMP
|
||||
WHERE id = sqlc.arg('id')
|
||||
RETURNING
|
||||
*;
|
||||
|
||||
-- name: ExamPrepDeleteCatalogCourse :exec
|
||||
DELETE FROM exam_prep.catalog_courses
|
||||
WHERE id = $1;
|
||||
52
db/query/exam_prep_lesson_practices.sql
Normal file
52
db/query/exam_prep_lesson_practices.sql
Normal file
|
|
@ -0,0 +1,52 @@
|
|||
-- name: ExamPrepCreateLessonPractice :one
|
||||
INSERT INTO exam_prep.lesson_practices (
|
||||
unit_module_lesson_id,
|
||||
title,
|
||||
story_description,
|
||||
story_image,
|
||||
persona_id,
|
||||
question_set_id,
|
||||
quick_tips
|
||||
) VALUES ($1, $2, $3, $4, $5, $6, $7)
|
||||
RETURNING *;
|
||||
|
||||
-- name: ExamPrepGetLessonPracticeByID :one
|
||||
SELECT *
|
||||
FROM exam_prep.lesson_practices
|
||||
WHERE id = $1;
|
||||
|
||||
-- name: ExamPrepListLessonPracticesByLessonID :many
|
||||
SELECT
|
||||
COUNT(*) OVER () AS total_count,
|
||||
p.id,
|
||||
p.unit_module_lesson_id,
|
||||
p.title,
|
||||
p.story_description,
|
||||
p.story_image,
|
||||
p.persona_id,
|
||||
p.question_set_id,
|
||||
p.quick_tips,
|
||||
p.created_at,
|
||||
p.updated_at
|
||||
FROM exam_prep.lesson_practices p
|
||||
WHERE p.unit_module_lesson_id = $1
|
||||
ORDER BY p.created_at DESC
|
||||
LIMIT $2
|
||||
OFFSET $3;
|
||||
|
||||
-- name: ExamPrepUpdateLessonPractice :one
|
||||
UPDATE exam_prep.lesson_practices
|
||||
SET
|
||||
title = coalesce(sqlc.narg('title')::varchar, title),
|
||||
story_description = coalesce(sqlc.narg('story_description')::text, story_description),
|
||||
story_image = coalesce(sqlc.narg('story_image')::text, story_image),
|
||||
persona_id = coalesce(sqlc.narg('persona_id')::bigint, persona_id),
|
||||
question_set_id = coalesce(sqlc.narg('question_set_id')::bigint, question_set_id),
|
||||
quick_tips = coalesce(sqlc.narg('quick_tips')::text, quick_tips),
|
||||
updated_at = CURRENT_TIMESTAMP
|
||||
WHERE id = sqlc.arg('id')
|
||||
RETURNING *;
|
||||
|
||||
-- name: ExamPrepDeleteLessonPractice :exec
|
||||
DELETE FROM exam_prep.lesson_practices
|
||||
WHERE id = $1;
|
||||
68
db/query/exam_prep_unit_module_lessons.sql
Normal file
68
db/query/exam_prep_unit_module_lessons.sql
Normal file
|
|
@ -0,0 +1,68 @@
|
|||
-- name: ExamPrepCreateUnitModuleLesson :one
|
||||
INSERT INTO exam_prep.unit_module_lessons (unit_module_id, title, video_url, thumbnail, description, sort_order)
|
||||
SELECT
|
||||
$1,
|
||||
$2,
|
||||
$3,
|
||||
$4,
|
||||
$5,
|
||||
coalesce((
|
||||
SELECT
|
||||
max(l.sort_order)
|
||||
FROM exam_prep.unit_module_lessons l
|
||||
WHERE
|
||||
l.unit_module_id = $1), 0) + 1
|
||||
RETURNING
|
||||
*;
|
||||
|
||||
-- name: ExamPrepGetUnitModuleLessonByID :one
|
||||
SELECT *
|
||||
FROM exam_prep.unit_module_lessons
|
||||
WHERE id = $1;
|
||||
|
||||
-- name: ExamPrepListUnitModuleLessonIDsByUnitModule :many
|
||||
SELECT
|
||||
l.id
|
||||
FROM exam_prep.unit_module_lessons l
|
||||
WHERE
|
||||
l.unit_module_id = $1
|
||||
ORDER BY
|
||||
l.id;
|
||||
|
||||
-- name: ExamPrepListUnitModuleLessonsByUnitModuleID :many
|
||||
SELECT
|
||||
COUNT(*) OVER () AS total_count,
|
||||
l.id,
|
||||
l.unit_module_id,
|
||||
l.title,
|
||||
l.video_url,
|
||||
l.thumbnail,
|
||||
l.description,
|
||||
l.sort_order,
|
||||
l.created_at,
|
||||
l.updated_at
|
||||
FROM exam_prep.unit_module_lessons l
|
||||
WHERE
|
||||
l.unit_module_id = $1
|
||||
ORDER BY
|
||||
l.sort_order ASC,
|
||||
l.id ASC
|
||||
LIMIT $2
|
||||
OFFSET $3;
|
||||
|
||||
-- name: ExamPrepUpdateUnitModuleLesson :one
|
||||
UPDATE exam_prep.unit_module_lessons
|
||||
SET
|
||||
title = coalesce(sqlc.narg('title')::varchar, title),
|
||||
video_url = coalesce(sqlc.narg('video_url')::text, video_url),
|
||||
thumbnail = coalesce(sqlc.narg('thumbnail')::text, thumbnail),
|
||||
description = coalesce(sqlc.narg('description')::text, description),
|
||||
sort_order = coalesce(sqlc.narg('sort_order')::int, sort_order),
|
||||
updated_at = CURRENT_TIMESTAMP
|
||||
WHERE id = sqlc.arg('id')
|
||||
RETURNING
|
||||
*;
|
||||
|
||||
-- name: ExamPrepDeleteUnitModuleLesson :exec
|
||||
DELETE FROM exam_prep.unit_module_lessons
|
||||
WHERE id = $1;
|
||||
68
db/query/exam_prep_unit_modules.sql
Normal file
68
db/query/exam_prep_unit_modules.sql
Normal file
|
|
@ -0,0 +1,68 @@
|
|||
-- name: ExamPrepCreateUnitModule :one
|
||||
INSERT INTO exam_prep.unit_modules (unit_id, name, description, thumbnail, icon, sort_order)
|
||||
SELECT
|
||||
$1,
|
||||
$2,
|
||||
$3,
|
||||
$4,
|
||||
$5,
|
||||
coalesce((
|
||||
SELECT
|
||||
max(m.sort_order)
|
||||
FROM exam_prep.unit_modules m
|
||||
WHERE
|
||||
m.unit_id = $1), 0) + 1
|
||||
RETURNING
|
||||
*;
|
||||
|
||||
-- name: ExamPrepGetUnitModuleByID :one
|
||||
SELECT *
|
||||
FROM exam_prep.unit_modules
|
||||
WHERE id = $1;
|
||||
|
||||
-- name: ExamPrepListUnitModuleIDsByUnit :many
|
||||
SELECT
|
||||
m.id
|
||||
FROM exam_prep.unit_modules m
|
||||
WHERE
|
||||
m.unit_id = $1
|
||||
ORDER BY
|
||||
m.id;
|
||||
|
||||
-- name: ExamPrepListUnitModulesByUnit :many
|
||||
SELECT
|
||||
COUNT(*) OVER () AS total_count,
|
||||
m.id,
|
||||
m.unit_id,
|
||||
m.name,
|
||||
m.description,
|
||||
m.thumbnail,
|
||||
m.icon,
|
||||
m.sort_order,
|
||||
m.created_at,
|
||||
m.updated_at
|
||||
FROM exam_prep.unit_modules m
|
||||
WHERE
|
||||
m.unit_id = $1
|
||||
ORDER BY
|
||||
m.sort_order ASC,
|
||||
m.id ASC
|
||||
LIMIT $2
|
||||
OFFSET $3;
|
||||
|
||||
-- name: ExamPrepUpdateUnitModule :one
|
||||
UPDATE exam_prep.unit_modules
|
||||
SET
|
||||
name = coalesce(sqlc.narg('name')::varchar, name),
|
||||
description = coalesce(sqlc.narg('description')::text, description),
|
||||
thumbnail = coalesce(sqlc.narg('thumbnail')::text, thumbnail),
|
||||
icon = coalesce(sqlc.narg('icon')::text, icon),
|
||||
sort_order = coalesce(sqlc.narg('sort_order')::int, sort_order),
|
||||
updated_at = CURRENT_TIMESTAMP
|
||||
WHERE id = sqlc.arg('id')
|
||||
RETURNING
|
||||
*;
|
||||
|
||||
-- name: ExamPrepDeleteUnitModule :exec
|
||||
DELETE FROM exam_prep.unit_modules
|
||||
WHERE id = $1;
|
||||
65
db/query/exam_prep_units.sql
Normal file
65
db/query/exam_prep_units.sql
Normal file
|
|
@ -0,0 +1,65 @@
|
|||
-- name: ExamPrepCreateUnit :one
|
||||
INSERT INTO exam_prep.units (catalog_course_id, name, description, thumbnail, sort_order)
|
||||
SELECT
|
||||
$1,
|
||||
$2,
|
||||
$3,
|
||||
$4,
|
||||
coalesce((
|
||||
SELECT
|
||||
max(u.sort_order)
|
||||
FROM exam_prep.units u
|
||||
WHERE
|
||||
u.catalog_course_id = $1), 0) + 1
|
||||
RETURNING
|
||||
*;
|
||||
|
||||
-- name: ExamPrepGetUnitByID :one
|
||||
SELECT *
|
||||
FROM exam_prep.units
|
||||
WHERE id = $1;
|
||||
|
||||
-- name: ExamPrepListUnitIDsByCatalogCourse :many
|
||||
SELECT
|
||||
u.id
|
||||
FROM exam_prep.units u
|
||||
WHERE
|
||||
u.catalog_course_id = $1
|
||||
ORDER BY
|
||||
u.id;
|
||||
|
||||
-- name: ExamPrepListUnitsByCatalogCourse :many
|
||||
SELECT
|
||||
COUNT(*) OVER () AS total_count,
|
||||
u.id,
|
||||
u.catalog_course_id,
|
||||
u.name,
|
||||
u.description,
|
||||
u.thumbnail,
|
||||
u.sort_order,
|
||||
u.created_at,
|
||||
u.updated_at
|
||||
FROM exam_prep.units u
|
||||
WHERE
|
||||
u.catalog_course_id = $1
|
||||
ORDER BY
|
||||
u.sort_order ASC,
|
||||
u.id ASC
|
||||
LIMIT $2
|
||||
OFFSET $3;
|
||||
|
||||
-- name: ExamPrepUpdateUnit :one
|
||||
UPDATE exam_prep.units
|
||||
SET
|
||||
name = coalesce(sqlc.narg('name')::varchar, name),
|
||||
description = coalesce(sqlc.narg('description')::text, description),
|
||||
thumbnail = coalesce(sqlc.narg('thumbnail')::text, thumbnail),
|
||||
sort_order = coalesce(sqlc.narg('sort_order')::int, sort_order),
|
||||
updated_at = CURRENT_TIMESTAMP
|
||||
WHERE id = sqlc.arg('id')
|
||||
RETURNING
|
||||
*;
|
||||
|
||||
-- name: ExamPrepDeleteUnit :exec
|
||||
DELETE FROM exam_prep.units
|
||||
WHERE id = $1;
|
||||
|
|
@ -39,7 +39,33 @@ SELECT
|
|||
c.thumbnail,
|
||||
c.sort_order,
|
||||
c.created_at,
|
||||
c.updated_at
|
||||
c.updated_at,
|
||||
(
|
||||
SELECT
|
||||
COUNT(*)::bigint
|
||||
FROM
|
||||
modules m
|
||||
WHERE
|
||||
m.course_id = c.id) AS module_count,
|
||||
(
|
||||
SELECT
|
||||
COUNT(*)::bigint
|
||||
FROM
|
||||
lessons l
|
||||
INNER JOIN modules m ON l.module_id = m.id
|
||||
WHERE
|
||||
m.course_id = c.id) AS lesson_count,
|
||||
-- Practices whose parent is the course only (lms_practices.course_id). Excludes
|
||||
-- practices linked via module_id or lesson_id, even for modules/lessons in this course.
|
||||
(
|
||||
SELECT
|
||||
COUNT(*)::bigint
|
||||
FROM
|
||||
lms_practices p
|
||||
WHERE
|
||||
p.course_id = c.id
|
||||
AND p.module_id IS NULL
|
||||
AND p.lesson_id IS NULL) AS practice_count
|
||||
FROM
|
||||
courses c
|
||||
WHERE
|
||||
|
|
|
|||
|
|
@ -246,3 +246,95 @@ FROM
|
|||
WHERE
|
||||
c.program_id = $1
|
||||
AND ulp.user_id = $2;
|
||||
|
||||
-- Published practices in a module (module-level and lesson-level practices should carry module_id).
|
||||
-- name: CountPublishedPracticesInModule :one
|
||||
SELECT
|
||||
count(*)::int AS n
|
||||
FROM
|
||||
lms_practices lp
|
||||
INNER JOIN question_sets qs ON qs.id = lp.question_set_id
|
||||
WHERE
|
||||
lp.module_id = $1
|
||||
AND qs.set_type = 'PRACTICE'
|
||||
AND qs.status = 'PUBLISHED';
|
||||
|
||||
-- name: CountUserCompletedPublishedPracticesInModule :one
|
||||
SELECT
|
||||
count(*)::int AS n
|
||||
FROM
|
||||
lms_practices lp
|
||||
INNER JOIN question_sets qs ON qs.id = lp.question_set_id
|
||||
INNER JOIN user_practice_progress upp ON upp.question_set_id = lp.question_set_id
|
||||
WHERE
|
||||
lp.module_id = $1
|
||||
AND upp.user_id = $2
|
||||
AND upp.completed_at IS NOT NULL
|
||||
AND qs.set_type = 'PRACTICE'
|
||||
AND qs.status = 'PUBLISHED';
|
||||
|
||||
-- name: CountPublishedPracticesInCourse :one
|
||||
SELECT
|
||||
count(*)::int AS n
|
||||
FROM
|
||||
lms_practices lp
|
||||
INNER JOIN question_sets qs ON qs.id = lp.question_set_id
|
||||
WHERE
|
||||
lp.course_id = $1
|
||||
AND qs.set_type = 'PRACTICE'
|
||||
AND qs.status = 'PUBLISHED';
|
||||
|
||||
-- name: CountUserCompletedPublishedPracticesInCourse :one
|
||||
SELECT
|
||||
count(*)::int AS n
|
||||
FROM
|
||||
lms_practices lp
|
||||
INNER JOIN question_sets qs ON qs.id = lp.question_set_id
|
||||
INNER JOIN user_practice_progress upp ON upp.question_set_id = lp.question_set_id
|
||||
WHERE
|
||||
lp.course_id = $1
|
||||
AND upp.user_id = $2
|
||||
AND upp.completed_at IS NOT NULL
|
||||
AND qs.set_type = 'PRACTICE'
|
||||
AND qs.status = 'PUBLISHED';
|
||||
|
||||
-- name: CountPublishedPracticesInProgram :one
|
||||
SELECT
|
||||
count(*)::int AS n
|
||||
FROM
|
||||
lms_practices lp
|
||||
INNER JOIN courses c ON c.id = lp.course_id
|
||||
INNER JOIN question_sets qs ON qs.id = lp.question_set_id
|
||||
WHERE
|
||||
c.program_id = $1
|
||||
AND qs.set_type = 'PRACTICE'
|
||||
AND qs.status = 'PUBLISHED';
|
||||
|
||||
-- name: CountUserCompletedPublishedPracticesInProgram :one
|
||||
SELECT
|
||||
count(*)::int AS n
|
||||
FROM
|
||||
lms_practices lp
|
||||
INNER JOIN courses c ON c.id = lp.course_id
|
||||
INNER JOIN question_sets qs ON qs.id = lp.question_set_id
|
||||
INNER JOIN user_practice_progress upp ON upp.question_set_id = lp.question_set_id
|
||||
WHERE
|
||||
c.program_id = $1
|
||||
AND upp.user_id = $2
|
||||
AND upp.completed_at IS NOT NULL
|
||||
AND qs.set_type = 'PRACTICE'
|
||||
AND qs.status = 'PUBLISHED';
|
||||
|
||||
-- name: GetPracticeScopeByQuestionSetID :one
|
||||
SELECT
|
||||
id,
|
||||
course_id,
|
||||
module_id,
|
||||
lesson_id
|
||||
FROM
|
||||
lms_practices
|
||||
WHERE
|
||||
question_set_id = $1
|
||||
ORDER BY
|
||||
id DESC
|
||||
LIMIT 1;
|
||||
|
|
|
|||
|
|
@ -22,7 +22,16 @@ VALUES ($1, $2, $3, $4, $5, $6);
|
|||
SELECT id, user_id, sent_to, medium, otp_for, otp, used, used_at, created_at, expires_at
|
||||
FROM otps
|
||||
WHERE user_id = $1
|
||||
ORDER BY created_at DESC LIMIT 1;
|
||||
ORDER BY id DESC
|
||||
LIMIT 1;
|
||||
|
||||
-- name: GetOtpByCode :one
|
||||
SELECT id, user_id, sent_to, medium, otp_for, otp, used, used_at, created_at, expires_at
|
||||
FROM otps
|
||||
WHERE user_id = $1
|
||||
AND otp = $2
|
||||
ORDER BY id DESC
|
||||
LIMIT 1;
|
||||
|
||||
-- name: MarkOtpAsUsed :exec
|
||||
UPDATE otps
|
||||
|
|
|
|||
|
|
@ -21,10 +21,13 @@ SELECT
|
|||
q.explanation,
|
||||
q.tips,
|
||||
q.voice_prompt,
|
||||
q.sample_answer_voice_prompt,
|
||||
q.image_url,
|
||||
q.status as question_status
|
||||
q.status as question_status,
|
||||
qaa.correct_answer_text AS audio_correct_answer_text
|
||||
FROM question_set_items qsi
|
||||
JOIN questions q ON q.id = qsi.question_id
|
||||
LEFT JOIN question_audio_answers qaa ON qaa.question_id = q.id
|
||||
WHERE qsi.set_id = $1
|
||||
AND q.status != 'ARCHIVED'
|
||||
ORDER BY qsi.display_order;
|
||||
|
|
@ -70,9 +73,12 @@ SELECT
|
|||
q.explanation,
|
||||
q.tips,
|
||||
q.voice_prompt,
|
||||
q.image_url
|
||||
q.sample_answer_voice_prompt,
|
||||
q.image_url,
|
||||
qaa.correct_answer_text AS audio_correct_answer_text
|
||||
FROM question_set_items qsi
|
||||
JOIN questions q ON q.id = qsi.question_id
|
||||
LEFT JOIN question_audio_answers qaa ON qaa.question_id = q.id
|
||||
WHERE qsi.set_id = $1
|
||||
AND q.status = 'PUBLISHED'
|
||||
ORDER BY qsi.display_order;
|
||||
|
|
|
|||
933
docs/docs.go
933
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": [
|
||||
|
|
|
|||
|
|
@ -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": [
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
207
gen/db/exam_prep_catalog_courses.sql.go
Normal file
207
gen/db/exam_prep_catalog_courses.sql.go
Normal file
|
|
@ -0,0 +1,207 @@
|
|||
// Code generated by sqlc. DO NOT EDIT.
|
||||
// versions:
|
||||
// sqlc v1.30.0
|
||||
// source: exam_prep_catalog_courses.sql
|
||||
|
||||
package dbgen
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/jackc/pgx/v5/pgtype"
|
||||
)
|
||||
|
||||
const ExamPrepCreateCatalogCourse = `-- name: ExamPrepCreateCatalogCourse :one
|
||||
INSERT INTO exam_prep.catalog_courses (name, description, thumbnail, sort_order)
|
||||
SELECT
|
||||
$1,
|
||||
$2,
|
||||
$3,
|
||||
coalesce((
|
||||
SELECT
|
||||
max(c.sort_order)
|
||||
FROM exam_prep.catalog_courses AS c), 0) + 1
|
||||
RETURNING
|
||||
id, name, description, thumbnail, sort_order, created_at, updated_at
|
||||
`
|
||||
|
||||
type ExamPrepCreateCatalogCourseParams struct {
|
||||
Name string `json:"name"`
|
||||
Description pgtype.Text `json:"description"`
|
||||
Thumbnail pgtype.Text `json:"thumbnail"`
|
||||
}
|
||||
|
||||
func (q *Queries) ExamPrepCreateCatalogCourse(ctx context.Context, arg ExamPrepCreateCatalogCourseParams) (ExamPrepCatalogCourse, error) {
|
||||
row := q.db.QueryRow(ctx, ExamPrepCreateCatalogCourse, arg.Name, arg.Description, arg.Thumbnail)
|
||||
var i ExamPrepCatalogCourse
|
||||
err := row.Scan(
|
||||
&i.ID,
|
||||
&i.Name,
|
||||
&i.Description,
|
||||
&i.Thumbnail,
|
||||
&i.SortOrder,
|
||||
&i.CreatedAt,
|
||||
&i.UpdatedAt,
|
||||
)
|
||||
return i, err
|
||||
}
|
||||
|
||||
const ExamPrepDeleteCatalogCourse = `-- name: ExamPrepDeleteCatalogCourse :exec
|
||||
DELETE FROM exam_prep.catalog_courses
|
||||
WHERE id = $1
|
||||
`
|
||||
|
||||
func (q *Queries) ExamPrepDeleteCatalogCourse(ctx context.Context, id int64) error {
|
||||
_, err := q.db.Exec(ctx, ExamPrepDeleteCatalogCourse, id)
|
||||
return err
|
||||
}
|
||||
|
||||
const ExamPrepGetCatalogCourseByID = `-- name: ExamPrepGetCatalogCourseByID :one
|
||||
SELECT id, name, description, thumbnail, sort_order, created_at, updated_at
|
||||
FROM exam_prep.catalog_courses
|
||||
WHERE id = $1
|
||||
`
|
||||
|
||||
func (q *Queries) ExamPrepGetCatalogCourseByID(ctx context.Context, id int64) (ExamPrepCatalogCourse, error) {
|
||||
row := q.db.QueryRow(ctx, ExamPrepGetCatalogCourseByID, id)
|
||||
var i ExamPrepCatalogCourse
|
||||
err := row.Scan(
|
||||
&i.ID,
|
||||
&i.Name,
|
||||
&i.Description,
|
||||
&i.Thumbnail,
|
||||
&i.SortOrder,
|
||||
&i.CreatedAt,
|
||||
&i.UpdatedAt,
|
||||
)
|
||||
return i, err
|
||||
}
|
||||
|
||||
const ExamPrepListAllCatalogCourseIDs = `-- name: ExamPrepListAllCatalogCourseIDs :many
|
||||
SELECT
|
||||
id
|
||||
FROM exam_prep.catalog_courses
|
||||
ORDER BY id
|
||||
`
|
||||
|
||||
func (q *Queries) ExamPrepListAllCatalogCourseIDs(ctx context.Context) ([]int64, error) {
|
||||
rows, err := q.db.Query(ctx, ExamPrepListAllCatalogCourseIDs)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
var items []int64
|
||||
for rows.Next() {
|
||||
var id int64
|
||||
if err := rows.Scan(&id); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
items = append(items, id)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return items, nil
|
||||
}
|
||||
|
||||
const ExamPrepListCatalogCourses = `-- name: ExamPrepListCatalogCourses :many
|
||||
SELECT
|
||||
COUNT(*) OVER () AS total_count,
|
||||
c.id,
|
||||
c.name,
|
||||
c.description,
|
||||
c.thumbnail,
|
||||
c.sort_order,
|
||||
c.created_at,
|
||||
c.updated_at
|
||||
FROM exam_prep.catalog_courses c
|
||||
ORDER BY c.sort_order ASC, c.id ASC
|
||||
LIMIT $1 OFFSET $2
|
||||
`
|
||||
|
||||
type ExamPrepListCatalogCoursesParams struct {
|
||||
Limit int32 `json:"limit"`
|
||||
Offset int32 `json:"offset"`
|
||||
}
|
||||
|
||||
type ExamPrepListCatalogCoursesRow struct {
|
||||
TotalCount int64 `json:"total_count"`
|
||||
ID int64 `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Description pgtype.Text `json:"description"`
|
||||
Thumbnail pgtype.Text `json:"thumbnail"`
|
||||
SortOrder int32 `json:"sort_order"`
|
||||
CreatedAt pgtype.Timestamptz `json:"created_at"`
|
||||
UpdatedAt pgtype.Timestamptz `json:"updated_at"`
|
||||
}
|
||||
|
||||
func (q *Queries) ExamPrepListCatalogCourses(ctx context.Context, arg ExamPrepListCatalogCoursesParams) ([]ExamPrepListCatalogCoursesRow, error) {
|
||||
rows, err := q.db.Query(ctx, ExamPrepListCatalogCourses, arg.Limit, arg.Offset)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
var items []ExamPrepListCatalogCoursesRow
|
||||
for rows.Next() {
|
||||
var i ExamPrepListCatalogCoursesRow
|
||||
if err := rows.Scan(
|
||||
&i.TotalCount,
|
||||
&i.ID,
|
||||
&i.Name,
|
||||
&i.Description,
|
||||
&i.Thumbnail,
|
||||
&i.SortOrder,
|
||||
&i.CreatedAt,
|
||||
&i.UpdatedAt,
|
||||
); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
items = append(items, i)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return items, nil
|
||||
}
|
||||
|
||||
const ExamPrepUpdateCatalogCourse = `-- name: ExamPrepUpdateCatalogCourse :one
|
||||
UPDATE exam_prep.catalog_courses
|
||||
SET
|
||||
name = coalesce($1::varchar, name),
|
||||
description = coalesce($2::text, description),
|
||||
thumbnail = coalesce($3::text, thumbnail),
|
||||
sort_order = coalesce($4::int, sort_order),
|
||||
updated_at = CURRENT_TIMESTAMP
|
||||
WHERE id = $5
|
||||
RETURNING
|
||||
id, name, description, thumbnail, sort_order, created_at, updated_at
|
||||
`
|
||||
|
||||
type ExamPrepUpdateCatalogCourseParams struct {
|
||||
Name pgtype.Text `json:"name"`
|
||||
Description pgtype.Text `json:"description"`
|
||||
Thumbnail pgtype.Text `json:"thumbnail"`
|
||||
SortOrder pgtype.Int4 `json:"sort_order"`
|
||||
ID int64 `json:"id"`
|
||||
}
|
||||
|
||||
func (q *Queries) ExamPrepUpdateCatalogCourse(ctx context.Context, arg ExamPrepUpdateCatalogCourseParams) (ExamPrepCatalogCourse, error) {
|
||||
row := q.db.QueryRow(ctx, ExamPrepUpdateCatalogCourse,
|
||||
arg.Name,
|
||||
arg.Description,
|
||||
arg.Thumbnail,
|
||||
arg.SortOrder,
|
||||
arg.ID,
|
||||
)
|
||||
var i ExamPrepCatalogCourse
|
||||
err := row.Scan(
|
||||
&i.ID,
|
||||
&i.Name,
|
||||
&i.Description,
|
||||
&i.Thumbnail,
|
||||
&i.SortOrder,
|
||||
&i.CreatedAt,
|
||||
&i.UpdatedAt,
|
||||
)
|
||||
return i, err
|
||||
}
|
||||
217
gen/db/exam_prep_lesson_practices.sql.go
Normal file
217
gen/db/exam_prep_lesson_practices.sql.go
Normal file
|
|
@ -0,0 +1,217 @@
|
|||
// Code generated by sqlc. DO NOT EDIT.
|
||||
// versions:
|
||||
// sqlc v1.30.0
|
||||
// source: exam_prep_lesson_practices.sql
|
||||
|
||||
package dbgen
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/jackc/pgx/v5/pgtype"
|
||||
)
|
||||
|
||||
const ExamPrepCreateLessonPractice = `-- name: ExamPrepCreateLessonPractice :one
|
||||
INSERT INTO exam_prep.lesson_practices (
|
||||
unit_module_lesson_id,
|
||||
title,
|
||||
story_description,
|
||||
story_image,
|
||||
persona_id,
|
||||
question_set_id,
|
||||
quick_tips
|
||||
) VALUES ($1, $2, $3, $4, $5, $6, $7)
|
||||
RETURNING id, unit_module_lesson_id, title, story_description, story_image, persona_id, question_set_id, quick_tips, created_at, updated_at
|
||||
`
|
||||
|
||||
type ExamPrepCreateLessonPracticeParams struct {
|
||||
UnitModuleLessonID int64 `json:"unit_module_lesson_id"`
|
||||
Title string `json:"title"`
|
||||
StoryDescription pgtype.Text `json:"story_description"`
|
||||
StoryImage pgtype.Text `json:"story_image"`
|
||||
PersonaID pgtype.Int8 `json:"persona_id"`
|
||||
QuestionSetID int64 `json:"question_set_id"`
|
||||
QuickTips pgtype.Text `json:"quick_tips"`
|
||||
}
|
||||
|
||||
func (q *Queries) ExamPrepCreateLessonPractice(ctx context.Context, arg ExamPrepCreateLessonPracticeParams) (ExamPrepLessonPractice, error) {
|
||||
row := q.db.QueryRow(ctx, ExamPrepCreateLessonPractice,
|
||||
arg.UnitModuleLessonID,
|
||||
arg.Title,
|
||||
arg.StoryDescription,
|
||||
arg.StoryImage,
|
||||
arg.PersonaID,
|
||||
arg.QuestionSetID,
|
||||
arg.QuickTips,
|
||||
)
|
||||
var i ExamPrepLessonPractice
|
||||
err := row.Scan(
|
||||
&i.ID,
|
||||
&i.UnitModuleLessonID,
|
||||
&i.Title,
|
||||
&i.StoryDescription,
|
||||
&i.StoryImage,
|
||||
&i.PersonaID,
|
||||
&i.QuestionSetID,
|
||||
&i.QuickTips,
|
||||
&i.CreatedAt,
|
||||
&i.UpdatedAt,
|
||||
)
|
||||
return i, err
|
||||
}
|
||||
|
||||
const ExamPrepDeleteLessonPractice = `-- name: ExamPrepDeleteLessonPractice :exec
|
||||
DELETE FROM exam_prep.lesson_practices
|
||||
WHERE id = $1
|
||||
`
|
||||
|
||||
func (q *Queries) ExamPrepDeleteLessonPractice(ctx context.Context, id int64) error {
|
||||
_, err := q.db.Exec(ctx, ExamPrepDeleteLessonPractice, id)
|
||||
return err
|
||||
}
|
||||
|
||||
const ExamPrepGetLessonPracticeByID = `-- name: ExamPrepGetLessonPracticeByID :one
|
||||
SELECT id, unit_module_lesson_id, title, story_description, story_image, persona_id, question_set_id, quick_tips, created_at, updated_at
|
||||
FROM exam_prep.lesson_practices
|
||||
WHERE id = $1
|
||||
`
|
||||
|
||||
func (q *Queries) ExamPrepGetLessonPracticeByID(ctx context.Context, id int64) (ExamPrepLessonPractice, error) {
|
||||
row := q.db.QueryRow(ctx, ExamPrepGetLessonPracticeByID, id)
|
||||
var i ExamPrepLessonPractice
|
||||
err := row.Scan(
|
||||
&i.ID,
|
||||
&i.UnitModuleLessonID,
|
||||
&i.Title,
|
||||
&i.StoryDescription,
|
||||
&i.StoryImage,
|
||||
&i.PersonaID,
|
||||
&i.QuestionSetID,
|
||||
&i.QuickTips,
|
||||
&i.CreatedAt,
|
||||
&i.UpdatedAt,
|
||||
)
|
||||
return i, err
|
||||
}
|
||||
|
||||
const ExamPrepListLessonPracticesByLessonID = `-- name: ExamPrepListLessonPracticesByLessonID :many
|
||||
SELECT
|
||||
COUNT(*) OVER () AS total_count,
|
||||
p.id,
|
||||
p.unit_module_lesson_id,
|
||||
p.title,
|
||||
p.story_description,
|
||||
p.story_image,
|
||||
p.persona_id,
|
||||
p.question_set_id,
|
||||
p.quick_tips,
|
||||
p.created_at,
|
||||
p.updated_at
|
||||
FROM exam_prep.lesson_practices p
|
||||
WHERE p.unit_module_lesson_id = $1
|
||||
ORDER BY p.created_at DESC
|
||||
LIMIT $2
|
||||
OFFSET $3
|
||||
`
|
||||
|
||||
type ExamPrepListLessonPracticesByLessonIDParams struct {
|
||||
UnitModuleLessonID int64 `json:"unit_module_lesson_id"`
|
||||
Limit int32 `json:"limit"`
|
||||
Offset int32 `json:"offset"`
|
||||
}
|
||||
|
||||
type ExamPrepListLessonPracticesByLessonIDRow struct {
|
||||
TotalCount int64 `json:"total_count"`
|
||||
ID int64 `json:"id"`
|
||||
UnitModuleLessonID int64 `json:"unit_module_lesson_id"`
|
||||
Title string `json:"title"`
|
||||
StoryDescription pgtype.Text `json:"story_description"`
|
||||
StoryImage pgtype.Text `json:"story_image"`
|
||||
PersonaID pgtype.Int8 `json:"persona_id"`
|
||||
QuestionSetID int64 `json:"question_set_id"`
|
||||
QuickTips pgtype.Text `json:"quick_tips"`
|
||||
CreatedAt pgtype.Timestamptz `json:"created_at"`
|
||||
UpdatedAt pgtype.Timestamptz `json:"updated_at"`
|
||||
}
|
||||
|
||||
func (q *Queries) ExamPrepListLessonPracticesByLessonID(ctx context.Context, arg ExamPrepListLessonPracticesByLessonIDParams) ([]ExamPrepListLessonPracticesByLessonIDRow, error) {
|
||||
rows, err := q.db.Query(ctx, ExamPrepListLessonPracticesByLessonID, arg.UnitModuleLessonID, arg.Limit, arg.Offset)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
var items []ExamPrepListLessonPracticesByLessonIDRow
|
||||
for rows.Next() {
|
||||
var i ExamPrepListLessonPracticesByLessonIDRow
|
||||
if err := rows.Scan(
|
||||
&i.TotalCount,
|
||||
&i.ID,
|
||||
&i.UnitModuleLessonID,
|
||||
&i.Title,
|
||||
&i.StoryDescription,
|
||||
&i.StoryImage,
|
||||
&i.PersonaID,
|
||||
&i.QuestionSetID,
|
||||
&i.QuickTips,
|
||||
&i.CreatedAt,
|
||||
&i.UpdatedAt,
|
||||
); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
items = append(items, i)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return items, nil
|
||||
}
|
||||
|
||||
const ExamPrepUpdateLessonPractice = `-- name: ExamPrepUpdateLessonPractice :one
|
||||
UPDATE exam_prep.lesson_practices
|
||||
SET
|
||||
title = coalesce($1::varchar, title),
|
||||
story_description = coalesce($2::text, story_description),
|
||||
story_image = coalesce($3::text, story_image),
|
||||
persona_id = coalesce($4::bigint, persona_id),
|
||||
question_set_id = coalesce($5::bigint, question_set_id),
|
||||
quick_tips = coalesce($6::text, quick_tips),
|
||||
updated_at = CURRENT_TIMESTAMP
|
||||
WHERE id = $7
|
||||
RETURNING id, unit_module_lesson_id, title, story_description, story_image, persona_id, question_set_id, quick_tips, created_at, updated_at
|
||||
`
|
||||
|
||||
type ExamPrepUpdateLessonPracticeParams struct {
|
||||
Title pgtype.Text `json:"title"`
|
||||
StoryDescription pgtype.Text `json:"story_description"`
|
||||
StoryImage pgtype.Text `json:"story_image"`
|
||||
PersonaID pgtype.Int8 `json:"persona_id"`
|
||||
QuestionSetID pgtype.Int8 `json:"question_set_id"`
|
||||
QuickTips pgtype.Text `json:"quick_tips"`
|
||||
ID int64 `json:"id"`
|
||||
}
|
||||
|
||||
func (q *Queries) ExamPrepUpdateLessonPractice(ctx context.Context, arg ExamPrepUpdateLessonPracticeParams) (ExamPrepLessonPractice, error) {
|
||||
row := q.db.QueryRow(ctx, ExamPrepUpdateLessonPractice,
|
||||
arg.Title,
|
||||
arg.StoryDescription,
|
||||
arg.StoryImage,
|
||||
arg.PersonaID,
|
||||
arg.QuestionSetID,
|
||||
arg.QuickTips,
|
||||
arg.ID,
|
||||
)
|
||||
var i ExamPrepLessonPractice
|
||||
err := row.Scan(
|
||||
&i.ID,
|
||||
&i.UnitModuleLessonID,
|
||||
&i.Title,
|
||||
&i.StoryDescription,
|
||||
&i.StoryImage,
|
||||
&i.PersonaID,
|
||||
&i.QuestionSetID,
|
||||
&i.QuickTips,
|
||||
&i.CreatedAt,
|
||||
&i.UpdatedAt,
|
||||
)
|
||||
return i, err
|
||||
}
|
||||
243
gen/db/exam_prep_unit_module_lessons.sql.go
Normal file
243
gen/db/exam_prep_unit_module_lessons.sql.go
Normal file
|
|
@ -0,0 +1,243 @@
|
|||
// Code generated by sqlc. DO NOT EDIT.
|
||||
// versions:
|
||||
// sqlc v1.30.0
|
||||
// source: exam_prep_unit_module_lessons.sql
|
||||
|
||||
package dbgen
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/jackc/pgx/v5/pgtype"
|
||||
)
|
||||
|
||||
const ExamPrepCreateUnitModuleLesson = `-- name: ExamPrepCreateUnitModuleLesson :one
|
||||
INSERT INTO exam_prep.unit_module_lessons (unit_module_id, title, video_url, thumbnail, description, sort_order)
|
||||
SELECT
|
||||
$1,
|
||||
$2,
|
||||
$3,
|
||||
$4,
|
||||
$5,
|
||||
coalesce((
|
||||
SELECT
|
||||
max(l.sort_order)
|
||||
FROM exam_prep.unit_module_lessons l
|
||||
WHERE
|
||||
l.unit_module_id = $1), 0) + 1
|
||||
RETURNING
|
||||
id, unit_module_id, title, video_url, thumbnail, description, sort_order, created_at, updated_at
|
||||
`
|
||||
|
||||
type ExamPrepCreateUnitModuleLessonParams struct {
|
||||
UnitModuleID int64 `json:"unit_module_id"`
|
||||
Title string `json:"title"`
|
||||
VideoUrl pgtype.Text `json:"video_url"`
|
||||
Thumbnail pgtype.Text `json:"thumbnail"`
|
||||
Description pgtype.Text `json:"description"`
|
||||
}
|
||||
|
||||
func (q *Queries) ExamPrepCreateUnitModuleLesson(ctx context.Context, arg ExamPrepCreateUnitModuleLessonParams) (ExamPrepUnitModuleLesson, error) {
|
||||
row := q.db.QueryRow(ctx, ExamPrepCreateUnitModuleLesson,
|
||||
arg.UnitModuleID,
|
||||
arg.Title,
|
||||
arg.VideoUrl,
|
||||
arg.Thumbnail,
|
||||
arg.Description,
|
||||
)
|
||||
var i ExamPrepUnitModuleLesson
|
||||
err := row.Scan(
|
||||
&i.ID,
|
||||
&i.UnitModuleID,
|
||||
&i.Title,
|
||||
&i.VideoUrl,
|
||||
&i.Thumbnail,
|
||||
&i.Description,
|
||||
&i.SortOrder,
|
||||
&i.CreatedAt,
|
||||
&i.UpdatedAt,
|
||||
)
|
||||
return i, err
|
||||
}
|
||||
|
||||
const ExamPrepDeleteUnitModuleLesson = `-- name: ExamPrepDeleteUnitModuleLesson :exec
|
||||
DELETE FROM exam_prep.unit_module_lessons
|
||||
WHERE id = $1
|
||||
`
|
||||
|
||||
func (q *Queries) ExamPrepDeleteUnitModuleLesson(ctx context.Context, id int64) error {
|
||||
_, err := q.db.Exec(ctx, ExamPrepDeleteUnitModuleLesson, id)
|
||||
return err
|
||||
}
|
||||
|
||||
const ExamPrepGetUnitModuleLessonByID = `-- name: ExamPrepGetUnitModuleLessonByID :one
|
||||
SELECT id, unit_module_id, title, video_url, thumbnail, description, sort_order, created_at, updated_at
|
||||
FROM exam_prep.unit_module_lessons
|
||||
WHERE id = $1
|
||||
`
|
||||
|
||||
func (q *Queries) ExamPrepGetUnitModuleLessonByID(ctx context.Context, id int64) (ExamPrepUnitModuleLesson, error) {
|
||||
row := q.db.QueryRow(ctx, ExamPrepGetUnitModuleLessonByID, id)
|
||||
var i ExamPrepUnitModuleLesson
|
||||
err := row.Scan(
|
||||
&i.ID,
|
||||
&i.UnitModuleID,
|
||||
&i.Title,
|
||||
&i.VideoUrl,
|
||||
&i.Thumbnail,
|
||||
&i.Description,
|
||||
&i.SortOrder,
|
||||
&i.CreatedAt,
|
||||
&i.UpdatedAt,
|
||||
)
|
||||
return i, err
|
||||
}
|
||||
|
||||
const ExamPrepListUnitModuleLessonIDsByUnitModule = `-- name: ExamPrepListUnitModuleLessonIDsByUnitModule :many
|
||||
SELECT
|
||||
l.id
|
||||
FROM exam_prep.unit_module_lessons l
|
||||
WHERE
|
||||
l.unit_module_id = $1
|
||||
ORDER BY
|
||||
l.id
|
||||
`
|
||||
|
||||
func (q *Queries) ExamPrepListUnitModuleLessonIDsByUnitModule(ctx context.Context, unitModuleID int64) ([]int64, error) {
|
||||
rows, err := q.db.Query(ctx, ExamPrepListUnitModuleLessonIDsByUnitModule, unitModuleID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
var items []int64
|
||||
for rows.Next() {
|
||||
var id int64
|
||||
if err := rows.Scan(&id); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
items = append(items, id)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return items, nil
|
||||
}
|
||||
|
||||
const ExamPrepListUnitModuleLessonsByUnitModuleID = `-- name: ExamPrepListUnitModuleLessonsByUnitModuleID :many
|
||||
SELECT
|
||||
COUNT(*) OVER () AS total_count,
|
||||
l.id,
|
||||
l.unit_module_id,
|
||||
l.title,
|
||||
l.video_url,
|
||||
l.thumbnail,
|
||||
l.description,
|
||||
l.sort_order,
|
||||
l.created_at,
|
||||
l.updated_at
|
||||
FROM exam_prep.unit_module_lessons l
|
||||
WHERE
|
||||
l.unit_module_id = $1
|
||||
ORDER BY
|
||||
l.sort_order ASC,
|
||||
l.id ASC
|
||||
LIMIT $2
|
||||
OFFSET $3
|
||||
`
|
||||
|
||||
type ExamPrepListUnitModuleLessonsByUnitModuleIDParams struct {
|
||||
UnitModuleID int64 `json:"unit_module_id"`
|
||||
Limit int32 `json:"limit"`
|
||||
Offset int32 `json:"offset"`
|
||||
}
|
||||
|
||||
type ExamPrepListUnitModuleLessonsByUnitModuleIDRow struct {
|
||||
TotalCount int64 `json:"total_count"`
|
||||
ID int64 `json:"id"`
|
||||
UnitModuleID int64 `json:"unit_module_id"`
|
||||
Title string `json:"title"`
|
||||
VideoUrl pgtype.Text `json:"video_url"`
|
||||
Thumbnail pgtype.Text `json:"thumbnail"`
|
||||
Description pgtype.Text `json:"description"`
|
||||
SortOrder int32 `json:"sort_order"`
|
||||
CreatedAt pgtype.Timestamptz `json:"created_at"`
|
||||
UpdatedAt pgtype.Timestamptz `json:"updated_at"`
|
||||
}
|
||||
|
||||
func (q *Queries) ExamPrepListUnitModuleLessonsByUnitModuleID(ctx context.Context, arg ExamPrepListUnitModuleLessonsByUnitModuleIDParams) ([]ExamPrepListUnitModuleLessonsByUnitModuleIDRow, error) {
|
||||
rows, err := q.db.Query(ctx, ExamPrepListUnitModuleLessonsByUnitModuleID, arg.UnitModuleID, arg.Limit, arg.Offset)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
var items []ExamPrepListUnitModuleLessonsByUnitModuleIDRow
|
||||
for rows.Next() {
|
||||
var i ExamPrepListUnitModuleLessonsByUnitModuleIDRow
|
||||
if err := rows.Scan(
|
||||
&i.TotalCount,
|
||||
&i.ID,
|
||||
&i.UnitModuleID,
|
||||
&i.Title,
|
||||
&i.VideoUrl,
|
||||
&i.Thumbnail,
|
||||
&i.Description,
|
||||
&i.SortOrder,
|
||||
&i.CreatedAt,
|
||||
&i.UpdatedAt,
|
||||
); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
items = append(items, i)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return items, nil
|
||||
}
|
||||
|
||||
const ExamPrepUpdateUnitModuleLesson = `-- name: ExamPrepUpdateUnitModuleLesson :one
|
||||
UPDATE exam_prep.unit_module_lessons
|
||||
SET
|
||||
title = coalesce($1::varchar, title),
|
||||
video_url = coalesce($2::text, video_url),
|
||||
thumbnail = coalesce($3::text, thumbnail),
|
||||
description = coalesce($4::text, description),
|
||||
sort_order = coalesce($5::int, sort_order),
|
||||
updated_at = CURRENT_TIMESTAMP
|
||||
WHERE id = $6
|
||||
RETURNING
|
||||
id, unit_module_id, title, video_url, thumbnail, description, sort_order, created_at, updated_at
|
||||
`
|
||||
|
||||
type ExamPrepUpdateUnitModuleLessonParams struct {
|
||||
Title pgtype.Text `json:"title"`
|
||||
VideoUrl pgtype.Text `json:"video_url"`
|
||||
Thumbnail pgtype.Text `json:"thumbnail"`
|
||||
Description pgtype.Text `json:"description"`
|
||||
SortOrder pgtype.Int4 `json:"sort_order"`
|
||||
ID int64 `json:"id"`
|
||||
}
|
||||
|
||||
func (q *Queries) ExamPrepUpdateUnitModuleLesson(ctx context.Context, arg ExamPrepUpdateUnitModuleLessonParams) (ExamPrepUnitModuleLesson, error) {
|
||||
row := q.db.QueryRow(ctx, ExamPrepUpdateUnitModuleLesson,
|
||||
arg.Title,
|
||||
arg.VideoUrl,
|
||||
arg.Thumbnail,
|
||||
arg.Description,
|
||||
arg.SortOrder,
|
||||
arg.ID,
|
||||
)
|
||||
var i ExamPrepUnitModuleLesson
|
||||
err := row.Scan(
|
||||
&i.ID,
|
||||
&i.UnitModuleID,
|
||||
&i.Title,
|
||||
&i.VideoUrl,
|
||||
&i.Thumbnail,
|
||||
&i.Description,
|
||||
&i.SortOrder,
|
||||
&i.CreatedAt,
|
||||
&i.UpdatedAt,
|
||||
)
|
||||
return i, err
|
||||
}
|
||||
243
gen/db/exam_prep_unit_modules.sql.go
Normal file
243
gen/db/exam_prep_unit_modules.sql.go
Normal file
|
|
@ -0,0 +1,243 @@
|
|||
// Code generated by sqlc. DO NOT EDIT.
|
||||
// versions:
|
||||
// sqlc v1.30.0
|
||||
// source: exam_prep_unit_modules.sql
|
||||
|
||||
package dbgen
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/jackc/pgx/v5/pgtype"
|
||||
)
|
||||
|
||||
const ExamPrepCreateUnitModule = `-- name: ExamPrepCreateUnitModule :one
|
||||
INSERT INTO exam_prep.unit_modules (unit_id, name, description, thumbnail, icon, sort_order)
|
||||
SELECT
|
||||
$1,
|
||||
$2,
|
||||
$3,
|
||||
$4,
|
||||
$5,
|
||||
coalesce((
|
||||
SELECT
|
||||
max(m.sort_order)
|
||||
FROM exam_prep.unit_modules m
|
||||
WHERE
|
||||
m.unit_id = $1), 0) + 1
|
||||
RETURNING
|
||||
id, unit_id, name, description, thumbnail, icon, sort_order, created_at, updated_at
|
||||
`
|
||||
|
||||
type ExamPrepCreateUnitModuleParams struct {
|
||||
UnitID int64 `json:"unit_id"`
|
||||
Name string `json:"name"`
|
||||
Description pgtype.Text `json:"description"`
|
||||
Thumbnail pgtype.Text `json:"thumbnail"`
|
||||
Icon pgtype.Text `json:"icon"`
|
||||
}
|
||||
|
||||
func (q *Queries) ExamPrepCreateUnitModule(ctx context.Context, arg ExamPrepCreateUnitModuleParams) (ExamPrepUnitModule, error) {
|
||||
row := q.db.QueryRow(ctx, ExamPrepCreateUnitModule,
|
||||
arg.UnitID,
|
||||
arg.Name,
|
||||
arg.Description,
|
||||
arg.Thumbnail,
|
||||
arg.Icon,
|
||||
)
|
||||
var i ExamPrepUnitModule
|
||||
err := row.Scan(
|
||||
&i.ID,
|
||||
&i.UnitID,
|
||||
&i.Name,
|
||||
&i.Description,
|
||||
&i.Thumbnail,
|
||||
&i.Icon,
|
||||
&i.SortOrder,
|
||||
&i.CreatedAt,
|
||||
&i.UpdatedAt,
|
||||
)
|
||||
return i, err
|
||||
}
|
||||
|
||||
const ExamPrepDeleteUnitModule = `-- name: ExamPrepDeleteUnitModule :exec
|
||||
DELETE FROM exam_prep.unit_modules
|
||||
WHERE id = $1
|
||||
`
|
||||
|
||||
func (q *Queries) ExamPrepDeleteUnitModule(ctx context.Context, id int64) error {
|
||||
_, err := q.db.Exec(ctx, ExamPrepDeleteUnitModule, id)
|
||||
return err
|
||||
}
|
||||
|
||||
const ExamPrepGetUnitModuleByID = `-- name: ExamPrepGetUnitModuleByID :one
|
||||
SELECT id, unit_id, name, description, thumbnail, icon, sort_order, created_at, updated_at
|
||||
FROM exam_prep.unit_modules
|
||||
WHERE id = $1
|
||||
`
|
||||
|
||||
func (q *Queries) ExamPrepGetUnitModuleByID(ctx context.Context, id int64) (ExamPrepUnitModule, error) {
|
||||
row := q.db.QueryRow(ctx, ExamPrepGetUnitModuleByID, id)
|
||||
var i ExamPrepUnitModule
|
||||
err := row.Scan(
|
||||
&i.ID,
|
||||
&i.UnitID,
|
||||
&i.Name,
|
||||
&i.Description,
|
||||
&i.Thumbnail,
|
||||
&i.Icon,
|
||||
&i.SortOrder,
|
||||
&i.CreatedAt,
|
||||
&i.UpdatedAt,
|
||||
)
|
||||
return i, err
|
||||
}
|
||||
|
||||
const ExamPrepListUnitModuleIDsByUnit = `-- name: ExamPrepListUnitModuleIDsByUnit :many
|
||||
SELECT
|
||||
m.id
|
||||
FROM exam_prep.unit_modules m
|
||||
WHERE
|
||||
m.unit_id = $1
|
||||
ORDER BY
|
||||
m.id
|
||||
`
|
||||
|
||||
func (q *Queries) ExamPrepListUnitModuleIDsByUnit(ctx context.Context, unitID int64) ([]int64, error) {
|
||||
rows, err := q.db.Query(ctx, ExamPrepListUnitModuleIDsByUnit, unitID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
var items []int64
|
||||
for rows.Next() {
|
||||
var id int64
|
||||
if err := rows.Scan(&id); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
items = append(items, id)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return items, nil
|
||||
}
|
||||
|
||||
const ExamPrepListUnitModulesByUnit = `-- name: ExamPrepListUnitModulesByUnit :many
|
||||
SELECT
|
||||
COUNT(*) OVER () AS total_count,
|
||||
m.id,
|
||||
m.unit_id,
|
||||
m.name,
|
||||
m.description,
|
||||
m.thumbnail,
|
||||
m.icon,
|
||||
m.sort_order,
|
||||
m.created_at,
|
||||
m.updated_at
|
||||
FROM exam_prep.unit_modules m
|
||||
WHERE
|
||||
m.unit_id = $1
|
||||
ORDER BY
|
||||
m.sort_order ASC,
|
||||
m.id ASC
|
||||
LIMIT $2
|
||||
OFFSET $3
|
||||
`
|
||||
|
||||
type ExamPrepListUnitModulesByUnitParams struct {
|
||||
UnitID int64 `json:"unit_id"`
|
||||
Limit int32 `json:"limit"`
|
||||
Offset int32 `json:"offset"`
|
||||
}
|
||||
|
||||
type ExamPrepListUnitModulesByUnitRow struct {
|
||||
TotalCount int64 `json:"total_count"`
|
||||
ID int64 `json:"id"`
|
||||
UnitID int64 `json:"unit_id"`
|
||||
Name string `json:"name"`
|
||||
Description pgtype.Text `json:"description"`
|
||||
Thumbnail pgtype.Text `json:"thumbnail"`
|
||||
Icon pgtype.Text `json:"icon"`
|
||||
SortOrder int32 `json:"sort_order"`
|
||||
CreatedAt pgtype.Timestamptz `json:"created_at"`
|
||||
UpdatedAt pgtype.Timestamptz `json:"updated_at"`
|
||||
}
|
||||
|
||||
func (q *Queries) ExamPrepListUnitModulesByUnit(ctx context.Context, arg ExamPrepListUnitModulesByUnitParams) ([]ExamPrepListUnitModulesByUnitRow, error) {
|
||||
rows, err := q.db.Query(ctx, ExamPrepListUnitModulesByUnit, arg.UnitID, arg.Limit, arg.Offset)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
var items []ExamPrepListUnitModulesByUnitRow
|
||||
for rows.Next() {
|
||||
var i ExamPrepListUnitModulesByUnitRow
|
||||
if err := rows.Scan(
|
||||
&i.TotalCount,
|
||||
&i.ID,
|
||||
&i.UnitID,
|
||||
&i.Name,
|
||||
&i.Description,
|
||||
&i.Thumbnail,
|
||||
&i.Icon,
|
||||
&i.SortOrder,
|
||||
&i.CreatedAt,
|
||||
&i.UpdatedAt,
|
||||
); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
items = append(items, i)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return items, nil
|
||||
}
|
||||
|
||||
const ExamPrepUpdateUnitModule = `-- name: ExamPrepUpdateUnitModule :one
|
||||
UPDATE exam_prep.unit_modules
|
||||
SET
|
||||
name = coalesce($1::varchar, name),
|
||||
description = coalesce($2::text, description),
|
||||
thumbnail = coalesce($3::text, thumbnail),
|
||||
icon = coalesce($4::text, icon),
|
||||
sort_order = coalesce($5::int, sort_order),
|
||||
updated_at = CURRENT_TIMESTAMP
|
||||
WHERE id = $6
|
||||
RETURNING
|
||||
id, unit_id, name, description, thumbnail, icon, sort_order, created_at, updated_at
|
||||
`
|
||||
|
||||
type ExamPrepUpdateUnitModuleParams struct {
|
||||
Name pgtype.Text `json:"name"`
|
||||
Description pgtype.Text `json:"description"`
|
||||
Thumbnail pgtype.Text `json:"thumbnail"`
|
||||
Icon pgtype.Text `json:"icon"`
|
||||
SortOrder pgtype.Int4 `json:"sort_order"`
|
||||
ID int64 `json:"id"`
|
||||
}
|
||||
|
||||
func (q *Queries) ExamPrepUpdateUnitModule(ctx context.Context, arg ExamPrepUpdateUnitModuleParams) (ExamPrepUnitModule, error) {
|
||||
row := q.db.QueryRow(ctx, ExamPrepUpdateUnitModule,
|
||||
arg.Name,
|
||||
arg.Description,
|
||||
arg.Thumbnail,
|
||||
arg.Icon,
|
||||
arg.SortOrder,
|
||||
arg.ID,
|
||||
)
|
||||
var i ExamPrepUnitModule
|
||||
err := row.Scan(
|
||||
&i.ID,
|
||||
&i.UnitID,
|
||||
&i.Name,
|
||||
&i.Description,
|
||||
&i.Thumbnail,
|
||||
&i.Icon,
|
||||
&i.SortOrder,
|
||||
&i.CreatedAt,
|
||||
&i.UpdatedAt,
|
||||
)
|
||||
return i, err
|
||||
}
|
||||
231
gen/db/exam_prep_units.sql.go
Normal file
231
gen/db/exam_prep_units.sql.go
Normal file
|
|
@ -0,0 +1,231 @@
|
|||
// Code generated by sqlc. DO NOT EDIT.
|
||||
// versions:
|
||||
// sqlc v1.30.0
|
||||
// source: exam_prep_units.sql
|
||||
|
||||
package dbgen
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/jackc/pgx/v5/pgtype"
|
||||
)
|
||||
|
||||
const ExamPrepCreateUnit = `-- name: ExamPrepCreateUnit :one
|
||||
INSERT INTO exam_prep.units (catalog_course_id, name, description, thumbnail, sort_order)
|
||||
SELECT
|
||||
$1,
|
||||
$2,
|
||||
$3,
|
||||
$4,
|
||||
coalesce((
|
||||
SELECT
|
||||
max(u.sort_order)
|
||||
FROM exam_prep.units u
|
||||
WHERE
|
||||
u.catalog_course_id = $1), 0) + 1
|
||||
RETURNING
|
||||
id, catalog_course_id, name, description, thumbnail, sort_order, created_at, updated_at
|
||||
`
|
||||
|
||||
type ExamPrepCreateUnitParams struct {
|
||||
CatalogCourseID int64 `json:"catalog_course_id"`
|
||||
Name string `json:"name"`
|
||||
Description pgtype.Text `json:"description"`
|
||||
Thumbnail pgtype.Text `json:"thumbnail"`
|
||||
}
|
||||
|
||||
func (q *Queries) ExamPrepCreateUnit(ctx context.Context, arg ExamPrepCreateUnitParams) (ExamPrepUnit, error) {
|
||||
row := q.db.QueryRow(ctx, ExamPrepCreateUnit,
|
||||
arg.CatalogCourseID,
|
||||
arg.Name,
|
||||
arg.Description,
|
||||
arg.Thumbnail,
|
||||
)
|
||||
var i ExamPrepUnit
|
||||
err := row.Scan(
|
||||
&i.ID,
|
||||
&i.CatalogCourseID,
|
||||
&i.Name,
|
||||
&i.Description,
|
||||
&i.Thumbnail,
|
||||
&i.SortOrder,
|
||||
&i.CreatedAt,
|
||||
&i.UpdatedAt,
|
||||
)
|
||||
return i, err
|
||||
}
|
||||
|
||||
const ExamPrepDeleteUnit = `-- name: ExamPrepDeleteUnit :exec
|
||||
DELETE FROM exam_prep.units
|
||||
WHERE id = $1
|
||||
`
|
||||
|
||||
func (q *Queries) ExamPrepDeleteUnit(ctx context.Context, id int64) error {
|
||||
_, err := q.db.Exec(ctx, ExamPrepDeleteUnit, id)
|
||||
return err
|
||||
}
|
||||
|
||||
const ExamPrepGetUnitByID = `-- name: ExamPrepGetUnitByID :one
|
||||
SELECT id, catalog_course_id, name, description, thumbnail, sort_order, created_at, updated_at
|
||||
FROM exam_prep.units
|
||||
WHERE id = $1
|
||||
`
|
||||
|
||||
func (q *Queries) ExamPrepGetUnitByID(ctx context.Context, id int64) (ExamPrepUnit, error) {
|
||||
row := q.db.QueryRow(ctx, ExamPrepGetUnitByID, id)
|
||||
var i ExamPrepUnit
|
||||
err := row.Scan(
|
||||
&i.ID,
|
||||
&i.CatalogCourseID,
|
||||
&i.Name,
|
||||
&i.Description,
|
||||
&i.Thumbnail,
|
||||
&i.SortOrder,
|
||||
&i.CreatedAt,
|
||||
&i.UpdatedAt,
|
||||
)
|
||||
return i, err
|
||||
}
|
||||
|
||||
const ExamPrepListUnitIDsByCatalogCourse = `-- name: ExamPrepListUnitIDsByCatalogCourse :many
|
||||
SELECT
|
||||
u.id
|
||||
FROM exam_prep.units u
|
||||
WHERE
|
||||
u.catalog_course_id = $1
|
||||
ORDER BY
|
||||
u.id
|
||||
`
|
||||
|
||||
func (q *Queries) ExamPrepListUnitIDsByCatalogCourse(ctx context.Context, catalogCourseID int64) ([]int64, error) {
|
||||
rows, err := q.db.Query(ctx, ExamPrepListUnitIDsByCatalogCourse, catalogCourseID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
var items []int64
|
||||
for rows.Next() {
|
||||
var id int64
|
||||
if err := rows.Scan(&id); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
items = append(items, id)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return items, nil
|
||||
}
|
||||
|
||||
const ExamPrepListUnitsByCatalogCourse = `-- name: ExamPrepListUnitsByCatalogCourse :many
|
||||
SELECT
|
||||
COUNT(*) OVER () AS total_count,
|
||||
u.id,
|
||||
u.catalog_course_id,
|
||||
u.name,
|
||||
u.description,
|
||||
u.thumbnail,
|
||||
u.sort_order,
|
||||
u.created_at,
|
||||
u.updated_at
|
||||
FROM exam_prep.units u
|
||||
WHERE
|
||||
u.catalog_course_id = $1
|
||||
ORDER BY
|
||||
u.sort_order ASC,
|
||||
u.id ASC
|
||||
LIMIT $2
|
||||
OFFSET $3
|
||||
`
|
||||
|
||||
type ExamPrepListUnitsByCatalogCourseParams struct {
|
||||
CatalogCourseID int64 `json:"catalog_course_id"`
|
||||
Limit int32 `json:"limit"`
|
||||
Offset int32 `json:"offset"`
|
||||
}
|
||||
|
||||
type ExamPrepListUnitsByCatalogCourseRow struct {
|
||||
TotalCount int64 `json:"total_count"`
|
||||
ID int64 `json:"id"`
|
||||
CatalogCourseID int64 `json:"catalog_course_id"`
|
||||
Name string `json:"name"`
|
||||
Description pgtype.Text `json:"description"`
|
||||
Thumbnail pgtype.Text `json:"thumbnail"`
|
||||
SortOrder int32 `json:"sort_order"`
|
||||
CreatedAt pgtype.Timestamptz `json:"created_at"`
|
||||
UpdatedAt pgtype.Timestamptz `json:"updated_at"`
|
||||
}
|
||||
|
||||
func (q *Queries) ExamPrepListUnitsByCatalogCourse(ctx context.Context, arg ExamPrepListUnitsByCatalogCourseParams) ([]ExamPrepListUnitsByCatalogCourseRow, error) {
|
||||
rows, err := q.db.Query(ctx, ExamPrepListUnitsByCatalogCourse, arg.CatalogCourseID, arg.Limit, arg.Offset)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
var items []ExamPrepListUnitsByCatalogCourseRow
|
||||
for rows.Next() {
|
||||
var i ExamPrepListUnitsByCatalogCourseRow
|
||||
if err := rows.Scan(
|
||||
&i.TotalCount,
|
||||
&i.ID,
|
||||
&i.CatalogCourseID,
|
||||
&i.Name,
|
||||
&i.Description,
|
||||
&i.Thumbnail,
|
||||
&i.SortOrder,
|
||||
&i.CreatedAt,
|
||||
&i.UpdatedAt,
|
||||
); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
items = append(items, i)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return items, nil
|
||||
}
|
||||
|
||||
const ExamPrepUpdateUnit = `-- name: ExamPrepUpdateUnit :one
|
||||
UPDATE exam_prep.units
|
||||
SET
|
||||
name = coalesce($1::varchar, name),
|
||||
description = coalesce($2::text, description),
|
||||
thumbnail = coalesce($3::text, thumbnail),
|
||||
sort_order = coalesce($4::int, sort_order),
|
||||
updated_at = CURRENT_TIMESTAMP
|
||||
WHERE id = $5
|
||||
RETURNING
|
||||
id, catalog_course_id, name, description, thumbnail, sort_order, created_at, updated_at
|
||||
`
|
||||
|
||||
type ExamPrepUpdateUnitParams struct {
|
||||
Name pgtype.Text `json:"name"`
|
||||
Description pgtype.Text `json:"description"`
|
||||
Thumbnail pgtype.Text `json:"thumbnail"`
|
||||
SortOrder pgtype.Int4 `json:"sort_order"`
|
||||
ID int64 `json:"id"`
|
||||
}
|
||||
|
||||
func (q *Queries) ExamPrepUpdateUnit(ctx context.Context, arg ExamPrepUpdateUnitParams) (ExamPrepUnit, error) {
|
||||
row := q.db.QueryRow(ctx, ExamPrepUpdateUnit,
|
||||
arg.Name,
|
||||
arg.Description,
|
||||
arg.Thumbnail,
|
||||
arg.SortOrder,
|
||||
arg.ID,
|
||||
)
|
||||
var i ExamPrepUnit
|
||||
err := row.Scan(
|
||||
&i.ID,
|
||||
&i.CatalogCourseID,
|
||||
&i.Name,
|
||||
&i.Description,
|
||||
&i.Thumbnail,
|
||||
&i.SortOrder,
|
||||
&i.CreatedAt,
|
||||
&i.UpdatedAt,
|
||||
)
|
||||
return i, err
|
||||
}
|
||||
|
|
@ -129,7 +129,33 @@ SELECT
|
|||
c.thumbnail,
|
||||
c.sort_order,
|
||||
c.created_at,
|
||||
c.updated_at
|
||||
c.updated_at,
|
||||
(
|
||||
SELECT
|
||||
COUNT(*)::bigint
|
||||
FROM
|
||||
modules m
|
||||
WHERE
|
||||
m.course_id = c.id) AS module_count,
|
||||
(
|
||||
SELECT
|
||||
COUNT(*)::bigint
|
||||
FROM
|
||||
lessons l
|
||||
INNER JOIN modules m ON l.module_id = m.id
|
||||
WHERE
|
||||
m.course_id = c.id) AS lesson_count,
|
||||
-- Practices whose parent is the course only (lms_practices.course_id). Excludes
|
||||
-- practices linked via module_id or lesson_id, even for modules/lessons in this course.
|
||||
(
|
||||
SELECT
|
||||
COUNT(*)::bigint
|
||||
FROM
|
||||
lms_practices p
|
||||
WHERE
|
||||
p.course_id = c.id
|
||||
AND p.module_id IS NULL
|
||||
AND p.lesson_id IS NULL) AS practice_count
|
||||
FROM
|
||||
courses c
|
||||
WHERE
|
||||
|
|
@ -147,15 +173,18 @@ type ListCoursesByProgramIDParams struct {
|
|||
}
|
||||
|
||||
type ListCoursesByProgramIDRow struct {
|
||||
TotalCount int64 `json:"total_count"`
|
||||
ID int64 `json:"id"`
|
||||
ProgramID int64 `json:"program_id"`
|
||||
Name string `json:"name"`
|
||||
Description pgtype.Text `json:"description"`
|
||||
Thumbnail pgtype.Text `json:"thumbnail"`
|
||||
SortOrder int32 `json:"sort_order"`
|
||||
CreatedAt pgtype.Timestamptz `json:"created_at"`
|
||||
UpdatedAt pgtype.Timestamptz `json:"updated_at"`
|
||||
TotalCount int64 `json:"total_count"`
|
||||
ID int64 `json:"id"`
|
||||
ProgramID int64 `json:"program_id"`
|
||||
Name string `json:"name"`
|
||||
Description pgtype.Text `json:"description"`
|
||||
Thumbnail pgtype.Text `json:"thumbnail"`
|
||||
SortOrder int32 `json:"sort_order"`
|
||||
CreatedAt pgtype.Timestamptz `json:"created_at"`
|
||||
UpdatedAt pgtype.Timestamptz `json:"updated_at"`
|
||||
ModuleCount int64 `json:"module_count"`
|
||||
LessonCount int64 `json:"lesson_count"`
|
||||
PracticeCount int64 `json:"practice_count"`
|
||||
}
|
||||
|
||||
func (q *Queries) ListCoursesByProgramID(ctx context.Context, arg ListCoursesByProgramIDParams) ([]ListCoursesByProgramIDRow, error) {
|
||||
|
|
@ -177,6 +206,9 @@ func (q *Queries) ListCoursesByProgramID(ctx context.Context, arg ListCoursesByP
|
|||
&i.SortOrder,
|
||||
&i.CreatedAt,
|
||||
&i.UpdatedAt,
|
||||
&i.ModuleCount,
|
||||
&i.LessonCount,
|
||||
&i.PracticeCount,
|
||||
); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
|
|
|||
|
|
@ -7,6 +7,8 @@ package dbgen
|
|||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/jackc/pgx/v5/pgtype"
|
||||
)
|
||||
|
||||
const CountCoursesInProgram = `-- name: CountCoursesInProgram :one
|
||||
|
|
@ -94,6 +96,65 @@ func (q *Queries) CountModulesInCourse(ctx context.Context, courseID int64) (int
|
|||
return n, err
|
||||
}
|
||||
|
||||
const CountPublishedPracticesInCourse = `-- name: CountPublishedPracticesInCourse :one
|
||||
SELECT
|
||||
count(*)::int AS n
|
||||
FROM
|
||||
lms_practices lp
|
||||
INNER JOIN question_sets qs ON qs.id = lp.question_set_id
|
||||
WHERE
|
||||
lp.course_id = $1
|
||||
AND qs.set_type = 'PRACTICE'
|
||||
AND qs.status = 'PUBLISHED'
|
||||
`
|
||||
|
||||
func (q *Queries) CountPublishedPracticesInCourse(ctx context.Context, courseID pgtype.Int8) (int32, error) {
|
||||
row := q.db.QueryRow(ctx, CountPublishedPracticesInCourse, courseID)
|
||||
var n int32
|
||||
err := row.Scan(&n)
|
||||
return n, err
|
||||
}
|
||||
|
||||
const CountPublishedPracticesInModule = `-- name: CountPublishedPracticesInModule :one
|
||||
SELECT
|
||||
count(*)::int AS n
|
||||
FROM
|
||||
lms_practices lp
|
||||
INNER JOIN question_sets qs ON qs.id = lp.question_set_id
|
||||
WHERE
|
||||
lp.module_id = $1
|
||||
AND qs.set_type = 'PRACTICE'
|
||||
AND qs.status = 'PUBLISHED'
|
||||
`
|
||||
|
||||
// Published practices in a module (module-level and lesson-level practices should carry module_id).
|
||||
func (q *Queries) CountPublishedPracticesInModule(ctx context.Context, moduleID pgtype.Int8) (int32, error) {
|
||||
row := q.db.QueryRow(ctx, CountPublishedPracticesInModule, moduleID)
|
||||
var n int32
|
||||
err := row.Scan(&n)
|
||||
return n, err
|
||||
}
|
||||
|
||||
const CountPublishedPracticesInProgram = `-- name: CountPublishedPracticesInProgram :one
|
||||
SELECT
|
||||
count(*)::int AS n
|
||||
FROM
|
||||
lms_practices lp
|
||||
INNER JOIN courses c ON c.id = lp.course_id
|
||||
INNER JOIN question_sets qs ON qs.id = lp.question_set_id
|
||||
WHERE
|
||||
c.program_id = $1
|
||||
AND qs.set_type = 'PRACTICE'
|
||||
AND qs.status = 'PUBLISHED'
|
||||
`
|
||||
|
||||
func (q *Queries) CountPublishedPracticesInProgram(ctx context.Context, programID int64) (int32, error) {
|
||||
row := q.db.QueryRow(ctx, CountPublishedPracticesInProgram, programID)
|
||||
var n int32
|
||||
err := row.Scan(&n)
|
||||
return n, err
|
||||
}
|
||||
|
||||
const CountUserCompletedCoursesInProgram = `-- name: CountUserCompletedCoursesInProgram :one
|
||||
SELECT
|
||||
count(*)::int AS n
|
||||
|
|
@ -212,6 +273,122 @@ func (q *Queries) CountUserCompletedModulesInCourse(ctx context.Context, arg Cou
|
|||
return n, err
|
||||
}
|
||||
|
||||
const CountUserCompletedPublishedPracticesInCourse = `-- name: CountUserCompletedPublishedPracticesInCourse :one
|
||||
SELECT
|
||||
count(*)::int AS n
|
||||
FROM
|
||||
lms_practices lp
|
||||
INNER JOIN question_sets qs ON qs.id = lp.question_set_id
|
||||
INNER JOIN user_practice_progress upp ON upp.question_set_id = lp.question_set_id
|
||||
WHERE
|
||||
lp.course_id = $1
|
||||
AND upp.user_id = $2
|
||||
AND upp.completed_at IS NOT NULL
|
||||
AND qs.set_type = 'PRACTICE'
|
||||
AND qs.status = 'PUBLISHED'
|
||||
`
|
||||
|
||||
type CountUserCompletedPublishedPracticesInCourseParams struct {
|
||||
CourseID pgtype.Int8 `json:"course_id"`
|
||||
UserID int64 `json:"user_id"`
|
||||
}
|
||||
|
||||
func (q *Queries) CountUserCompletedPublishedPracticesInCourse(ctx context.Context, arg CountUserCompletedPublishedPracticesInCourseParams) (int32, error) {
|
||||
row := q.db.QueryRow(ctx, CountUserCompletedPublishedPracticesInCourse, arg.CourseID, arg.UserID)
|
||||
var n int32
|
||||
err := row.Scan(&n)
|
||||
return n, err
|
||||
}
|
||||
|
||||
const CountUserCompletedPublishedPracticesInModule = `-- name: CountUserCompletedPublishedPracticesInModule :one
|
||||
SELECT
|
||||
count(*)::int AS n
|
||||
FROM
|
||||
lms_practices lp
|
||||
INNER JOIN question_sets qs ON qs.id = lp.question_set_id
|
||||
INNER JOIN user_practice_progress upp ON upp.question_set_id = lp.question_set_id
|
||||
WHERE
|
||||
lp.module_id = $1
|
||||
AND upp.user_id = $2
|
||||
AND upp.completed_at IS NOT NULL
|
||||
AND qs.set_type = 'PRACTICE'
|
||||
AND qs.status = 'PUBLISHED'
|
||||
`
|
||||
|
||||
type CountUserCompletedPublishedPracticesInModuleParams struct {
|
||||
ModuleID pgtype.Int8 `json:"module_id"`
|
||||
UserID int64 `json:"user_id"`
|
||||
}
|
||||
|
||||
func (q *Queries) CountUserCompletedPublishedPracticesInModule(ctx context.Context, arg CountUserCompletedPublishedPracticesInModuleParams) (int32, error) {
|
||||
row := q.db.QueryRow(ctx, CountUserCompletedPublishedPracticesInModule, arg.ModuleID, arg.UserID)
|
||||
var n int32
|
||||
err := row.Scan(&n)
|
||||
return n, err
|
||||
}
|
||||
|
||||
const CountUserCompletedPublishedPracticesInProgram = `-- name: CountUserCompletedPublishedPracticesInProgram :one
|
||||
SELECT
|
||||
count(*)::int AS n
|
||||
FROM
|
||||
lms_practices lp
|
||||
INNER JOIN courses c ON c.id = lp.course_id
|
||||
INNER JOIN question_sets qs ON qs.id = lp.question_set_id
|
||||
INNER JOIN user_practice_progress upp ON upp.question_set_id = lp.question_set_id
|
||||
WHERE
|
||||
c.program_id = $1
|
||||
AND upp.user_id = $2
|
||||
AND upp.completed_at IS NOT NULL
|
||||
AND qs.set_type = 'PRACTICE'
|
||||
AND qs.status = 'PUBLISHED'
|
||||
`
|
||||
|
||||
type CountUserCompletedPublishedPracticesInProgramParams struct {
|
||||
ProgramID int64 `json:"program_id"`
|
||||
UserID int64 `json:"user_id"`
|
||||
}
|
||||
|
||||
func (q *Queries) CountUserCompletedPublishedPracticesInProgram(ctx context.Context, arg CountUserCompletedPublishedPracticesInProgramParams) (int32, error) {
|
||||
row := q.db.QueryRow(ctx, CountUserCompletedPublishedPracticesInProgram, arg.ProgramID, arg.UserID)
|
||||
var n int32
|
||||
err := row.Scan(&n)
|
||||
return n, err
|
||||
}
|
||||
|
||||
const GetPracticeScopeByQuestionSetID = `-- name: GetPracticeScopeByQuestionSetID :one
|
||||
SELECT
|
||||
id,
|
||||
course_id,
|
||||
module_id,
|
||||
lesson_id
|
||||
FROM
|
||||
lms_practices
|
||||
WHERE
|
||||
question_set_id = $1
|
||||
ORDER BY
|
||||
id DESC
|
||||
LIMIT 1
|
||||
`
|
||||
|
||||
type GetPracticeScopeByQuestionSetIDRow struct {
|
||||
ID int64 `json:"id"`
|
||||
CourseID pgtype.Int8 `json:"course_id"`
|
||||
ModuleID pgtype.Int8 `json:"module_id"`
|
||||
LessonID pgtype.Int8 `json:"lesson_id"`
|
||||
}
|
||||
|
||||
func (q *Queries) GetPracticeScopeByQuestionSetID(ctx context.Context, questionSetID int64) (GetPracticeScopeByQuestionSetIDRow, error) {
|
||||
row := q.db.QueryRow(ctx, GetPracticeScopeByQuestionSetID, questionSetID)
|
||||
var i GetPracticeScopeByQuestionSetIDRow
|
||||
err := row.Scan(
|
||||
&i.ID,
|
||||
&i.CourseID,
|
||||
&i.ModuleID,
|
||||
&i.LessonID,
|
||||
)
|
||||
return i, err
|
||||
}
|
||||
|
||||
const GetPreviousCourseInProgram = `-- name: GetPreviousCourseInProgram :one
|
||||
SELECT
|
||||
c2.id, c2.program_id, c2.name, c2.description, c2.thumbnail, c2.created_at, c2.updated_at, c2.sort_order
|
||||
|
|
|
|||
|
|
@ -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"`
|
||||
|
|
|
|||
|
|
@ -48,7 +48,8 @@ const GetOtp = `-- name: GetOtp :one
|
|||
SELECT id, user_id, sent_to, medium, otp_for, otp, used, used_at, created_at, expires_at
|
||||
FROM otps
|
||||
WHERE user_id = $1
|
||||
ORDER BY created_at DESC LIMIT 1
|
||||
ORDER BY id DESC
|
||||
LIMIT 1
|
||||
`
|
||||
|
||||
type GetOtpRow struct {
|
||||
|
|
@ -82,6 +83,51 @@ func (q *Queries) GetOtp(ctx context.Context, userID int64) (GetOtpRow, error) {
|
|||
return i, err
|
||||
}
|
||||
|
||||
const GetOtpByCode = `-- name: GetOtpByCode :one
|
||||
SELECT id, user_id, sent_to, medium, otp_for, otp, used, used_at, created_at, expires_at
|
||||
FROM otps
|
||||
WHERE user_id = $1
|
||||
AND otp = $2
|
||||
ORDER BY id DESC
|
||||
LIMIT 1
|
||||
`
|
||||
|
||||
type GetOtpByCodeParams struct {
|
||||
UserID int64 `json:"user_id"`
|
||||
Otp string `json:"otp"`
|
||||
}
|
||||
|
||||
type GetOtpByCodeRow struct {
|
||||
ID int64 `json:"id"`
|
||||
UserID int64 `json:"user_id"`
|
||||
SentTo string `json:"sent_to"`
|
||||
Medium string `json:"medium"`
|
||||
OtpFor string `json:"otp_for"`
|
||||
Otp string `json:"otp"`
|
||||
Used bool `json:"used"`
|
||||
UsedAt pgtype.Timestamptz `json:"used_at"`
|
||||
CreatedAt pgtype.Timestamptz `json:"created_at"`
|
||||
ExpiresAt pgtype.Timestamptz `json:"expires_at"`
|
||||
}
|
||||
|
||||
func (q *Queries) GetOtpByCode(ctx context.Context, arg GetOtpByCodeParams) (GetOtpByCodeRow, error) {
|
||||
row := q.db.QueryRow(ctx, GetOtpByCode, arg.UserID, arg.Otp)
|
||||
var i GetOtpByCodeRow
|
||||
err := row.Scan(
|
||||
&i.ID,
|
||||
&i.UserID,
|
||||
&i.SentTo,
|
||||
&i.Medium,
|
||||
&i.OtpFor,
|
||||
&i.Otp,
|
||||
&i.Used,
|
||||
&i.UsedAt,
|
||||
&i.CreatedAt,
|
||||
&i.ExpiresAt,
|
||||
)
|
||||
return i, err
|
||||
}
|
||||
|
||||
const MarkOtpAsUsed = `-- name: MarkOtpAsUsed :exec
|
||||
UPDATE otps
|
||||
SET used = TRUE, used_at = $2
|
||||
|
|
|
|||
|
|
@ -69,27 +69,32 @@ SELECT
|
|||
q.explanation,
|
||||
q.tips,
|
||||
q.voice_prompt,
|
||||
q.image_url
|
||||
q.sample_answer_voice_prompt,
|
||||
q.image_url,
|
||||
qaa.correct_answer_text AS audio_correct_answer_text
|
||||
FROM question_set_items qsi
|
||||
JOIN questions q ON q.id = qsi.question_id
|
||||
LEFT JOIN question_audio_answers qaa ON qaa.question_id = q.id
|
||||
WHERE qsi.set_id = $1
|
||||
AND q.status = 'PUBLISHED'
|
||||
ORDER BY qsi.display_order
|
||||
`
|
||||
|
||||
type GetPublishedQuestionsInSetRow struct {
|
||||
ID int64 `json:"id"`
|
||||
SetID int64 `json:"set_id"`
|
||||
QuestionID int64 `json:"question_id"`
|
||||
DisplayOrder int32 `json:"display_order"`
|
||||
QuestionText string `json:"question_text"`
|
||||
QuestionType string `json:"question_type"`
|
||||
DifficultyLevel pgtype.Text `json:"difficulty_level"`
|
||||
Points int32 `json:"points"`
|
||||
Explanation pgtype.Text `json:"explanation"`
|
||||
Tips pgtype.Text `json:"tips"`
|
||||
VoicePrompt pgtype.Text `json:"voice_prompt"`
|
||||
ImageUrl pgtype.Text `json:"image_url"`
|
||||
ID int64 `json:"id"`
|
||||
SetID int64 `json:"set_id"`
|
||||
QuestionID int64 `json:"question_id"`
|
||||
DisplayOrder int32 `json:"display_order"`
|
||||
QuestionText string `json:"question_text"`
|
||||
QuestionType string `json:"question_type"`
|
||||
DifficultyLevel pgtype.Text `json:"difficulty_level"`
|
||||
Points int32 `json:"points"`
|
||||
Explanation pgtype.Text `json:"explanation"`
|
||||
Tips pgtype.Text `json:"tips"`
|
||||
VoicePrompt pgtype.Text `json:"voice_prompt"`
|
||||
SampleAnswerVoicePrompt pgtype.Text `json:"sample_answer_voice_prompt"`
|
||||
ImageUrl pgtype.Text `json:"image_url"`
|
||||
AudioCorrectAnswerText pgtype.Text `json:"audio_correct_answer_text"`
|
||||
}
|
||||
|
||||
func (q *Queries) GetPublishedQuestionsInSet(ctx context.Context, setID int64) ([]GetPublishedQuestionsInSetRow, error) {
|
||||
|
|
@ -113,7 +118,9 @@ func (q *Queries) GetPublishedQuestionsInSet(ctx context.Context, setID int64) (
|
|||
&i.Explanation,
|
||||
&i.Tips,
|
||||
&i.VoicePrompt,
|
||||
&i.SampleAnswerVoicePrompt,
|
||||
&i.ImageUrl,
|
||||
&i.AudioCorrectAnswerText,
|
||||
); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
|
@ -138,29 +145,34 @@ SELECT
|
|||
q.explanation,
|
||||
q.tips,
|
||||
q.voice_prompt,
|
||||
q.sample_answer_voice_prompt,
|
||||
q.image_url,
|
||||
q.status as question_status
|
||||
q.status as question_status,
|
||||
qaa.correct_answer_text AS audio_correct_answer_text
|
||||
FROM question_set_items qsi
|
||||
JOIN questions q ON q.id = qsi.question_id
|
||||
LEFT JOIN question_audio_answers qaa ON qaa.question_id = q.id
|
||||
WHERE qsi.set_id = $1
|
||||
AND q.status != 'ARCHIVED'
|
||||
ORDER BY qsi.display_order
|
||||
`
|
||||
|
||||
type GetQuestionSetItemsRow struct {
|
||||
ID int64 `json:"id"`
|
||||
SetID int64 `json:"set_id"`
|
||||
QuestionID int64 `json:"question_id"`
|
||||
DisplayOrder int32 `json:"display_order"`
|
||||
QuestionText string `json:"question_text"`
|
||||
QuestionType string `json:"question_type"`
|
||||
DifficultyLevel pgtype.Text `json:"difficulty_level"`
|
||||
Points int32 `json:"points"`
|
||||
Explanation pgtype.Text `json:"explanation"`
|
||||
Tips pgtype.Text `json:"tips"`
|
||||
VoicePrompt pgtype.Text `json:"voice_prompt"`
|
||||
ImageUrl pgtype.Text `json:"image_url"`
|
||||
QuestionStatus string `json:"question_status"`
|
||||
ID int64 `json:"id"`
|
||||
SetID int64 `json:"set_id"`
|
||||
QuestionID int64 `json:"question_id"`
|
||||
DisplayOrder int32 `json:"display_order"`
|
||||
QuestionText string `json:"question_text"`
|
||||
QuestionType string `json:"question_type"`
|
||||
DifficultyLevel pgtype.Text `json:"difficulty_level"`
|
||||
Points int32 `json:"points"`
|
||||
Explanation pgtype.Text `json:"explanation"`
|
||||
Tips pgtype.Text `json:"tips"`
|
||||
VoicePrompt pgtype.Text `json:"voice_prompt"`
|
||||
SampleAnswerVoicePrompt pgtype.Text `json:"sample_answer_voice_prompt"`
|
||||
ImageUrl pgtype.Text `json:"image_url"`
|
||||
QuestionStatus string `json:"question_status"`
|
||||
AudioCorrectAnswerText pgtype.Text `json:"audio_correct_answer_text"`
|
||||
}
|
||||
|
||||
func (q *Queries) GetQuestionSetItems(ctx context.Context, setID int64) ([]GetQuestionSetItemsRow, error) {
|
||||
|
|
@ -184,8 +196,10 @@ func (q *Queries) GetQuestionSetItems(ctx context.Context, setID int64) ([]GetQu
|
|||
&i.Explanation,
|
||||
&i.Tips,
|
||||
&i.VoicePrompt,
|
||||
&i.SampleAnswerVoicePrompt,
|
||||
&i.ImageUrl,
|
||||
&i.QuestionStatus,
|
||||
&i.AudioCorrectAnswerText,
|
||||
); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2,8 +2,15 @@ package domain
|
|||
|
||||
import "time"
|
||||
|
||||
// DefaultCEFRCourseNames are the standard course names seeded for each program (migration 000048).
|
||||
// Creating a course via the API may use any of these or a custom name.
|
||||
// DefaultCEFRCoursesByProgramName maps seeded program names to default course names
|
||||
// (migrations 000048 and 000050). The API may still create courses with any name.
|
||||
var DefaultCEFRCoursesByProgramName = map[string][]string{
|
||||
"Beginner": {"A1", "A2"},
|
||||
"Intermediate": {"B1", "B2"},
|
||||
"Advanced": {"C1", "C2"},
|
||||
}
|
||||
|
||||
// DefaultCEFRCourseNames is every CEFR label used in DefaultCEFRCoursesByProgramName.
|
||||
var DefaultCEFRCourseNames = []string{"A1", "A2", "B1", "B2", "C1", "C2"}
|
||||
|
||||
// Course belongs to a Program.
|
||||
|
|
@ -16,7 +23,12 @@ type Course struct {
|
|||
SortOrder int `json:"sort_order"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt *time.Time `json:"updated_at,omitempty"`
|
||||
Access *LMSEntityAccess `json:"access,omitempty"`
|
||||
// Populated on list-by-program. Practice count: lms_practices rows with course_id = course only
|
||||
// (not practices attached to a module or lesson under this course).
|
||||
ModuleCount int `json:"module_count"`
|
||||
LessonCount int `json:"lesson_count"`
|
||||
PracticeCount int `json:"practice_count"`
|
||||
Access *LMSEntityAccess `json:"access,omitempty"`
|
||||
}
|
||||
|
||||
type CreateCourseInput struct {
|
||||
|
|
|
|||
27
internal/domain/exam_prep_catalog_course.go
Normal file
27
internal/domain/exam_prep_catalog_course.go
Normal file
|
|
@ -0,0 +1,27 @@
|
|||
package domain
|
||||
|
||||
import "time"
|
||||
|
||||
// ExamPrepCatalogCourse is a top-level exam-prep track (e.g. DET, IELTS) in schema exam_prep — separate from LMS Learn English courses.
|
||||
type ExamPrepCatalogCourse struct {
|
||||
ID int64 `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Description *string `json:"description,omitempty"`
|
||||
Thumbnail *string `json:"thumbnail,omitempty"`
|
||||
SortOrder int `json:"sort_order"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt *time.Time `json:"updated_at,omitempty"`
|
||||
}
|
||||
|
||||
type CreateExamPrepCatalogCourseInput struct {
|
||||
Name string `json:"name" validate:"required"`
|
||||
Description *string `json:"description,omitempty"`
|
||||
Thumbnail *string `json:"thumbnail,omitempty"`
|
||||
}
|
||||
|
||||
type UpdateExamPrepCatalogCourseInput struct {
|
||||
Name *string `json:"name,omitempty"`
|
||||
Description *string `json:"description,omitempty"`
|
||||
Thumbnail *string `json:"thumbnail,omitempty"`
|
||||
SortOrder *int `json:"sort_order,omitempty"`
|
||||
}
|
||||
31
internal/domain/exam_prep_lesson.go
Normal file
31
internal/domain/exam_prep_lesson.go
Normal file
|
|
@ -0,0 +1,31 @@
|
|||
package domain
|
||||
|
||||
import "time"
|
||||
|
||||
// ExamPrepLesson is a video lesson under an exam-prep unit module (exam_prep.unit_module_lessons).
|
||||
type ExamPrepLesson struct {
|
||||
ID int64 `json:"id"`
|
||||
UnitModuleID int64 `json:"unit_module_id"`
|
||||
Title string `json:"title"`
|
||||
VideoURL *string `json:"video_url,omitempty"`
|
||||
Thumbnail *string `json:"thumbnail,omitempty"`
|
||||
Description *string `json:"description,omitempty"`
|
||||
SortOrder int `json:"sort_order"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt *time.Time `json:"updated_at,omitempty"`
|
||||
}
|
||||
|
||||
type CreateExamPrepLessonInput struct {
|
||||
Title string `json:"title" validate:"required"`
|
||||
VideoURL *string `json:"video_url,omitempty"`
|
||||
Thumbnail *string `json:"thumbnail,omitempty"`
|
||||
Description *string `json:"description,omitempty"`
|
||||
}
|
||||
|
||||
type UpdateExamPrepLessonInput struct {
|
||||
Title *string `json:"title,omitempty"`
|
||||
VideoURL *string `json:"video_url,omitempty"`
|
||||
Thumbnail *string `json:"thumbnail,omitempty"`
|
||||
Description *string `json:"description,omitempty"`
|
||||
SortOrder *int `json:"sort_order,omitempty"`
|
||||
}
|
||||
31
internal/domain/exam_prep_module.go
Normal file
31
internal/domain/exam_prep_module.go
Normal file
|
|
@ -0,0 +1,31 @@
|
|||
package domain
|
||||
|
||||
import "time"
|
||||
|
||||
// ExamPrepModule is a module under an exam-prep unit (stored in exam_prep.unit_modules).
|
||||
type ExamPrepModule struct {
|
||||
ID int64 `json:"id"`
|
||||
UnitID int64 `json:"unit_id"`
|
||||
Name string `json:"name"`
|
||||
Description *string `json:"description,omitempty"`
|
||||
Thumbnail *string `json:"thumbnail,omitempty"`
|
||||
Icon *string `json:"icon,omitempty"`
|
||||
SortOrder int `json:"sort_order"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt *time.Time `json:"updated_at,omitempty"`
|
||||
}
|
||||
|
||||
type CreateExamPrepModuleInput struct {
|
||||
Name string `json:"name" validate:"required"`
|
||||
Description *string `json:"description,omitempty"`
|
||||
Thumbnail *string `json:"thumbnail,omitempty"`
|
||||
Icon *string `json:"icon,omitempty"`
|
||||
}
|
||||
|
||||
type UpdateExamPrepModuleInput struct {
|
||||
Name *string `json:"name,omitempty"`
|
||||
Description *string `json:"description,omitempty"`
|
||||
Thumbnail *string `json:"thumbnail,omitempty"`
|
||||
Icon *string `json:"icon,omitempty"`
|
||||
SortOrder *int `json:"sort_order,omitempty"`
|
||||
}
|
||||
36
internal/domain/exam_prep_practice.go
Normal file
36
internal/domain/exam_prep_practice.go
Normal file
|
|
@ -0,0 +1,36 @@
|
|||
package domain
|
||||
|
||||
import "time"
|
||||
|
||||
// ExamPrepPractice is question-set content tied to an exam-prep lesson; uses shared question_sets / questions.
|
||||
type ExamPrepPractice struct {
|
||||
ID int64 `json:"id"`
|
||||
LessonID int64 `json:"lesson_id"` // exam_prep.unit_module_lessons.id
|
||||
Title string `json:"title"`
|
||||
StoryDescription *string `json:"story_description,omitempty"`
|
||||
StoryImage *string `json:"story_image,omitempty"`
|
||||
PersonaID *int64 `json:"persona_id,omitempty"`
|
||||
QuestionSetID int64 `json:"question_set_id"`
|
||||
QuickTips *string `json:"quick_tips,omitempty"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt *time.Time `json:"updated_at,omitempty"`
|
||||
}
|
||||
|
||||
// CreateExamPrepPracticeInput is the body for POST .../exam-prep/lessons/{lessonId}/practices (lesson from path).
|
||||
type CreateExamPrepPracticeInput struct {
|
||||
Title string `json:"title" validate:"required"`
|
||||
StoryDescription *string `json:"story_description,omitempty"`
|
||||
StoryImage *string `json:"story_image,omitempty"`
|
||||
PersonaID *int64 `json:"persona_id,omitempty"`
|
||||
QuestionSetID int64 `json:"question_set_id" validate:"required,gt=0"`
|
||||
QuickTips *string `json:"quick_tips,omitempty"`
|
||||
}
|
||||
|
||||
type UpdateExamPrepPracticeInput struct {
|
||||
Title *string `json:"title,omitempty"`
|
||||
StoryDescription *string `json:"story_description,omitempty"`
|
||||
StoryImage *string `json:"story_image,omitempty"`
|
||||
PersonaID *int64 `json:"persona_id,omitempty"`
|
||||
QuestionSetID *int64 `json:"question_set_id,omitempty"`
|
||||
QuickTips *string `json:"quick_tips,omitempty"`
|
||||
}
|
||||
28
internal/domain/exam_prep_unit.go
Normal file
28
internal/domain/exam_prep_unit.go
Normal file
|
|
@ -0,0 +1,28 @@
|
|||
package domain
|
||||
|
||||
import "time"
|
||||
|
||||
// ExamPrepUnit is a chapter-like grouping under an exam-prep catalog course (schema exam_prep.units).
|
||||
type ExamPrepUnit struct {
|
||||
ID int64 `json:"id"`
|
||||
CatalogCourseID int64 `json:"catalog_course_id"`
|
||||
Name string `json:"name"`
|
||||
Description *string `json:"description,omitempty"`
|
||||
Thumbnail *string `json:"thumbnail,omitempty"`
|
||||
SortOrder int `json:"sort_order"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt *time.Time `json:"updated_at,omitempty"`
|
||||
}
|
||||
|
||||
type CreateExamPrepUnitInput struct {
|
||||
Name string `json:"name" validate:"required"`
|
||||
Description *string `json:"description,omitempty"`
|
||||
Thumbnail *string `json:"thumbnail,omitempty"`
|
||||
}
|
||||
|
||||
type UpdateExamPrepUnitInput struct {
|
||||
Name *string `json:"name,omitempty"`
|
||||
Description *string `json:"description,omitempty"`
|
||||
Thumbnail *string `json:"thumbnail,omitempty"`
|
||||
SortOrder *int `json:"sort_order,omitempty"`
|
||||
}
|
||||
223
internal/domain/question_type_builder.go
Normal file
223
internal/domain/question_type_builder.go
Normal file
|
|
@ -0,0 +1,223 @@
|
|||
package domain
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"sort"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// Stimulus-side components for the question-type builder (Section A — question input types).
|
||||
type StimulusComponentKind string
|
||||
|
||||
const (
|
||||
StimulusPrepTime StimulusComponentKind = "PREP_TIME"
|
||||
StimulusInstruction StimulusComponentKind = "INSTRUCTION"
|
||||
StimulusAudioClip StimulusComponentKind = "AUDIO_CLIP"
|
||||
StimulusTextPassage StimulusComponentKind = "TEXT_PASSAGE"
|
||||
StimulusImage StimulusComponentKind = "IMAGE"
|
||||
StimulusMatchingInputs StimulusComponentKind = "MATCHING_INPUTS"
|
||||
StimulusSelectMissingWords StimulusComponentKind = "SELECT_MISSING_WORDS"
|
||||
StimulusTable StimulusComponentKind = "TABLE"
|
||||
StimulusFlowChart StimulusComponentKind = "FLOW_CHART"
|
||||
)
|
||||
|
||||
// Response-side components for the question-type builder (Section B — answer types).
|
||||
type ResponseComponentKind string
|
||||
|
||||
const (
|
||||
ResponseAudioResponse ResponseComponentKind = "AUDIO_RESPONSE"
|
||||
ResponseTextInput ResponseComponentKind = "TEXT_INPUT"
|
||||
ResponseShortAnswer ResponseComponentKind = "SHORT_ANSWER"
|
||||
ResponseMultipleChoice ResponseComponentKind = "MULTIPLE_CHOICE"
|
||||
ResponseAnswerTimer ResponseComponentKind = "ANSWER_TIMER"
|
||||
ResponseSelectMissingWords ResponseComponentKind = "SELECT_MISSING_WORDS"
|
||||
ResponsePDFUpload ResponseComponentKind = "PDF_UPLOAD"
|
||||
ResponseMatchingAnswer ResponseComponentKind = "MATCHING_ANSWER"
|
||||
ResponseLabelSelection ResponseComponentKind = "LABEL_SELECTION"
|
||||
)
|
||||
|
||||
var (
|
||||
stimulusCatalog = []StimulusComponentKind{
|
||||
StimulusPrepTime,
|
||||
StimulusInstruction,
|
||||
StimulusAudioClip,
|
||||
StimulusTextPassage,
|
||||
StimulusImage,
|
||||
StimulusMatchingInputs,
|
||||
StimulusSelectMissingWords,
|
||||
StimulusTable,
|
||||
StimulusFlowChart,
|
||||
}
|
||||
stimulusSet map[string]struct{}
|
||||
|
||||
responseCatalog = []ResponseComponentKind{
|
||||
ResponseAudioResponse,
|
||||
ResponseTextInput,
|
||||
ResponseShortAnswer,
|
||||
ResponseMultipleChoice,
|
||||
ResponseAnswerTimer,
|
||||
ResponseSelectMissingWords,
|
||||
ResponsePDFUpload,
|
||||
ResponseMatchingAnswer,
|
||||
ResponseLabelSelection,
|
||||
}
|
||||
responseSet map[string]struct{}
|
||||
|
||||
// responseKindsAuxiliary are allowed but cannot be the only selected answer kinds.
|
||||
responseKindsAuxiliary = map[string]struct{}{
|
||||
string(ResponseAnswerTimer): {},
|
||||
}
|
||||
)
|
||||
|
||||
func init() {
|
||||
stimulusSet = make(map[string]struct{}, len(stimulusCatalog))
|
||||
for _, k := range stimulusCatalog {
|
||||
stimulusSet[string(k)] = struct{}{}
|
||||
}
|
||||
responseSet = make(map[string]struct{}, len(responseCatalog))
|
||||
for _, k := range responseCatalog {
|
||||
responseSet[string(k)] = struct{}{}
|
||||
}
|
||||
}
|
||||
|
||||
// StimulusComponentCatalog returns all valid stimulus component kind strings (stable order).
|
||||
func StimulusComponentCatalog() []string {
|
||||
out := make([]string, len(stimulusCatalog))
|
||||
for i, k := range stimulusCatalog {
|
||||
out[i] = string(k)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// ResponseComponentCatalog returns all valid response component kind strings (stable order).
|
||||
func ResponseComponentCatalog() []string {
|
||||
out := make([]string, len(responseCatalog))
|
||||
for i, k := range responseCatalog {
|
||||
out[i] = string(k)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// IsValidStimulusComponentKind reports whether s is a known stimulus component kind.
|
||||
func IsValidStimulusComponentKind(s string) bool {
|
||||
_, ok := stimulusSet[s]
|
||||
return ok
|
||||
}
|
||||
|
||||
// IsValidResponseComponentKind reports whether s is a known response component kind.
|
||||
func IsValidResponseComponentKind(s string) bool {
|
||||
_, ok := responseSet[s]
|
||||
return ok
|
||||
}
|
||||
|
||||
// ValidateDynamicQuestionTypeDefinition checks stimulus and response component selections for a
|
||||
// temporary dynamic question-type definition. Empty or duplicate entries, unknown kinds, and
|
||||
// disallowed combinations return a non-nil error.
|
||||
func ValidateDynamicQuestionTypeDefinition(stimulusKinds, responseKinds []string) error {
|
||||
var errs []string
|
||||
|
||||
stimulus := normalizeKindList(stimulusKinds)
|
||||
response := normalizeKindList(responseKinds)
|
||||
|
||||
if len(stimulus) == 0 {
|
||||
errs = append(errs, "at least one stimulus (question input) component is required")
|
||||
}
|
||||
if len(response) == 0 {
|
||||
errs = append(errs, "at least one response component is required")
|
||||
}
|
||||
|
||||
for i, k := range stimulus {
|
||||
if k == "" {
|
||||
errs = append(errs, fmt.Sprintf("stimulus [%d]: empty component kind", i))
|
||||
continue
|
||||
}
|
||||
if !IsValidStimulusComponentKind(k) {
|
||||
errs = append(errs, fmt.Sprintf("stimulus: unknown component kind %q", k))
|
||||
}
|
||||
}
|
||||
|
||||
for i, k := range response {
|
||||
if k == "" {
|
||||
errs = append(errs, fmt.Sprintf("response [%d]: empty component kind", i))
|
||||
continue
|
||||
}
|
||||
if !IsValidResponseComponentKind(k) {
|
||||
errs = append(errs, fmt.Sprintf("response: unknown component kind %q", k))
|
||||
}
|
||||
}
|
||||
|
||||
if dup := findDuplicates(stimulus); len(dup) > 0 {
|
||||
errs = append(errs, fmt.Sprintf("stimulus: duplicate component kinds: %s", strings.Join(dup, ", ")))
|
||||
}
|
||||
if dup := findDuplicates(response); len(dup) > 0 {
|
||||
errs = append(errs, fmt.Sprintf("response: duplicate component kinds: %s", strings.Join(dup, ", ")))
|
||||
}
|
||||
|
||||
countPrep := 0
|
||||
for _, k := range stimulus {
|
||||
if k == string(StimulusPrepTime) {
|
||||
countPrep++
|
||||
}
|
||||
}
|
||||
if countPrep > 1 {
|
||||
errs = append(errs, "stimulus: at most one PREP_TIME is allowed")
|
||||
}
|
||||
|
||||
countAnsTimer := 0
|
||||
for _, k := range response {
|
||||
if k == string(ResponseAnswerTimer) {
|
||||
countAnsTimer++
|
||||
}
|
||||
}
|
||||
if countAnsTimer > 1 {
|
||||
errs = append(errs, "response: at most one ANSWER_TIMER is allowed")
|
||||
}
|
||||
|
||||
if len(response) > 0 && onlyAuxiliaryResponseKinds(response) {
|
||||
errs = append(errs, "response: at least one non-timer answer component is required (ANSWER_TIMER alone is not sufficient)")
|
||||
}
|
||||
|
||||
if len(errs) == 0 {
|
||||
return nil
|
||||
}
|
||||
return fmt.Errorf("%s", strings.Join(errs, "; "))
|
||||
}
|
||||
|
||||
func normalizeKindList(in []string) []string {
|
||||
var out []string
|
||||
for _, s := range in {
|
||||
t := strings.TrimSpace(s)
|
||||
if t == "" {
|
||||
continue
|
||||
}
|
||||
out = append(out, t)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func findDuplicates(kinds []string) []string {
|
||||
seen := make(map[string]int)
|
||||
for _, k := range kinds {
|
||||
seen[k]++
|
||||
}
|
||||
var dups []string
|
||||
for k, n := range seen {
|
||||
if n > 1 {
|
||||
dups = append(dups, k)
|
||||
}
|
||||
}
|
||||
sort.Strings(dups)
|
||||
return dups
|
||||
}
|
||||
|
||||
func onlyAuxiliaryResponseKinds(response []string) bool {
|
||||
if len(response) == 0 {
|
||||
return false
|
||||
}
|
||||
for _, k := range response {
|
||||
if _, aux := responseKindsAuxiliary[k]; !aux {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
66
internal/domain/question_type_builder_test.go
Normal file
66
internal/domain/question_type_builder_test.go
Normal file
|
|
@ -0,0 +1,66 @@
|
|||
package domain
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestValidateDynamicQuestionTypeDefinition_valid(t *testing.T) {
|
||||
err := ValidateDynamicQuestionTypeDefinition(
|
||||
[]string{"INSTRUCTION", "IMAGE"},
|
||||
[]string{"AUDIO_RESPONSE"},
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatalf("expected nil, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateDynamicQuestionTypeDefinition_unknownStimulus(t *testing.T) {
|
||||
err := ValidateDynamicQuestionTypeDefinition(
|
||||
[]string{"NOT_A_KIND"},
|
||||
[]string{"SHORT_ANSWER"},
|
||||
)
|
||||
if err == nil || !strings.Contains(err.Error(), "unknown") {
|
||||
t.Fatalf("expected unknown stimulus error, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateDynamicQuestionTypeDefinition_emptyResponse(t *testing.T) {
|
||||
err := ValidateDynamicQuestionTypeDefinition(
|
||||
[]string{"INSTRUCTION"},
|
||||
nil,
|
||||
)
|
||||
if err == nil || !strings.Contains(err.Error(), "at least one response") {
|
||||
t.Fatalf("expected empty response error, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateDynamicQuestionTypeDefinition_duplicateStimulus(t *testing.T) {
|
||||
err := ValidateDynamicQuestionTypeDefinition(
|
||||
[]string{"IMAGE", "IMAGE"},
|
||||
[]string{"MULTIPLE_CHOICE"},
|
||||
)
|
||||
if err == nil || !strings.Contains(err.Error(), "duplicate") {
|
||||
t.Fatalf("expected duplicate error, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateDynamicQuestionTypeDefinition_timerOnlyResponse(t *testing.T) {
|
||||
err := ValidateDynamicQuestionTypeDefinition(
|
||||
[]string{"TEXT_PASSAGE"},
|
||||
[]string{"ANSWER_TIMER"},
|
||||
)
|
||||
if err == nil || !strings.Contains(err.Error(), "ANSWER_TIMER alone") {
|
||||
t.Fatalf("expected auxiliary-only error, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateDynamicQuestionTypeDefinition_twoPrepTimes(t *testing.T) {
|
||||
err := ValidateDynamicQuestionTypeDefinition(
|
||||
[]string{"PREP_TIME", "PREP_TIME", "INSTRUCTION"},
|
||||
[]string{"TEXT_INPUT"},
|
||||
)
|
||||
if err == nil || !strings.Contains(err.Error(), "PREP_TIME") {
|
||||
t.Fatalf("expected at most one PREP_TIME, got %v", err)
|
||||
}
|
||||
}
|
||||
|
|
@ -14,6 +14,10 @@ type Client struct {
|
|||
bucketName string
|
||||
}
|
||||
|
||||
func (c *Client) BucketName() string {
|
||||
return c.bucketName
|
||||
}
|
||||
|
||||
func NewClient(endpoint, accessKey, secretKey string, useSSL bool, bucketName string) (*Client, error) {
|
||||
mc, err := minio.New(endpoint, &minio.Options{
|
||||
Creds: credentials.NewStaticV4(accessKey, secretKey, ""),
|
||||
|
|
|
|||
17
internal/ports/exam_prep_catalog_course.go
Normal file
17
internal/ports/exam_prep_catalog_course.go
Normal file
|
|
@ -0,0 +1,17 @@
|
|||
package ports
|
||||
|
||||
import (
|
||||
"Yimaru-Backend/internal/domain"
|
||||
"context"
|
||||
)
|
||||
|
||||
// ExamPrepCatalogCourseStore persists exam_prep.catalog_courses (DET / IELTS / … tracks).
|
||||
type ExamPrepCatalogCourseStore interface {
|
||||
CreateExamPrepCatalogCourse(ctx context.Context, input domain.CreateExamPrepCatalogCourseInput) (domain.ExamPrepCatalogCourse, error)
|
||||
GetExamPrepCatalogCourseByID(ctx context.Context, id int64) (domain.ExamPrepCatalogCourse, error)
|
||||
ListExamPrepCatalogCourses(ctx context.Context, limit, offset int32) ([]domain.ExamPrepCatalogCourse, int64, error)
|
||||
ListAllExamPrepCatalogCourseIDs(ctx context.Context) ([]int64, error)
|
||||
UpdateExamPrepCatalogCourse(ctx context.Context, id int64, input domain.UpdateExamPrepCatalogCourseInput) (domain.ExamPrepCatalogCourse, error)
|
||||
DeleteExamPrepCatalogCourse(ctx context.Context, id int64) error
|
||||
ReorderExamPrepCatalogCourses(ctx context.Context, orderedIDs []int64) error
|
||||
}
|
||||
17
internal/ports/exam_prep_lesson.go
Normal file
17
internal/ports/exam_prep_lesson.go
Normal file
|
|
@ -0,0 +1,17 @@
|
|||
package ports
|
||||
|
||||
import (
|
||||
"Yimaru-Backend/internal/domain"
|
||||
"context"
|
||||
)
|
||||
|
||||
// ExamPrepLessonStore persists exam_prep.unit_module_lessons.
|
||||
type ExamPrepLessonStore interface {
|
||||
CreateExamPrepUnitModuleLesson(ctx context.Context, unitModuleID int64, input domain.CreateExamPrepLessonInput) (domain.ExamPrepLesson, error)
|
||||
GetExamPrepUnitModuleLessonByID(ctx context.Context, id int64) (domain.ExamPrepLesson, error)
|
||||
ListExamPrepUnitModuleLessonsByUnitModuleID(ctx context.Context, unitModuleID int64, limit, offset int32) ([]domain.ExamPrepLesson, int64, error)
|
||||
ListExamPrepUnitModuleLessonIDsByUnitModule(ctx context.Context, unitModuleID int64) ([]int64, error)
|
||||
UpdateExamPrepUnitModuleLesson(ctx context.Context, id int64, input domain.UpdateExamPrepLessonInput) (domain.ExamPrepLesson, error)
|
||||
DeleteExamPrepUnitModuleLesson(ctx context.Context, id int64) error
|
||||
ReorderExamPrepUnitModuleLessonsInUnitModule(ctx context.Context, unitModuleID int64, orderedIDs []int64) error
|
||||
}
|
||||
17
internal/ports/exam_prep_module.go
Normal file
17
internal/ports/exam_prep_module.go
Normal file
|
|
@ -0,0 +1,17 @@
|
|||
package ports
|
||||
|
||||
import (
|
||||
"Yimaru-Backend/internal/domain"
|
||||
"context"
|
||||
)
|
||||
|
||||
// ExamPrepModuleStore persists exam_prep.unit_modules.
|
||||
type ExamPrepModuleStore interface {
|
||||
CreateExamPrepUnitModule(ctx context.Context, unitID int64, input domain.CreateExamPrepModuleInput) (domain.ExamPrepModule, error)
|
||||
GetExamPrepUnitModuleByID(ctx context.Context, id int64) (domain.ExamPrepModule, error)
|
||||
ListExamPrepUnitModulesByUnit(ctx context.Context, unitID int64, limit, offset int32) ([]domain.ExamPrepModule, int64, error)
|
||||
ListExamPrepUnitModuleIDsByUnit(ctx context.Context, unitID int64) ([]int64, error)
|
||||
UpdateExamPrepUnitModule(ctx context.Context, id int64, input domain.UpdateExamPrepModuleInput) (domain.ExamPrepModule, error)
|
||||
DeleteExamPrepUnitModule(ctx context.Context, id int64) error
|
||||
ReorderExamPrepUnitModulesInUnit(ctx context.Context, unitID int64, orderedIDs []int64) error
|
||||
}
|
||||
15
internal/ports/exam_prep_practice.go
Normal file
15
internal/ports/exam_prep_practice.go
Normal file
|
|
@ -0,0 +1,15 @@
|
|||
package ports
|
||||
|
||||
import (
|
||||
"Yimaru-Backend/internal/domain"
|
||||
"context"
|
||||
)
|
||||
|
||||
// ExamPrepPracticeStore persists exam_prep.lesson_practices.
|
||||
type ExamPrepPracticeStore interface {
|
||||
CreateExamPrepLessonPractice(ctx context.Context, lessonID int64, in domain.CreateExamPrepPracticeInput) (domain.ExamPrepPractice, error)
|
||||
GetExamPrepLessonPracticeByID(ctx context.Context, id int64) (domain.ExamPrepPractice, error)
|
||||
ListExamPrepLessonPracticesByLessonID(ctx context.Context, lessonID int64, limit, offset int32) ([]domain.ExamPrepPractice, int64, error)
|
||||
UpdateExamPrepLessonPractice(ctx context.Context, id int64, input domain.UpdateExamPrepPracticeInput) (domain.ExamPrepPractice, error)
|
||||
DeleteExamPrepLessonPractice(ctx context.Context, id int64) error
|
||||
}
|
||||
17
internal/ports/exam_prep_unit.go
Normal file
17
internal/ports/exam_prep_unit.go
Normal file
|
|
@ -0,0 +1,17 @@
|
|||
package ports
|
||||
|
||||
import (
|
||||
"Yimaru-Backend/internal/domain"
|
||||
"context"
|
||||
)
|
||||
|
||||
// ExamPrepUnitStore persists exam_prep.units.
|
||||
type ExamPrepUnitStore interface {
|
||||
CreateExamPrepUnit(ctx context.Context, catalogCourseID int64, input domain.CreateExamPrepUnitInput) (domain.ExamPrepUnit, error)
|
||||
GetExamPrepUnitByID(ctx context.Context, id int64) (domain.ExamPrepUnit, error)
|
||||
ListExamPrepUnitsByCatalogCourse(ctx context.Context, catalogCourseID int64, limit, offset int32) ([]domain.ExamPrepUnit, int64, error)
|
||||
ListExamPrepUnitIDsByCatalogCourse(ctx context.Context, catalogCourseID int64) ([]int64, error)
|
||||
UpdateExamPrepUnit(ctx context.Context, id int64, input domain.UpdateExamPrepUnitInput) (domain.ExamPrepUnit, error)
|
||||
DeleteExamPrepUnit(ctx context.Context, id int64) error
|
||||
ReorderExamPrepUnitsInCatalogCourse(ctx context.Context, catalogCourseID int64, orderedIDs []int64) error
|
||||
}
|
||||
|
|
@ -94,4 +94,5 @@ type OtpStore interface {
|
|||
MarkOtpAsUsed(ctx context.Context, otp domain.Otp) error
|
||||
CreateOtp(ctx context.Context, otp domain.Otp) error
|
||||
GetOtp(ctx context.Context, userID int64) (domain.Otp, error)
|
||||
GetOtpByCode(ctx context.Context, userID int64, otpCode string) (domain.Otp, error)
|
||||
}
|
||||
|
|
|
|||
112
internal/repository/exam_prep_catalog_courses.go
Normal file
112
internal/repository/exam_prep_catalog_courses.go
Normal file
|
|
@ -0,0 +1,112 @@
|
|||
package repository
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
|
||||
dbgen "Yimaru-Backend/gen/db"
|
||||
"Yimaru-Backend/internal/domain"
|
||||
|
||||
"github.com/jackc/pgx/v5"
|
||||
"github.com/jackc/pgx/v5/pgtype"
|
||||
)
|
||||
|
||||
func examPrepCatalogCourseToDomain(c dbgen.ExamPrepCatalogCourse) domain.ExamPrepCatalogCourse {
|
||||
out := domain.ExamPrepCatalogCourse{
|
||||
ID: c.ID,
|
||||
Name: c.Name,
|
||||
SortOrder: int(c.SortOrder),
|
||||
}
|
||||
out.Description = fromPgText(c.Description)
|
||||
out.Thumbnail = fromPgText(c.Thumbnail)
|
||||
out.CreatedAt = c.CreatedAt.Time
|
||||
if c.UpdatedAt.Valid {
|
||||
t := c.UpdatedAt.Time
|
||||
out.UpdatedAt = &t
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func (s *Store) CreateExamPrepCatalogCourse(ctx context.Context, input domain.CreateExamPrepCatalogCourseInput) (domain.ExamPrepCatalogCourse, error) {
|
||||
c, err := s.queries.ExamPrepCreateCatalogCourse(ctx, dbgen.ExamPrepCreateCatalogCourseParams{
|
||||
Name: input.Name,
|
||||
Description: toPgText(input.Description),
|
||||
Thumbnail: toPgText(input.Thumbnail),
|
||||
})
|
||||
if err != nil {
|
||||
return domain.ExamPrepCatalogCourse{}, err
|
||||
}
|
||||
return examPrepCatalogCourseToDomain(c), nil
|
||||
}
|
||||
|
||||
func (s *Store) GetExamPrepCatalogCourseByID(ctx context.Context, id int64) (domain.ExamPrepCatalogCourse, error) {
|
||||
c, err := s.queries.ExamPrepGetCatalogCourseByID(ctx, id)
|
||||
if err != nil {
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return domain.ExamPrepCatalogCourse{}, pgx.ErrNoRows
|
||||
}
|
||||
return domain.ExamPrepCatalogCourse{}, err
|
||||
}
|
||||
return examPrepCatalogCourseToDomain(c), nil
|
||||
}
|
||||
|
||||
func (s *Store) ListExamPrepCatalogCourses(ctx context.Context, limit, offset int32) ([]domain.ExamPrepCatalogCourse, int64, error) {
|
||||
rows, err := s.queries.ExamPrepListCatalogCourses(ctx, dbgen.ExamPrepListCatalogCoursesParams{
|
||||
Limit: limit,
|
||||
Offset: offset,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
if len(rows) == 0 {
|
||||
return []domain.ExamPrepCatalogCourse{}, 0, nil
|
||||
}
|
||||
var total int64
|
||||
out := make([]domain.ExamPrepCatalogCourse, 0, len(rows))
|
||||
for i, r := range rows {
|
||||
if i == 0 {
|
||||
total = r.TotalCount
|
||||
}
|
||||
out = append(out, examPrepCatalogCourseToDomain(dbgen.ExamPrepCatalogCourse{
|
||||
ID: r.ID,
|
||||
Name: r.Name,
|
||||
Description: r.Description,
|
||||
Thumbnail: r.Thumbnail,
|
||||
SortOrder: r.SortOrder,
|
||||
CreatedAt: r.CreatedAt,
|
||||
UpdatedAt: r.UpdatedAt,
|
||||
}))
|
||||
}
|
||||
return out, total, nil
|
||||
}
|
||||
|
||||
func (s *Store) ListAllExamPrepCatalogCourseIDs(ctx context.Context) ([]int64, error) {
|
||||
return s.queries.ExamPrepListAllCatalogCourseIDs(ctx)
|
||||
}
|
||||
|
||||
func (s *Store) UpdateExamPrepCatalogCourse(ctx context.Context, id int64, input domain.UpdateExamPrepCatalogCourseInput) (domain.ExamPrepCatalogCourse, error) {
|
||||
var nameText pgtype.Text
|
||||
if input.Name != nil {
|
||||
nameText = pgtype.Text{String: *input.Name, Valid: true}
|
||||
} else {
|
||||
nameText = pgtype.Text{Valid: false}
|
||||
}
|
||||
c, err := s.queries.ExamPrepUpdateCatalogCourse(ctx, dbgen.ExamPrepUpdateCatalogCourseParams{
|
||||
ID: id,
|
||||
Name: nameText,
|
||||
Description: optionalTextUpdate(input.Description),
|
||||
Thumbnail: optionalTextUpdate(input.Thumbnail),
|
||||
SortOrder: optionalInt4Update(input.SortOrder),
|
||||
})
|
||||
if err != nil {
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return domain.ExamPrepCatalogCourse{}, pgx.ErrNoRows
|
||||
}
|
||||
return domain.ExamPrepCatalogCourse{}, err
|
||||
}
|
||||
return examPrepCatalogCourseToDomain(c), nil
|
||||
}
|
||||
|
||||
func (s *Store) DeleteExamPrepCatalogCourse(ctx context.Context, id int64) error {
|
||||
return s.queries.ExamPrepDeleteCatalogCourse(ctx, id)
|
||||
}
|
||||
126
internal/repository/exam_prep_lesson_practices.go
Normal file
126
internal/repository/exam_prep_lesson_practices.go
Normal file
|
|
@ -0,0 +1,126 @@
|
|||
package repository
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
|
||||
dbgen "Yimaru-Backend/gen/db"
|
||||
"Yimaru-Backend/internal/domain"
|
||||
|
||||
"github.com/jackc/pgx/v5"
|
||||
"github.com/jackc/pgx/v5/pgtype"
|
||||
)
|
||||
|
||||
func examPrepPracticeFromListRow(r dbgen.ExamPrepListLessonPracticesByLessonIDRow) domain.ExamPrepPractice {
|
||||
return examPrepPracticeToDomain(dbgen.ExamPrepLessonPractice{
|
||||
ID: r.ID,
|
||||
UnitModuleLessonID: r.UnitModuleLessonID,
|
||||
Title: r.Title,
|
||||
StoryDescription: r.StoryDescription,
|
||||
StoryImage: r.StoryImage,
|
||||
PersonaID: r.PersonaID,
|
||||
QuestionSetID: r.QuestionSetID,
|
||||
QuickTips: r.QuickTips,
|
||||
CreatedAt: r.CreatedAt,
|
||||
UpdatedAt: r.UpdatedAt,
|
||||
})
|
||||
}
|
||||
|
||||
func examPrepPracticeToDomain(p dbgen.ExamPrepLessonPractice) domain.ExamPrepPractice {
|
||||
out := domain.ExamPrepPractice{
|
||||
ID: p.ID,
|
||||
LessonID: p.UnitModuleLessonID,
|
||||
Title: p.Title,
|
||||
QuestionSetID: p.QuestionSetID,
|
||||
}
|
||||
out.StoryDescription = fromPgText(p.StoryDescription)
|
||||
out.StoryImage = fromPgText(p.StoryImage)
|
||||
out.QuickTips = fromPgText(p.QuickTips)
|
||||
out.PersonaID = fromPgInt8ID(p.PersonaID)
|
||||
out.CreatedAt = p.CreatedAt.Time
|
||||
if p.UpdatedAt.Valid {
|
||||
t := p.UpdatedAt.Time
|
||||
out.UpdatedAt = &t
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func (s *Store) CreateExamPrepLessonPractice(ctx context.Context, lessonID int64, in domain.CreateExamPrepPracticeInput) (domain.ExamPrepPractice, error) {
|
||||
p, err := s.queries.ExamPrepCreateLessonPractice(ctx, dbgen.ExamPrepCreateLessonPracticeParams{
|
||||
UnitModuleLessonID: lessonID,
|
||||
Title: in.Title,
|
||||
StoryDescription: toPgText(in.StoryDescription),
|
||||
StoryImage: toPgText(in.StoryImage),
|
||||
PersonaID: int64PtrToPg8(in.PersonaID),
|
||||
QuestionSetID: in.QuestionSetID,
|
||||
QuickTips: toPgText(in.QuickTips),
|
||||
})
|
||||
if err != nil {
|
||||
return domain.ExamPrepPractice{}, err
|
||||
}
|
||||
return examPrepPracticeToDomain(p), nil
|
||||
}
|
||||
|
||||
func (s *Store) GetExamPrepLessonPracticeByID(ctx context.Context, id int64) (domain.ExamPrepPractice, error) {
|
||||
p, err := s.queries.ExamPrepGetLessonPracticeByID(ctx, id)
|
||||
if err != nil {
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return domain.ExamPrepPractice{}, pgx.ErrNoRows
|
||||
}
|
||||
return domain.ExamPrepPractice{}, err
|
||||
}
|
||||
return examPrepPracticeToDomain(p), nil
|
||||
}
|
||||
|
||||
func (s *Store) ListExamPrepLessonPracticesByLessonID(ctx context.Context, lessonID int64, limit, offset int32) ([]domain.ExamPrepPractice, int64, error) {
|
||||
rows, err := s.queries.ExamPrepListLessonPracticesByLessonID(ctx, dbgen.ExamPrepListLessonPracticesByLessonIDParams{
|
||||
UnitModuleLessonID: lessonID,
|
||||
Limit: limit,
|
||||
Offset: offset,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
if len(rows) == 0 {
|
||||
return []domain.ExamPrepPractice{}, 0, nil
|
||||
}
|
||||
var total int64
|
||||
out := make([]domain.ExamPrepPractice, 0, len(rows))
|
||||
for i, r := range rows {
|
||||
if i == 0 {
|
||||
total = r.TotalCount
|
||||
}
|
||||
out = append(out, examPrepPracticeFromListRow(r))
|
||||
}
|
||||
return out, total, nil
|
||||
}
|
||||
|
||||
func (s *Store) UpdateExamPrepLessonPractice(ctx context.Context, id int64, input domain.UpdateExamPrepPracticeInput) (domain.ExamPrepPractice, error) {
|
||||
var titleText pgtype.Text
|
||||
if input.Title != nil {
|
||||
titleText = pgtype.Text{String: *input.Title, Valid: true}
|
||||
} else {
|
||||
titleText = pgtype.Text{Valid: false}
|
||||
}
|
||||
qs := optionalInt8UpdateID(input.QuestionSetID)
|
||||
p, err := s.queries.ExamPrepUpdateLessonPractice(ctx, dbgen.ExamPrepUpdateLessonPracticeParams{
|
||||
ID: id,
|
||||
Title: titleText,
|
||||
StoryDescription: optionalTextUpdate(input.StoryDescription),
|
||||
StoryImage: optionalTextUpdate(input.StoryImage),
|
||||
PersonaID: optionalInt8UpdateID(input.PersonaID),
|
||||
QuestionSetID: qs,
|
||||
QuickTips: optionalTextUpdate(input.QuickTips),
|
||||
})
|
||||
if err != nil {
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return domain.ExamPrepPractice{}, pgx.ErrNoRows
|
||||
}
|
||||
return domain.ExamPrepPractice{}, err
|
||||
}
|
||||
return examPrepPracticeToDomain(p), nil
|
||||
}
|
||||
|
||||
func (s *Store) DeleteExamPrepLessonPractice(ctx context.Context, id int64) error {
|
||||
return s.queries.ExamPrepDeleteLessonPractice(ctx, id)
|
||||
}
|
||||
113
internal/repository/exam_prep_reorder.go
Normal file
113
internal/repository/exam_prep_reorder.go
Normal file
|
|
@ -0,0 +1,113 @@
|
|||
package repository
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
)
|
||||
|
||||
const lessonReorderSortBump int32 = 1_000_000
|
||||
|
||||
// ReorderExamPrepCatalogCourses sets sort_order to 1..n for all catalog course rows (transactional).
|
||||
func (s *Store) ReorderExamPrepCatalogCourses(ctx context.Context, orderedIDs []int64) error {
|
||||
tx, err := s.conn.Begin(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer func() { _ = tx.Rollback(ctx) }()
|
||||
for i, id := range orderedIDs {
|
||||
tag, err := tx.Exec(ctx, `
|
||||
UPDATE exam_prep.catalog_courses
|
||||
SET sort_order = $1,
|
||||
updated_at = CURRENT_TIMESTAMP
|
||||
WHERE id = $2`, int32(i+1), id)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if tag.RowsAffected() == 0 {
|
||||
return fmt.Errorf("exam prep catalog course id %d not found", id)
|
||||
}
|
||||
}
|
||||
return tx.Commit(ctx)
|
||||
}
|
||||
|
||||
// ReorderExamPrepUnitsInCatalogCourse sets sort_order to 1..n for units under catalogCourseID.
|
||||
func (s *Store) ReorderExamPrepUnitsInCatalogCourse(ctx context.Context, catalogCourseID int64, orderedIDs []int64) error {
|
||||
tx, err := s.conn.Begin(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer func() { _ = tx.Rollback(ctx) }()
|
||||
for i, id := range orderedIDs {
|
||||
tag, err := tx.Exec(ctx, `
|
||||
UPDATE exam_prep.units
|
||||
SET sort_order = $1,
|
||||
updated_at = CURRENT_TIMESTAMP
|
||||
WHERE id = $2
|
||||
AND catalog_course_id = $3`, int32(i+1), id, catalogCourseID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if tag.RowsAffected() == 0 {
|
||||
return fmt.Errorf("unit id %d not in catalog course %d", id, catalogCourseID)
|
||||
}
|
||||
}
|
||||
return tx.Commit(ctx)
|
||||
}
|
||||
|
||||
// ReorderExamPrepUnitModulesInUnit sets sort_order to 1..n for modules under unitID.
|
||||
func (s *Store) ReorderExamPrepUnitModulesInUnit(ctx context.Context, unitID int64, orderedIDs []int64) error {
|
||||
tx, err := s.conn.Begin(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer func() { _ = tx.Rollback(ctx) }()
|
||||
for i, id := range orderedIDs {
|
||||
tag, err := tx.Exec(ctx, `
|
||||
UPDATE exam_prep.unit_modules
|
||||
SET sort_order = $1,
|
||||
updated_at = CURRENT_TIMESTAMP
|
||||
WHERE id = $2
|
||||
AND unit_id = $3`, int32(i+1), id, unitID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if tag.RowsAffected() == 0 {
|
||||
return fmt.Errorf("module id %d not in unit %d", id, unitID)
|
||||
}
|
||||
}
|
||||
return tx.Commit(ctx)
|
||||
}
|
||||
|
||||
// ReorderExamPrepUnitModuleLessonsInUnitModule sets sort_order to 1..n under unitModuleID.
|
||||
// Uses an intermediate bump so UNIQUE (unit_module_id, sort_order) is never violated mid-reorder.
|
||||
func (s *Store) ReorderExamPrepUnitModuleLessonsInUnitModule(ctx context.Context, unitModuleID int64, orderedIDs []int64) error {
|
||||
tx, err := s.conn.Begin(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer func() { _ = tx.Rollback(ctx) }()
|
||||
|
||||
if _, err := tx.Exec(ctx, `
|
||||
UPDATE exam_prep.unit_module_lessons
|
||||
SET sort_order = sort_order + $1,
|
||||
updated_at = CURRENT_TIMESTAMP
|
||||
WHERE unit_module_id = $2`, lessonReorderSortBump, unitModuleID); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
for i, id := range orderedIDs {
|
||||
tag, err := tx.Exec(ctx, `
|
||||
UPDATE exam_prep.unit_module_lessons
|
||||
SET sort_order = $1,
|
||||
updated_at = CURRENT_TIMESTAMP
|
||||
WHERE id = $2
|
||||
AND unit_module_id = $3`, int32(i+1), id, unitModuleID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if tag.RowsAffected() == 0 {
|
||||
return fmt.Errorf("lesson id %d not in exam prep module %d", id, unitModuleID)
|
||||
}
|
||||
}
|
||||
return tx.Commit(ctx)
|
||||
}
|
||||
120
internal/repository/exam_prep_unit_module_lessons.go
Normal file
120
internal/repository/exam_prep_unit_module_lessons.go
Normal file
|
|
@ -0,0 +1,120 @@
|
|||
package repository
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
|
||||
dbgen "Yimaru-Backend/gen/db"
|
||||
"Yimaru-Backend/internal/domain"
|
||||
|
||||
"github.com/jackc/pgx/v5"
|
||||
"github.com/jackc/pgx/v5/pgtype"
|
||||
)
|
||||
|
||||
func examPrepLessonToDomain(l dbgen.ExamPrepUnitModuleLesson) domain.ExamPrepLesson {
|
||||
out := domain.ExamPrepLesson{
|
||||
ID: l.ID,
|
||||
UnitModuleID: l.UnitModuleID,
|
||||
Title: l.Title,
|
||||
SortOrder: int(l.SortOrder),
|
||||
}
|
||||
out.VideoURL = fromPgText(l.VideoUrl)
|
||||
out.Thumbnail = fromPgText(l.Thumbnail)
|
||||
out.Description = fromPgText(l.Description)
|
||||
out.CreatedAt = l.CreatedAt.Time
|
||||
if l.UpdatedAt.Valid {
|
||||
t := l.UpdatedAt.Time
|
||||
out.UpdatedAt = &t
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func (s *Store) CreateExamPrepUnitModuleLesson(ctx context.Context, unitModuleID int64, input domain.CreateExamPrepLessonInput) (domain.ExamPrepLesson, error) {
|
||||
l, err := s.queries.ExamPrepCreateUnitModuleLesson(ctx, dbgen.ExamPrepCreateUnitModuleLessonParams{
|
||||
UnitModuleID: unitModuleID,
|
||||
Title: input.Title,
|
||||
VideoUrl: toPgText(input.VideoURL),
|
||||
Thumbnail: toPgText(input.Thumbnail),
|
||||
Description: toPgText(input.Description),
|
||||
})
|
||||
if err != nil {
|
||||
return domain.ExamPrepLesson{}, err
|
||||
}
|
||||
return examPrepLessonToDomain(l), nil
|
||||
}
|
||||
|
||||
func (s *Store) GetExamPrepUnitModuleLessonByID(ctx context.Context, id int64) (domain.ExamPrepLesson, error) {
|
||||
l, err := s.queries.ExamPrepGetUnitModuleLessonByID(ctx, id)
|
||||
if err != nil {
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return domain.ExamPrepLesson{}, pgx.ErrNoRows
|
||||
}
|
||||
return domain.ExamPrepLesson{}, err
|
||||
}
|
||||
return examPrepLessonToDomain(l), nil
|
||||
}
|
||||
|
||||
func (s *Store) ListExamPrepUnitModuleLessonsByUnitModuleID(ctx context.Context, unitModuleID int64, limit, offset int32) ([]domain.ExamPrepLesson, int64, error) {
|
||||
rows, err := s.queries.ExamPrepListUnitModuleLessonsByUnitModuleID(ctx, dbgen.ExamPrepListUnitModuleLessonsByUnitModuleIDParams{
|
||||
UnitModuleID: unitModuleID,
|
||||
Limit: limit,
|
||||
Offset: offset,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
if len(rows) == 0 {
|
||||
return []domain.ExamPrepLesson{}, 0, nil
|
||||
}
|
||||
var total int64
|
||||
out := make([]domain.ExamPrepLesson, 0, len(rows))
|
||||
for i, r := range rows {
|
||||
if i == 0 {
|
||||
total = r.TotalCount
|
||||
}
|
||||
out = append(out, examPrepLessonToDomain(dbgen.ExamPrepUnitModuleLesson{
|
||||
ID: r.ID,
|
||||
UnitModuleID: r.UnitModuleID,
|
||||
Title: r.Title,
|
||||
VideoUrl: r.VideoUrl,
|
||||
Thumbnail: r.Thumbnail,
|
||||
Description: r.Description,
|
||||
SortOrder: r.SortOrder,
|
||||
CreatedAt: r.CreatedAt,
|
||||
UpdatedAt: r.UpdatedAt,
|
||||
}))
|
||||
}
|
||||
return out, total, nil
|
||||
}
|
||||
|
||||
func (s *Store) ListExamPrepUnitModuleLessonIDsByUnitModule(ctx context.Context, unitModuleID int64) ([]int64, error) {
|
||||
return s.queries.ExamPrepListUnitModuleLessonIDsByUnitModule(ctx, unitModuleID)
|
||||
}
|
||||
|
||||
func (s *Store) UpdateExamPrepUnitModuleLesson(ctx context.Context, id int64, input domain.UpdateExamPrepLessonInput) (domain.ExamPrepLesson, error) {
|
||||
var titleText pgtype.Text
|
||||
if input.Title != nil {
|
||||
titleText = pgtype.Text{String: *input.Title, Valid: true}
|
||||
} else {
|
||||
titleText = pgtype.Text{Valid: false}
|
||||
}
|
||||
l, err := s.queries.ExamPrepUpdateUnitModuleLesson(ctx, dbgen.ExamPrepUpdateUnitModuleLessonParams{
|
||||
ID: id,
|
||||
Title: titleText,
|
||||
VideoUrl: optionalTextUpdate(input.VideoURL),
|
||||
Thumbnail: optionalTextUpdate(input.Thumbnail),
|
||||
Description: optionalTextUpdate(input.Description),
|
||||
SortOrder: optionalInt4Update(input.SortOrder),
|
||||
})
|
||||
if err != nil {
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return domain.ExamPrepLesson{}, pgx.ErrNoRows
|
||||
}
|
||||
return domain.ExamPrepLesson{}, err
|
||||
}
|
||||
return examPrepLessonToDomain(l), nil
|
||||
}
|
||||
|
||||
func (s *Store) DeleteExamPrepUnitModuleLesson(ctx context.Context, id int64) error {
|
||||
return s.queries.ExamPrepDeleteUnitModuleLesson(ctx, id)
|
||||
}
|
||||
120
internal/repository/exam_prep_unit_modules.go
Normal file
120
internal/repository/exam_prep_unit_modules.go
Normal file
|
|
@ -0,0 +1,120 @@
|
|||
package repository
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
|
||||
dbgen "Yimaru-Backend/gen/db"
|
||||
"Yimaru-Backend/internal/domain"
|
||||
|
||||
"github.com/jackc/pgx/v5"
|
||||
"github.com/jackc/pgx/v5/pgtype"
|
||||
)
|
||||
|
||||
func examPrepModuleToDomain(m dbgen.ExamPrepUnitModule) domain.ExamPrepModule {
|
||||
out := domain.ExamPrepModule{
|
||||
ID: m.ID,
|
||||
UnitID: m.UnitID,
|
||||
Name: m.Name,
|
||||
SortOrder: int(m.SortOrder),
|
||||
}
|
||||
out.Description = fromPgText(m.Description)
|
||||
out.Thumbnail = fromPgText(m.Thumbnail)
|
||||
out.Icon = fromPgText(m.Icon)
|
||||
out.CreatedAt = m.CreatedAt.Time
|
||||
if m.UpdatedAt.Valid {
|
||||
t := m.UpdatedAt.Time
|
||||
out.UpdatedAt = &t
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func (s *Store) CreateExamPrepUnitModule(ctx context.Context, unitID int64, input domain.CreateExamPrepModuleInput) (domain.ExamPrepModule, error) {
|
||||
m, err := s.queries.ExamPrepCreateUnitModule(ctx, dbgen.ExamPrepCreateUnitModuleParams{
|
||||
UnitID: unitID,
|
||||
Name: input.Name,
|
||||
Description: toPgText(input.Description),
|
||||
Thumbnail: toPgText(input.Thumbnail),
|
||||
Icon: toPgText(input.Icon),
|
||||
})
|
||||
if err != nil {
|
||||
return domain.ExamPrepModule{}, err
|
||||
}
|
||||
return examPrepModuleToDomain(m), nil
|
||||
}
|
||||
|
||||
func (s *Store) GetExamPrepUnitModuleByID(ctx context.Context, id int64) (domain.ExamPrepModule, error) {
|
||||
m, err := s.queries.ExamPrepGetUnitModuleByID(ctx, id)
|
||||
if err != nil {
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return domain.ExamPrepModule{}, pgx.ErrNoRows
|
||||
}
|
||||
return domain.ExamPrepModule{}, err
|
||||
}
|
||||
return examPrepModuleToDomain(m), nil
|
||||
}
|
||||
|
||||
func (s *Store) ListExamPrepUnitModulesByUnit(ctx context.Context, unitID int64, limit, offset int32) ([]domain.ExamPrepModule, int64, error) {
|
||||
rows, err := s.queries.ExamPrepListUnitModulesByUnit(ctx, dbgen.ExamPrepListUnitModulesByUnitParams{
|
||||
UnitID: unitID,
|
||||
Limit: limit,
|
||||
Offset: offset,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
if len(rows) == 0 {
|
||||
return []domain.ExamPrepModule{}, 0, nil
|
||||
}
|
||||
var total int64
|
||||
out := make([]domain.ExamPrepModule, 0, len(rows))
|
||||
for i, r := range rows {
|
||||
if i == 0 {
|
||||
total = r.TotalCount
|
||||
}
|
||||
out = append(out, examPrepModuleToDomain(dbgen.ExamPrepUnitModule{
|
||||
ID: r.ID,
|
||||
UnitID: r.UnitID,
|
||||
Name: r.Name,
|
||||
Description: r.Description,
|
||||
Thumbnail: r.Thumbnail,
|
||||
Icon: r.Icon,
|
||||
SortOrder: r.SortOrder,
|
||||
CreatedAt: r.CreatedAt,
|
||||
UpdatedAt: r.UpdatedAt,
|
||||
}))
|
||||
}
|
||||
return out, total, nil
|
||||
}
|
||||
|
||||
func (s *Store) ListExamPrepUnitModuleIDsByUnit(ctx context.Context, unitID int64) ([]int64, error) {
|
||||
return s.queries.ExamPrepListUnitModuleIDsByUnit(ctx, unitID)
|
||||
}
|
||||
|
||||
func (s *Store) UpdateExamPrepUnitModule(ctx context.Context, id int64, input domain.UpdateExamPrepModuleInput) (domain.ExamPrepModule, error) {
|
||||
var nameText pgtype.Text
|
||||
if input.Name != nil {
|
||||
nameText = pgtype.Text{String: *input.Name, Valid: true}
|
||||
} else {
|
||||
nameText = pgtype.Text{Valid: false}
|
||||
}
|
||||
m, err := s.queries.ExamPrepUpdateUnitModule(ctx, dbgen.ExamPrepUpdateUnitModuleParams{
|
||||
ID: id,
|
||||
Name: nameText,
|
||||
Description: optionalTextUpdate(input.Description),
|
||||
Thumbnail: optionalTextUpdate(input.Thumbnail),
|
||||
Icon: optionalTextUpdate(input.Icon),
|
||||
SortOrder: optionalInt4Update(input.SortOrder),
|
||||
})
|
||||
if err != nil {
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return domain.ExamPrepModule{}, pgx.ErrNoRows
|
||||
}
|
||||
return domain.ExamPrepModule{}, err
|
||||
}
|
||||
return examPrepModuleToDomain(m), nil
|
||||
}
|
||||
|
||||
func (s *Store) DeleteExamPrepUnitModule(ctx context.Context, id int64) error {
|
||||
return s.queries.ExamPrepDeleteUnitModule(ctx, id)
|
||||
}
|
||||
116
internal/repository/exam_prep_units.go
Normal file
116
internal/repository/exam_prep_units.go
Normal file
|
|
@ -0,0 +1,116 @@
|
|||
package repository
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
|
||||
dbgen "Yimaru-Backend/gen/db"
|
||||
"Yimaru-Backend/internal/domain"
|
||||
|
||||
"github.com/jackc/pgx/v5"
|
||||
"github.com/jackc/pgx/v5/pgtype"
|
||||
)
|
||||
|
||||
func examPrepUnitToDomain(u dbgen.ExamPrepUnit) domain.ExamPrepUnit {
|
||||
out := domain.ExamPrepUnit{
|
||||
ID: u.ID,
|
||||
CatalogCourseID: u.CatalogCourseID,
|
||||
Name: u.Name,
|
||||
SortOrder: int(u.SortOrder),
|
||||
}
|
||||
out.Description = fromPgText(u.Description)
|
||||
out.Thumbnail = fromPgText(u.Thumbnail)
|
||||
out.CreatedAt = u.CreatedAt.Time
|
||||
if u.UpdatedAt.Valid {
|
||||
t := u.UpdatedAt.Time
|
||||
out.UpdatedAt = &t
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func (s *Store) CreateExamPrepUnit(ctx context.Context, catalogCourseID int64, input domain.CreateExamPrepUnitInput) (domain.ExamPrepUnit, error) {
|
||||
u, err := s.queries.ExamPrepCreateUnit(ctx, dbgen.ExamPrepCreateUnitParams{
|
||||
CatalogCourseID: catalogCourseID,
|
||||
Name: input.Name,
|
||||
Description: toPgText(input.Description),
|
||||
Thumbnail: toPgText(input.Thumbnail),
|
||||
})
|
||||
if err != nil {
|
||||
return domain.ExamPrepUnit{}, err
|
||||
}
|
||||
return examPrepUnitToDomain(u), nil
|
||||
}
|
||||
|
||||
func (s *Store) GetExamPrepUnitByID(ctx context.Context, id int64) (domain.ExamPrepUnit, error) {
|
||||
u, err := s.queries.ExamPrepGetUnitByID(ctx, id)
|
||||
if err != nil {
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return domain.ExamPrepUnit{}, pgx.ErrNoRows
|
||||
}
|
||||
return domain.ExamPrepUnit{}, err
|
||||
}
|
||||
return examPrepUnitToDomain(u), nil
|
||||
}
|
||||
|
||||
func (s *Store) ListExamPrepUnitsByCatalogCourse(ctx context.Context, catalogCourseID int64, limit, offset int32) ([]domain.ExamPrepUnit, int64, error) {
|
||||
rows, err := s.queries.ExamPrepListUnitsByCatalogCourse(ctx, dbgen.ExamPrepListUnitsByCatalogCourseParams{
|
||||
CatalogCourseID: catalogCourseID,
|
||||
Limit: limit,
|
||||
Offset: offset,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
if len(rows) == 0 {
|
||||
return []domain.ExamPrepUnit{}, 0, nil
|
||||
}
|
||||
var total int64
|
||||
out := make([]domain.ExamPrepUnit, 0, len(rows))
|
||||
for i, r := range rows {
|
||||
if i == 0 {
|
||||
total = r.TotalCount
|
||||
}
|
||||
out = append(out, examPrepUnitToDomain(dbgen.ExamPrepUnit{
|
||||
ID: r.ID,
|
||||
CatalogCourseID: r.CatalogCourseID,
|
||||
Name: r.Name,
|
||||
Description: r.Description,
|
||||
Thumbnail: r.Thumbnail,
|
||||
SortOrder: r.SortOrder,
|
||||
CreatedAt: r.CreatedAt,
|
||||
UpdatedAt: r.UpdatedAt,
|
||||
}))
|
||||
}
|
||||
return out, total, nil
|
||||
}
|
||||
|
||||
func (s *Store) ListExamPrepUnitIDsByCatalogCourse(ctx context.Context, catalogCourseID int64) ([]int64, error) {
|
||||
return s.queries.ExamPrepListUnitIDsByCatalogCourse(ctx, catalogCourseID)
|
||||
}
|
||||
|
||||
func (s *Store) UpdateExamPrepUnit(ctx context.Context, id int64, input domain.UpdateExamPrepUnitInput) (domain.ExamPrepUnit, error) {
|
||||
var nameText pgtype.Text
|
||||
if input.Name != nil {
|
||||
nameText = pgtype.Text{String: *input.Name, Valid: true}
|
||||
} else {
|
||||
nameText = pgtype.Text{Valid: false}
|
||||
}
|
||||
u, err := s.queries.ExamPrepUpdateUnit(ctx, dbgen.ExamPrepUpdateUnitParams{
|
||||
ID: id,
|
||||
Name: nameText,
|
||||
Description: optionalTextUpdate(input.Description),
|
||||
Thumbnail: optionalTextUpdate(input.Thumbnail),
|
||||
SortOrder: optionalInt4Update(input.SortOrder),
|
||||
})
|
||||
if err != nil {
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return domain.ExamPrepUnit{}, pgx.ErrNoRows
|
||||
}
|
||||
return domain.ExamPrepUnit{}, err
|
||||
}
|
||||
return examPrepUnitToDomain(u), nil
|
||||
}
|
||||
|
||||
func (s *Store) DeleteExamPrepUnit(ctx context.Context, id int64) error {
|
||||
return s.queries.ExamPrepDeleteUnit(ctx, id)
|
||||
}
|
||||
|
|
@ -38,50 +38,89 @@ func (s *Store) LmsUserHasLessonProgress(ctx context.Context, userID, lessonID i
|
|||
return s.queries.UserHasLessonProgress(ctx, dbgen.UserHasLessonProgressParams{UserID: userID, LessonID: lessonID})
|
||||
}
|
||||
|
||||
// LmsUserLessonProgressInModule returns completed and total lesson counts in a module (for progress UI).
|
||||
// LmsUserLessonProgressInModule returns combined completed/total counts for lessons + published practices in a module.
|
||||
func (s *Store) LmsUserLessonProgressInModule(ctx context.Context, userID, moduleID int64) (completed, total int32, err error) {
|
||||
total, err = s.queries.CountLessonsInModule(ctx, moduleID)
|
||||
lessonTotal, err := s.queries.CountLessonsInModule(ctx, moduleID)
|
||||
if err != nil {
|
||||
return 0, 0, err
|
||||
}
|
||||
completed, err = s.queries.CountUserCompletedLessonsInModule(ctx, dbgen.CountUserCompletedLessonsInModuleParams{
|
||||
lessonCompleted, err := s.queries.CountUserCompletedLessonsInModule(ctx, dbgen.CountUserCompletedLessonsInModuleParams{
|
||||
ModuleID: moduleID,
|
||||
UserID: userID,
|
||||
})
|
||||
if err != nil {
|
||||
return 0, 0, err
|
||||
}
|
||||
return completed, total, nil
|
||||
}
|
||||
|
||||
// LmsUserLessonProgressInCourse returns completed and total lesson counts in a course (all modules).
|
||||
func (s *Store) LmsUserLessonProgressInCourse(ctx context.Context, userID, courseID int64) (completed, total int32, err error) {
|
||||
total, err = s.queries.CountLessonsInCourse(ctx, courseID)
|
||||
practiceTotal, err := s.queries.CountPublishedPracticesInModule(ctx, toPgInt8(&moduleID))
|
||||
if err != nil {
|
||||
return 0, 0, err
|
||||
}
|
||||
completed, err = s.queries.CountUserCompletedLessonsInCourse(ctx, dbgen.CountUserCompletedLessonsInCourseParams{
|
||||
practiceCompleted, err := s.queries.CountUserCompletedPublishedPracticesInModule(ctx, dbgen.CountUserCompletedPublishedPracticesInModuleParams{
|
||||
ModuleID: toPgInt8(&moduleID),
|
||||
UserID: userID,
|
||||
})
|
||||
if err != nil {
|
||||
return 0, 0, err
|
||||
}
|
||||
total = lessonTotal + practiceTotal
|
||||
completed = lessonCompleted + practiceCompleted
|
||||
return completed, total, nil
|
||||
}
|
||||
|
||||
// LmsUserLessonProgressInCourse returns combined completed/total counts for lessons + published practices in a course.
|
||||
func (s *Store) LmsUserLessonProgressInCourse(ctx context.Context, userID, courseID int64) (completed, total int32, err error) {
|
||||
lessonTotal, err := s.queries.CountLessonsInCourse(ctx, courseID)
|
||||
if err != nil {
|
||||
return 0, 0, err
|
||||
}
|
||||
lessonCompleted, err := s.queries.CountUserCompletedLessonsInCourse(ctx, dbgen.CountUserCompletedLessonsInCourseParams{
|
||||
CourseID: courseID,
|
||||
UserID: userID,
|
||||
})
|
||||
if err != nil {
|
||||
return 0, 0, err
|
||||
}
|
||||
return completed, total, nil
|
||||
}
|
||||
|
||||
// LmsUserLessonProgressInProgram returns completed and total lesson counts in a program (all courses).
|
||||
func (s *Store) LmsUserLessonProgressInProgram(ctx context.Context, userID, programID int64) (completed, total int32, err error) {
|
||||
total, err = s.queries.CountLessonsInProgram(ctx, programID)
|
||||
practiceTotal, err := s.queries.CountPublishedPracticesInCourse(ctx, toPgInt8(&courseID))
|
||||
if err != nil {
|
||||
return 0, 0, err
|
||||
}
|
||||
completed, err = s.queries.CountUserCompletedLessonsInProgram(ctx, dbgen.CountUserCompletedLessonsInProgramParams{
|
||||
practiceCompleted, err := s.queries.CountUserCompletedPublishedPracticesInCourse(ctx, dbgen.CountUserCompletedPublishedPracticesInCourseParams{
|
||||
CourseID: toPgInt8(&courseID),
|
||||
UserID: userID,
|
||||
})
|
||||
if err != nil {
|
||||
return 0, 0, err
|
||||
}
|
||||
total = lessonTotal + practiceTotal
|
||||
completed = lessonCompleted + practiceCompleted
|
||||
return completed, total, nil
|
||||
}
|
||||
|
||||
// LmsUserLessonProgressInProgram returns combined completed/total counts for lessons + published practices in a program.
|
||||
func (s *Store) LmsUserLessonProgressInProgram(ctx context.Context, userID, programID int64) (completed, total int32, err error) {
|
||||
lessonTotal, err := s.queries.CountLessonsInProgram(ctx, programID)
|
||||
if err != nil {
|
||||
return 0, 0, err
|
||||
}
|
||||
lessonCompleted, err := s.queries.CountUserCompletedLessonsInProgram(ctx, dbgen.CountUserCompletedLessonsInProgramParams{
|
||||
ProgramID: programID,
|
||||
UserID: userID,
|
||||
})
|
||||
if err != nil {
|
||||
return 0, 0, err
|
||||
}
|
||||
practiceTotal, err := s.queries.CountPublishedPracticesInProgram(ctx, programID)
|
||||
if err != nil {
|
||||
return 0, 0, err
|
||||
}
|
||||
practiceCompleted, err := s.queries.CountUserCompletedPublishedPracticesInProgram(ctx, dbgen.CountUserCompletedPublishedPracticesInProgramParams{
|
||||
ProgramID: programID,
|
||||
UserID: userID,
|
||||
})
|
||||
if err != nil {
|
||||
return 0, 0, err
|
||||
}
|
||||
total = lessonTotal + practiceTotal
|
||||
completed = lessonCompleted + practiceCompleted
|
||||
return completed, total, nil
|
||||
}
|
||||
|
|
|
|||
|
|
@ -74,7 +74,7 @@ func (s *Store) ListCoursesByProgramID(ctx context.Context, programID int64, lim
|
|||
if i == 0 {
|
||||
total = r.TotalCount
|
||||
}
|
||||
out = append(out, courseToDomain(dbgen.Course{
|
||||
co := courseToDomain(dbgen.Course{
|
||||
ID: r.ID,
|
||||
ProgramID: r.ProgramID,
|
||||
Name: r.Name,
|
||||
|
|
@ -83,7 +83,11 @@ func (s *Store) ListCoursesByProgramID(ctx context.Context, programID int64, lim
|
|||
CreatedAt: r.CreatedAt,
|
||||
UpdatedAt: r.UpdatedAt,
|
||||
SortOrder: r.SortOrder,
|
||||
}))
|
||||
})
|
||||
co.ModuleCount = int(r.ModuleCount)
|
||||
co.LessonCount = int(r.LessonCount)
|
||||
co.PracticeCount = int(r.PracticeCount)
|
||||
out = append(out, co)
|
||||
}
|
||||
return out, total, nil
|
||||
}
|
||||
|
|
|
|||
|
|
@ -93,13 +93,78 @@ func (s *Store) ListModulesByProgramAndCourse(ctx context.Context, programID, co
|
|||
}
|
||||
|
||||
func (s *Store) UpdateModule(ctx context.Context, id int64, input domain.UpdateModuleInput) (domain.Module, error) {
|
||||
q, tx, err := s.BeginTx(ctx)
|
||||
if err != nil {
|
||||
return domain.Module{}, err
|
||||
}
|
||||
defer tx.Rollback(ctx)
|
||||
|
||||
var current dbgen.Module
|
||||
err = tx.QueryRow(ctx, `
|
||||
SELECT id, program_id, course_id, name, description, icon, sort_order, created_at, updated_at
|
||||
FROM modules
|
||||
WHERE id = $1
|
||||
FOR UPDATE
|
||||
`, id).Scan(
|
||||
¤t.ID,
|
||||
¤t.ProgramID,
|
||||
¤t.CourseID,
|
||||
¤t.Name,
|
||||
¤t.Description,
|
||||
¤t.Icon,
|
||||
¤t.SortOrder,
|
||||
¤t.CreatedAt,
|
||||
¤t.UpdatedAt,
|
||||
)
|
||||
if err != nil {
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return domain.Module{}, pgx.ErrNoRows
|
||||
}
|
||||
return domain.Module{}, err
|
||||
}
|
||||
|
||||
if input.SortOrder != nil {
|
||||
targetSort := int32(*input.SortOrder)
|
||||
if targetSort != current.SortOrder {
|
||||
var conflictID int64
|
||||
err = tx.QueryRow(ctx, `
|
||||
SELECT id
|
||||
FROM modules
|
||||
WHERE course_id = $1
|
||||
AND sort_order = $2
|
||||
AND id <> $3
|
||||
FOR UPDATE
|
||||
`, current.CourseID, targetSort, id).Scan(&conflictID)
|
||||
if err != nil && !errors.Is(err, pgx.ErrNoRows) {
|
||||
return domain.Module{}, err
|
||||
}
|
||||
if err == nil {
|
||||
var tempSort int32
|
||||
if err := tx.QueryRow(ctx, `
|
||||
SELECT COALESCE(MIN(sort_order), 0) - 1
|
||||
FROM modules
|
||||
WHERE course_id = $1
|
||||
`, current.CourseID).Scan(&tempSort); err != nil {
|
||||
return domain.Module{}, err
|
||||
}
|
||||
|
||||
if _, err := tx.Exec(ctx, `UPDATE modules SET sort_order = $1 WHERE id = $2`, tempSort, id); err != nil {
|
||||
return domain.Module{}, err
|
||||
}
|
||||
if _, err := tx.Exec(ctx, `UPDATE modules SET sort_order = $1 WHERE id = $2`, current.SortOrder, conflictID); err != nil {
|
||||
return domain.Module{}, err
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
var nameText pgtype.Text
|
||||
if input.Name != nil {
|
||||
nameText = pgtype.Text{String: *input.Name, Valid: true}
|
||||
} else {
|
||||
nameText = pgtype.Text{Valid: false}
|
||||
}
|
||||
m, err := s.queries.UpdateModule(ctx, dbgen.UpdateModuleParams{
|
||||
m, err := q.UpdateModule(ctx, dbgen.UpdateModuleParams{
|
||||
ID: id,
|
||||
Name: nameText,
|
||||
Description: optionalTextUpdate(input.Description),
|
||||
|
|
@ -112,6 +177,10 @@ func (s *Store) UpdateModule(ctx context.Context, id int64, input domain.UpdateM
|
|||
}
|
||||
return domain.Module{}, err
|
||||
}
|
||||
|
||||
if err := tx.Commit(ctx); err != nil {
|
||||
return domain.Module{}, err
|
||||
}
|
||||
return moduleToDomain(m), nil
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -7,8 +7,8 @@ import (
|
|||
dbgen "Yimaru-Backend/gen/db"
|
||||
)
|
||||
|
||||
// CompleteLessonForUser records lesson completion and cascades to module, course, and program when the user
|
||||
// has fully completed the preceding scope. Runs in a single transaction.
|
||||
// CompleteLessonForUser records lesson completion and cascades completion upward when
|
||||
// both lesson and related practice requirements are satisfied.
|
||||
func (s *Store) CompleteLessonForUser(ctx context.Context, userID, lessonID int64) error {
|
||||
q, tx, err := s.BeginTx(ctx)
|
||||
if err != nil {
|
||||
|
|
@ -31,56 +31,162 @@ func (s *Store) CompleteLessonForUser(ctx context.Context, userID, lessonID int6
|
|||
if err != nil {
|
||||
return err
|
||||
}
|
||||
nLess, err := q.CountLessonsInModule(ctx, lesson.ModuleID)
|
||||
if err != nil {
|
||||
|
||||
if err := s.cascadeLMSCompletion(ctx, q, userID, mod.ID, crs.ID, crs.ProgramID); err != nil {
|
||||
return err
|
||||
}
|
||||
nDoneLess, err := q.CountUserCompletedLessonsInModule(ctx, dbgen.CountUserCompletedLessonsInModuleParams{
|
||||
ModuleID: lesson.ModuleID,
|
||||
UserID: userID,
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if nLess > 0 && nDoneLess >= nLess {
|
||||
if err := q.InsertUserModuleProgress(ctx, dbgen.InsertUserModuleProgressParams{UserID: userID, ModuleID: mod.ID}); err != nil {
|
||||
return err
|
||||
}
|
||||
nMods, err := q.CountModulesInCourse(ctx, mod.CourseID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
nDoneMods, err := q.CountUserCompletedModulesInCourse(ctx, dbgen.CountUserCompletedModulesInCourseParams{
|
||||
CourseID: mod.CourseID,
|
||||
UserID: userID,
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if nMods > 0 && nDoneMods >= nMods {
|
||||
if err := q.InsertUserCourseProgress(ctx, dbgen.InsertUserCourseProgressParams{UserID: userID, CourseID: crs.ID}); err != nil {
|
||||
return err
|
||||
}
|
||||
nCr, err := q.CountCoursesInProgram(ctx, crs.ProgramID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
nCrDone, err := q.CountUserCompletedCoursesInProgram(ctx, dbgen.CountUserCompletedCoursesInProgramParams{
|
||||
ProgramID: crs.ProgramID,
|
||||
UserID: userID,
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if nCr > 0 && nCrDone >= nCr {
|
||||
if err := q.InsertUserProgramProgress(ctx, dbgen.InsertUserProgramProgressParams{UserID: userID, ProgramID: crs.ProgramID}); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if err := tx.Commit(ctx); err != nil {
|
||||
return fmt.Errorf("commit: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// CompletePracticeForUser records practice completion and cascades completion upward when
|
||||
// both lesson and related practice requirements are satisfied.
|
||||
func (s *Store) CompletePracticeForUser(ctx context.Context, userID, questionSetID int64) error {
|
||||
q, tx, err := s.BeginTx(ctx)
|
||||
if err != nil {
|
||||
return fmt.Errorf("begin tx: %w", err)
|
||||
}
|
||||
defer func() { _ = tx.Rollback(ctx) }()
|
||||
|
||||
if _, err := q.MarkPracticeCompleted(ctx, dbgen.MarkPracticeCompletedParams{
|
||||
UserID: userID,
|
||||
QuestionSetID: questionSetID,
|
||||
}); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
scope, err := q.GetPracticeScopeByQuestionSetID(ctx, questionSetID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !scope.ModuleID.Valid {
|
||||
return fmt.Errorf("practice %d is not linked to a module", questionSetID)
|
||||
}
|
||||
|
||||
mod, err := q.GetModuleByID(ctx, scope.ModuleID.Int64)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
crs, err := q.GetCourseByID(ctx, mod.CourseID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := s.cascadeLMSCompletion(ctx, q, userID, mod.ID, crs.ID, crs.ProgramID); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := tx.Commit(ctx); err != nil {
|
||||
return fmt.Errorf("commit: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Store) cascadeLMSCompletion(ctx context.Context, q *dbgen.Queries, userID, moduleID, courseID, programID int64) error {
|
||||
moduleLessonsTotal, err := q.CountLessonsInModule(ctx, moduleID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
moduleLessonsDone, err := q.CountUserCompletedLessonsInModule(ctx, dbgen.CountUserCompletedLessonsInModuleParams{
|
||||
ModuleID: moduleID,
|
||||
UserID: userID,
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
modulePracticesTotal, err := q.CountPublishedPracticesInModule(ctx, toPgInt8(&moduleID))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
modulePracticesDone, err := q.CountUserCompletedPublishedPracticesInModule(ctx, dbgen.CountUserCompletedPublishedPracticesInModuleParams{
|
||||
ModuleID: toPgInt8(&moduleID),
|
||||
UserID: userID,
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
moduleLessonsComplete := moduleLessonsTotal > 0 && moduleLessonsDone >= moduleLessonsTotal
|
||||
modulePracticesComplete := modulePracticesDone >= modulePracticesTotal
|
||||
if !moduleLessonsComplete || !modulePracticesComplete {
|
||||
return nil
|
||||
}
|
||||
|
||||
if err := q.InsertUserModuleProgress(ctx, dbgen.InsertUserModuleProgressParams{UserID: userID, ModuleID: moduleID}); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
nMods, err := q.CountModulesInCourse(ctx, courseID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
nDoneMods, err := q.CountUserCompletedModulesInCourse(ctx, dbgen.CountUserCompletedModulesInCourseParams{
|
||||
CourseID: courseID,
|
||||
UserID: userID,
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
coursePracticesTotal, err := q.CountPublishedPracticesInCourse(ctx, toPgInt8(&courseID))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
coursePracticesDone, err := q.CountUserCompletedPublishedPracticesInCourse(ctx, dbgen.CountUserCompletedPublishedPracticesInCourseParams{
|
||||
CourseID: toPgInt8(&courseID),
|
||||
UserID: userID,
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
courseModulesComplete := nMods > 0 && nDoneMods >= nMods
|
||||
coursePracticesComplete := coursePracticesDone >= coursePracticesTotal
|
||||
if !courseModulesComplete || !coursePracticesComplete {
|
||||
return nil
|
||||
}
|
||||
|
||||
if err := q.InsertUserCourseProgress(ctx, dbgen.InsertUserCourseProgressParams{UserID: userID, CourseID: courseID}); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
nCr, err := q.CountCoursesInProgram(ctx, programID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
nCrDone, err := q.CountUserCompletedCoursesInProgram(ctx, dbgen.CountUserCompletedCoursesInProgramParams{
|
||||
ProgramID: programID,
|
||||
UserID: userID,
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
programPracticesTotal, err := q.CountPublishedPracticesInProgram(ctx, programID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
programPracticesDone, err := q.CountUserCompletedPublishedPracticesInProgram(ctx, dbgen.CountUserCompletedPublishedPracticesInProgramParams{
|
||||
ProgramID: programID,
|
||||
UserID: userID,
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
programCoursesComplete := nCr > 0 && nCrDone >= nCr
|
||||
programPracticesComplete := programPracticesDone >= programPracticesTotal
|
||||
if !programCoursesComplete || !programPracticesComplete {
|
||||
return nil
|
||||
}
|
||||
|
||||
if err := q.InsertUserProgramProgress(ctx, dbgen.InsertUserProgramProgressParams{UserID: userID, ProgramID: programID}); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
|
|
|||
|
|
@ -50,6 +50,10 @@ func (s *Store) GetOtp(ctx context.Context, userID int64) (domain.Otp, error) {
|
|||
}
|
||||
return domain.Otp{}, err
|
||||
}
|
||||
if !row.ExpiresAt.Valid {
|
||||
return domain.Otp{}, domain.ErrOtpNotFound
|
||||
}
|
||||
|
||||
return domain.Otp{
|
||||
ID: row.ID,
|
||||
UserID: row.UserID,
|
||||
|
|
@ -63,6 +67,36 @@ func (s *Store) GetOtp(ctx context.Context, userID int64) (domain.Otp, error) {
|
|||
ExpiresAt: row.ExpiresAt.Time,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (s *Store) GetOtpByCode(ctx context.Context, userID int64, otpCode string) (domain.Otp, error) {
|
||||
row, err := s.queries.GetOtpByCode(ctx, dbgen.GetOtpByCodeParams{
|
||||
UserID: userID,
|
||||
Otp: otpCode,
|
||||
})
|
||||
if err != nil {
|
||||
if err == sql.ErrNoRows {
|
||||
return domain.Otp{}, domain.ErrOtpNotFound
|
||||
}
|
||||
return domain.Otp{}, err
|
||||
}
|
||||
if !row.ExpiresAt.Valid {
|
||||
return domain.Otp{}, domain.ErrOtpNotFound
|
||||
}
|
||||
|
||||
return domain.Otp{
|
||||
ID: row.ID,
|
||||
UserID: row.UserID,
|
||||
SentTo: row.SentTo,
|
||||
Medium: domain.OtpMedium(row.Medium),
|
||||
For: domain.OtpFor(row.OtpFor),
|
||||
Otp: row.Otp,
|
||||
Used: row.Used,
|
||||
UsedAt: row.UsedAt.Time,
|
||||
CreatedAt: row.CreatedAt.Time,
|
||||
ExpiresAt: row.ExpiresAt.Time,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (s *Store) MarkOtpAsUsed(ctx context.Context, otp domain.Otp) error {
|
||||
return s.queries.MarkOtpAsUsed(ctx, dbgen.MarkOtpAsUsedParams{
|
||||
ID: otp.ID,
|
||||
|
|
|
|||
|
|
@ -607,6 +607,19 @@ func (s *Store) GetQuestionSetsByType(ctx context.Context, setType string, limit
|
|||
UpdatedAt: timePtr(r.UpdatedAt),
|
||||
}
|
||||
}
|
||||
|
||||
// COUNT(*) OVER() only appears when at least one row is returned.
|
||||
// For out-of-range offsets, fetch total count explicitly so pagination metadata stays correct.
|
||||
if len(rows) == 0 {
|
||||
err = s.conn.QueryRow(
|
||||
ctx,
|
||||
`SELECT COUNT(*) FROM question_sets WHERE set_type = $1 AND status != 'ARCHIVED'`,
|
||||
setType,
|
||||
).Scan(&totalCount)
|
||||
if err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
}
|
||||
return result, totalCount, nil
|
||||
}
|
||||
|
||||
|
|
@ -724,17 +737,17 @@ func (s *Store) GetQuestionSetItems(ctx context.Context, setID int64) ([]domain.
|
|||
QuestionID: r.QuestionID,
|
||||
DisplayOrder: r.DisplayOrder,
|
||||
},
|
||||
QuestionText: r.QuestionText,
|
||||
QuestionType: r.QuestionType,
|
||||
DifficultyLevel: fromPgText(r.DifficultyLevel),
|
||||
Points: r.Points,
|
||||
Explanation: fromPgText(r.Explanation),
|
||||
Tips: fromPgText(r.Tips),
|
||||
VoicePrompt: fromPgText(r.VoicePrompt),
|
||||
SampleAnswerVoicePrompt: nil,
|
||||
ImageURL: fromPgText(r.ImageUrl),
|
||||
AudioCorrectAnswerText: nil,
|
||||
QuestionStatus: r.QuestionStatus,
|
||||
QuestionText: r.QuestionText,
|
||||
QuestionType: r.QuestionType,
|
||||
DifficultyLevel: fromPgText(r.DifficultyLevel),
|
||||
Points: r.Points,
|
||||
Explanation: fromPgText(r.Explanation),
|
||||
Tips: fromPgText(r.Tips),
|
||||
VoicePrompt: fromPgText(r.VoicePrompt),
|
||||
SampleAnswerVoicePrompt: fromPgText(r.SampleAnswerVoicePrompt),
|
||||
ImageURL: fromPgText(r.ImageUrl),
|
||||
AudioCorrectAnswerText: fromPgText(r.AudioCorrectAnswerText),
|
||||
QuestionStatus: r.QuestionStatus,
|
||||
}
|
||||
}
|
||||
return result, nil
|
||||
|
|
@ -799,15 +812,17 @@ func (s *Store) GetPublishedQuestionsInSet(ctx context.Context, setID int64) ([]
|
|||
QuestionID: r.QuestionID,
|
||||
DisplayOrder: r.DisplayOrder,
|
||||
},
|
||||
QuestionText: r.QuestionText,
|
||||
QuestionType: r.QuestionType,
|
||||
DifficultyLevel: fromPgText(r.DifficultyLevel),
|
||||
Points: r.Points,
|
||||
Explanation: fromPgText(r.Explanation),
|
||||
Tips: fromPgText(r.Tips),
|
||||
VoicePrompt: fromPgText(r.VoicePrompt),
|
||||
ImageURL: fromPgText(r.ImageUrl),
|
||||
QuestionStatus: "PUBLISHED",
|
||||
QuestionText: r.QuestionText,
|
||||
QuestionType: r.QuestionType,
|
||||
DifficultyLevel: fromPgText(r.DifficultyLevel),
|
||||
Points: r.Points,
|
||||
Explanation: fromPgText(r.Explanation),
|
||||
Tips: fromPgText(r.Tips),
|
||||
VoicePrompt: fromPgText(r.VoicePrompt),
|
||||
SampleAnswerVoicePrompt: fromPgText(r.SampleAnswerVoicePrompt),
|
||||
ImageURL: fromPgText(r.ImageUrl),
|
||||
AudioCorrectAnswerText: fromPgText(r.AudioCorrectAnswerText),
|
||||
QuestionStatus: "PUBLISHED",
|
||||
}
|
||||
}
|
||||
return result, nil
|
||||
|
|
|
|||
|
|
@ -95,9 +95,13 @@ func (s *Service) VerifyOtp(
|
|||
return domain.LoginSuccess{}, err
|
||||
}
|
||||
|
||||
// 1. Retrieve OTP
|
||||
storedOtp, err := s.otpStore.GetOtp(ctx, user.ID)
|
||||
// 1. Retrieve OTP row matching submitted code.
|
||||
// This avoids false positives when another OTP row exists for the same user.
|
||||
storedOtp, err := s.otpStore.GetOtpByCode(ctx, user.ID, otpCode)
|
||||
if err != nil {
|
||||
if errors.Is(err, domain.ErrOtpNotFound) {
|
||||
return domain.LoginSuccess{}, domain.ErrInvalidOtp
|
||||
}
|
||||
return domain.LoginSuccess{}, err
|
||||
}
|
||||
|
||||
|
|
@ -111,12 +115,7 @@ func (s *Service) VerifyOtp(
|
|||
return domain.LoginSuccess{}, domain.ErrOtpExpired
|
||||
}
|
||||
|
||||
// 4. Invalid
|
||||
if storedOtp.Otp != otpCode {
|
||||
return domain.LoginSuccess{}, domain.ErrInvalidOtp
|
||||
}
|
||||
|
||||
// 5. Mark OTP as used
|
||||
// 4. Mark OTP as used
|
||||
storedOtp.Used = true
|
||||
storedOtp.UsedAt = timePtr(time.Now())
|
||||
|
||||
|
|
|
|||
407
internal/services/examprep/service.go
Normal file
407
internal/services/examprep/service.go
Normal file
|
|
@ -0,0 +1,407 @@
|
|||
package examprep
|
||||
|
||||
import (
|
||||
"Yimaru-Backend/internal/domain"
|
||||
"Yimaru-Backend/internal/ports"
|
||||
"context"
|
||||
"errors"
|
||||
|
||||
"github.com/jackc/pgx/v5"
|
||||
)
|
||||
|
||||
var ErrCatalogCourseNotFound = errors.New("exam prep catalog course not found")
|
||||
var ErrUnitNotFound = errors.New("exam prep unit not found")
|
||||
var ErrModuleNotFound = errors.New("exam prep module not found")
|
||||
var ErrLessonNotFound = errors.New("exam prep lesson not found")
|
||||
var ErrPracticeNotFound = errors.New("exam prep practice not found")
|
||||
|
||||
// examPrepStore is implemented by *repository.Store (catalog courses, units, modules, lessons, practices).
|
||||
type examPrepStore interface {
|
||||
ports.ExamPrepCatalogCourseStore
|
||||
ports.ExamPrepUnitStore
|
||||
ports.ExamPrepModuleStore
|
||||
ports.ExamPrepLessonStore
|
||||
ports.ExamPrepPracticeStore
|
||||
}
|
||||
|
||||
type Service struct {
|
||||
store examPrepStore
|
||||
}
|
||||
|
||||
func NewService(store examPrepStore) *Service {
|
||||
return &Service{store: store}
|
||||
}
|
||||
|
||||
func (s *Service) CreateCatalogCourse(ctx context.Context, input domain.CreateExamPrepCatalogCourseInput) (domain.ExamPrepCatalogCourse, error) {
|
||||
return s.store.CreateExamPrepCatalogCourse(ctx, input)
|
||||
}
|
||||
|
||||
func (s *Service) GetCatalogCourseByID(ctx context.Context, id int64) (domain.ExamPrepCatalogCourse, error) {
|
||||
c, err := s.store.GetExamPrepCatalogCourseByID(ctx, id)
|
||||
if err != nil {
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return domain.ExamPrepCatalogCourse{}, ErrCatalogCourseNotFound
|
||||
}
|
||||
return domain.ExamPrepCatalogCourse{}, err
|
||||
}
|
||||
return c, nil
|
||||
}
|
||||
|
||||
func (s *Service) ListCatalogCourses(ctx context.Context, limit, offset int32) ([]domain.ExamPrepCatalogCourse, int64, error) {
|
||||
if limit <= 0 {
|
||||
limit = 20
|
||||
}
|
||||
if limit > 200 {
|
||||
limit = 200
|
||||
}
|
||||
if offset < 0 {
|
||||
offset = 0
|
||||
}
|
||||
return s.store.ListExamPrepCatalogCourses(ctx, limit, offset)
|
||||
}
|
||||
|
||||
func (s *Service) UpdateCatalogCourse(ctx context.Context, id int64, input domain.UpdateExamPrepCatalogCourseInput) (domain.ExamPrepCatalogCourse, error) {
|
||||
c, err := s.store.UpdateExamPrepCatalogCourse(ctx, id, input)
|
||||
if err != nil {
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return domain.ExamPrepCatalogCourse{}, ErrCatalogCourseNotFound
|
||||
}
|
||||
return domain.ExamPrepCatalogCourse{}, err
|
||||
}
|
||||
return c, nil
|
||||
}
|
||||
|
||||
func (s *Service) DeleteCatalogCourse(ctx context.Context, id int64) error {
|
||||
if _, err := s.store.GetExamPrepCatalogCourseByID(ctx, id); err != nil {
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return ErrCatalogCourseNotFound
|
||||
}
|
||||
return err
|
||||
}
|
||||
return s.store.DeleteExamPrepCatalogCourse(ctx, id)
|
||||
}
|
||||
|
||||
func (s *Service) ReorderCatalogCourses(ctx context.Context, ordered []int64) error {
|
||||
expected, err := s.store.ListAllExamPrepCatalogCourseIDs(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := domain.ValidateReorderPermutation(ordered, expected); err != nil {
|
||||
return err
|
||||
}
|
||||
if len(ordered) == 0 {
|
||||
return nil
|
||||
}
|
||||
return s.store.ReorderExamPrepCatalogCourses(ctx, ordered)
|
||||
}
|
||||
|
||||
func (s *Service) ensureCatalogCourse(ctx context.Context, catalogCourseID int64) error {
|
||||
if _, err := s.store.GetExamPrepCatalogCourseByID(ctx, catalogCourseID); err != nil {
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return ErrCatalogCourseNotFound
|
||||
}
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Service) CreateUnit(ctx context.Context, catalogCourseID int64, input domain.CreateExamPrepUnitInput) (domain.ExamPrepUnit, error) {
|
||||
if err := s.ensureCatalogCourse(ctx, catalogCourseID); err != nil {
|
||||
return domain.ExamPrepUnit{}, err
|
||||
}
|
||||
return s.store.CreateExamPrepUnit(ctx, catalogCourseID, input)
|
||||
}
|
||||
|
||||
func (s *Service) ListUnitsByCatalogCourse(ctx context.Context, catalogCourseID int64, limit, offset int32) ([]domain.ExamPrepUnit, int64, error) {
|
||||
if err := s.ensureCatalogCourse(ctx, catalogCourseID); err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
if limit <= 0 {
|
||||
limit = 20
|
||||
}
|
||||
if limit > 200 {
|
||||
limit = 200
|
||||
}
|
||||
if offset < 0 {
|
||||
offset = 0
|
||||
}
|
||||
return s.store.ListExamPrepUnitsByCatalogCourse(ctx, catalogCourseID, limit, offset)
|
||||
}
|
||||
|
||||
func (s *Service) GetUnitByID(ctx context.Context, id int64) (domain.ExamPrepUnit, error) {
|
||||
u, err := s.store.GetExamPrepUnitByID(ctx, id)
|
||||
if err != nil {
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return domain.ExamPrepUnit{}, ErrUnitNotFound
|
||||
}
|
||||
return domain.ExamPrepUnit{}, err
|
||||
}
|
||||
return u, nil
|
||||
}
|
||||
|
||||
func (s *Service) UpdateUnit(ctx context.Context, id int64, input domain.UpdateExamPrepUnitInput) (domain.ExamPrepUnit, error) {
|
||||
u, err := s.store.UpdateExamPrepUnit(ctx, id, input)
|
||||
if err != nil {
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return domain.ExamPrepUnit{}, ErrUnitNotFound
|
||||
}
|
||||
return domain.ExamPrepUnit{}, err
|
||||
}
|
||||
return u, nil
|
||||
}
|
||||
|
||||
func (s *Service) DeleteUnit(ctx context.Context, id int64) error {
|
||||
if _, err := s.store.GetExamPrepUnitByID(ctx, id); err != nil {
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return ErrUnitNotFound
|
||||
}
|
||||
return err
|
||||
}
|
||||
return s.store.DeleteExamPrepUnit(ctx, id)
|
||||
}
|
||||
|
||||
func (s *Service) ReorderUnitsInCatalogCourse(ctx context.Context, catalogCourseID int64, ordered []int64) error {
|
||||
if err := s.ensureCatalogCourse(ctx, catalogCourseID); err != nil {
|
||||
return err
|
||||
}
|
||||
expected, err := s.store.ListExamPrepUnitIDsByCatalogCourse(ctx, catalogCourseID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := domain.ValidateReorderPermutation(ordered, expected); err != nil {
|
||||
return err
|
||||
}
|
||||
if len(ordered) == 0 {
|
||||
return nil
|
||||
}
|
||||
return s.store.ReorderExamPrepUnitsInCatalogCourse(ctx, catalogCourseID, ordered)
|
||||
}
|
||||
|
||||
func (s *Service) ensureUnit(ctx context.Context, unitID int64) error {
|
||||
if _, err := s.store.GetExamPrepUnitByID(ctx, unitID); err != nil {
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return ErrUnitNotFound
|
||||
}
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Service) CreateModule(ctx context.Context, unitID int64, input domain.CreateExamPrepModuleInput) (domain.ExamPrepModule, error) {
|
||||
if err := s.ensureUnit(ctx, unitID); err != nil {
|
||||
return domain.ExamPrepModule{}, err
|
||||
}
|
||||
return s.store.CreateExamPrepUnitModule(ctx, unitID, input)
|
||||
}
|
||||
|
||||
func (s *Service) ListModulesByUnit(ctx context.Context, unitID int64, limit, offset int32) ([]domain.ExamPrepModule, int64, error) {
|
||||
if err := s.ensureUnit(ctx, unitID); err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
if limit <= 0 {
|
||||
limit = 20
|
||||
}
|
||||
if limit > 200 {
|
||||
limit = 200
|
||||
}
|
||||
if offset < 0 {
|
||||
offset = 0
|
||||
}
|
||||
return s.store.ListExamPrepUnitModulesByUnit(ctx, unitID, limit, offset)
|
||||
}
|
||||
|
||||
func (s *Service) GetModuleByID(ctx context.Context, id int64) (domain.ExamPrepModule, error) {
|
||||
m, err := s.store.GetExamPrepUnitModuleByID(ctx, id)
|
||||
if err != nil {
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return domain.ExamPrepModule{}, ErrModuleNotFound
|
||||
}
|
||||
return domain.ExamPrepModule{}, err
|
||||
}
|
||||
return m, nil
|
||||
}
|
||||
|
||||
func (s *Service) UpdateModule(ctx context.Context, id int64, input domain.UpdateExamPrepModuleInput) (domain.ExamPrepModule, error) {
|
||||
m, err := s.store.UpdateExamPrepUnitModule(ctx, id, input)
|
||||
if err != nil {
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return domain.ExamPrepModule{}, ErrModuleNotFound
|
||||
}
|
||||
return domain.ExamPrepModule{}, err
|
||||
}
|
||||
return m, nil
|
||||
}
|
||||
|
||||
func (s *Service) DeleteModule(ctx context.Context, id int64) error {
|
||||
if _, err := s.store.GetExamPrepUnitModuleByID(ctx, id); err != nil {
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return ErrModuleNotFound
|
||||
}
|
||||
return err
|
||||
}
|
||||
return s.store.DeleteExamPrepUnitModule(ctx, id)
|
||||
}
|
||||
|
||||
func (s *Service) ReorderModulesInUnit(ctx context.Context, unitID int64, ordered []int64) error {
|
||||
if err := s.ensureUnit(ctx, unitID); err != nil {
|
||||
return err
|
||||
}
|
||||
expected, err := s.store.ListExamPrepUnitModuleIDsByUnit(ctx, unitID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := domain.ValidateReorderPermutation(ordered, expected); err != nil {
|
||||
return err
|
||||
}
|
||||
if len(ordered) == 0 {
|
||||
return nil
|
||||
}
|
||||
return s.store.ReorderExamPrepUnitModulesInUnit(ctx, unitID, ordered)
|
||||
}
|
||||
|
||||
func (s *Service) ensureModule(ctx context.Context, unitModuleID int64) error {
|
||||
if _, err := s.store.GetExamPrepUnitModuleByID(ctx, unitModuleID); err != nil {
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return ErrModuleNotFound
|
||||
}
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Service) CreateLesson(ctx context.Context, unitModuleID int64, input domain.CreateExamPrepLessonInput) (domain.ExamPrepLesson, error) {
|
||||
if err := s.ensureModule(ctx, unitModuleID); err != nil {
|
||||
return domain.ExamPrepLesson{}, err
|
||||
}
|
||||
return s.store.CreateExamPrepUnitModuleLesson(ctx, unitModuleID, input)
|
||||
}
|
||||
|
||||
func (s *Service) ListLessonsByUnitModule(ctx context.Context, unitModuleID int64, limit, offset int32) ([]domain.ExamPrepLesson, int64, error) {
|
||||
if err := s.ensureModule(ctx, unitModuleID); err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
if limit <= 0 {
|
||||
limit = 20
|
||||
}
|
||||
if limit > 200 {
|
||||
limit = 200
|
||||
}
|
||||
if offset < 0 {
|
||||
offset = 0
|
||||
}
|
||||
return s.store.ListExamPrepUnitModuleLessonsByUnitModuleID(ctx, unitModuleID, limit, offset)
|
||||
}
|
||||
|
||||
func (s *Service) GetLessonByID(ctx context.Context, id int64) (domain.ExamPrepLesson, error) {
|
||||
l, err := s.store.GetExamPrepUnitModuleLessonByID(ctx, id)
|
||||
if err != nil {
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return domain.ExamPrepLesson{}, ErrLessonNotFound
|
||||
}
|
||||
return domain.ExamPrepLesson{}, err
|
||||
}
|
||||
return l, nil
|
||||
}
|
||||
|
||||
func (s *Service) UpdateLesson(ctx context.Context, id int64, input domain.UpdateExamPrepLessonInput) (domain.ExamPrepLesson, error) {
|
||||
l, err := s.store.UpdateExamPrepUnitModuleLesson(ctx, id, input)
|
||||
if err != nil {
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return domain.ExamPrepLesson{}, ErrLessonNotFound
|
||||
}
|
||||
return domain.ExamPrepLesson{}, err
|
||||
}
|
||||
return l, nil
|
||||
}
|
||||
|
||||
func (s *Service) DeleteLesson(ctx context.Context, id int64) error {
|
||||
if _, err := s.store.GetExamPrepUnitModuleLessonByID(ctx, id); err != nil {
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return ErrLessonNotFound
|
||||
}
|
||||
return err
|
||||
}
|
||||
return s.store.DeleteExamPrepUnitModuleLesson(ctx, id)
|
||||
}
|
||||
|
||||
func (s *Service) ReorderLessonsInUnitModule(ctx context.Context, unitModuleID int64, ordered []int64) error {
|
||||
if err := s.ensureModule(ctx, unitModuleID); err != nil {
|
||||
return err
|
||||
}
|
||||
expected, err := s.store.ListExamPrepUnitModuleLessonIDsByUnitModule(ctx, unitModuleID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := domain.ValidateReorderPermutation(ordered, expected); err != nil {
|
||||
return err
|
||||
}
|
||||
if len(ordered) == 0 {
|
||||
return nil
|
||||
}
|
||||
return s.store.ReorderExamPrepUnitModuleLessonsInUnitModule(ctx, unitModuleID, ordered)
|
||||
}
|
||||
|
||||
func (s *Service) ensureLesson(ctx context.Context, lessonID int64) error {
|
||||
if _, err := s.store.GetExamPrepUnitModuleLessonByID(ctx, lessonID); err != nil {
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return ErrLessonNotFound
|
||||
}
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Service) CreateExamPrepPractice(ctx context.Context, lessonID int64, input domain.CreateExamPrepPracticeInput) (domain.ExamPrepPractice, error) {
|
||||
if err := s.ensureLesson(ctx, lessonID); err != nil {
|
||||
return domain.ExamPrepPractice{}, err
|
||||
}
|
||||
return s.store.CreateExamPrepLessonPractice(ctx, lessonID, input)
|
||||
}
|
||||
|
||||
func (s *Service) ListExamPrepPracticesByLesson(ctx context.Context, lessonID int64, limit, offset int32) ([]domain.ExamPrepPractice, int64, error) {
|
||||
if err := s.ensureLesson(ctx, lessonID); err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
if limit <= 0 {
|
||||
limit = 20
|
||||
}
|
||||
if limit > 200 {
|
||||
limit = 200
|
||||
}
|
||||
if offset < 0 {
|
||||
offset = 0
|
||||
}
|
||||
return s.store.ListExamPrepLessonPracticesByLessonID(ctx, lessonID, limit, offset)
|
||||
}
|
||||
|
||||
func (s *Service) GetExamPrepPracticeByID(ctx context.Context, id int64) (domain.ExamPrepPractice, error) {
|
||||
p, err := s.store.GetExamPrepLessonPracticeByID(ctx, id)
|
||||
if err != nil {
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return domain.ExamPrepPractice{}, ErrPracticeNotFound
|
||||
}
|
||||
return domain.ExamPrepPractice{}, err
|
||||
}
|
||||
return p, nil
|
||||
}
|
||||
|
||||
func (s *Service) UpdateExamPrepPractice(ctx context.Context, id int64, input domain.UpdateExamPrepPracticeInput) (domain.ExamPrepPractice, error) {
|
||||
p, err := s.store.UpdateExamPrepLessonPractice(ctx, id, input)
|
||||
if err != nil {
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return domain.ExamPrepPractice{}, ErrPracticeNotFound
|
||||
}
|
||||
return domain.ExamPrepPractice{}, err
|
||||
}
|
||||
return p, nil
|
||||
}
|
||||
|
||||
func (s *Service) DeleteExamPrepPractice(ctx context.Context, id int64) error {
|
||||
if _, err := s.store.GetExamPrepLessonPracticeByID(ctx, id); err != nil {
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return ErrPracticeNotFound
|
||||
}
|
||||
return err
|
||||
}
|
||||
return s.store.DeleteExamPrepLessonPractice(ctx, id)
|
||||
}
|
||||
|
|
@ -31,6 +31,11 @@ func (s *Service) CompleteLessonForUser(ctx context.Context, userID, lessonID in
|
|||
return s.store.CompleteLessonForUser(ctx, userID, lessonID)
|
||||
}
|
||||
|
||||
// CompletePracticeForUser records practice completion and rolls up to module, course, and program when applicable.
|
||||
func (s *Service) CompletePracticeForUser(ctx context.Context, userID, questionSetID int64) error {
|
||||
return s.store.CompletePracticeForUser(ctx, userID, questionSetID)
|
||||
}
|
||||
|
||||
// GetMyProgress returns completed lesson, module, course, and program IDs for the user.
|
||||
func (s *Service) GetMyProgress(ctx context.Context, userID int64) (domain.LMSUserProgress, error) {
|
||||
return s.store.GetLMSUserProgressSnapshot(ctx, userID)
|
||||
|
|
|
|||
|
|
@ -66,6 +66,10 @@ func (s *Service) GetURL(ctx context.Context, objectKey string, expiry time.Dura
|
|||
return s.client.GetFileURL(ctx, objectKey, expiry)
|
||||
}
|
||||
|
||||
func (s *Service) BucketName() string {
|
||||
return s.client.BucketName()
|
||||
}
|
||||
|
||||
// Delete removes a file from MinIO.
|
||||
func (s *Service) Delete(ctx context.Context, objectKey string) error {
|
||||
s.logger.Info("Deleting file from MinIO", zap.String("object_key", objectKey))
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
235
internal/web_server/handlers/exam_prep_catalog_course_handler.go
Normal file
235
internal/web_server/handlers/exam_prep_catalog_course_handler.go
Normal file
|
|
@ -0,0 +1,235 @@
|
|||
package handlers
|
||||
|
||||
import (
|
||||
"Yimaru-Backend/internal/domain"
|
||||
"Yimaru-Backend/internal/services/examprep"
|
||||
"errors"
|
||||
"strconv"
|
||||
|
||||
"github.com/gofiber/fiber/v2"
|
||||
)
|
||||
|
||||
// CreateExamPrepCatalogCourse godoc
|
||||
// @Summary Create exam-prep catalog course
|
||||
// @Description Top-level exam track (DET, IELTS, …) in schema exam_prep — separate from LMS programs/courses
|
||||
// @Tags exam-prep
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Param body body domain.CreateExamPrepCatalogCourseInput true "Catalog course"
|
||||
// @Success 201 {object} domain.Response
|
||||
// @Failure 400 {object} domain.ErrorResponse
|
||||
// @Router /api/v1/exam-prep/catalog-courses [post]
|
||||
func (h *Handler) CreateExamPrepCatalogCourse(c *fiber.Ctx) error {
|
||||
var req domain.CreateExamPrepCatalogCourseInput
|
||||
if err := c.BodyParser(&req); err != nil {
|
||||
return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{
|
||||
Message: "Invalid request body",
|
||||
Error: err.Error(),
|
||||
})
|
||||
}
|
||||
if valErrs, ok := h.validator.Validate(c, req); !ok {
|
||||
return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{
|
||||
Message: "Validation failed",
|
||||
Error: firstValidationError(valErrs),
|
||||
})
|
||||
}
|
||||
out, err := h.examPrepSvc.CreateCatalogCourse(c.Context(), req)
|
||||
if err != nil {
|
||||
return c.Status(fiber.StatusInternalServerError).JSON(domain.ErrorResponse{
|
||||
Message: "Failed to create catalog course",
|
||||
Error: err.Error(),
|
||||
})
|
||||
}
|
||||
return c.Status(fiber.StatusCreated).JSON(domain.Response{
|
||||
Message: "Catalog course created successfully",
|
||||
Data: out,
|
||||
Success: true,
|
||||
StatusCode: fiber.StatusCreated,
|
||||
})
|
||||
}
|
||||
|
||||
// ListExamPrepCatalogCourses godoc
|
||||
// @Summary List exam-prep catalog courses
|
||||
// @Tags exam-prep
|
||||
// @Produce json
|
||||
// @Param limit query int false "Page size" default(20)
|
||||
// @Param offset query int false "Offset" default(0)
|
||||
// @Success 200 {object} domain.Response
|
||||
// @Router /api/v1/exam-prep/catalog-courses [get]
|
||||
func (h *Handler) ListExamPrepCatalogCourses(c *fiber.Ctx) error {
|
||||
limit, _ := strconv.Atoi(c.Query("limit", "20"))
|
||||
offset, _ := strconv.Atoi(c.Query("offset", "0"))
|
||||
items, total, err := h.examPrepSvc.ListCatalogCourses(c.Context(), int32(limit), int32(offset))
|
||||
if err != nil {
|
||||
return c.Status(fiber.StatusInternalServerError).JSON(domain.ErrorResponse{
|
||||
Message: "Failed to list catalog courses",
|
||||
Error: err.Error(),
|
||||
})
|
||||
}
|
||||
return c.JSON(domain.Response{
|
||||
Message: "Catalog courses retrieved successfully",
|
||||
Data: fiber.Map{
|
||||
"catalog_courses": items,
|
||||
"total_count": total,
|
||||
"limit": limit,
|
||||
"offset": offset,
|
||||
},
|
||||
Success: true,
|
||||
StatusCode: fiber.StatusOK,
|
||||
})
|
||||
}
|
||||
|
||||
// ReorderExamPrepCatalogCourses godoc
|
||||
// @Summary Reorder all exam-prep catalog courses
|
||||
// @Tags exam-prep
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Param body body domain.ReorderIDsRequest true "ordered_ids: every catalog course id exactly once"
|
||||
// @Success 200 {object} domain.Response
|
||||
// @Router /api/v1/exam-prep/catalog-courses/reorder [put]
|
||||
func (h *Handler) ReorderExamPrepCatalogCourses(c *fiber.Ctx) error {
|
||||
var req domain.ReorderIDsRequest
|
||||
if err := c.BodyParser(&req); err != nil {
|
||||
return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{
|
||||
Message: "Invalid request body",
|
||||
Error: err.Error(),
|
||||
})
|
||||
}
|
||||
if req.OrderedIDs == nil {
|
||||
return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{
|
||||
Message: "ordered_ids is required (use an empty array if there are no catalog courses)",
|
||||
Error: "missing ordered_ids",
|
||||
})
|
||||
}
|
||||
if err := h.examPrepSvc.ReorderCatalogCourses(c.Context(), req.OrderedIDs); err != nil {
|
||||
if errors.Is(err, domain.ErrReorderInvalidIDSet) {
|
||||
return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{
|
||||
Message: err.Error(),
|
||||
Error: "INVALID_REORDER",
|
||||
})
|
||||
}
|
||||
return c.Status(fiber.StatusInternalServerError).JSON(domain.ErrorResponse{
|
||||
Message: "Failed to reorder catalog courses",
|
||||
Error: err.Error(),
|
||||
})
|
||||
}
|
||||
return c.JSON(domain.Response{
|
||||
Message: "Catalog courses reordered successfully",
|
||||
Success: true,
|
||||
StatusCode: fiber.StatusOK,
|
||||
})
|
||||
}
|
||||
|
||||
// GetExamPrepCatalogCourseByID godoc
|
||||
// @Summary Get exam-prep catalog course by ID
|
||||
// @Tags exam-prep
|
||||
// @Produce json
|
||||
// @Param id path int true "Catalog course ID"
|
||||
// @Success 200 {object} domain.Response
|
||||
// @Router /api/v1/exam-prep/catalog-courses/{id} [get]
|
||||
func (h *Handler) GetExamPrepCatalogCourseByID(c *fiber.Ctx) error {
|
||||
id, err := strconv.ParseInt(c.Params("id"), 10, 64)
|
||||
if err != nil {
|
||||
return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{
|
||||
Message: "Invalid catalog course id",
|
||||
Error: err.Error(),
|
||||
})
|
||||
}
|
||||
out, err := h.examPrepSvc.GetCatalogCourseByID(c.Context(), id)
|
||||
if err != nil {
|
||||
if errors.Is(err, examprep.ErrCatalogCourseNotFound) {
|
||||
return c.Status(fiber.StatusNotFound).JSON(domain.ErrorResponse{
|
||||
Message: "Catalog course not found",
|
||||
Error: err.Error(),
|
||||
})
|
||||
}
|
||||
return c.Status(fiber.StatusInternalServerError).JSON(domain.ErrorResponse{
|
||||
Message: "Failed to get catalog course",
|
||||
Error: err.Error(),
|
||||
})
|
||||
}
|
||||
return c.JSON(domain.Response{
|
||||
Message: "Catalog course retrieved successfully",
|
||||
Data: out,
|
||||
Success: true,
|
||||
StatusCode: fiber.StatusOK,
|
||||
})
|
||||
}
|
||||
|
||||
// UpdateExamPrepCatalogCourse godoc
|
||||
// @Summary Update exam-prep catalog course
|
||||
// @Tags exam-prep
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Param id path int true "Catalog course ID"
|
||||
// @Param body body domain.UpdateExamPrepCatalogCourseInput true "Fields to update"
|
||||
// @Success 200 {object} domain.Response
|
||||
// @Router /api/v1/exam-prep/catalog-courses/{id} [put]
|
||||
func (h *Handler) UpdateExamPrepCatalogCourse(c *fiber.Ctx) error {
|
||||
id, err := strconv.ParseInt(c.Params("id"), 10, 64)
|
||||
if err != nil {
|
||||
return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{
|
||||
Message: "Invalid catalog course id",
|
||||
Error: err.Error(),
|
||||
})
|
||||
}
|
||||
var req domain.UpdateExamPrepCatalogCourseInput
|
||||
if err := c.BodyParser(&req); err != nil {
|
||||
return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{
|
||||
Message: "Invalid request body",
|
||||
Error: err.Error(),
|
||||
})
|
||||
}
|
||||
out, err := h.examPrepSvc.UpdateCatalogCourse(c.Context(), id, req)
|
||||
if err != nil {
|
||||
if errors.Is(err, examprep.ErrCatalogCourseNotFound) {
|
||||
return c.Status(fiber.StatusNotFound).JSON(domain.ErrorResponse{
|
||||
Message: "Catalog course not found",
|
||||
Error: err.Error(),
|
||||
})
|
||||
}
|
||||
return c.Status(fiber.StatusInternalServerError).JSON(domain.ErrorResponse{
|
||||
Message: "Failed to update catalog course",
|
||||
Error: err.Error(),
|
||||
})
|
||||
}
|
||||
return c.JSON(domain.Response{
|
||||
Message: "Catalog course updated successfully",
|
||||
Data: out,
|
||||
Success: true,
|
||||
StatusCode: fiber.StatusOK,
|
||||
})
|
||||
}
|
||||
|
||||
// DeleteExamPrepCatalogCourse godoc
|
||||
// @Summary Delete exam-prep catalog course
|
||||
// @Tags exam-prep
|
||||
// @Param id path int true "Catalog course ID"
|
||||
// @Success 200 {object} domain.Response
|
||||
// @Router /api/v1/exam-prep/catalog-courses/{id} [delete]
|
||||
func (h *Handler) DeleteExamPrepCatalogCourse(c *fiber.Ctx) error {
|
||||
id, err := strconv.ParseInt(c.Params("id"), 10, 64)
|
||||
if err != nil {
|
||||
return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{
|
||||
Message: "Invalid catalog course id",
|
||||
Error: err.Error(),
|
||||
})
|
||||
}
|
||||
if err := h.examPrepSvc.DeleteCatalogCourse(c.Context(), id); err != nil {
|
||||
if errors.Is(err, examprep.ErrCatalogCourseNotFound) {
|
||||
return c.Status(fiber.StatusNotFound).JSON(domain.ErrorResponse{
|
||||
Message: "Catalog course not found",
|
||||
Error: err.Error(),
|
||||
})
|
||||
}
|
||||
return c.Status(fiber.StatusInternalServerError).JSON(domain.ErrorResponse{
|
||||
Message: "Failed to delete catalog course",
|
||||
Error: err.Error(),
|
||||
})
|
||||
}
|
||||
return c.JSON(domain.Response{
|
||||
Message: "Catalog course deleted successfully",
|
||||
Success: true,
|
||||
StatusCode: fiber.StatusOK,
|
||||
})
|
||||
}
|
||||
255
internal/web_server/handlers/exam_prep_lesson_handler.go
Normal file
255
internal/web_server/handlers/exam_prep_lesson_handler.go
Normal file
|
|
@ -0,0 +1,255 @@
|
|||
package handlers
|
||||
|
||||
import (
|
||||
"Yimaru-Backend/internal/domain"
|
||||
"Yimaru-Backend/internal/services/examprep"
|
||||
"errors"
|
||||
"strconv"
|
||||
|
||||
"github.com/gofiber/fiber/v2"
|
||||
)
|
||||
|
||||
// CreateExamPrepLesson godoc
|
||||
// @Summary Create exam-prep lesson (under a unit module)
|
||||
// @Tags exam-prep
|
||||
// @Param moduleId path int true "Exam prep unit module ID"
|
||||
// @Param body body domain.CreateExamPrepLessonInput true "Lesson"
|
||||
// @Router /api/v1/exam-prep/modules/{moduleId}/lessons [post]
|
||||
func (h *Handler) CreateExamPrepLesson(c *fiber.Ctx) error {
|
||||
moduleID, err := strconv.ParseInt(c.Params("moduleId"), 10, 64)
|
||||
if err != nil {
|
||||
return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{
|
||||
Message: "Invalid module id",
|
||||
Error: err.Error(),
|
||||
})
|
||||
}
|
||||
var req domain.CreateExamPrepLessonInput
|
||||
if err := c.BodyParser(&req); err != nil {
|
||||
return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{
|
||||
Message: "Invalid request body",
|
||||
Error: err.Error(),
|
||||
})
|
||||
}
|
||||
if valErrs, ok := h.validator.Validate(c, req); !ok {
|
||||
return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{
|
||||
Message: "Validation failed",
|
||||
Error: firstValidationError(valErrs),
|
||||
})
|
||||
}
|
||||
les, err := h.examPrepSvc.CreateLesson(c.Context(), moduleID, req)
|
||||
if err != nil {
|
||||
if errors.Is(err, examprep.ErrModuleNotFound) {
|
||||
return c.Status(fiber.StatusNotFound).JSON(domain.ErrorResponse{
|
||||
Message: "Module not found",
|
||||
Error: err.Error(),
|
||||
})
|
||||
}
|
||||
return c.Status(fiber.StatusInternalServerError).JSON(domain.ErrorResponse{
|
||||
Message: "Failed to create lesson",
|
||||
Error: err.Error(),
|
||||
})
|
||||
}
|
||||
return c.Status(fiber.StatusCreated).JSON(domain.Response{
|
||||
Message: "Lesson created successfully",
|
||||
Data: les,
|
||||
Success: true,
|
||||
StatusCode: fiber.StatusCreated,
|
||||
})
|
||||
}
|
||||
|
||||
// ListExamPrepLessonsByUnitModule godoc
|
||||
// @Summary List exam-prep lessons for a unit module
|
||||
// @Tags exam-prep
|
||||
// @Param moduleId path int true "Exam prep unit module ID"
|
||||
// @Param limit query int false "Page size" default(20)
|
||||
// @Param offset query int false "Offset" default(0)
|
||||
// @Router /api/v1/exam-prep/modules/{moduleId}/lessons [get]
|
||||
func (h *Handler) ListExamPrepLessonsByUnitModule(c *fiber.Ctx) error {
|
||||
moduleID, err := strconv.ParseInt(c.Params("moduleId"), 10, 64)
|
||||
if err != nil {
|
||||
return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{
|
||||
Message: "Invalid module id",
|
||||
Error: err.Error(),
|
||||
})
|
||||
}
|
||||
limit, _ := strconv.Atoi(c.Query("limit", "20"))
|
||||
offset, _ := strconv.Atoi(c.Query("offset", "0"))
|
||||
items, total, err := h.examPrepSvc.ListLessonsByUnitModule(c.Context(), moduleID, int32(limit), int32(offset))
|
||||
if err != nil {
|
||||
if errors.Is(err, examprep.ErrModuleNotFound) {
|
||||
return c.Status(fiber.StatusNotFound).JSON(domain.ErrorResponse{
|
||||
Message: "Module not found",
|
||||
Error: err.Error(),
|
||||
})
|
||||
}
|
||||
return c.Status(fiber.StatusInternalServerError).JSON(domain.ErrorResponse{
|
||||
Message: "Failed to list lessons",
|
||||
Error: err.Error(),
|
||||
})
|
||||
}
|
||||
return c.JSON(domain.Response{
|
||||
Message: "Lessons retrieved successfully",
|
||||
Data: fiber.Map{
|
||||
"lessons": items,
|
||||
"total_count": total,
|
||||
"limit": limit,
|
||||
"offset": offset,
|
||||
},
|
||||
Success: true,
|
||||
StatusCode: fiber.StatusOK,
|
||||
})
|
||||
}
|
||||
|
||||
// ReorderExamPrepLessonsInUnitModule godoc
|
||||
// @Summary Reorder lessons within an exam-prep unit module
|
||||
// @Tags exam-prep
|
||||
// @Router /api/v1/exam-prep/modules/{moduleId}/lessons/reorder [put]
|
||||
func (h *Handler) ReorderExamPrepLessonsInUnitModule(c *fiber.Ctx) error {
|
||||
moduleID, err := strconv.ParseInt(c.Params("moduleId"), 10, 64)
|
||||
if err != nil {
|
||||
return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{
|
||||
Message: "Invalid module id",
|
||||
Error: err.Error(),
|
||||
})
|
||||
}
|
||||
var req domain.ReorderIDsRequest
|
||||
if err := c.BodyParser(&req); err != nil {
|
||||
return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{
|
||||
Message: "Invalid request body",
|
||||
Error: err.Error(),
|
||||
})
|
||||
}
|
||||
if req.OrderedIDs == nil {
|
||||
return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{
|
||||
Message: "ordered_ids is required (use an empty array if there are no lessons)",
|
||||
Error: "missing ordered_ids",
|
||||
})
|
||||
}
|
||||
if err := h.examPrepSvc.ReorderLessonsInUnitModule(c.Context(), moduleID, req.OrderedIDs); err != nil {
|
||||
if errors.Is(err, examprep.ErrModuleNotFound) {
|
||||
return c.Status(fiber.StatusNotFound).JSON(domain.ErrorResponse{
|
||||
Message: "Module not found",
|
||||
Error: err.Error(),
|
||||
})
|
||||
}
|
||||
if errors.Is(err, domain.ErrReorderInvalidIDSet) {
|
||||
return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{
|
||||
Message: err.Error(),
|
||||
Error: "INVALID_REORDER",
|
||||
})
|
||||
}
|
||||
return c.Status(fiber.StatusInternalServerError).JSON(domain.ErrorResponse{
|
||||
Message: "Failed to reorder lessons",
|
||||
Error: err.Error(),
|
||||
})
|
||||
}
|
||||
return c.JSON(domain.Response{
|
||||
Message: "Lessons reordered successfully",
|
||||
Success: true,
|
||||
StatusCode: fiber.StatusOK,
|
||||
})
|
||||
}
|
||||
|
||||
// GetExamPrepLessonByID godoc
|
||||
// @Summary Get exam-prep lesson by ID
|
||||
// @Tags exam-prep
|
||||
// @Router /api/v1/exam-prep/lessons/{id} [get]
|
||||
func (h *Handler) GetExamPrepLessonByID(c *fiber.Ctx) error {
|
||||
id, err := strconv.ParseInt(c.Params("id"), 10, 64)
|
||||
if err != nil {
|
||||
return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{
|
||||
Message: "Invalid lesson id",
|
||||
Error: err.Error(),
|
||||
})
|
||||
}
|
||||
les, err := h.examPrepSvc.GetLessonByID(c.Context(), id)
|
||||
if err != nil {
|
||||
if errors.Is(err, examprep.ErrLessonNotFound) {
|
||||
return c.Status(fiber.StatusNotFound).JSON(domain.ErrorResponse{
|
||||
Message: "Lesson not found",
|
||||
Error: err.Error(),
|
||||
})
|
||||
}
|
||||
return c.Status(fiber.StatusInternalServerError).JSON(domain.ErrorResponse{
|
||||
Message: "Failed to get lesson",
|
||||
Error: err.Error(),
|
||||
})
|
||||
}
|
||||
return c.JSON(domain.Response{
|
||||
Message: "Lesson retrieved successfully",
|
||||
Data: les,
|
||||
Success: true,
|
||||
StatusCode: fiber.StatusOK,
|
||||
})
|
||||
}
|
||||
|
||||
// UpdateExamPrepLesson godoc
|
||||
// @Summary Update exam-prep lesson
|
||||
// @Tags exam-prep
|
||||
// @Router /api/v1/exam-prep/lessons/{id} [put]
|
||||
func (h *Handler) UpdateExamPrepLesson(c *fiber.Ctx) error {
|
||||
id, err := strconv.ParseInt(c.Params("id"), 10, 64)
|
||||
if err != nil {
|
||||
return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{
|
||||
Message: "Invalid lesson id",
|
||||
Error: err.Error(),
|
||||
})
|
||||
}
|
||||
var req domain.UpdateExamPrepLessonInput
|
||||
if err := c.BodyParser(&req); err != nil {
|
||||
return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{
|
||||
Message: "Invalid request body",
|
||||
Error: err.Error(),
|
||||
})
|
||||
}
|
||||
les, err := h.examPrepSvc.UpdateLesson(c.Context(), id, req)
|
||||
if err != nil {
|
||||
if errors.Is(err, examprep.ErrLessonNotFound) {
|
||||
return c.Status(fiber.StatusNotFound).JSON(domain.ErrorResponse{
|
||||
Message: "Lesson not found",
|
||||
Error: err.Error(),
|
||||
})
|
||||
}
|
||||
return c.Status(fiber.StatusInternalServerError).JSON(domain.ErrorResponse{
|
||||
Message: "Failed to update lesson",
|
||||
Error: err.Error(),
|
||||
})
|
||||
}
|
||||
return c.JSON(domain.Response{
|
||||
Message: "Lesson updated successfully",
|
||||
Data: les,
|
||||
Success: true,
|
||||
StatusCode: fiber.StatusOK,
|
||||
})
|
||||
}
|
||||
|
||||
// DeleteExamPrepLesson godoc
|
||||
// @Summary Delete exam-prep lesson
|
||||
// @Tags exam-prep
|
||||
// @Router /api/v1/exam-prep/lessons/{id} [delete]
|
||||
func (h *Handler) DeleteExamPrepLesson(c *fiber.Ctx) error {
|
||||
id, err := strconv.ParseInt(c.Params("id"), 10, 64)
|
||||
if err != nil {
|
||||
return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{
|
||||
Message: "Invalid lesson id",
|
||||
Error: err.Error(),
|
||||
})
|
||||
}
|
||||
if err := h.examPrepSvc.DeleteLesson(c.Context(), id); err != nil {
|
||||
if errors.Is(err, examprep.ErrLessonNotFound) {
|
||||
return c.Status(fiber.StatusNotFound).JSON(domain.ErrorResponse{
|
||||
Message: "Lesson not found",
|
||||
Error: err.Error(),
|
||||
})
|
||||
}
|
||||
return c.Status(fiber.StatusInternalServerError).JSON(domain.ErrorResponse{
|
||||
Message: "Failed to delete lesson",
|
||||
Error: err.Error(),
|
||||
})
|
||||
}
|
||||
return c.JSON(domain.Response{
|
||||
Message: "Lesson deleted successfully",
|
||||
Success: true,
|
||||
StatusCode: fiber.StatusOK,
|
||||
})
|
||||
}
|
||||
255
internal/web_server/handlers/exam_prep_module_handler.go
Normal file
255
internal/web_server/handlers/exam_prep_module_handler.go
Normal file
|
|
@ -0,0 +1,255 @@
|
|||
package handlers
|
||||
|
||||
import (
|
||||
"Yimaru-Backend/internal/domain"
|
||||
"Yimaru-Backend/internal/services/examprep"
|
||||
"errors"
|
||||
"strconv"
|
||||
|
||||
"github.com/gofiber/fiber/v2"
|
||||
)
|
||||
|
||||
// CreateExamPrepModule godoc
|
||||
// @Summary Create exam-prep module
|
||||
// @Tags exam-prep
|
||||
// @Param unitId path int true "Unit ID"
|
||||
// @Param body body domain.CreateExamPrepModuleInput true "Module"
|
||||
// @Router /api/v1/exam-prep/units/{unitId}/modules [post]
|
||||
func (h *Handler) CreateExamPrepModule(c *fiber.Ctx) error {
|
||||
unitID, err := strconv.ParseInt(c.Params("unitId"), 10, 64)
|
||||
if err != nil {
|
||||
return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{
|
||||
Message: "Invalid unit id",
|
||||
Error: err.Error(),
|
||||
})
|
||||
}
|
||||
var req domain.CreateExamPrepModuleInput
|
||||
if err := c.BodyParser(&req); err != nil {
|
||||
return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{
|
||||
Message: "Invalid request body",
|
||||
Error: err.Error(),
|
||||
})
|
||||
}
|
||||
if valErrs, ok := h.validator.Validate(c, req); !ok {
|
||||
return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{
|
||||
Message: "Validation failed",
|
||||
Error: firstValidationError(valErrs),
|
||||
})
|
||||
}
|
||||
out, err := h.examPrepSvc.CreateModule(c.Context(), unitID, req)
|
||||
if err != nil {
|
||||
if errors.Is(err, examprep.ErrUnitNotFound) {
|
||||
return c.Status(fiber.StatusNotFound).JSON(domain.ErrorResponse{
|
||||
Message: "Unit not found",
|
||||
Error: err.Error(),
|
||||
})
|
||||
}
|
||||
return c.Status(fiber.StatusInternalServerError).JSON(domain.ErrorResponse{
|
||||
Message: "Failed to create module",
|
||||
Error: err.Error(),
|
||||
})
|
||||
}
|
||||
return c.Status(fiber.StatusCreated).JSON(domain.Response{
|
||||
Message: "Module created successfully",
|
||||
Data: out,
|
||||
Success: true,
|
||||
StatusCode: fiber.StatusCreated,
|
||||
})
|
||||
}
|
||||
|
||||
// ListExamPrepModulesByUnit godoc
|
||||
// @Summary List exam-prep modules for a unit
|
||||
// @Tags exam-prep
|
||||
// @Param unitId path int true "Unit ID"
|
||||
// @Router /api/v1/exam-prep/units/{unitId}/modules [get]
|
||||
func (h *Handler) ListExamPrepModulesByUnit(c *fiber.Ctx) error {
|
||||
unitID, err := strconv.ParseInt(c.Params("unitId"), 10, 64)
|
||||
if err != nil {
|
||||
return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{
|
||||
Message: "Invalid unit id",
|
||||
Error: err.Error(),
|
||||
})
|
||||
}
|
||||
limit, _ := strconv.Atoi(c.Query("limit", "20"))
|
||||
offset, _ := strconv.Atoi(c.Query("offset", "0"))
|
||||
items, total, err := h.examPrepSvc.ListModulesByUnit(c.Context(), unitID, int32(limit), int32(offset))
|
||||
if err != nil {
|
||||
if errors.Is(err, examprep.ErrUnitNotFound) {
|
||||
return c.Status(fiber.StatusNotFound).JSON(domain.ErrorResponse{
|
||||
Message: "Unit not found",
|
||||
Error: err.Error(),
|
||||
})
|
||||
}
|
||||
return c.Status(fiber.StatusInternalServerError).JSON(domain.ErrorResponse{
|
||||
Message: "Failed to list modules",
|
||||
Error: err.Error(),
|
||||
})
|
||||
}
|
||||
return c.JSON(domain.Response{
|
||||
Message: "Modules retrieved successfully",
|
||||
Data: fiber.Map{
|
||||
"modules": items,
|
||||
"total_count": total,
|
||||
"limit": limit,
|
||||
"offset": offset,
|
||||
},
|
||||
Success: true,
|
||||
StatusCode: fiber.StatusOK,
|
||||
})
|
||||
}
|
||||
|
||||
// ReorderExamPrepModulesInUnit godoc
|
||||
// @Summary Reorder modules within a unit
|
||||
// @Tags exam-prep
|
||||
// @Param unitId path int true "Unit ID"
|
||||
// @Param body body domain.ReorderIDsRequest true "ordered_ids"
|
||||
// @Router /api/v1/exam-prep/units/{unitId}/modules/reorder [put]
|
||||
func (h *Handler) ReorderExamPrepModulesInUnit(c *fiber.Ctx) error {
|
||||
unitID, err := strconv.ParseInt(c.Params("unitId"), 10, 64)
|
||||
if err != nil {
|
||||
return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{
|
||||
Message: "Invalid unit id",
|
||||
Error: err.Error(),
|
||||
})
|
||||
}
|
||||
var req domain.ReorderIDsRequest
|
||||
if err := c.BodyParser(&req); err != nil {
|
||||
return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{
|
||||
Message: "Invalid request body",
|
||||
Error: err.Error(),
|
||||
})
|
||||
}
|
||||
if req.OrderedIDs == nil {
|
||||
return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{
|
||||
Message: "ordered_ids is required (use an empty array if there are no modules)",
|
||||
Error: "missing ordered_ids",
|
||||
})
|
||||
}
|
||||
if err := h.examPrepSvc.ReorderModulesInUnit(c.Context(), unitID, req.OrderedIDs); err != nil {
|
||||
if errors.Is(err, examprep.ErrUnitNotFound) {
|
||||
return c.Status(fiber.StatusNotFound).JSON(domain.ErrorResponse{
|
||||
Message: "Unit not found",
|
||||
Error: err.Error(),
|
||||
})
|
||||
}
|
||||
if errors.Is(err, domain.ErrReorderInvalidIDSet) {
|
||||
return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{
|
||||
Message: err.Error(),
|
||||
Error: "INVALID_REORDER",
|
||||
})
|
||||
}
|
||||
return c.Status(fiber.StatusInternalServerError).JSON(domain.ErrorResponse{
|
||||
Message: "Failed to reorder modules",
|
||||
Error: err.Error(),
|
||||
})
|
||||
}
|
||||
return c.JSON(domain.Response{
|
||||
Message: "Modules reordered successfully",
|
||||
Success: true,
|
||||
StatusCode: fiber.StatusOK,
|
||||
})
|
||||
}
|
||||
|
||||
// GetExamPrepModuleByID godoc
|
||||
// @Summary Get exam-prep module by ID
|
||||
// @Tags exam-prep
|
||||
// @Router /api/v1/exam-prep/modules/{id} [get]
|
||||
func (h *Handler) GetExamPrepModuleByID(c *fiber.Ctx) error {
|
||||
id, err := strconv.ParseInt(c.Params("id"), 10, 64)
|
||||
if err != nil {
|
||||
return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{
|
||||
Message: "Invalid module id",
|
||||
Error: err.Error(),
|
||||
})
|
||||
}
|
||||
out, err := h.examPrepSvc.GetModuleByID(c.Context(), id)
|
||||
if err != nil {
|
||||
if errors.Is(err, examprep.ErrModuleNotFound) {
|
||||
return c.Status(fiber.StatusNotFound).JSON(domain.ErrorResponse{
|
||||
Message: "Module not found",
|
||||
Error: err.Error(),
|
||||
})
|
||||
}
|
||||
return c.Status(fiber.StatusInternalServerError).JSON(domain.ErrorResponse{
|
||||
Message: "Failed to get module",
|
||||
Error: err.Error(),
|
||||
})
|
||||
}
|
||||
return c.JSON(domain.Response{
|
||||
Message: "Module retrieved successfully",
|
||||
Data: out,
|
||||
Success: true,
|
||||
StatusCode: fiber.StatusOK,
|
||||
})
|
||||
}
|
||||
|
||||
// UpdateExamPrepModule godoc
|
||||
// @Summary Update exam-prep module
|
||||
// @Tags exam-prep
|
||||
// @Router /api/v1/exam-prep/modules/{id} [put]
|
||||
func (h *Handler) UpdateExamPrepModule(c *fiber.Ctx) error {
|
||||
id, err := strconv.ParseInt(c.Params("id"), 10, 64)
|
||||
if err != nil {
|
||||
return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{
|
||||
Message: "Invalid module id",
|
||||
Error: err.Error(),
|
||||
})
|
||||
}
|
||||
var req domain.UpdateExamPrepModuleInput
|
||||
if err := c.BodyParser(&req); err != nil {
|
||||
return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{
|
||||
Message: "Invalid request body",
|
||||
Error: err.Error(),
|
||||
})
|
||||
}
|
||||
out, err := h.examPrepSvc.UpdateModule(c.Context(), id, req)
|
||||
if err != nil {
|
||||
if errors.Is(err, examprep.ErrModuleNotFound) {
|
||||
return c.Status(fiber.StatusNotFound).JSON(domain.ErrorResponse{
|
||||
Message: "Module not found",
|
||||
Error: err.Error(),
|
||||
})
|
||||
}
|
||||
return c.Status(fiber.StatusInternalServerError).JSON(domain.ErrorResponse{
|
||||
Message: "Failed to update module",
|
||||
Error: err.Error(),
|
||||
})
|
||||
}
|
||||
return c.JSON(domain.Response{
|
||||
Message: "Module updated successfully",
|
||||
Data: out,
|
||||
Success: true,
|
||||
StatusCode: fiber.StatusOK,
|
||||
})
|
||||
}
|
||||
|
||||
// DeleteExamPrepModule godoc
|
||||
// @Summary Delete exam-prep module
|
||||
// @Tags exam-prep
|
||||
// @Router /api/v1/exam-prep/modules/{id} [delete]
|
||||
func (h *Handler) DeleteExamPrepModule(c *fiber.Ctx) error {
|
||||
id, err := strconv.ParseInt(c.Params("id"), 10, 64)
|
||||
if err != nil {
|
||||
return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{
|
||||
Message: "Invalid module id",
|
||||
Error: err.Error(),
|
||||
})
|
||||
}
|
||||
if err := h.examPrepSvc.DeleteModule(c.Context(), id); err != nil {
|
||||
if errors.Is(err, examprep.ErrModuleNotFound) {
|
||||
return c.Status(fiber.StatusNotFound).JSON(domain.ErrorResponse{
|
||||
Message: "Module not found",
|
||||
Error: err.Error(),
|
||||
})
|
||||
}
|
||||
return c.Status(fiber.StatusInternalServerError).JSON(domain.ErrorResponse{
|
||||
Message: "Failed to delete module",
|
||||
Error: err.Error(),
|
||||
})
|
||||
}
|
||||
return c.JSON(domain.Response{
|
||||
Message: "Module deleted successfully",
|
||||
Success: true,
|
||||
StatusCode: fiber.StatusOK,
|
||||
})
|
||||
}
|
||||
209
internal/web_server/handlers/exam_prep_practice_handler.go
Normal file
209
internal/web_server/handlers/exam_prep_practice_handler.go
Normal file
|
|
@ -0,0 +1,209 @@
|
|||
package handlers
|
||||
|
||||
import (
|
||||
"Yimaru-Backend/internal/domain"
|
||||
"Yimaru-Backend/internal/services/examprep"
|
||||
"errors"
|
||||
"strconv"
|
||||
|
||||
"github.com/gofiber/fiber/v2"
|
||||
)
|
||||
|
||||
// CreateExamPrepPractice godoc
|
||||
// @Summary Create exam-prep practice (under a lesson; uses shared question_sets)
|
||||
// @Tags exam-prep
|
||||
// @Param lessonId path int true "Exam prep lesson ID (unit_module_lessons.id)"
|
||||
// @Param body body domain.CreateExamPrepPracticeInput true "Practice"
|
||||
// @Router /api/v1/exam-prep/lessons/{lessonId}/practices [post]
|
||||
func (h *Handler) CreateExamPrepPractice(c *fiber.Ctx) error {
|
||||
lessonID, err := strconv.ParseInt(c.Params("lessonId"), 10, 64)
|
||||
if err != nil {
|
||||
return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{
|
||||
Message: "Invalid lesson id",
|
||||
Error: err.Error(),
|
||||
})
|
||||
}
|
||||
var req domain.CreateExamPrepPracticeInput
|
||||
if err := c.BodyParser(&req); err != nil {
|
||||
return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{
|
||||
Message: "Invalid request body",
|
||||
Error: err.Error(),
|
||||
})
|
||||
}
|
||||
if valErrs, ok := h.validator.Validate(c, req); !ok {
|
||||
return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{
|
||||
Message: "Validation failed",
|
||||
Error: firstValidationError(valErrs),
|
||||
})
|
||||
}
|
||||
p, err := h.examPrepSvc.CreateExamPrepPractice(c.Context(), lessonID, req)
|
||||
if err != nil {
|
||||
if errors.Is(err, examprep.ErrLessonNotFound) {
|
||||
return c.Status(fiber.StatusNotFound).JSON(domain.ErrorResponse{
|
||||
Message: "Lesson not found",
|
||||
Error: err.Error(),
|
||||
})
|
||||
}
|
||||
return c.Status(fiber.StatusInternalServerError).JSON(domain.ErrorResponse{
|
||||
Message: "Failed to create practice",
|
||||
Error: err.Error(),
|
||||
})
|
||||
}
|
||||
return c.Status(fiber.StatusCreated).JSON(domain.Response{
|
||||
Message: "Practice created successfully",
|
||||
Data: p,
|
||||
Success: true,
|
||||
StatusCode: fiber.StatusCreated,
|
||||
})
|
||||
}
|
||||
|
||||
// ListExamPrepPracticesByLesson godoc
|
||||
// @Summary List exam-prep practices for a lesson
|
||||
// @Tags exam-prep
|
||||
// @Param lessonId path int true "Exam prep lesson ID"
|
||||
// @Param limit query int false "Page size" default(20)
|
||||
// @Param offset query int false "Offset" default(0)
|
||||
// @Router /api/v1/exam-prep/lessons/{lessonId}/practices [get]
|
||||
func (h *Handler) ListExamPrepPracticesByLesson(c *fiber.Ctx) error {
|
||||
lessonID, err := strconv.ParseInt(c.Params("lessonId"), 10, 64)
|
||||
if err != nil {
|
||||
return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{
|
||||
Message: "Invalid lesson id",
|
||||
Error: err.Error(),
|
||||
})
|
||||
}
|
||||
limit, _ := strconv.Atoi(c.Query("limit", "20"))
|
||||
offset, _ := strconv.Atoi(c.Query("offset", "0"))
|
||||
items, total, err := h.examPrepSvc.ListExamPrepPracticesByLesson(c.Context(), lessonID, int32(limit), int32(offset))
|
||||
if err != nil {
|
||||
if errors.Is(err, examprep.ErrLessonNotFound) {
|
||||
return c.Status(fiber.StatusNotFound).JSON(domain.ErrorResponse{
|
||||
Message: "Lesson not found",
|
||||
Error: err.Error(),
|
||||
})
|
||||
}
|
||||
return c.Status(fiber.StatusInternalServerError).JSON(domain.ErrorResponse{
|
||||
Message: "Failed to list practices",
|
||||
Error: err.Error(),
|
||||
})
|
||||
}
|
||||
return c.JSON(domain.Response{
|
||||
Message: "Practices retrieved successfully",
|
||||
Data: fiber.Map{
|
||||
"practices": items,
|
||||
"total_count": total,
|
||||
"limit": limit,
|
||||
"offset": offset,
|
||||
},
|
||||
Success: true,
|
||||
StatusCode: fiber.StatusOK,
|
||||
})
|
||||
}
|
||||
|
||||
// GetExamPrepPracticeByID godoc
|
||||
// @Summary Get exam-prep practice by ID
|
||||
// @Tags exam-prep
|
||||
// @Param id path int true "Exam prep practice ID"
|
||||
// @Router /api/v1/exam-prep/practices/{id} [get]
|
||||
func (h *Handler) GetExamPrepPracticeByID(c *fiber.Ctx) error {
|
||||
id, err := strconv.ParseInt(c.Params("id"), 10, 64)
|
||||
if err != nil {
|
||||
return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{
|
||||
Message: "Invalid practice id",
|
||||
Error: err.Error(),
|
||||
})
|
||||
}
|
||||
p, err := h.examPrepSvc.GetExamPrepPracticeByID(c.Context(), id)
|
||||
if err != nil {
|
||||
if errors.Is(err, examprep.ErrPracticeNotFound) {
|
||||
return c.Status(fiber.StatusNotFound).JSON(domain.ErrorResponse{
|
||||
Message: "Practice not found",
|
||||
Error: err.Error(),
|
||||
})
|
||||
}
|
||||
return c.Status(fiber.StatusInternalServerError).JSON(domain.ErrorResponse{
|
||||
Message: "Failed to load practice",
|
||||
Error: err.Error(),
|
||||
})
|
||||
}
|
||||
return c.JSON(domain.Response{
|
||||
Message: "Practice retrieved successfully",
|
||||
Data: p,
|
||||
Success: true,
|
||||
StatusCode: fiber.StatusOK,
|
||||
})
|
||||
}
|
||||
|
||||
// UpdateExamPrepPractice godoc
|
||||
// @Summary Update exam-prep practice
|
||||
// @Tags exam-prep
|
||||
// @Param id path int true "Exam prep practice ID"
|
||||
// @Param body body domain.UpdateExamPrepPracticeInput true "Fields to update"
|
||||
// @Router /api/v1/exam-prep/practices/{id} [put]
|
||||
func (h *Handler) UpdateExamPrepPractice(c *fiber.Ctx) error {
|
||||
id, err := strconv.ParseInt(c.Params("id"), 10, 64)
|
||||
if err != nil {
|
||||
return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{
|
||||
Message: "Invalid practice id",
|
||||
Error: err.Error(),
|
||||
})
|
||||
}
|
||||
var req domain.UpdateExamPrepPracticeInput
|
||||
if err := c.BodyParser(&req); err != nil {
|
||||
return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{
|
||||
Message: "Invalid request body",
|
||||
Error: err.Error(),
|
||||
})
|
||||
}
|
||||
p, err := h.examPrepSvc.UpdateExamPrepPractice(c.Context(), id, req)
|
||||
if err != nil {
|
||||
if errors.Is(err, examprep.ErrPracticeNotFound) {
|
||||
return c.Status(fiber.StatusNotFound).JSON(domain.ErrorResponse{
|
||||
Message: "Practice not found",
|
||||
Error: err.Error(),
|
||||
})
|
||||
}
|
||||
return c.Status(fiber.StatusInternalServerError).JSON(domain.ErrorResponse{
|
||||
Message: "Failed to update practice",
|
||||
Error: err.Error(),
|
||||
})
|
||||
}
|
||||
return c.JSON(domain.Response{
|
||||
Message: "Practice updated successfully",
|
||||
Data: p,
|
||||
Success: true,
|
||||
StatusCode: fiber.StatusOK,
|
||||
})
|
||||
}
|
||||
|
||||
// DeleteExamPrepPractice godoc
|
||||
// @Summary Delete exam-prep practice
|
||||
// @Tags exam-prep
|
||||
// @Param id path int true "Exam prep practice ID"
|
||||
// @Router /api/v1/exam-prep/practices/{id} [delete]
|
||||
func (h *Handler) DeleteExamPrepPractice(c *fiber.Ctx) error {
|
||||
id, err := strconv.ParseInt(c.Params("id"), 10, 64)
|
||||
if err != nil {
|
||||
return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{
|
||||
Message: "Invalid practice id",
|
||||
Error: err.Error(),
|
||||
})
|
||||
}
|
||||
if err := h.examPrepSvc.DeleteExamPrepPractice(c.Context(), id); err != nil {
|
||||
if errors.Is(err, examprep.ErrPracticeNotFound) {
|
||||
return c.Status(fiber.StatusNotFound).JSON(domain.ErrorResponse{
|
||||
Message: "Practice not found",
|
||||
Error: err.Error(),
|
||||
})
|
||||
}
|
||||
return c.Status(fiber.StatusInternalServerError).JSON(domain.ErrorResponse{
|
||||
Message: "Failed to delete practice",
|
||||
Error: err.Error(),
|
||||
})
|
||||
}
|
||||
return c.JSON(domain.Response{
|
||||
Message: "Practice deleted successfully",
|
||||
Success: true,
|
||||
StatusCode: fiber.StatusOK,
|
||||
})
|
||||
}
|
||||
266
internal/web_server/handlers/exam_prep_unit_handler.go
Normal file
266
internal/web_server/handlers/exam_prep_unit_handler.go
Normal file
|
|
@ -0,0 +1,266 @@
|
|||
package handlers
|
||||
|
||||
import (
|
||||
"Yimaru-Backend/internal/domain"
|
||||
"Yimaru-Backend/internal/services/examprep"
|
||||
"errors"
|
||||
"strconv"
|
||||
|
||||
"github.com/gofiber/fiber/v2"
|
||||
)
|
||||
|
||||
// CreateExamPrepUnit godoc
|
||||
// @Summary Create exam-prep unit
|
||||
// @Description Unit under a catalog course (e.g. chapter title)
|
||||
// @Tags exam-prep
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Param catalogCourseId path int true "Catalog course ID"
|
||||
// @Param body body domain.CreateExamPrepUnitInput true "Unit"
|
||||
// @Success 201 {object} domain.Response
|
||||
// @Router /api/v1/exam-prep/catalog-courses/{catalogCourseId}/units [post]
|
||||
func (h *Handler) CreateExamPrepUnit(c *fiber.Ctx) error {
|
||||
catalogCourseID, err := strconv.ParseInt(c.Params("catalogCourseId"), 10, 64)
|
||||
if err != nil {
|
||||
return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{
|
||||
Message: "Invalid catalog course id",
|
||||
Error: err.Error(),
|
||||
})
|
||||
}
|
||||
var req domain.CreateExamPrepUnitInput
|
||||
if err := c.BodyParser(&req); err != nil {
|
||||
return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{
|
||||
Message: "Invalid request body",
|
||||
Error: err.Error(),
|
||||
})
|
||||
}
|
||||
if valErrs, ok := h.validator.Validate(c, req); !ok {
|
||||
return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{
|
||||
Message: "Validation failed",
|
||||
Error: firstValidationError(valErrs),
|
||||
})
|
||||
}
|
||||
out, err := h.examPrepSvc.CreateUnit(c.Context(), catalogCourseID, req)
|
||||
if err != nil {
|
||||
if errors.Is(err, examprep.ErrCatalogCourseNotFound) {
|
||||
return c.Status(fiber.StatusNotFound).JSON(domain.ErrorResponse{
|
||||
Message: "Catalog course not found",
|
||||
Error: err.Error(),
|
||||
})
|
||||
}
|
||||
return c.Status(fiber.StatusInternalServerError).JSON(domain.ErrorResponse{
|
||||
Message: "Failed to create unit",
|
||||
Error: err.Error(),
|
||||
})
|
||||
}
|
||||
return c.Status(fiber.StatusCreated).JSON(domain.Response{
|
||||
Message: "Unit created successfully",
|
||||
Data: out,
|
||||
Success: true,
|
||||
StatusCode: fiber.StatusCreated,
|
||||
})
|
||||
}
|
||||
|
||||
// ListExamPrepUnitsByCatalogCourse godoc
|
||||
// @Summary List exam-prep units for a catalog course
|
||||
// @Tags exam-prep
|
||||
// @Param catalogCourseId path int true "Catalog course ID"
|
||||
// @Param limit query int false "Page size" default(20)
|
||||
// @Param offset query int false "Offset" default(0)
|
||||
// @Success 200 {object} domain.Response
|
||||
// @Router /api/v1/exam-prep/catalog-courses/{catalogCourseId}/units [get]
|
||||
func (h *Handler) ListExamPrepUnitsByCatalogCourse(c *fiber.Ctx) error {
|
||||
catalogCourseID, err := strconv.ParseInt(c.Params("catalogCourseId"), 10, 64)
|
||||
if err != nil {
|
||||
return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{
|
||||
Message: "Invalid catalog course id",
|
||||
Error: err.Error(),
|
||||
})
|
||||
}
|
||||
limit, _ := strconv.Atoi(c.Query("limit", "20"))
|
||||
offset, _ := strconv.Atoi(c.Query("offset", "0"))
|
||||
items, total, err := h.examPrepSvc.ListUnitsByCatalogCourse(c.Context(), catalogCourseID, int32(limit), int32(offset))
|
||||
if err != nil {
|
||||
if errors.Is(err, examprep.ErrCatalogCourseNotFound) {
|
||||
return c.Status(fiber.StatusNotFound).JSON(domain.ErrorResponse{
|
||||
Message: "Catalog course not found",
|
||||
Error: err.Error(),
|
||||
})
|
||||
}
|
||||
return c.Status(fiber.StatusInternalServerError).JSON(domain.ErrorResponse{
|
||||
Message: "Failed to list units",
|
||||
Error: err.Error(),
|
||||
})
|
||||
}
|
||||
return c.JSON(domain.Response{
|
||||
Message: "Units retrieved successfully",
|
||||
Data: fiber.Map{
|
||||
"units": items,
|
||||
"total_count": total,
|
||||
"limit": limit,
|
||||
"offset": offset,
|
||||
},
|
||||
Success: true,
|
||||
StatusCode: fiber.StatusOK,
|
||||
})
|
||||
}
|
||||
|
||||
// ReorderExamPrepUnitsInCatalogCourse godoc
|
||||
// @Summary Reorder units within a catalog course
|
||||
// @Tags exam-prep
|
||||
// @Param catalogCourseId path int true "Catalog course ID"
|
||||
// @Param body body domain.ReorderIDsRequest true "ordered_ids: every unit id in this catalog course, new order"
|
||||
// @Router /api/v1/exam-prep/catalog-courses/{catalogCourseId}/units/reorder [put]
|
||||
func (h *Handler) ReorderExamPrepUnitsInCatalogCourse(c *fiber.Ctx) error {
|
||||
catalogCourseID, err := strconv.ParseInt(c.Params("catalogCourseId"), 10, 64)
|
||||
if err != nil {
|
||||
return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{
|
||||
Message: "Invalid catalog course id",
|
||||
Error: err.Error(),
|
||||
})
|
||||
}
|
||||
var req domain.ReorderIDsRequest
|
||||
if err := c.BodyParser(&req); err != nil {
|
||||
return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{
|
||||
Message: "Invalid request body",
|
||||
Error: err.Error(),
|
||||
})
|
||||
}
|
||||
if req.OrderedIDs == nil {
|
||||
return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{
|
||||
Message: "ordered_ids is required (use an empty array if there are no units)",
|
||||
Error: "missing ordered_ids",
|
||||
})
|
||||
}
|
||||
if err := h.examPrepSvc.ReorderUnitsInCatalogCourse(c.Context(), catalogCourseID, req.OrderedIDs); err != nil {
|
||||
if errors.Is(err, examprep.ErrCatalogCourseNotFound) {
|
||||
return c.Status(fiber.StatusNotFound).JSON(domain.ErrorResponse{
|
||||
Message: "Catalog course not found",
|
||||
Error: err.Error(),
|
||||
})
|
||||
}
|
||||
if errors.Is(err, domain.ErrReorderInvalidIDSet) {
|
||||
return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{
|
||||
Message: err.Error(),
|
||||
Error: "INVALID_REORDER",
|
||||
})
|
||||
}
|
||||
return c.Status(fiber.StatusInternalServerError).JSON(domain.ErrorResponse{
|
||||
Message: "Failed to reorder units",
|
||||
Error: err.Error(),
|
||||
})
|
||||
}
|
||||
return c.JSON(domain.Response{
|
||||
Message: "Units reordered successfully",
|
||||
Success: true,
|
||||
StatusCode: fiber.StatusOK,
|
||||
})
|
||||
}
|
||||
|
||||
// GetExamPrepUnitByID godoc
|
||||
// @Summary Get exam-prep unit by ID
|
||||
// @Tags exam-prep
|
||||
// @Param id path int true "Unit ID"
|
||||
// @Router /api/v1/exam-prep/units/{id} [get]
|
||||
func (h *Handler) GetExamPrepUnitByID(c *fiber.Ctx) error {
|
||||
id, err := strconv.ParseInt(c.Params("id"), 10, 64)
|
||||
if err != nil {
|
||||
return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{
|
||||
Message: "Invalid unit id",
|
||||
Error: err.Error(),
|
||||
})
|
||||
}
|
||||
out, err := h.examPrepSvc.GetUnitByID(c.Context(), id)
|
||||
if err != nil {
|
||||
if errors.Is(err, examprep.ErrUnitNotFound) {
|
||||
return c.Status(fiber.StatusNotFound).JSON(domain.ErrorResponse{
|
||||
Message: "Unit not found",
|
||||
Error: err.Error(),
|
||||
})
|
||||
}
|
||||
return c.Status(fiber.StatusInternalServerError).JSON(domain.ErrorResponse{
|
||||
Message: "Failed to get unit",
|
||||
Error: err.Error(),
|
||||
})
|
||||
}
|
||||
return c.JSON(domain.Response{
|
||||
Message: "Unit retrieved successfully",
|
||||
Data: out,
|
||||
Success: true,
|
||||
StatusCode: fiber.StatusOK,
|
||||
})
|
||||
}
|
||||
|
||||
// UpdateExamPrepUnit godoc
|
||||
// @Summary Update exam-prep unit
|
||||
// @Tags exam-prep
|
||||
// @Param id path int true "Unit ID"
|
||||
// @Param body body domain.UpdateExamPrepUnitInput true "Fields to update"
|
||||
// @Router /api/v1/exam-prep/units/{id} [put]
|
||||
func (h *Handler) UpdateExamPrepUnit(c *fiber.Ctx) error {
|
||||
id, err := strconv.ParseInt(c.Params("id"), 10, 64)
|
||||
if err != nil {
|
||||
return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{
|
||||
Message: "Invalid unit id",
|
||||
Error: err.Error(),
|
||||
})
|
||||
}
|
||||
var req domain.UpdateExamPrepUnitInput
|
||||
if err := c.BodyParser(&req); err != nil {
|
||||
return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{
|
||||
Message: "Invalid request body",
|
||||
Error: err.Error(),
|
||||
})
|
||||
}
|
||||
out, err := h.examPrepSvc.UpdateUnit(c.Context(), id, req)
|
||||
if err != nil {
|
||||
if errors.Is(err, examprep.ErrUnitNotFound) {
|
||||
return c.Status(fiber.StatusNotFound).JSON(domain.ErrorResponse{
|
||||
Message: "Unit not found",
|
||||
Error: err.Error(),
|
||||
})
|
||||
}
|
||||
return c.Status(fiber.StatusInternalServerError).JSON(domain.ErrorResponse{
|
||||
Message: "Failed to update unit",
|
||||
Error: err.Error(),
|
||||
})
|
||||
}
|
||||
return c.JSON(domain.Response{
|
||||
Message: "Unit updated successfully",
|
||||
Data: out,
|
||||
Success: true,
|
||||
StatusCode: fiber.StatusOK,
|
||||
})
|
||||
}
|
||||
|
||||
// DeleteExamPrepUnit godoc
|
||||
// @Summary Delete exam-prep unit
|
||||
// @Tags exam-prep
|
||||
// @Param id path int true "Unit ID"
|
||||
// @Router /api/v1/exam-prep/units/{id} [delete]
|
||||
func (h *Handler) DeleteExamPrepUnit(c *fiber.Ctx) error {
|
||||
id, err := strconv.ParseInt(c.Params("id"), 10, 64)
|
||||
if err != nil {
|
||||
return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{
|
||||
Message: "Invalid unit id",
|
||||
Error: err.Error(),
|
||||
})
|
||||
}
|
||||
if err := h.examPrepSvc.DeleteUnit(c.Context(), id); err != nil {
|
||||
if errors.Is(err, examprep.ErrUnitNotFound) {
|
||||
return c.Status(fiber.StatusNotFound).JSON(domain.ErrorResponse{
|
||||
Message: "Unit not found",
|
||||
Error: err.Error(),
|
||||
})
|
||||
}
|
||||
return c.Status(fiber.StatusInternalServerError).JSON(domain.ErrorResponse{
|
||||
Message: "Failed to delete unit",
|
||||
Error: err.Error(),
|
||||
})
|
||||
}
|
||||
return c.JSON(domain.Response{
|
||||
Message: "Unit deleted successfully",
|
||||
Success: true,
|
||||
StatusCode: fiber.StatusOK,
|
||||
})
|
||||
}
|
||||
|
|
@ -7,6 +7,7 @@ import (
|
|||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
|
@ -31,6 +32,10 @@ type uploadMediaByURLReq struct {
|
|||
Description string `json:"description"`
|
||||
}
|
||||
|
||||
type refreshFileURLReq struct {
|
||||
Reference string `json:"reference"`
|
||||
}
|
||||
|
||||
// resolveFileURL converts a stored file path to a usable URL.
|
||||
// If the path starts with "minio://", it generates a presigned URL.
|
||||
// Otherwise it returns the path as-is (e.g. "/static/...").
|
||||
|
|
@ -82,6 +87,93 @@ func (h *Handler) GetFileURL(c *fiber.Ctx) error {
|
|||
})
|
||||
}
|
||||
|
||||
func (h *Handler) extractObjectKeyFromReference(reference string) (string, error) {
|
||||
ref := strings.TrimSpace(reference)
|
||||
if ref == "" {
|
||||
return "", fmt.Errorf("reference is required")
|
||||
}
|
||||
|
||||
if strings.HasPrefix(ref, "minio://") {
|
||||
key := strings.TrimPrefix(ref, "minio://")
|
||||
if key == "" {
|
||||
return "", fmt.Errorf("invalid minio reference")
|
||||
}
|
||||
return key, nil
|
||||
}
|
||||
|
||||
u, err := url.Parse(ref)
|
||||
if err == nil && u.Scheme != "" && u.Host != "" {
|
||||
path := strings.TrimPrefix(u.Path, "/")
|
||||
if path == "" {
|
||||
return "", fmt.Errorf("invalid file URL")
|
||||
}
|
||||
bucket := strings.TrimSpace(h.minioSvc.BucketName())
|
||||
if bucket != "" {
|
||||
prefix := bucket + "/"
|
||||
if strings.HasPrefix(path, prefix) {
|
||||
path = strings.TrimPrefix(path, prefix)
|
||||
}
|
||||
}
|
||||
if path == "" {
|
||||
return "", fmt.Errorf("invalid file URL")
|
||||
}
|
||||
return path, nil
|
||||
}
|
||||
|
||||
return ref, nil
|
||||
}
|
||||
|
||||
// RefreshFileURL generates a new presigned URL from an object key, minio:// URI, or stale presigned URL.
|
||||
// @Summary Refresh presigned URL for a file
|
||||
// @Tags files
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Param body body refreshFileURLReq true "reference (object key, minio://..., or existing presigned URL)"
|
||||
// @Success 200 {object} domain.Response
|
||||
// @Router /api/v1/files/refresh-url [post]
|
||||
func (h *Handler) RefreshFileURL(c *fiber.Ctx) error {
|
||||
if h.minioSvc == nil {
|
||||
return c.Status(fiber.StatusServiceUnavailable).JSON(domain.ErrorResponse{
|
||||
Message: "File storage service is not available",
|
||||
})
|
||||
}
|
||||
|
||||
var req refreshFileURLReq
|
||||
if err := c.BodyParser(&req); err != nil {
|
||||
return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{
|
||||
Message: "Invalid request body",
|
||||
Error: err.Error(),
|
||||
})
|
||||
}
|
||||
|
||||
objectKey, err := h.extractObjectKeyFromReference(req.Reference)
|
||||
if err != nil {
|
||||
return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{
|
||||
Message: "Invalid file reference",
|
||||
Error: err.Error(),
|
||||
})
|
||||
}
|
||||
|
||||
freshURL, err := h.minioSvc.GetURL(c.Context(), objectKey, 1*time.Hour)
|
||||
if err != nil {
|
||||
return c.Status(fiber.StatusInternalServerError).JSON(domain.ErrorResponse{
|
||||
Message: "Failed to refresh file URL",
|
||||
Error: err.Error(),
|
||||
})
|
||||
}
|
||||
|
||||
return c.Status(fiber.StatusOK).JSON(domain.Response{
|
||||
Message: "File URL refreshed",
|
||||
Data: map[string]interface{}{
|
||||
"object_key": objectKey,
|
||||
"url": freshURL,
|
||||
"expires_in": int((1 * time.Hour).Seconds()),
|
||||
},
|
||||
Success: true,
|
||||
StatusCode: fiber.StatusOK,
|
||||
})
|
||||
}
|
||||
|
||||
// UploadMedia uploads an image/audio/video file and returns its URL and key.
|
||||
// @Summary Upload media file
|
||||
// @Tags files
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
66
internal/web_server/handlers/question_type_builder.go
Normal file
66
internal/web_server/handlers/question_type_builder.go
Normal file
|
|
@ -0,0 +1,66 @@
|
|||
package handlers
|
||||
|
||||
import (
|
||||
"Yimaru-Backend/internal/domain"
|
||||
|
||||
"github.com/gofiber/fiber/v2"
|
||||
)
|
||||
|
||||
type componentCatalogRes struct {
|
||||
StimulusKinds []string `json:"stimulus_component_kinds"`
|
||||
ResponseKinds []string `json:"response_component_kinds"`
|
||||
}
|
||||
|
||||
type validateQuestionTypeDefinitionReq struct {
|
||||
StimulusComponentKinds []string `json:"stimulus_component_kinds"`
|
||||
ResponseComponentKinds []string `json:"response_component_kinds"`
|
||||
}
|
||||
|
||||
// GetQuestionTypeComponentCatalog godoc
|
||||
// @Summary Question-type builder component catalog
|
||||
// @Description Valid stimulus and response component kind codes for dynamic question-type definitions
|
||||
// @Tags questions
|
||||
// @Produce json
|
||||
// @Success 200 {object} domain.Response
|
||||
// @Router /api/v1/questions/component-catalog [get]
|
||||
func (h *Handler) GetQuestionTypeComponentCatalog(c *fiber.Ctx) error {
|
||||
return c.JSON(domain.Response{
|
||||
Message: "Component catalog",
|
||||
Data: componentCatalogRes{
|
||||
StimulusKinds: domain.StimulusComponentCatalog(),
|
||||
ResponseKinds: domain.ResponseComponentCatalog(),
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
// ValidateQuestionTypeDefinition godoc
|
||||
// @Summary Validate dynamic question-type definition
|
||||
// @Description Validates selected stimulus and response component kinds for temporary question-type definitions
|
||||
// @Tags questions
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Param body body validateQuestionTypeDefinitionReq true "Stimulus and response component kinds"
|
||||
// @Success 200 {object} domain.Response
|
||||
// @Failure 400 {object} domain.ErrorResponse
|
||||
// @Router /api/v1/questions/validate-question-type-definition [post]
|
||||
func (h *Handler) ValidateQuestionTypeDefinition(c *fiber.Ctx) error {
|
||||
var req validateQuestionTypeDefinitionReq
|
||||
if err := c.BodyParser(&req); err != nil {
|
||||
return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{
|
||||
Message: "Invalid request body",
|
||||
Error: err.Error(),
|
||||
})
|
||||
}
|
||||
|
||||
if err := domain.ValidateDynamicQuestionTypeDefinition(req.StimulusComponentKinds, req.ResponseComponentKinds); err != nil {
|
||||
return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{
|
||||
Message: "Invalid question type definition",
|
||||
Error: err.Error(),
|
||||
})
|
||||
}
|
||||
|
||||
return c.JSON(domain.Response{
|
||||
Message: "Question type definition is valid",
|
||||
Data: fiber.Map{"valid": true},
|
||||
})
|
||||
}
|
||||
|
|
@ -550,6 +550,27 @@ type listQuestionSetsRes struct {
|
|||
TotalCount int64 `json:"total_count"`
|
||||
}
|
||||
|
||||
var validInitialAssessmentLevels = map[string]struct{}{
|
||||
"A1": {},
|
||||
"A2": {},
|
||||
"B1": {},
|
||||
"B2": {},
|
||||
}
|
||||
|
||||
func normalizeInitialAssessmentLevel(description *string) (string, error) {
|
||||
if description == nil {
|
||||
return "", fmt.Errorf("description is required and must be one of: A1, A2, B1, B2")
|
||||
}
|
||||
level := strings.ToUpper(strings.TrimSpace(*description))
|
||||
if level == "" {
|
||||
return "", fmt.Errorf("description is required and must be one of: A1, A2, B1, B2")
|
||||
}
|
||||
if _, ok := validInitialAssessmentLevels[level]; !ok {
|
||||
return "", fmt.Errorf("description must be one of: A1, A2, B1, B2")
|
||||
}
|
||||
return level, nil
|
||||
}
|
||||
|
||||
func isSequenceGatedPractice(set domain.QuestionSet) bool {
|
||||
if !strings.EqualFold(set.SetType, string(domain.QuestionSetTypePractice)) || set.OwnerType == nil {
|
||||
return false
|
||||
|
|
@ -605,12 +626,19 @@ func (h *Handler) CreateQuestionSet(c *fiber.Ctx) error {
|
|||
Error: err.Error(),
|
||||
})
|
||||
}
|
||||
|
||||
if strings.EqualFold(req.SetType, string(domain.QuestionSetTypeInitialAssessment)) {
|
||||
if req.OwnerType == nil || req.OwnerID == nil {
|
||||
normalizedLevel, err := normalizeInitialAssessmentLevel(req.Description)
|
||||
if err != nil {
|
||||
return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{
|
||||
Message: "Invalid initial assessment ownership",
|
||||
Error: "INITIAL_ASSESSMENT question sets must include owner_type and owner_id",
|
||||
Message: "Invalid initial assessment level",
|
||||
Error: err.Error(),
|
||||
})
|
||||
}
|
||||
req.Description = &normalizedLevel
|
||||
if req.PassingScore == nil {
|
||||
return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{
|
||||
Message: "Invalid initial assessment set",
|
||||
Error: "passing_score is required for INITIAL_ASSESSMENT question sets",
|
||||
})
|
||||
}
|
||||
}
|
||||
|
|
@ -762,7 +790,7 @@ func (h *Handler) GetQuestionSetsByType(c *fiber.Ctx) error {
|
|||
})
|
||||
}
|
||||
|
||||
var setResponses []questionSetRes
|
||||
setResponses := make([]questionSetRes, 0, len(sets))
|
||||
for _, s := range sets {
|
||||
setResponses = append(setResponses, questionSetRes{
|
||||
ID: s.ID,
|
||||
|
|
@ -784,6 +812,8 @@ func (h *Handler) GetQuestionSetsByType(c *fiber.Ctx) error {
|
|||
|
||||
return c.JSON(domain.Response{
|
||||
Message: "Question sets retrieved successfully",
|
||||
Success: true,
|
||||
StatusCode: fiber.StatusOK,
|
||||
Data: listQuestionSetsRes{
|
||||
QuestionSets: setResponses,
|
||||
TotalCount: totalCount,
|
||||
|
|
@ -890,6 +920,14 @@ func (h *Handler) UpdateQuestionSet(c *fiber.Ctx) error {
|
|||
})
|
||||
}
|
||||
|
||||
existingSet, err := h.questionsSvc.GetQuestionSetByID(c.Context(), id)
|
||||
if err != nil {
|
||||
return c.Status(fiber.StatusNotFound).JSON(domain.ErrorResponse{
|
||||
Message: "Question set not found",
|
||||
Error: err.Error(),
|
||||
})
|
||||
}
|
||||
|
||||
var req updateQuestionSetReq
|
||||
if err := c.BodyParser(&req); err != nil {
|
||||
return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{
|
||||
|
|
@ -898,11 +936,46 @@ func (h *Handler) UpdateQuestionSet(c *fiber.Ctx) error {
|
|||
})
|
||||
}
|
||||
|
||||
title := ""
|
||||
title := existingSet.Title
|
||||
if req.Title != nil {
|
||||
title = *req.Title
|
||||
}
|
||||
|
||||
if strings.EqualFold(existingSet.SetType, string(domain.QuestionSetTypeInitialAssessment)) {
|
||||
effectiveDescription := existingSet.Description
|
||||
if req.Description != nil {
|
||||
effectiveDescription = req.Description
|
||||
}
|
||||
normalizedLevel, err := normalizeInitialAssessmentLevel(effectiveDescription)
|
||||
if err != nil {
|
||||
return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{
|
||||
Message: "Invalid initial assessment level",
|
||||
Error: err.Error(),
|
||||
})
|
||||
}
|
||||
req.Description = &normalizedLevel
|
||||
|
||||
effectivePassingScore := existingSet.PassingScore
|
||||
if req.PassingScore != nil {
|
||||
effectivePassingScore = req.PassingScore
|
||||
}
|
||||
if effectivePassingScore == nil {
|
||||
return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{
|
||||
Message: "Invalid initial assessment set",
|
||||
Error: "passing_score is required for INITIAL_ASSESSMENT question sets",
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
status := req.Status
|
||||
if status == nil {
|
||||
status = &existingSet.Status
|
||||
}
|
||||
shuffleQuestions := req.ShuffleQuestions
|
||||
if shuffleQuestions == nil {
|
||||
shuffleQuestions = &existingSet.ShuffleQuestions
|
||||
}
|
||||
|
||||
input := domain.CreateQuestionSetInput{
|
||||
Title: title,
|
||||
Description: req.Description,
|
||||
|
|
@ -910,8 +983,8 @@ func (h *Handler) UpdateQuestionSet(c *fiber.Ctx) error {
|
|||
Persona: req.Persona,
|
||||
TimeLimitMinutes: req.TimeLimitMinutes,
|
||||
PassingScore: req.PassingScore,
|
||||
ShuffleQuestions: req.ShuffleQuestions,
|
||||
Status: req.Status,
|
||||
ShuffleQuestions: shuffleQuestions,
|
||||
Status: status,
|
||||
IntroVideoURL: req.IntroVideoURL,
|
||||
}
|
||||
|
||||
|
|
@ -1127,11 +1200,64 @@ func (h *Handler) GetQuestionsInSet(c *fiber.Ctx) error {
|
|||
})
|
||||
}
|
||||
|
||||
itemResponses := questionSetItemsToRes(items)
|
||||
questionResponses := make([]questionRes, 0, len(items))
|
||||
for _, item := range items {
|
||||
question, err := h.questionsSvc.GetQuestionWithDetails(c.Context(), item.QuestionID)
|
||||
if err != nil {
|
||||
return c.Status(fiber.StatusInternalServerError).JSON(domain.ErrorResponse{
|
||||
Message: "Failed to get question details",
|
||||
Error: err.Error(),
|
||||
})
|
||||
}
|
||||
|
||||
options := make([]optionRes, 0, len(question.Options))
|
||||
for _, opt := range question.Options {
|
||||
options = append(options, optionRes{
|
||||
ID: opt.ID,
|
||||
OptionText: opt.OptionText,
|
||||
OptionOrder: opt.OptionOrder,
|
||||
IsCorrect: opt.IsCorrect,
|
||||
})
|
||||
}
|
||||
|
||||
shortAnswers := make([]shortAnswerRes, 0, len(question.ShortAnswers))
|
||||
for _, sa := range question.ShortAnswers {
|
||||
shortAnswers = append(shortAnswers, shortAnswerRes{
|
||||
ID: sa.ID,
|
||||
AcceptableAnswer: sa.AcceptableAnswer,
|
||||
MatchType: sa.MatchType,
|
||||
})
|
||||
}
|
||||
|
||||
var audioCorrectAnswerText *string
|
||||
if question.AudioAnswer != nil {
|
||||
audioCorrectAnswerText = &question.AudioAnswer.CorrectAnswerText
|
||||
}
|
||||
|
||||
questionResponses = append(questionResponses, questionRes{
|
||||
ID: question.ID,
|
||||
QuestionText: question.QuestionText,
|
||||
QuestionType: question.QuestionType,
|
||||
DifficultyLevel: question.DifficultyLevel,
|
||||
Points: question.Points,
|
||||
Explanation: question.Explanation,
|
||||
Tips: question.Tips,
|
||||
VoicePrompt: question.VoicePrompt,
|
||||
SampleAnswerVoicePrompt: question.SampleAnswerVoicePrompt,
|
||||
ImageURL: question.ImageURL,
|
||||
Status: question.Status,
|
||||
CreatedAt: question.CreatedAt.String(),
|
||||
Options: options,
|
||||
ShortAnswers: shortAnswers,
|
||||
AudioCorrectAnswerText: audioCorrectAnswerText,
|
||||
})
|
||||
}
|
||||
|
||||
return c.JSON(domain.Response{
|
||||
Message: "Questions retrieved successfully",
|
||||
Data: itemResponses,
|
||||
Success: true,
|
||||
StatusCode: fiber.StatusOK,
|
||||
Data: questionResponses,
|
||||
})
|
||||
}
|
||||
|
||||
|
|
@ -1271,7 +1397,7 @@ func (h *Handler) CompletePractice(c *fiber.Ctx) error {
|
|||
})
|
||||
}
|
||||
|
||||
if err := h.questionsSvc.MarkPracticeCompleted(c.Context(), userID, set.ID); err != nil {
|
||||
if err := h.lmsProgressSvc.CompletePracticeForUser(c.Context(), userID, set.ID); err != nil {
|
||||
return c.Status(fiber.StatusInternalServerError).JSON(domain.ErrorResponse{
|
||||
Message: "Failed to complete practice",
|
||||
Error: err.Error(),
|
||||
|
|
|
|||
|
|
@ -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{
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -15,6 +15,7 @@ func (a *App) initAppRoutes() {
|
|||
h := handlers.New(
|
||||
a.assessmentSvc,
|
||||
a.questionsSvc,
|
||||
a.examPrepSvc,
|
||||
a.programSvc,
|
||||
a.courseSvc,
|
||||
a.moduleSvc,
|
||||
|
|
@ -76,43 +77,81 @@ func (a *App) initAppRoutes() {
|
|||
groupV1.Post("/programs", a.authMiddleware, a.RequirePermission("programs.create"), h.CreateProgram)
|
||||
groupV1.Get("/programs", a.authMiddleware, a.RequirePermission("programs.list"), h.ListPrograms)
|
||||
groupV1.Put("/programs/reorder", a.authMiddleware, a.RequirePermission("programs.reorder"), h.ReorderPrograms)
|
||||
groupV1.Get("/lms/progress", a.authMiddleware, a.RequirePermission("lms.get_my_progress"), h.GetMyLMSProgress)
|
||||
groupV1.Get("/lms/progress", a.authMiddleware, a.RequireActiveSubscription(), a.RequirePermission("lms.get_my_progress"), h.GetMyLMSProgress)
|
||||
groupV1.Get("/programs/:id", a.authMiddleware, a.RequirePermission("programs.get"), h.GetProgram)
|
||||
groupV1.Put("/programs/:id", a.authMiddleware, a.RequirePermission("programs.update"), h.UpdateProgram)
|
||||
groupV1.Delete("/programs/:id", a.authMiddleware, a.RequirePermission("programs.delete"), h.DeleteProgram)
|
||||
|
||||
// Exam prep (schema exam_prep — separate from LMS Learn English). Students need an active subscription.
|
||||
examPrep := groupV1.Group("/exam-prep", a.authMiddleware, a.RequireActiveSubscription())
|
||||
examPrep.Post("/catalog-courses", a.RequirePermission("exam_prep.catalog_courses.create"), h.CreateExamPrepCatalogCourse)
|
||||
examPrep.Get("/catalog-courses", a.RequirePermission("exam_prep.catalog_courses.list"), h.ListExamPrepCatalogCourses)
|
||||
examPrep.Put("/catalog-courses/reorder", a.RequirePermission("exam_prep.catalog_courses.reorder"), h.ReorderExamPrepCatalogCourses)
|
||||
examPrep.Get("/catalog-courses/:id", a.RequirePermission("exam_prep.catalog_courses.get"), h.GetExamPrepCatalogCourseByID)
|
||||
examPrep.Put("/catalog-courses/:id", a.RequirePermission("exam_prep.catalog_courses.update"), h.UpdateExamPrepCatalogCourse)
|
||||
examPrep.Delete("/catalog-courses/:id", a.RequirePermission("exam_prep.catalog_courses.delete"), h.DeleteExamPrepCatalogCourse)
|
||||
|
||||
examPrep.Post("/catalog-courses/:catalogCourseId/units", a.RequirePermission("exam_prep.units.create"), h.CreateExamPrepUnit)
|
||||
examPrep.Get("/catalog-courses/:catalogCourseId/units", a.RequirePermission("exam_prep.units.list"), h.ListExamPrepUnitsByCatalogCourse)
|
||||
examPrep.Put("/catalog-courses/:catalogCourseId/units/reorder", a.RequirePermission("exam_prep.units.reorder"), h.ReorderExamPrepUnitsInCatalogCourse)
|
||||
|
||||
examPrep.Post("/units/:unitId/modules", a.RequirePermission("exam_prep.modules.create"), h.CreateExamPrepModule)
|
||||
examPrep.Get("/units/:unitId/modules", a.RequirePermission("exam_prep.modules.list"), h.ListExamPrepModulesByUnit)
|
||||
examPrep.Put("/units/:unitId/modules/reorder", a.RequirePermission("exam_prep.modules.reorder"), h.ReorderExamPrepModulesInUnit)
|
||||
|
||||
examPrep.Post("/modules/:moduleId/lessons", a.RequirePermission("exam_prep.lessons.create"), h.CreateExamPrepLesson)
|
||||
examPrep.Get("/modules/:moduleId/lessons", a.RequirePermission("exam_prep.lessons.list_by_module"), h.ListExamPrepLessonsByUnitModule)
|
||||
examPrep.Put("/modules/:moduleId/lessons/reorder", a.RequirePermission("exam_prep.lessons.reorder"), h.ReorderExamPrepLessonsInUnitModule)
|
||||
examPrep.Post("/lessons/:lessonId/practices", a.RequirePermission("exam_prep.practices.create"), h.CreateExamPrepPractice)
|
||||
examPrep.Get("/lessons/:lessonId/practices", a.RequirePermission("exam_prep.practices.list_by_lesson"), h.ListExamPrepPracticesByLesson)
|
||||
examPrep.Get("/practices/:id", a.RequirePermission("exam_prep.practices.get"), h.GetExamPrepPracticeByID)
|
||||
examPrep.Put("/practices/:id", a.RequirePermission("exam_prep.practices.update"), h.UpdateExamPrepPractice)
|
||||
examPrep.Delete("/practices/:id", a.RequirePermission("exam_prep.practices.delete"), h.DeleteExamPrepPractice)
|
||||
examPrep.Get("/lessons/:id", a.RequirePermission("exam_prep.lessons.get"), h.GetExamPrepLessonByID)
|
||||
examPrep.Put("/lessons/:id", a.RequirePermission("exam_prep.lessons.update"), h.UpdateExamPrepLesson)
|
||||
examPrep.Delete("/lessons/:id", a.RequirePermission("exam_prep.lessons.delete"), h.DeleteExamPrepLesson)
|
||||
|
||||
examPrep.Get("/modules/:id", a.RequirePermission("exam_prep.modules.get"), h.GetExamPrepModuleByID)
|
||||
examPrep.Put("/modules/:id", a.RequirePermission("exam_prep.modules.update"), h.UpdateExamPrepModule)
|
||||
examPrep.Delete("/modules/:id", a.RequirePermission("exam_prep.modules.delete"), h.DeleteExamPrepModule)
|
||||
|
||||
examPrep.Get("/units/:id", a.RequirePermission("exam_prep.units.get"), h.GetExamPrepUnitByID)
|
||||
examPrep.Put("/units/:id", a.RequirePermission("exam_prep.units.update"), h.UpdateExamPrepUnit)
|
||||
examPrep.Delete("/units/:id", a.RequirePermission("exam_prep.units.delete"), h.DeleteExamPrepUnit)
|
||||
|
||||
// Courses
|
||||
groupV1.Post("/programs/:id/courses", a.authMiddleware, a.RequirePermission("courses.create"), h.CreateCourse)
|
||||
groupV1.Put("/programs/:id/courses/reorder", a.authMiddleware, a.RequirePermission("courses.reorder"), h.ReorderCoursesInProgram)
|
||||
groupV1.Get("/programs/:id/courses", a.authMiddleware, a.RequirePermission("courses.list_by_program"), h.ListCoursesByProgram)
|
||||
groupV1.Get("/courses/:id/practices", a.authMiddleware, a.RequirePermission("practices.list"), h.ListPracticesByCourse)
|
||||
groupV1.Get("/courses/:id", a.authMiddleware, a.RequirePermission("courses.get"), h.GetCourse)
|
||||
groupV1.Get("/programs/:id/courses", a.authMiddleware, a.RequireActiveSubscription(), a.RequirePermission("courses.list_by_program"), h.ListCoursesByProgram)
|
||||
groupV1.Get("/courses/:id/practices", a.authMiddleware, a.RequireActiveSubscription(), a.RequirePermission("practices.list"), h.ListPracticesByCourse)
|
||||
groupV1.Get("/courses/:id", a.authMiddleware, a.RequireActiveSubscription(), a.RequirePermission("courses.get"), h.GetCourse)
|
||||
groupV1.Put("/courses/:id", a.authMiddleware, a.RequirePermission("courses.update"), h.UpdateCourse)
|
||||
groupV1.Delete("/courses/:id", a.authMiddleware, a.RequirePermission("courses.delete"), h.DeleteCourse)
|
||||
groupV1.Post("/courses/:courseId/modules", a.authMiddleware, a.RequirePermission("modules.create"), h.CreateModule)
|
||||
groupV1.Put("/courses/:courseId/modules/reorder", a.authMiddleware, a.RequirePermission("modules.reorder"), h.ReorderModulesInCourse)
|
||||
groupV1.Get("/courses/:courseId/modules", a.authMiddleware, a.RequirePermission("modules.list_by_course"), h.ListModulesByCourse)
|
||||
groupV1.Get("/courses/:courseId/modules", a.authMiddleware, a.RequireActiveSubscription(), a.RequirePermission("modules.list_by_course"), h.ListModulesByCourse)
|
||||
|
||||
// /modules/:moduleId/lessons before /modules/:id; /modules/:id/practices before /modules/:id
|
||||
groupV1.Post("/modules/:moduleId/lessons", a.authMiddleware, a.RequirePermission("lessons.create"), h.CreateLesson)
|
||||
groupV1.Get("/modules/:moduleId/lessons", a.authMiddleware, a.RequirePermission("lessons.list_by_module"), h.ListLessonsByModule)
|
||||
groupV1.Get("/modules/:id/practices", a.authMiddleware, a.RequirePermission("practices.list"), h.ListPracticesByModule)
|
||||
groupV1.Get("/modules/:id", a.authMiddleware, a.RequirePermission("modules.get"), h.GetModule)
|
||||
groupV1.Get("/modules/:moduleId/lessons", a.authMiddleware, a.RequireActiveSubscription(), a.RequirePermission("lessons.list_by_module"), h.ListLessonsByModule)
|
||||
groupV1.Get("/modules/:id/practices", a.authMiddleware, a.RequireActiveSubscription(), a.RequirePermission("practices.list"), h.ListPracticesByModule)
|
||||
groupV1.Get("/modules/:id", a.authMiddleware, a.RequireActiveSubscription(), a.RequirePermission("modules.get"), h.GetModule)
|
||||
groupV1.Put("/modules/:id", a.authMiddleware, a.RequirePermission("modules.update"), h.UpdateModule)
|
||||
groupV1.Delete("/modules/:id", a.authMiddleware, a.RequirePermission("modules.delete"), h.DeleteModule)
|
||||
groupV1.Get("/lessons/:id/practices", a.authMiddleware, a.RequirePermission("practices.list"), h.ListPracticesByLesson)
|
||||
groupV1.Post("/lessons/:id/complete", a.authMiddleware, a.RequirePermission("lessons.complete"), h.CompleteLesson)
|
||||
groupV1.Get("/lessons/:id", a.authMiddleware, a.RequirePermission("lessons.get"), h.GetLesson)
|
||||
groupV1.Get("/lessons/:id/practices", a.authMiddleware, a.RequireActiveSubscription(), a.RequirePermission("practices.list"), h.ListPracticesByLesson)
|
||||
groupV1.Post("/lessons/:id/complete", a.authMiddleware, a.RequireActiveSubscription(), a.RequirePermission("lessons.complete"), h.CompleteLesson)
|
||||
groupV1.Get("/lessons/:id", a.authMiddleware, a.RequireActiveSubscription(), a.RequirePermission("lessons.get"), h.GetLesson)
|
||||
groupV1.Put("/lessons/:id", a.authMiddleware, a.RequirePermission("lessons.update"), h.UpdateLesson)
|
||||
groupV1.Delete("/lessons/:id", a.authMiddleware, a.RequirePermission("lessons.delete"), h.DeleteLesson)
|
||||
|
||||
groupV1.Post("/practices", a.authMiddleware, a.RequirePermission("practices.create"), h.CreatePractice)
|
||||
groupV1.Get("/practices/:id", a.authMiddleware, a.RequirePermission("practices.get"), h.GetPractice)
|
||||
groupV1.Get("/practices/:id", a.authMiddleware, a.RequireActiveSubscription(), a.RequirePermission("practices.get"), h.GetPractice)
|
||||
groupV1.Put("/practices/:id", a.authMiddleware, a.RequirePermission("practices.update"), h.UpdatePractice)
|
||||
groupV1.Delete("/practices/:id", a.authMiddleware, a.RequirePermission("practices.delete"), h.DeletePractice)
|
||||
|
||||
// File storage (MinIO)
|
||||
groupV1.Get("/files/url", a.authMiddleware, h.GetFileURL)
|
||||
groupV1.Post("/files/refresh-url", a.authMiddleware, h.RefreshFileURL)
|
||||
groupV1.Post("/files/upload", a.authMiddleware, h.UploadMedia)
|
||||
groupV1.Post("/files/audio", a.authMiddleware, h.UploadAudio)
|
||||
groupV1.Post("/questions/audio-answer", a.authMiddleware, h.SubmitAudioAnswer)
|
||||
|
|
@ -126,6 +165,8 @@ func (a *App) initAppRoutes() {
|
|||
groupV1.Post("/questions", a.authMiddleware, a.RequirePermission("questions.create"), h.CreateQuestion)
|
||||
groupV1.Get("/questions", a.authMiddleware, a.RequirePermission("questions.list"), h.ListQuestions)
|
||||
groupV1.Get("/questions/search", a.authMiddleware, a.RequirePermission("questions.search"), h.SearchQuestions)
|
||||
groupV1.Get("/questions/component-catalog", a.authMiddleware, a.RequirePermission("questions.list"), h.GetQuestionTypeComponentCatalog)
|
||||
groupV1.Post("/questions/validate-question-type-definition", a.authMiddleware, a.RequirePermission("questions.create"), h.ValidateQuestionTypeDefinition)
|
||||
groupV1.Get("/questions/:id", a.authMiddleware, a.RequirePermission("questions.get"), h.GetQuestionByID)
|
||||
groupV1.Put("/questions/:id", a.authMiddleware, a.RequirePermission("questions.update"), h.UpdateQuestion)
|
||||
groupV1.Delete("/questions/:id", a.authMiddleware, a.RequirePermission("questions.delete"), h.DeleteQuestion)
|
||||
|
|
@ -141,7 +182,7 @@ func (a *App) initAppRoutes() {
|
|||
// Question Set Items
|
||||
groupV1.Post("/question-sets/:setId/questions", a.authMiddleware, a.RequirePermission("question_set_items.add"), h.AddQuestionToSet)
|
||||
groupV1.Get("/question-sets/:setId/questions", a.authMiddleware, a.RequirePermission("question_set_items.list"), h.GetQuestionsInSet)
|
||||
groupV1.Get("/practices/:practiceId/questions", a.authMiddleware, a.RequirePermission("question_set_items.list"), h.GetQuestionsByPractice)
|
||||
groupV1.Get("/practices/:practiceId/questions", a.authMiddleware, a.RequireActiveSubscription(), a.RequirePermission("question_set_items.list"), h.GetQuestionsByPractice)
|
||||
groupV1.Delete("/question-sets/:setId/questions/:questionId", a.authMiddleware, a.RequirePermission("question_set_items.remove"), h.RemoveQuestionFromSet)
|
||||
groupV1.Put("/question-sets/:setId/questions/:questionId/order", a.authMiddleware, a.RequirePermission("question_set_items.update_order"), h.UpdateQuestionOrderInSet)
|
||||
|
||||
|
|
|
|||
457
postman/Duolingo-ExamPrep.postman_collection.json
Normal file
457
postman/Duolingo-ExamPrep.postman_collection.json
Normal file
|
|
@ -0,0 +1,457 @@
|
|||
{
|
||||
"info": {
|
||||
"_postman_id": "f7c2e4a1-8b3d-4e9f-a2c6-11dd99ee5501",
|
||||
"name": "Yimaru Exam Prep (Duolingo)",
|
||||
"description": "Exam-prep tree API (`/api/v1/exam-prep/...`): catalog courses → units → modules → lessons → practices. Requires Bearer token.\n\n**Courses** = `catalog-courses` in the backend. Set collection variables before chaining requests.",
|
||||
"schema": "https://schema.getpostman.com/json/collection/v2.1.0/collection.json"
|
||||
},
|
||||
"auth": {
|
||||
"type": "bearer",
|
||||
"bearer": [
|
||||
{
|
||||
"key": "token",
|
||||
"value": "{{accessToken}}",
|
||||
"type": "string"
|
||||
}
|
||||
]
|
||||
},
|
||||
"variable": [
|
||||
{
|
||||
"key": "baseUrl",
|
||||
"value": "http://localhost:8080"
|
||||
},
|
||||
{
|
||||
"key": "accessToken",
|
||||
"value": ""
|
||||
},
|
||||
{
|
||||
"key": "catalogCourseId",
|
||||
"value": "1"
|
||||
},
|
||||
{
|
||||
"key": "unitId",
|
||||
"value": "1"
|
||||
},
|
||||
{
|
||||
"key": "moduleId",
|
||||
"value": "1"
|
||||
},
|
||||
{
|
||||
"key": "lessonId",
|
||||
"value": "1"
|
||||
},
|
||||
{
|
||||
"key": "practiceId",
|
||||
"value": "1"
|
||||
}
|
||||
],
|
||||
"item": [
|
||||
{
|
||||
"name": "Duolingo",
|
||||
"item": [
|
||||
{
|
||||
"name": "Courses",
|
||||
"description": "Backend route group: **`catalog-courses`** (`exam_prep.catalog_courses.*`)",
|
||||
"item": [
|
||||
{
|
||||
"name": "Create catalog course",
|
||||
"request": {
|
||||
"method": "POST",
|
||||
"header": [
|
||||
{
|
||||
"key": "Content-Type",
|
||||
"value": "application/json"
|
||||
}
|
||||
],
|
||||
"body": {
|
||||
"mode": "raw",
|
||||
"raw": "{\n \"name\": \"IELTS Prep\",\n \"description\": \"Optional description\",\n \"thumbnail\": null\n}"
|
||||
},
|
||||
"url": "{{baseUrl}}/api/v1/exam-prep/catalog-courses",
|
||||
"description": "Permission: `exam_prep.catalog_courses.create`"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "List catalog courses",
|
||||
"request": {
|
||||
"method": "GET",
|
||||
"url": "{{baseUrl}}/api/v1/exam-prep/catalog-courses?limit=20&offset=0",
|
||||
"description": "Permission: `exam_prep.catalog_courses.list`"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "Reorder catalog courses",
|
||||
"request": {
|
||||
"method": "PUT",
|
||||
"header": [
|
||||
{
|
||||
"key": "Content-Type",
|
||||
"value": "application/json"
|
||||
}
|
||||
],
|
||||
"body": {
|
||||
"mode": "raw",
|
||||
"raw": "{\n \"ordered_ids\": [1, 2, 3]\n}"
|
||||
},
|
||||
"url": "{{baseUrl}}/api/v1/exam-prep/catalog-courses/reorder",
|
||||
"description": "Permission: `exam_prep.catalog_courses.reorder`. Must include every id in scope exactly once."
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "Get catalog course by ID",
|
||||
"request": {
|
||||
"method": "GET",
|
||||
"url": "{{baseUrl}}/api/v1/exam-prep/catalog-courses/{{catalogCourseId}}",
|
||||
"description": "Permission: `exam_prep.catalog_courses.get`"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "Update catalog course",
|
||||
"request": {
|
||||
"method": "PUT",
|
||||
"header": [
|
||||
{
|
||||
"key": "Content-Type",
|
||||
"value": "application/json"
|
||||
}
|
||||
],
|
||||
"body": {
|
||||
"mode": "raw",
|
||||
"raw": "{\n \"name\": \"Updated name\",\n \"description\": null,\n \"thumbnail\": null,\n \"sort_order\": 1\n}"
|
||||
},
|
||||
"url": "{{baseUrl}}/api/v1/exam-prep/catalog-courses/{{catalogCourseId}}",
|
||||
"description": "Permission: `exam_prep.catalog_courses.update`"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "Delete catalog course",
|
||||
"request": {
|
||||
"method": "DELETE",
|
||||
"url": "{{baseUrl}}/api/v1/exam-prep/catalog-courses/{{catalogCourseId}}",
|
||||
"description": "Permission: `exam_prep.catalog_courses.delete`"
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "Units",
|
||||
"description": "Nested under catalog course (`exam_prep.units.*`)",
|
||||
"item": [
|
||||
{
|
||||
"name": "Create unit",
|
||||
"request": {
|
||||
"method": "POST",
|
||||
"header": [
|
||||
{
|
||||
"key": "Content-Type",
|
||||
"value": "application/json"
|
||||
}
|
||||
],
|
||||
"body": {
|
||||
"mode": "raw",
|
||||
"raw": "{\n \"name\": \"Grammar foundations\",\n \"description\": null,\n \"thumbnail\": null\n}"
|
||||
},
|
||||
"url": "{{baseUrl}}/api/v1/exam-prep/catalog-courses/{{catalogCourseId}}/units",
|
||||
"description": "Permission: `exam_prep.units.create`"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "List units by catalog course",
|
||||
"request": {
|
||||
"method": "GET",
|
||||
"url": "{{baseUrl}}/api/v1/exam-prep/catalog-courses/{{catalogCourseId}}/units?limit=20&offset=0",
|
||||
"description": "Permission: `exam_prep.units.list`"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "Reorder units in catalog course",
|
||||
"request": {
|
||||
"method": "PUT",
|
||||
"header": [
|
||||
{
|
||||
"key": "Content-Type",
|
||||
"value": "application/json"
|
||||
}
|
||||
],
|
||||
"body": {
|
||||
"mode": "raw",
|
||||
"raw": "{\n \"ordered_ids\": [1, 2, 3]\n}"
|
||||
},
|
||||
"url": "{{baseUrl}}/api/v1/exam-prep/catalog-courses/{{catalogCourseId}}/units/reorder",
|
||||
"description": "Permission: `exam_prep.units.reorder`"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "Get unit by ID",
|
||||
"request": {
|
||||
"method": "GET",
|
||||
"url": "{{baseUrl}}/api/v1/exam-prep/units/{{unitId}}",
|
||||
"description": "Permission: `exam_prep.units.get`"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "Update unit",
|
||||
"request": {
|
||||
"method": "PUT",
|
||||
"header": [
|
||||
{
|
||||
"key": "Content-Type",
|
||||
"value": "application/json"
|
||||
}
|
||||
],
|
||||
"body": {
|
||||
"mode": "raw",
|
||||
"raw": "{\n \"name\": \"Updated unit\",\n \"description\": null,\n \"thumbnail\": null,\n \"sort_order\": 1\n}"
|
||||
},
|
||||
"url": "{{baseUrl}}/api/v1/exam-prep/units/{{unitId}}",
|
||||
"description": "Permission: `exam_prep.units.update`"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "Delete unit",
|
||||
"request": {
|
||||
"method": "DELETE",
|
||||
"url": "{{baseUrl}}/api/v1/exam-prep/units/{{unitId}}",
|
||||
"description": "Permission: `exam_prep.units.delete`"
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "Modules",
|
||||
"description": "Exam-prep **`unit_modules`** (`exam_prep.modules.*`)",
|
||||
"item": [
|
||||
{
|
||||
"name": "Create module",
|
||||
"request": {
|
||||
"method": "POST",
|
||||
"header": [
|
||||
{
|
||||
"key": "Content-Type",
|
||||
"value": "application/json"
|
||||
}
|
||||
],
|
||||
"body": {
|
||||
"mode": "raw",
|
||||
"raw": "{\n \"name\": \"Present tense\",\n \"description\": null,\n \"thumbnail\": null,\n \"icon\": null\n}"
|
||||
},
|
||||
"url": "{{baseUrl}}/api/v1/exam-prep/units/{{unitId}}/modules",
|
||||
"description": "Permission: `exam_prep.modules.create`"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "List modules by unit",
|
||||
"request": {
|
||||
"method": "GET",
|
||||
"url": "{{baseUrl}}/api/v1/exam-prep/units/{{unitId}}/modules?limit=20&offset=0",
|
||||
"description": "Permission: `exam_prep.modules.list`"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "Reorder modules in unit",
|
||||
"request": {
|
||||
"method": "PUT",
|
||||
"header": [
|
||||
{
|
||||
"key": "Content-Type",
|
||||
"value": "application/json"
|
||||
}
|
||||
],
|
||||
"body": {
|
||||
"mode": "raw",
|
||||
"raw": "{\n \"ordered_ids\": [1, 2, 3]\n}"
|
||||
},
|
||||
"url": "{{baseUrl}}/api/v1/exam-prep/units/{{unitId}}/modules/reorder",
|
||||
"description": "Permission: `exam_prep.modules.reorder`"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "Get module by ID",
|
||||
"request": {
|
||||
"method": "GET",
|
||||
"url": "{{baseUrl}}/api/v1/exam-prep/modules/{{moduleId}}",
|
||||
"description": "Permission: `exam_prep.modules.get`"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "Update module",
|
||||
"request": {
|
||||
"method": "PUT",
|
||||
"header": [
|
||||
{
|
||||
"key": "Content-Type",
|
||||
"value": "application/json"
|
||||
}
|
||||
],
|
||||
"body": {
|
||||
"mode": "raw",
|
||||
"raw": "{\n \"name\": \"Updated module\",\n \"description\": null,\n \"thumbnail\": null,\n \"icon\": null,\n \"sort_order\": 1\n}"
|
||||
},
|
||||
"url": "{{baseUrl}}/api/v1/exam-prep/modules/{{moduleId}}",
|
||||
"description": "Permission: `exam_prep.modules.update`"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "Delete module",
|
||||
"request": {
|
||||
"method": "DELETE",
|
||||
"url": "{{baseUrl}}/api/v1/exam-prep/modules/{{moduleId}}",
|
||||
"description": "Permission: `exam_prep.modules.delete`"
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "Lessons",
|
||||
"description": "`exam_prep.lessons.*`",
|
||||
"item": [
|
||||
{
|
||||
"name": "Create lesson",
|
||||
"request": {
|
||||
"method": "POST",
|
||||
"header": [
|
||||
{
|
||||
"key": "Content-Type",
|
||||
"value": "application/json"
|
||||
}
|
||||
],
|
||||
"body": {
|
||||
"mode": "raw",
|
||||
"raw": "{\n \"title\": \"Intro video\",\n \"video_url\": \"https://example.com/video\",\n \"thumbnail\": null,\n \"description\": null\n}"
|
||||
},
|
||||
"url": "{{baseUrl}}/api/v1/exam-prep/modules/{{moduleId}}/lessons",
|
||||
"description": "Permission: `exam_prep.lessons.create`"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "List lessons by module",
|
||||
"request": {
|
||||
"method": "GET",
|
||||
"url": "{{baseUrl}}/api/v1/exam-prep/modules/{{moduleId}}/lessons?limit=20&offset=0",
|
||||
"description": "Permission: `exam_prep.lessons.list_by_module`"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "Reorder lessons in module",
|
||||
"request": {
|
||||
"method": "PUT",
|
||||
"header": [
|
||||
{
|
||||
"key": "Content-Type",
|
||||
"value": "application/json"
|
||||
}
|
||||
],
|
||||
"body": {
|
||||
"mode": "raw",
|
||||
"raw": "{\n \"ordered_ids\": [1, 2, 3]\n}"
|
||||
},
|
||||
"url": "{{baseUrl}}/api/v1/exam-prep/modules/{{moduleId}}/lessons/reorder",
|
||||
"description": "Permission: `exam_prep.lessons.reorder`"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "Get lesson by ID",
|
||||
"request": {
|
||||
"method": "GET",
|
||||
"url": "{{baseUrl}}/api/v1/exam-prep/lessons/{{lessonId}}",
|
||||
"description": "Permission: `exam_prep.lessons.get`"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "Update lesson",
|
||||
"request": {
|
||||
"method": "PUT",
|
||||
"header": [
|
||||
{
|
||||
"key": "Content-Type",
|
||||
"value": "application/json"
|
||||
}
|
||||
],
|
||||
"body": {
|
||||
"mode": "raw",
|
||||
"raw": "{\n \"title\": \"Updated lesson\",\n \"video_url\": null,\n \"thumbnail\": null,\n \"description\": null,\n \"sort_order\": 1\n}"
|
||||
},
|
||||
"url": "{{baseUrl}}/api/v1/exam-prep/lessons/{{lessonId}}",
|
||||
"description": "Permission: `exam_prep.lessons.update`"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "Delete lesson",
|
||||
"request": {
|
||||
"method": "DELETE",
|
||||
"url": "{{baseUrl}}/api/v1/exam-prep/lessons/{{lessonId}}",
|
||||
"description": "Permission: `exam_prep.lessons.delete`"
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "Practices",
|
||||
"description": "Tied to lesson; **`question_set_id`** references shared `question_sets`. `exam_prep.practices.*`",
|
||||
"item": [
|
||||
{
|
||||
"name": "Create practice",
|
||||
"request": {
|
||||
"method": "POST",
|
||||
"header": [
|
||||
{
|
||||
"key": "Content-Type",
|
||||
"value": "application/json"
|
||||
}
|
||||
],
|
||||
"body": {
|
||||
"mode": "raw",
|
||||
"raw": "{\n \"title\": \"Drill: articles\",\n \"story_description\": null,\n \"story_image\": null,\n \"persona_id\": null,\n \"question_set_id\": 1,\n \"quick_tips\": null\n}"
|
||||
},
|
||||
"url": "{{baseUrl}}/api/v1/exam-prep/lessons/{{lessonId}}/practices",
|
||||
"description": "Permission: `exam_prep.practices.create`"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "List practices by lesson",
|
||||
"request": {
|
||||
"method": "GET",
|
||||
"url": "{{baseUrl}}/api/v1/exam-prep/lessons/{{lessonId}}/practices?limit=20&offset=0",
|
||||
"description": "Permission: `exam_prep.practices.list_by_lesson`"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "Get practice by ID",
|
||||
"request": {
|
||||
"method": "GET",
|
||||
"url": "{{baseUrl}}/api/v1/exam-prep/practices/{{practiceId}}",
|
||||
"description": "Permission: `exam_prep.practices.get`"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "Update practice",
|
||||
"request": {
|
||||
"method": "PUT",
|
||||
"header": [
|
||||
{
|
||||
"key": "Content-Type",
|
||||
"value": "application/json"
|
||||
}
|
||||
],
|
||||
"body": {
|
||||
"mode": "raw",
|
||||
"raw": "{\n \"title\": \"Updated practice\",\n \"story_description\": null,\n \"story_image\": null,\n \"persona_id\": null,\n \"question_set_id\": 1,\n \"quick_tips\": null\n}"
|
||||
},
|
||||
"url": "{{baseUrl}}/api/v1/exam-prep/practices/{{practiceId}}",
|
||||
"description": "Permission: `exam_prep.practices.update`. Omit fields you do not change."
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "Delete practice",
|
||||
"request": {
|
||||
"method": "DELETE",
|
||||
"url": "{{baseUrl}}/api/v1/exam-prep/practices/{{practiceId}}",
|
||||
"description": "Permission: `exam_prep.practices.delete`"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user