diff --git a/db/migrations/000035_sub_module_capstones.down.sql b/db/migrations/000035_sub_module_capstones.down.sql new file mode 100644 index 0000000..2465e5d --- /dev/null +++ b/db/migrations/000035_sub_module_capstones.down.sql @@ -0,0 +1,12 @@ +DROP INDEX IF EXISTS idx_sub_module_capstones_sub_module_id; +DROP TABLE IF EXISTS sub_module_capstones; + +ALTER TABLE question_sets DROP CONSTRAINT IF EXISTS question_sets_set_type_check; +ALTER TABLE question_sets ADD CONSTRAINT question_sets_set_type_check + CHECK (set_type IN ( + 'PRACTICE', + 'INITIAL_ASSESSMENT', + 'QUIZ', + 'EXAM', + 'SURVEY' + )); diff --git a/db/migrations/000035_sub_module_capstones.up.sql b/db/migrations/000035_sub_module_capstones.up.sql new file mode 100644 index 0000000..253b3ab --- /dev/null +++ b/db/migrations/000035_sub_module_capstones.up.sql @@ -0,0 +1,29 @@ +-- Capstone assessments: sub-module scoped, backed by question_sets (type CAPSTONE). + +ALTER TABLE question_sets DROP CONSTRAINT IF EXISTS question_sets_set_type_check; +ALTER TABLE question_sets ADD CONSTRAINT question_sets_set_type_check + CHECK (set_type IN ( + 'PRACTICE', + 'INITIAL_ASSESSMENT', + 'QUIZ', + 'EXAM', + 'SURVEY', + 'CAPSTONE' + )); + +CREATE TABLE IF NOT EXISTS sub_module_capstones ( + id BIGSERIAL PRIMARY KEY, + sub_module_id BIGINT NOT NULL REFERENCES sub_modules(id) ON DELETE CASCADE, + title VARCHAR(255) NOT NULL, + description TEXT, + tips TEXT, + thumbnail TEXT, + question_set_id BIGINT NOT NULL REFERENCES question_sets(id) ON DELETE CASCADE, + display_order INT NOT NULL DEFAULT 0, + is_active BOOLEAN NOT NULL DEFAULT TRUE, + created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP, + UNIQUE (question_set_id) +); + +CREATE INDEX IF NOT EXISTS idx_sub_module_capstones_sub_module_id + ON sub_module_capstones (sub_module_id); diff --git a/db/migrations/000036_module_icon_and_module_capstones.down.sql b/db/migrations/000036_module_icon_and_module_capstones.down.sql new file mode 100644 index 0000000..ea2af26 --- /dev/null +++ b/db/migrations/000036_module_icon_and_module_capstones.down.sql @@ -0,0 +1,4 @@ +DROP INDEX IF EXISTS idx_module_capstones_module_id; +DROP TABLE IF EXISTS module_capstones; + +ALTER TABLE modules DROP COLUMN IF EXISTS icon_url; diff --git a/db/migrations/000036_module_icon_and_module_capstones.up.sql b/db/migrations/000036_module_icon_and_module_capstones.up.sql new file mode 100644 index 0000000..c89c9d6 --- /dev/null +++ b/db/migrations/000036_module_icon_and_module_capstones.up.sql @@ -0,0 +1,19 @@ +ALTER TABLE modules + ADD COLUMN IF NOT EXISTS icon_url TEXT; + +CREATE TABLE IF NOT EXISTS module_capstones ( + id BIGSERIAL PRIMARY KEY, + module_id BIGINT NOT NULL REFERENCES modules(id) ON DELETE CASCADE, + title VARCHAR(255) NOT NULL, + description TEXT, + tips TEXT, + thumbnail TEXT, + question_set_id BIGINT NOT NULL REFERENCES question_sets(id) ON DELETE CASCADE, + display_order INT NOT NULL DEFAULT 0, + is_active BOOLEAN NOT NULL DEFAULT TRUE, + created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP, + UNIQUE (question_set_id) +); + +CREATE INDEX IF NOT EXISTS idx_module_capstones_module_id + ON module_capstones (module_id); diff --git a/db/migrations/000037_sub_modules_thumbnail_tips.down.sql b/db/migrations/000037_sub_modules_thumbnail_tips.down.sql new file mode 100644 index 0000000..f069baf --- /dev/null +++ b/db/migrations/000037_sub_modules_thumbnail_tips.down.sql @@ -0,0 +1,3 @@ +ALTER TABLE sub_modules + DROP COLUMN IF EXISTS tips, + DROP COLUMN IF EXISTS thumbnail; diff --git a/db/migrations/000037_sub_modules_thumbnail_tips.up.sql b/db/migrations/000037_sub_modules_thumbnail_tips.up.sql new file mode 100644 index 0000000..9e2c90a --- /dev/null +++ b/db/migrations/000037_sub_modules_thumbnail_tips.up.sql @@ -0,0 +1,3 @@ +ALTER TABLE sub_modules + ADD COLUMN IF NOT EXISTS thumbnail TEXT, + ADD COLUMN IF NOT EXISTS tips TEXT; diff --git a/db/query/hierarchy.sql b/db/query/hierarchy.sql index 19260c7..81f85a3 100644 --- a/db/query/hierarchy.sql +++ b/db/query/hierarchy.sql @@ -136,6 +136,53 @@ WHERE smp.id = $1 AND smp.is_active = TRUE AND qs.set_type = 'PRACTICE'; +-- name: GetSubModuleCapstones :many +SELECT + smc.id, + smc.sub_module_id, + smc.title, + smc.description, + smc.tips, + smc.thumbnail, + smc.question_set_id, + smc.display_order, + smc.is_active, + qs.status, + qs.set_type, + qs.time_limit_minutes, + qs.passing_score, + qs.shuffle_questions, + (SELECT COUNT(*) FROM question_set_items qsi WHERE qsi.set_id = qs.id) AS question_count +FROM sub_module_capstones smc +JOIN question_sets qs ON qs.id = smc.question_set_id +WHERE smc.sub_module_id = $1 + AND smc.is_active = TRUE + AND qs.set_type = 'CAPSTONE' +ORDER BY smc.display_order ASC, smc.id ASC; + +-- name: GetSubModuleCapstoneByID :one +SELECT + smc.id, + smc.sub_module_id, + smc.title, + smc.description, + smc.tips, + smc.thumbnail, + smc.question_set_id, + smc.display_order, + smc.is_active, + qs.status, + qs.set_type, + qs.time_limit_minutes, + qs.passing_score, + qs.shuffle_questions, + (SELECT COUNT(*) FROM question_set_items qsi WHERE qsi.set_id = qs.id) AS question_count +FROM sub_module_capstones smc +JOIN question_sets qs ON qs.id = smc.question_set_id +WHERE smc.id = $1 + AND smc.is_active = TRUE + AND qs.set_type = 'CAPSTONE'; + -- name: GetFullHierarchyByCourseID :many SELECT c.id AS course_id, @@ -147,8 +194,13 @@ SELECT l.thumbnail AS level_thumbnail, m.id AS module_id, m.title AS module_title, + m.icon_url AS module_icon_url, sm.id AS sub_module_id, - sm.title AS sub_module_title + sm.title AS sub_module_title, + sm.description AS sub_module_description, + sm.thumbnail AS sub_module_thumbnail, + sm.tips AS sub_module_tips, + sm.display_order AS sub_module_display_order FROM courses c LEFT JOIN levels l ON l.course_id = c.id AND l.is_active = TRUE LEFT JOIN modules m ON m.level_id = l.id AND m.is_active = TRUE @@ -234,10 +286,22 @@ INSERT INTO modules ( level_id, title, description, + icon_url, display_order, is_active ) -VALUES ($1, $2, $3, COALESCE($4, 0), COALESCE($5, TRUE)) +VALUES ($1, $2, $3, $4, COALESCE($5, 0), COALESCE($6, TRUE)) +RETURNING *; + +-- name: UpdateModule :one +UPDATE modules +SET + title = $1, + description = $2, + icon_url = $3, + display_order = $4, + is_active = $5 +WHERE id = $6 RETURNING *; -- name: CreateSubModule :one @@ -245,10 +309,24 @@ INSERT INTO sub_modules ( module_id, title, description, + thumbnail, + tips, display_order, is_active ) -VALUES ($1, $2, $3, COALESCE($4, 0), COALESCE($5, TRUE)) +VALUES ($1, $2, $3, $4, $5, COALESCE($6, 0), COALESCE($7, TRUE)) +RETURNING *; + +-- name: UpdateSubModule :one +UPDATE sub_modules +SET + title = $1, + description = $2, + thumbnail = $3, + tips = $4, + display_order = $5, + is_active = $6 +WHERE id = $7 RETURNING *; -- name: CreateSubModuleVideo :one @@ -337,3 +415,102 @@ INSERT INTO sub_module_practices ( VALUES ($1, $2, $3, $4, $5, $6, COALESCE($7, 0), COALESCE($8, TRUE)) RETURNING *; +-- name: CreateSubModuleCapstone :one +INSERT INTO sub_module_capstones ( + sub_module_id, + title, + description, + tips, + thumbnail, + question_set_id, + display_order, + is_active +) +VALUES ($1, $2, $3, $4, $5, $6, COALESCE($7, 0), COALESCE($8, TRUE)) +RETURNING *; + +-- name: UpdateSubModuleCapstone :one +UPDATE sub_module_capstones +SET + title = $1, + description = $2, + tips = $3, + thumbnail = $4, + display_order = $5, + is_active = $6 +WHERE id = $7 +RETURNING *; + +-- name: GetModuleCapstones :many +SELECT + mc.id, + mc.module_id, + mc.title, + mc.description, + mc.tips, + mc.thumbnail, + mc.question_set_id, + mc.display_order, + mc.is_active, + qs.status, + qs.set_type, + qs.time_limit_minutes, + qs.passing_score, + qs.shuffle_questions, + (SELECT COUNT(*) FROM question_set_items qsi WHERE qsi.set_id = qs.id) AS question_count +FROM module_capstones mc +JOIN question_sets qs ON qs.id = mc.question_set_id +WHERE mc.module_id = $1 + AND mc.is_active = TRUE + AND qs.set_type = 'CAPSTONE' +ORDER BY mc.display_order ASC, mc.id ASC; + +-- name: GetModuleCapstoneByID :one +SELECT + mc.id, + mc.module_id, + mc.title, + mc.description, + mc.tips, + mc.thumbnail, + mc.question_set_id, + mc.display_order, + mc.is_active, + qs.status, + qs.set_type, + qs.time_limit_minutes, + qs.passing_score, + qs.shuffle_questions, + (SELECT COUNT(*) FROM question_set_items qsi WHERE qsi.set_id = qs.id) AS question_count +FROM module_capstones mc +JOIN question_sets qs ON qs.id = mc.question_set_id +WHERE mc.id = $1 + AND mc.is_active = TRUE + AND qs.set_type = 'CAPSTONE'; + +-- name: CreateModuleCapstone :one +INSERT INTO module_capstones ( + module_id, + title, + description, + tips, + thumbnail, + question_set_id, + display_order, + is_active +) +VALUES ($1, $2, $3, $4, $5, $6, COALESCE($7, 0), COALESCE($8, TRUE)) +RETURNING *; + +-- name: UpdateModuleCapstone :one +UPDATE module_capstones +SET + title = $1, + description = $2, + tips = $3, + thumbnail = $4, + display_order = $5, + is_active = $6 +WHERE id = $7 +RETURNING *; + diff --git a/docs/docs.go b/docs/docs.go index bb7c100..ad66a02 100644 --- a/docs/docs.go +++ b/docs/docs.go @@ -713,6 +713,149 @@ const docTemplate = `{ } } }, + "/api/v1/course-management/capstones/{capstoneId}": { + "get": { + "description": "Returns one capstone with question-set fields and the ordered question list", + "produces": [ + "application/json" + ], + "tags": [ + "course-management" + ], + "summary": "Get capstone detail", + "parameters": [ + { + "type": "integer", + "description": "Capstone ID", + "name": "capstoneId", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/domain.Response" + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/domain.ErrorResponse" + } + }, + "404": { + "description": "Not Found", + "schema": { + "$ref": "#/definitions/domain.ErrorResponse" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/domain.ErrorResponse" + } + } + } + }, + "put": { + "description": "Updates capstone content, question-set assessment settings, and optionally replaces the question list", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "course-management" + ], + "summary": "Update capstone", + "parameters": [ + { + "type": "integer", + "description": "Capstone ID", + "name": "capstoneId", + "in": "path", + "required": true + }, + { + "description": "Update capstone payload", + "name": "body", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/handlers.updateSubModuleCapstoneReq" + } + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/domain.Response" + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/domain.ErrorResponse" + } + }, + "404": { + "description": "Not Found", + "schema": { + "$ref": "#/definitions/domain.ErrorResponse" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/domain.ErrorResponse" + } + } + } + }, + "delete": { + "description": "Deletes the capstone and its backing question set (and question items)", + "produces": [ + "application/json" + ], + "tags": [ + "course-management" + ], + "summary": "Delete capstone", + "parameters": [ + { + "type": "integer", + "description": "Capstone ID", + "name": "capstoneId", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/domain.Response" + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/domain.ErrorResponse" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/domain.ErrorResponse" + } + } + } + } + }, "/api/v1/course-management/categories": { "get": { "description": "Legacy-compatible endpoint for listing course categories", @@ -1568,6 +1711,195 @@ const docTemplate = `{ } } }, + "/api/v1/course-management/module-capstones": { + "post": { + "description": "Creates a module-level capstone with a new CAPSTONE question set and ordered questions", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "course-management" + ], + "summary": "Create module capstone", + "parameters": [ + { + "description": "Create module capstone payload", + "name": "body", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/handlers.createModuleCapstoneReq" + } + } + ], + "responses": { + "201": { + "description": "Created", + "schema": { + "$ref": "#/definitions/domain.Response" + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/domain.ErrorResponse" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/domain.ErrorResponse" + } + } + } + } + }, + "/api/v1/course-management/module-capstones/{moduleCapstoneId}": { + "get": { + "description": "Returns one module capstone with question-set fields and the ordered question list", + "produces": [ + "application/json" + ], + "tags": [ + "course-management" + ], + "summary": "Get module capstone detail", + "parameters": [ + { + "type": "integer", + "description": "Module capstone ID", + "name": "moduleCapstoneId", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/domain.Response" + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/domain.ErrorResponse" + } + }, + "404": { + "description": "Not Found", + "schema": { + "$ref": "#/definitions/domain.ErrorResponse" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/domain.ErrorResponse" + } + } + } + }, + "put": { + "description": "Updates module capstone content, question-set assessment settings, and optionally replaces the question list", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "course-management" + ], + "summary": "Update module capstone", + "parameters": [ + { + "type": "integer", + "description": "Module capstone ID", + "name": "moduleCapstoneId", + "in": "path", + "required": true + }, + { + "description": "Update module capstone payload", + "name": "body", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/handlers.updateModuleCapstoneReq" + } + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/domain.Response" + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/domain.ErrorResponse" + } + }, + "404": { + "description": "Not Found", + "schema": { + "$ref": "#/definitions/domain.ErrorResponse" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/domain.ErrorResponse" + } + } + } + }, + "delete": { + "description": "Deletes the module capstone and its backing question set", + "produces": [ + "application/json" + ], + "tags": [ + "course-management" + ], + "summary": "Delete module capstone", + "parameters": [ + { + "type": "integer", + "description": "Module capstone ID", + "name": "moduleCapstoneId", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/domain.Response" + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/domain.ErrorResponse" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/domain.ErrorResponse" + } + } + } + } + }, "/api/v1/course-management/modules": { "get": { "description": "Returns all modules with pagination", @@ -1608,7 +1940,7 @@ const docTemplate = `{ } }, "post": { - "description": "Creates a module under a level", + "description": "Creates a module under a level; optional icon_url stores a module icon image URL", "consumes": [ "application/json" ], @@ -1697,6 +2029,104 @@ const docTemplate = `{ } } } + }, + "put": { + "description": "Updates module title, description, icon URL, display order, and active flag", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "course-management" + ], + "summary": "Update module", + "parameters": [ + { + "type": "integer", + "description": "Module ID", + "name": "moduleId", + "in": "path", + "required": true + }, + { + "description": "Update module payload", + "name": "body", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/handlers.updateModuleReq" + } + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/domain.Response" + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/domain.ErrorResponse" + } + }, + "404": { + "description": "Not Found", + "schema": { + "$ref": "#/definitions/domain.ErrorResponse" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/domain.ErrorResponse" + } + } + } + } + }, + "/api/v1/course-management/modules/{moduleId}/capstones": { + "get": { + "description": "Returns active module capstones with question-set settings and question counts", + "produces": [ + "application/json" + ], + "tags": [ + "course-management" + ], + "summary": "List capstones under module", + "parameters": [ + { + "type": "integer", + "description": "Module ID", + "name": "moduleId", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/domain.Response" + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/domain.ErrorResponse" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/domain.ErrorResponse" + } + } + } } }, "/api/v1/course-management/modules/{moduleId}/sub-modules": { @@ -1927,6 +2357,52 @@ const docTemplate = `{ } } }, + "/api/v1/course-management/sub-module-capstones": { + "post": { + "description": "Creates a capstone assessment with a new CAPSTONE question set, metadata, and ordered questions", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "course-management" + ], + "summary": "Create capstone under sub-module", + "parameters": [ + { + "description": "Create capstone payload", + "name": "body", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/handlers.createSubModuleCapstoneReq" + } + } + ], + "responses": { + "201": { + "description": "Created", + "schema": { + "$ref": "#/definitions/domain.Response" + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/domain.ErrorResponse" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/domain.ErrorResponse" + } + } + } + } + }, "/api/v1/course-management/sub-module-lessons": { "post": { "description": "Creates a sub-module lesson with teaching content (text, image, audio, video URLs) and optional thumbnail", @@ -2212,7 +2688,7 @@ const docTemplate = `{ } }, "post": { - "description": "Creates a sub-module under a module", + "description": "Creates a sub-module under a module; optional thumbnail (image URL) and tips text", "consumes": [ "application/json" ], @@ -2303,6 +2779,47 @@ const docTemplate = `{ } } }, + "/api/v1/course-management/sub-modules/{subModuleId}/capstones": { + "get": { + "description": "Returns active capstones for a sub-module with question-set settings and question counts", + "produces": [ + "application/json" + ], + "tags": [ + "course-management" + ], + "summary": "List capstones under sub-module", + "parameters": [ + { + "type": "integer", + "description": "Sub-module ID", + "name": "subModuleId", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/domain.Response" + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/domain.ErrorResponse" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/domain.ErrorResponse" + } + } + } + } + }, "/api/v1/course-management/sub-modules/{subModuleId}/lessons": { "get": { "description": "Returns all active lessons for a sub-module (teaching content metadata)", @@ -4040,7 +4557,7 @@ const docTemplate = `{ "parameters": [ { "type": "string", - "description": "Set type (PRACTICE, INITIAL_ASSESSMENT, QUIZ, EXAM, SURVEY)", + "description": "Set type (PRACTICE, INITIAL_ASSESSMENT, QUIZ, EXAM, SURVEY, CAPSTONE)", "name": "set_type", "in": "query", "required": true @@ -10300,6 +10817,17 @@ const docTemplate = `{ } } }, + "handlers.capstoneQuestionItem": { + "type": "object", + "properties": { + "display_order": { + "type": "integer" + }, + "question_id": { + "type": "integer" + } + } + }, "handlers.changePasswordReq": { "type": "object", "required": [ @@ -10432,6 +10960,50 @@ const docTemplate = `{ } } }, + "handlers.createModuleCapstoneReq": { + "type": "object", + "properties": { + "description": { + "type": "string" + }, + "display_order": { + "type": "integer" + }, + "is_active": { + "type": "boolean" + }, + "module_id": { + "type": "integer" + }, + "passing_score": { + "type": "integer" + }, + "questions": { + "type": "array", + "items": { + "$ref": "#/definitions/handlers.capstoneQuestionItem" + } + }, + "shuffle_questions": { + "type": "boolean" + }, + "status": { + "type": "string" + }, + "thumbnail": { + "type": "string" + }, + "time_limit_minutes": { + "type": "integer" + }, + "tips": { + "type": "string" + }, + "title": { + "type": "string" + } + } + }, "handlers.createModuleReq": { "type": "object", "properties": { @@ -10441,6 +11013,9 @@ const docTemplate = `{ "display_order": { "type": "integer" }, + "icon_url": { + "type": "string" + }, "is_active": { "type": "boolean" }, @@ -10588,7 +11163,8 @@ const docTemplate = `{ "INITIAL_ASSESSMENT", "QUIZ", "EXAM", - "SURVEY" + "SURVEY", + "CAPSTONE" ] }, "shuffle_questions": { @@ -10608,6 +11184,50 @@ const docTemplate = `{ } } }, + "handlers.createSubModuleCapstoneReq": { + "type": "object", + "properties": { + "description": { + "type": "string" + }, + "display_order": { + "type": "integer" + }, + "is_active": { + "type": "boolean" + }, + "passing_score": { + "type": "integer" + }, + "questions": { + "type": "array", + "items": { + "$ref": "#/definitions/handlers.capstoneQuestionItem" + } + }, + "shuffle_questions": { + "type": "boolean" + }, + "status": { + "type": "string" + }, + "sub_module_id": { + "type": "integer" + }, + "thumbnail": { + "type": "string" + }, + "time_limit_minutes": { + "type": "integer" + }, + "tips": { + "type": "string" + }, + "title": { + "type": "string" + } + } + }, "handlers.createSubModuleLessonReq": { "type": "object", "properties": { @@ -10687,6 +11307,12 @@ const docTemplate = `{ "module_id": { "type": "integer" }, + "thumbnail": { + "type": "string" + }, + "tips": { + "type": "string" + }, "title": { "type": "string" } @@ -11073,6 +11699,67 @@ const docTemplate = `{ } } }, + "handlers.updateModuleCapstoneReq": { + "type": "object", + "properties": { + "description": { + "type": "string" + }, + "display_order": { + "type": "integer" + }, + "is_active": { + "type": "boolean" + }, + "passing_score": { + "type": "integer" + }, + "questions": { + "type": "array", + "items": { + "$ref": "#/definitions/handlers.capstoneQuestionItem" + } + }, + "shuffle_questions": { + "type": "boolean" + }, + "status": { + "type": "string" + }, + "thumbnail": { + "type": "string" + }, + "time_limit_minutes": { + "type": "integer" + }, + "tips": { + "type": "string" + }, + "title": { + "type": "string" + } + } + }, + "handlers.updateModuleReq": { + "type": "object", + "properties": { + "description": { + "type": "string" + }, + "display_order": { + "type": "integer" + }, + "icon_url": { + "type": "string" + }, + "is_active": { + "type": "boolean" + }, + "title": { + "type": "string" + } + } + }, "handlers.updatePlanReq": { "type": "object", "properties": { @@ -11195,6 +11882,47 @@ const docTemplate = `{ } } }, + "handlers.updateSubModuleCapstoneReq": { + "type": "object", + "properties": { + "description": { + "type": "string" + }, + "display_order": { + "type": "integer" + }, + "is_active": { + "type": "boolean" + }, + "passing_score": { + "type": "integer" + }, + "questions": { + "type": "array", + "items": { + "$ref": "#/definitions/handlers.capstoneQuestionItem" + } + }, + "shuffle_questions": { + "type": "boolean" + }, + "status": { + "type": "string" + }, + "thumbnail": { + "type": "string" + }, + "time_limit_minutes": { + "type": "integer" + }, + "tips": { + "type": "string" + }, + "title": { + "type": "string" + } + } + }, "handlers.updateSubModuleLessonReq": { "type": "object", "properties": { diff --git a/docs/swagger.json b/docs/swagger.json index 0a0cb78..05f404c 100644 --- a/docs/swagger.json +++ b/docs/swagger.json @@ -705,6 +705,149 @@ } } }, + "/api/v1/course-management/capstones/{capstoneId}": { + "get": { + "description": "Returns one capstone with question-set fields and the ordered question list", + "produces": [ + "application/json" + ], + "tags": [ + "course-management" + ], + "summary": "Get capstone detail", + "parameters": [ + { + "type": "integer", + "description": "Capstone ID", + "name": "capstoneId", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/domain.Response" + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/domain.ErrorResponse" + } + }, + "404": { + "description": "Not Found", + "schema": { + "$ref": "#/definitions/domain.ErrorResponse" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/domain.ErrorResponse" + } + } + } + }, + "put": { + "description": "Updates capstone content, question-set assessment settings, and optionally replaces the question list", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "course-management" + ], + "summary": "Update capstone", + "parameters": [ + { + "type": "integer", + "description": "Capstone ID", + "name": "capstoneId", + "in": "path", + "required": true + }, + { + "description": "Update capstone payload", + "name": "body", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/handlers.updateSubModuleCapstoneReq" + } + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/domain.Response" + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/domain.ErrorResponse" + } + }, + "404": { + "description": "Not Found", + "schema": { + "$ref": "#/definitions/domain.ErrorResponse" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/domain.ErrorResponse" + } + } + } + }, + "delete": { + "description": "Deletes the capstone and its backing question set (and question items)", + "produces": [ + "application/json" + ], + "tags": [ + "course-management" + ], + "summary": "Delete capstone", + "parameters": [ + { + "type": "integer", + "description": "Capstone ID", + "name": "capstoneId", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/domain.Response" + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/domain.ErrorResponse" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/domain.ErrorResponse" + } + } + } + } + }, "/api/v1/course-management/categories": { "get": { "description": "Legacy-compatible endpoint for listing course categories", @@ -1560,6 +1703,195 @@ } } }, + "/api/v1/course-management/module-capstones": { + "post": { + "description": "Creates a module-level capstone with a new CAPSTONE question set and ordered questions", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "course-management" + ], + "summary": "Create module capstone", + "parameters": [ + { + "description": "Create module capstone payload", + "name": "body", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/handlers.createModuleCapstoneReq" + } + } + ], + "responses": { + "201": { + "description": "Created", + "schema": { + "$ref": "#/definitions/domain.Response" + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/domain.ErrorResponse" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/domain.ErrorResponse" + } + } + } + } + }, + "/api/v1/course-management/module-capstones/{moduleCapstoneId}": { + "get": { + "description": "Returns one module capstone with question-set fields and the ordered question list", + "produces": [ + "application/json" + ], + "tags": [ + "course-management" + ], + "summary": "Get module capstone detail", + "parameters": [ + { + "type": "integer", + "description": "Module capstone ID", + "name": "moduleCapstoneId", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/domain.Response" + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/domain.ErrorResponse" + } + }, + "404": { + "description": "Not Found", + "schema": { + "$ref": "#/definitions/domain.ErrorResponse" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/domain.ErrorResponse" + } + } + } + }, + "put": { + "description": "Updates module capstone content, question-set assessment settings, and optionally replaces the question list", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "course-management" + ], + "summary": "Update module capstone", + "parameters": [ + { + "type": "integer", + "description": "Module capstone ID", + "name": "moduleCapstoneId", + "in": "path", + "required": true + }, + { + "description": "Update module capstone payload", + "name": "body", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/handlers.updateModuleCapstoneReq" + } + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/domain.Response" + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/domain.ErrorResponse" + } + }, + "404": { + "description": "Not Found", + "schema": { + "$ref": "#/definitions/domain.ErrorResponse" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/domain.ErrorResponse" + } + } + } + }, + "delete": { + "description": "Deletes the module capstone and its backing question set", + "produces": [ + "application/json" + ], + "tags": [ + "course-management" + ], + "summary": "Delete module capstone", + "parameters": [ + { + "type": "integer", + "description": "Module capstone ID", + "name": "moduleCapstoneId", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/domain.Response" + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/domain.ErrorResponse" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/domain.ErrorResponse" + } + } + } + } + }, "/api/v1/course-management/modules": { "get": { "description": "Returns all modules with pagination", @@ -1600,7 +1932,7 @@ } }, "post": { - "description": "Creates a module under a level", + "description": "Creates a module under a level; optional icon_url stores a module icon image URL", "consumes": [ "application/json" ], @@ -1689,6 +2021,104 @@ } } } + }, + "put": { + "description": "Updates module title, description, icon URL, display order, and active flag", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "course-management" + ], + "summary": "Update module", + "parameters": [ + { + "type": "integer", + "description": "Module ID", + "name": "moduleId", + "in": "path", + "required": true + }, + { + "description": "Update module payload", + "name": "body", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/handlers.updateModuleReq" + } + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/domain.Response" + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/domain.ErrorResponse" + } + }, + "404": { + "description": "Not Found", + "schema": { + "$ref": "#/definitions/domain.ErrorResponse" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/domain.ErrorResponse" + } + } + } + } + }, + "/api/v1/course-management/modules/{moduleId}/capstones": { + "get": { + "description": "Returns active module capstones with question-set settings and question counts", + "produces": [ + "application/json" + ], + "tags": [ + "course-management" + ], + "summary": "List capstones under module", + "parameters": [ + { + "type": "integer", + "description": "Module ID", + "name": "moduleId", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/domain.Response" + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/domain.ErrorResponse" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/domain.ErrorResponse" + } + } + } } }, "/api/v1/course-management/modules/{moduleId}/sub-modules": { @@ -1919,6 +2349,52 @@ } } }, + "/api/v1/course-management/sub-module-capstones": { + "post": { + "description": "Creates a capstone assessment with a new CAPSTONE question set, metadata, and ordered questions", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "course-management" + ], + "summary": "Create capstone under sub-module", + "parameters": [ + { + "description": "Create capstone payload", + "name": "body", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/handlers.createSubModuleCapstoneReq" + } + } + ], + "responses": { + "201": { + "description": "Created", + "schema": { + "$ref": "#/definitions/domain.Response" + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/domain.ErrorResponse" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/domain.ErrorResponse" + } + } + } + } + }, "/api/v1/course-management/sub-module-lessons": { "post": { "description": "Creates a sub-module lesson with teaching content (text, image, audio, video URLs) and optional thumbnail", @@ -2204,7 +2680,7 @@ } }, "post": { - "description": "Creates a sub-module under a module", + "description": "Creates a sub-module under a module; optional thumbnail (image URL) and tips text", "consumes": [ "application/json" ], @@ -2295,6 +2771,47 @@ } } }, + "/api/v1/course-management/sub-modules/{subModuleId}/capstones": { + "get": { + "description": "Returns active capstones for a sub-module with question-set settings and question counts", + "produces": [ + "application/json" + ], + "tags": [ + "course-management" + ], + "summary": "List capstones under sub-module", + "parameters": [ + { + "type": "integer", + "description": "Sub-module ID", + "name": "subModuleId", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/domain.Response" + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/domain.ErrorResponse" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/domain.ErrorResponse" + } + } + } + } + }, "/api/v1/course-management/sub-modules/{subModuleId}/lessons": { "get": { "description": "Returns all active lessons for a sub-module (teaching content metadata)", @@ -4032,7 +4549,7 @@ "parameters": [ { "type": "string", - "description": "Set type (PRACTICE, INITIAL_ASSESSMENT, QUIZ, EXAM, SURVEY)", + "description": "Set type (PRACTICE, INITIAL_ASSESSMENT, QUIZ, EXAM, SURVEY, CAPSTONE)", "name": "set_type", "in": "query", "required": true @@ -10292,6 +10809,17 @@ } } }, + "handlers.capstoneQuestionItem": { + "type": "object", + "properties": { + "display_order": { + "type": "integer" + }, + "question_id": { + "type": "integer" + } + } + }, "handlers.changePasswordReq": { "type": "object", "required": [ @@ -10424,6 +10952,50 @@ } } }, + "handlers.createModuleCapstoneReq": { + "type": "object", + "properties": { + "description": { + "type": "string" + }, + "display_order": { + "type": "integer" + }, + "is_active": { + "type": "boolean" + }, + "module_id": { + "type": "integer" + }, + "passing_score": { + "type": "integer" + }, + "questions": { + "type": "array", + "items": { + "$ref": "#/definitions/handlers.capstoneQuestionItem" + } + }, + "shuffle_questions": { + "type": "boolean" + }, + "status": { + "type": "string" + }, + "thumbnail": { + "type": "string" + }, + "time_limit_minutes": { + "type": "integer" + }, + "tips": { + "type": "string" + }, + "title": { + "type": "string" + } + } + }, "handlers.createModuleReq": { "type": "object", "properties": { @@ -10433,6 +11005,9 @@ "display_order": { "type": "integer" }, + "icon_url": { + "type": "string" + }, "is_active": { "type": "boolean" }, @@ -10580,7 +11155,8 @@ "INITIAL_ASSESSMENT", "QUIZ", "EXAM", - "SURVEY" + "SURVEY", + "CAPSTONE" ] }, "shuffle_questions": { @@ -10600,6 +11176,50 @@ } } }, + "handlers.createSubModuleCapstoneReq": { + "type": "object", + "properties": { + "description": { + "type": "string" + }, + "display_order": { + "type": "integer" + }, + "is_active": { + "type": "boolean" + }, + "passing_score": { + "type": "integer" + }, + "questions": { + "type": "array", + "items": { + "$ref": "#/definitions/handlers.capstoneQuestionItem" + } + }, + "shuffle_questions": { + "type": "boolean" + }, + "status": { + "type": "string" + }, + "sub_module_id": { + "type": "integer" + }, + "thumbnail": { + "type": "string" + }, + "time_limit_minutes": { + "type": "integer" + }, + "tips": { + "type": "string" + }, + "title": { + "type": "string" + } + } + }, "handlers.createSubModuleLessonReq": { "type": "object", "properties": { @@ -10679,6 +11299,12 @@ "module_id": { "type": "integer" }, + "thumbnail": { + "type": "string" + }, + "tips": { + "type": "string" + }, "title": { "type": "string" } @@ -11065,6 +11691,67 @@ } } }, + "handlers.updateModuleCapstoneReq": { + "type": "object", + "properties": { + "description": { + "type": "string" + }, + "display_order": { + "type": "integer" + }, + "is_active": { + "type": "boolean" + }, + "passing_score": { + "type": "integer" + }, + "questions": { + "type": "array", + "items": { + "$ref": "#/definitions/handlers.capstoneQuestionItem" + } + }, + "shuffle_questions": { + "type": "boolean" + }, + "status": { + "type": "string" + }, + "thumbnail": { + "type": "string" + }, + "time_limit_minutes": { + "type": "integer" + }, + "tips": { + "type": "string" + }, + "title": { + "type": "string" + } + } + }, + "handlers.updateModuleReq": { + "type": "object", + "properties": { + "description": { + "type": "string" + }, + "display_order": { + "type": "integer" + }, + "icon_url": { + "type": "string" + }, + "is_active": { + "type": "boolean" + }, + "title": { + "type": "string" + } + } + }, "handlers.updatePlanReq": { "type": "object", "properties": { @@ -11187,6 +11874,47 @@ } } }, + "handlers.updateSubModuleCapstoneReq": { + "type": "object", + "properties": { + "description": { + "type": "string" + }, + "display_order": { + "type": "integer" + }, + "is_active": { + "type": "boolean" + }, + "passing_score": { + "type": "integer" + }, + "questions": { + "type": "array", + "items": { + "$ref": "#/definitions/handlers.capstoneQuestionItem" + } + }, + "shuffle_questions": { + "type": "boolean" + }, + "status": { + "type": "string" + }, + "thumbnail": { + "type": "string" + }, + "time_limit_minutes": { + "type": "integer" + }, + "tips": { + "type": "string" + }, + "title": { + "type": "string" + } + } + }, "handlers.updateSubModuleLessonReq": { "type": "object", "properties": { diff --git a/docs/swagger.yaml b/docs/swagger.yaml index 75a0547..0a25b12 100644 --- a/docs/swagger.yaml +++ b/docs/swagger.yaml @@ -916,6 +916,13 @@ definitions: auto_renew: type: boolean type: object + handlers.capstoneQuestionItem: + properties: + display_order: + type: integer + question_id: + type: integer + type: object handlers.changePasswordReq: properties: current_password: @@ -1004,12 +1011,43 @@ definitions: title: type: string type: object + handlers.createModuleCapstoneReq: + properties: + description: + type: string + display_order: + type: integer + is_active: + type: boolean + module_id: + type: integer + passing_score: + type: integer + questions: + items: + $ref: '#/definitions/handlers.capstoneQuestionItem' + type: array + shuffle_questions: + type: boolean + status: + type: string + thumbnail: + type: string + time_limit_minutes: + type: integer + tips: + type: string + title: + type: string + type: object handlers.createModuleReq: properties: description: type: string display_order: type: integer + icon_url: + type: string is_active: type: boolean level_id: @@ -1111,6 +1149,7 @@ definitions: - QUIZ - EXAM - SURVEY + - CAPSTONE type: string shuffle_questions: type: boolean @@ -1126,6 +1165,35 @@ definitions: - set_type - title type: object + handlers.createSubModuleCapstoneReq: + properties: + description: + type: string + display_order: + type: integer + is_active: + type: boolean + passing_score: + type: integer + questions: + items: + $ref: '#/definitions/handlers.capstoneQuestionItem' + type: array + shuffle_questions: + type: boolean + status: + type: string + sub_module_id: + type: integer + thumbnail: + type: string + time_limit_minutes: + type: integer + tips: + type: string + title: + type: string + type: object handlers.createSubModuleLessonReq: properties: description: @@ -1178,6 +1246,10 @@ definitions: type: boolean module_id: type: integer + thumbnail: + type: string + tips: + type: string title: type: string type: object @@ -1437,6 +1509,46 @@ definitions: title: type: string type: object + handlers.updateModuleCapstoneReq: + properties: + description: + type: string + display_order: + type: integer + is_active: + type: boolean + passing_score: + type: integer + questions: + items: + $ref: '#/definitions/handlers.capstoneQuestionItem' + type: array + shuffle_questions: + type: boolean + status: + type: string + thumbnail: + type: string + time_limit_minutes: + type: integer + tips: + type: string + title: + type: string + type: object + handlers.updateModuleReq: + properties: + description: + type: string + display_order: + type: integer + icon_url: + type: string + is_active: + type: boolean + title: + type: string + type: object handlers.updatePlanReq: properties: currency: @@ -1517,6 +1629,33 @@ definitions: title: type: string type: object + handlers.updateSubModuleCapstoneReq: + properties: + description: + type: string + display_order: + type: integer + is_active: + type: boolean + passing_score: + type: integer + questions: + items: + $ref: '#/definitions/handlers.capstoneQuestionItem' + type: array + shuffle_questions: + type: boolean + status: + type: string + thumbnail: + type: string + time_limit_minutes: + type: integer + tips: + type: string + title: + type: string + type: object handlers.updateSubModuleLessonReq: properties: description: @@ -2417,6 +2556,104 @@ paths: summary: Refresh token tags: - auth + /api/v1/course-management/capstones/{capstoneId}: + delete: + description: Deletes the capstone and its backing question set (and question + items) + parameters: + - description: Capstone ID + in: path + name: capstoneId + required: true + type: integer + produces: + - application/json + responses: + "200": + description: OK + schema: + $ref: '#/definitions/domain.Response' + "400": + description: Bad Request + schema: + $ref: '#/definitions/domain.ErrorResponse' + "500": + description: Internal Server Error + schema: + $ref: '#/definitions/domain.ErrorResponse' + summary: Delete capstone + tags: + - course-management + get: + description: Returns one capstone with question-set fields and the ordered question + list + parameters: + - description: Capstone ID + in: path + name: capstoneId + required: true + type: integer + produces: + - application/json + responses: + "200": + description: OK + schema: + $ref: '#/definitions/domain.Response' + "400": + description: Bad Request + schema: + $ref: '#/definitions/domain.ErrorResponse' + "404": + description: Not Found + schema: + $ref: '#/definitions/domain.ErrorResponse' + "500": + description: Internal Server Error + schema: + $ref: '#/definitions/domain.ErrorResponse' + summary: Get capstone detail + tags: + - course-management + put: + consumes: + - application/json + description: Updates capstone content, question-set assessment settings, and + optionally replaces the question list + parameters: + - description: Capstone ID + in: path + name: capstoneId + required: true + type: integer + - description: Update capstone payload + in: body + name: body + required: true + schema: + $ref: '#/definitions/handlers.updateSubModuleCapstoneReq' + produces: + - application/json + responses: + "200": + description: OK + schema: + $ref: '#/definitions/domain.Response' + "400": + description: Bad Request + schema: + $ref: '#/definitions/domain.ErrorResponse' + "404": + description: Not Found + schema: + $ref: '#/definitions/domain.ErrorResponse' + "500": + description: Internal Server Error + schema: + $ref: '#/definitions/domain.ErrorResponse' + summary: Update capstone + tags: + - course-management /api/v1/course-management/categories: get: description: Legacy-compatible endpoint for listing course categories @@ -2982,6 +3219,134 @@ paths: summary: List modules by level tags: - course-management + /api/v1/course-management/module-capstones: + post: + consumes: + - application/json + description: Creates a module-level capstone with a new CAPSTONE question set + and ordered questions + parameters: + - description: Create module capstone payload + in: body + name: body + required: true + schema: + $ref: '#/definitions/handlers.createModuleCapstoneReq' + produces: + - application/json + responses: + "201": + description: Created + schema: + $ref: '#/definitions/domain.Response' + "400": + description: Bad Request + schema: + $ref: '#/definitions/domain.ErrorResponse' + "500": + description: Internal Server Error + schema: + $ref: '#/definitions/domain.ErrorResponse' + summary: Create module capstone + tags: + - course-management + /api/v1/course-management/module-capstones/{moduleCapstoneId}: + delete: + description: Deletes the module capstone and its backing question set + parameters: + - description: Module capstone ID + in: path + name: moduleCapstoneId + required: true + type: integer + produces: + - application/json + responses: + "200": + description: OK + schema: + $ref: '#/definitions/domain.Response' + "400": + description: Bad Request + schema: + $ref: '#/definitions/domain.ErrorResponse' + "500": + description: Internal Server Error + schema: + $ref: '#/definitions/domain.ErrorResponse' + summary: Delete module capstone + tags: + - course-management + get: + description: Returns one module capstone with question-set fields and the ordered + question list + parameters: + - description: Module capstone ID + in: path + name: moduleCapstoneId + required: true + type: integer + produces: + - application/json + responses: + "200": + description: OK + schema: + $ref: '#/definitions/domain.Response' + "400": + description: Bad Request + schema: + $ref: '#/definitions/domain.ErrorResponse' + "404": + description: Not Found + schema: + $ref: '#/definitions/domain.ErrorResponse' + "500": + description: Internal Server Error + schema: + $ref: '#/definitions/domain.ErrorResponse' + summary: Get module capstone detail + tags: + - course-management + put: + consumes: + - application/json + description: Updates module capstone content, question-set assessment settings, + and optionally replaces the question list + parameters: + - description: Module capstone ID + in: path + name: moduleCapstoneId + required: true + type: integer + - description: Update module capstone payload + in: body + name: body + required: true + schema: + $ref: '#/definitions/handlers.updateModuleCapstoneReq' + produces: + - application/json + responses: + "200": + description: OK + schema: + $ref: '#/definitions/domain.Response' + "400": + description: Bad Request + schema: + $ref: '#/definitions/domain.ErrorResponse' + "404": + description: Not Found + schema: + $ref: '#/definitions/domain.ErrorResponse' + "500": + description: Internal Server Error + schema: + $ref: '#/definitions/domain.ErrorResponse' + summary: Update module capstone + tags: + - course-management /api/v1/course-management/modules: get: description: Returns all modules with pagination @@ -3011,7 +3376,8 @@ paths: post: consumes: - application/json - description: Creates a module under a level + description: Creates a module under a level; optional icon_url stores a module + icon image URL parameters: - description: Create module payload in: body @@ -3068,6 +3434,73 @@ paths: summary: Get module detail tags: - course-management + put: + consumes: + - application/json + description: Updates module title, description, icon URL, display order, and + active flag + parameters: + - description: Module ID + in: path + name: moduleId + required: true + type: integer + - description: Update module payload + in: body + name: body + required: true + schema: + $ref: '#/definitions/handlers.updateModuleReq' + produces: + - application/json + responses: + "200": + description: OK + schema: + $ref: '#/definitions/domain.Response' + "400": + description: Bad Request + schema: + $ref: '#/definitions/domain.ErrorResponse' + "404": + description: Not Found + schema: + $ref: '#/definitions/domain.ErrorResponse' + "500": + description: Internal Server Error + schema: + $ref: '#/definitions/domain.ErrorResponse' + summary: Update module + tags: + - course-management + /api/v1/course-management/modules/{moduleId}/capstones: + get: + description: Returns active module capstones with question-set settings and + question counts + parameters: + - description: Module ID + in: path + name: moduleId + required: true + type: integer + produces: + - application/json + responses: + "200": + description: OK + schema: + $ref: '#/definitions/domain.Response' + "400": + description: Bad Request + schema: + $ref: '#/definitions/domain.ErrorResponse' + "500": + description: Internal Server Error + schema: + $ref: '#/definitions/domain.ErrorResponse' + summary: List capstones under module + tags: + - course-management /api/v1/course-management/modules/{moduleId}/sub-modules: get: description: Returns all active sub-modules for one module @@ -3218,6 +3651,37 @@ paths: summary: List courses by sub-category tags: - course-management + /api/v1/course-management/sub-module-capstones: + post: + consumes: + - application/json + description: Creates a capstone assessment with a new CAPSTONE question set, + metadata, and ordered questions + parameters: + - description: Create capstone payload + in: body + name: body + required: true + schema: + $ref: '#/definitions/handlers.createSubModuleCapstoneReq' + produces: + - application/json + responses: + "201": + description: Created + schema: + $ref: '#/definitions/domain.Response' + "400": + description: Bad Request + schema: + $ref: '#/definitions/domain.ErrorResponse' + "500": + description: Internal Server Error + schema: + $ref: '#/definitions/domain.ErrorResponse' + summary: Create capstone under sub-module + tags: + - course-management /api/v1/course-management/sub-module-lessons: post: consumes: @@ -3411,7 +3875,8 @@ paths: post: consumes: - application/json - description: Creates a sub-module under a module + description: Creates a sub-module under a module; optional thumbnail (image + URL) and tips text parameters: - description: Create sub-module payload in: body @@ -3468,6 +3933,34 @@ paths: summary: Get sub-module detail tags: - course-management + /api/v1/course-management/sub-modules/{subModuleId}/capstones: + get: + description: Returns active capstones for a sub-module with question-set settings + and question counts + parameters: + - description: Sub-module ID + in: path + name: subModuleId + required: true + type: integer + produces: + - application/json + responses: + "200": + description: OK + schema: + $ref: '#/definitions/domain.Response' + "400": + description: Bad Request + schema: + $ref: '#/definitions/domain.ErrorResponse' + "500": + description: Internal Server Error + schema: + $ref: '#/definitions/domain.ErrorResponse' + summary: List capstones under sub-module + tags: + - course-management /api/v1/course-management/sub-modules/{subModuleId}/lessons: get: consumes: @@ -4597,7 +5090,7 @@ paths: get: description: Returns a paginated list of question sets filtered by type parameters: - - description: Set type (PRACTICE, INITIAL_ASSESSMENT, QUIZ, EXAM, SURVEY) + - description: Set type (PRACTICE, INITIAL_ASSESSMENT, QUIZ, EXAM, SURVEY, CAPSTONE) in: query name: set_type required: true diff --git a/gen/db/compat_course_management.go b/gen/db/compat_course_management.go index 3b1d8cb..ffa2cee 100644 --- a/gen/db/compat_course_management.go +++ b/gen/db/compat_course_management.go @@ -8,7 +8,7 @@ import ( func (q *Queries) GetSubModuleByIDCompat(ctx context.Context, id int64) (SubModule, error) { row := q.db.QueryRow(ctx, ` -SELECT id, module_id, title, description, display_order, is_active, created_at, legacy_sub_course_id +SELECT id, module_id, title, description, display_order, is_active, created_at, legacy_sub_course_id, thumbnail, tips FROM sub_modules WHERE id = $1 `, id) @@ -22,19 +22,24 @@ WHERE id = $1 &i.IsActive, &i.CreatedAt, &i.LegacySubCourseID, + &i.Thumbnail, + &i.Tips, ) return i, err } -func (q *Queries) UpdateSubModuleCompat(ctx context.Context, id int64, title string, description string, isActive bool) error { +func (q *Queries) UpdateSubModuleCompat(ctx context.Context, id int64, title string, description string, thumbnail string, tips string, displayOrder int32, isActive bool) error { _, err := q.db.Exec(ctx, ` UPDATE sub_modules SET title = $1, description = NULLIF($2, ''), - is_active = $3 -WHERE id = $4 -`, title, description, isActive, id) + thumbnail = NULLIF($3, ''), + tips = NULLIF($4, ''), + display_order = $5, + is_active = $6 +WHERE id = $7 +`, title, description, thumbnail, tips, displayOrder, isActive, id) return err } @@ -119,6 +124,24 @@ func (q *Queries) DeletePracticeCompat(ctx context.Context, id int64) error { return err } +// DeleteCapstoneCompat removes the backing question set (and cascades sub_module_capstones). +func (q *Queries) DeleteCapstoneCompat(ctx context.Context, capstoneID int64) error { + _, err := q.db.Exec(ctx, ` +DELETE FROM question_sets +WHERE id = (SELECT question_set_id FROM sub_module_capstones WHERE id = $1) +`, capstoneID) + return err +} + +// DeleteModuleCapstoneCompat removes the backing question set (and cascades module_capstones). +func (q *Queries) DeleteModuleCapstoneCompat(ctx context.Context, capstoneID int64) error { + _, err := q.db.Exec(ctx, ` +DELETE FROM question_sets +WHERE id = (SELECT question_set_id FROM module_capstones WHERE id = $1) +`, capstoneID) + return err +} + func (q *Queries) CreateCourseCompat( ctx context.Context, categoryID int64, diff --git a/gen/db/hierarchy.sql.go b/gen/db/hierarchy.sql.go index ac8a3c5..67b4de6 100644 --- a/gen/db/hierarchy.sql.go +++ b/gen/db/hierarchy.sql.go @@ -106,19 +106,21 @@ INSERT INTO modules ( level_id, title, description, + icon_url, display_order, is_active ) -VALUES ($1, $2, $3, COALESCE($4, 0), COALESCE($5, TRUE)) -RETURNING id, level_id, title, description, display_order, is_active, created_at +VALUES ($1, $2, $3, $4, COALESCE($5, 0), COALESCE($6, TRUE)) +RETURNING id, level_id, title, description, display_order, is_active, created_at, icon_url ` type CreateModuleParams struct { LevelID int64 `json:"level_id"` Title string `json:"title"` Description pgtype.Text `json:"description"` - Column4 interface{} `json:"column_4"` + IconUrl pgtype.Text `json:"icon_url"` Column5 interface{} `json:"column_5"` + Column6 interface{} `json:"column_6"` } func (q *Queries) CreateModule(ctx context.Context, arg CreateModuleParams) (Module, error) { @@ -126,8 +128,9 @@ func (q *Queries) CreateModule(ctx context.Context, arg CreateModuleParams) (Mod arg.LevelID, arg.Title, arg.Description, - arg.Column4, + arg.IconUrl, arg.Column5, + arg.Column6, ) var i Module err := row.Scan( @@ -138,6 +141,60 @@ func (q *Queries) CreateModule(ctx context.Context, arg CreateModuleParams) (Mod &i.DisplayOrder, &i.IsActive, &i.CreatedAt, + &i.IconUrl, + ) + return i, err +} + +const CreateModuleCapstone = `-- name: CreateModuleCapstone :one +INSERT INTO module_capstones ( + module_id, + title, + description, + tips, + thumbnail, + question_set_id, + display_order, + is_active +) +VALUES ($1, $2, $3, $4, $5, $6, COALESCE($7, 0), COALESCE($8, TRUE)) +RETURNING id, module_id, title, description, tips, thumbnail, question_set_id, display_order, is_active, created_at +` + +type CreateModuleCapstoneParams struct { + ModuleID int64 `json:"module_id"` + Title string `json:"title"` + Description pgtype.Text `json:"description"` + Tips pgtype.Text `json:"tips"` + Thumbnail pgtype.Text `json:"thumbnail"` + QuestionSetID int64 `json:"question_set_id"` + Column7 interface{} `json:"column_7"` + Column8 interface{} `json:"column_8"` +} + +func (q *Queries) CreateModuleCapstone(ctx context.Context, arg CreateModuleCapstoneParams) (ModuleCapstone, error) { + row := q.db.QueryRow(ctx, CreateModuleCapstone, + arg.ModuleID, + arg.Title, + arg.Description, + arg.Tips, + arg.Thumbnail, + arg.QuestionSetID, + arg.Column7, + arg.Column8, + ) + var i ModuleCapstone + err := row.Scan( + &i.ID, + &i.ModuleID, + &i.Title, + &i.Description, + &i.Tips, + &i.Thumbnail, + &i.QuestionSetID, + &i.DisplayOrder, + &i.IsActive, + &i.CreatedAt, ) return i, err } @@ -147,19 +204,23 @@ INSERT INTO sub_modules ( module_id, title, description, + thumbnail, + tips, display_order, is_active ) -VALUES ($1, $2, $3, COALESCE($4, 0), COALESCE($5, TRUE)) -RETURNING id, module_id, title, description, display_order, is_active, created_at, legacy_sub_course_id +VALUES ($1, $2, $3, $4, $5, COALESCE($6, 0), COALESCE($7, TRUE)) +RETURNING id, module_id, title, description, display_order, is_active, created_at, legacy_sub_course_id, thumbnail, tips ` type CreateSubModuleParams struct { ModuleID int64 `json:"module_id"` Title string `json:"title"` Description pgtype.Text `json:"description"` - Column4 interface{} `json:"column_4"` - Column5 interface{} `json:"column_5"` + Thumbnail pgtype.Text `json:"thumbnail"` + Tips pgtype.Text `json:"tips"` + Column6 interface{} `json:"column_6"` + Column7 interface{} `json:"column_7"` } func (q *Queries) CreateSubModule(ctx context.Context, arg CreateSubModuleParams) (SubModule, error) { @@ -167,8 +228,10 @@ func (q *Queries) CreateSubModule(ctx context.Context, arg CreateSubModuleParams arg.ModuleID, arg.Title, arg.Description, - arg.Column4, - arg.Column5, + arg.Thumbnail, + arg.Tips, + arg.Column6, + arg.Column7, ) var i SubModule err := row.Scan( @@ -180,6 +243,61 @@ func (q *Queries) CreateSubModule(ctx context.Context, arg CreateSubModuleParams &i.IsActive, &i.CreatedAt, &i.LegacySubCourseID, + &i.Thumbnail, + &i.Tips, + ) + return i, err +} + +const CreateSubModuleCapstone = `-- name: CreateSubModuleCapstone :one +INSERT INTO sub_module_capstones ( + sub_module_id, + title, + description, + tips, + thumbnail, + question_set_id, + display_order, + is_active +) +VALUES ($1, $2, $3, $4, $5, $6, COALESCE($7, 0), COALESCE($8, TRUE)) +RETURNING id, sub_module_id, title, description, tips, thumbnail, question_set_id, display_order, is_active, created_at +` + +type CreateSubModuleCapstoneParams struct { + SubModuleID int64 `json:"sub_module_id"` + Title string `json:"title"` + Description pgtype.Text `json:"description"` + Tips pgtype.Text `json:"tips"` + Thumbnail pgtype.Text `json:"thumbnail"` + QuestionSetID int64 `json:"question_set_id"` + Column7 interface{} `json:"column_7"` + Column8 interface{} `json:"column_8"` +} + +func (q *Queries) CreateSubModuleCapstone(ctx context.Context, arg CreateSubModuleCapstoneParams) (SubModuleCapstone, error) { + row := q.db.QueryRow(ctx, CreateSubModuleCapstone, + arg.SubModuleID, + arg.Title, + arg.Description, + arg.Tips, + arg.Thumbnail, + arg.QuestionSetID, + arg.Column7, + arg.Column8, + ) + var i SubModuleCapstone + err := row.Scan( + &i.ID, + &i.SubModuleID, + &i.Title, + &i.Description, + &i.Tips, + &i.Thumbnail, + &i.QuestionSetID, + &i.DisplayOrder, + &i.IsActive, + &i.CreatedAt, ) return i, err } @@ -469,7 +587,7 @@ func (q *Queries) GetAllLevels(ctx context.Context, arg GetAllLevelsParams) ([]G const GetAllModules = `-- name: GetAllModules :many SELECT COUNT(*) OVER () AS total_count, - m.id, m.level_id, m.title, m.description, m.display_order, m.is_active, m.created_at + m.id, m.level_id, m.title, m.description, m.display_order, m.is_active, m.created_at, m.icon_url FROM modules m ORDER BY m.display_order ASC, m.id ASC LIMIT $2::INT @@ -490,6 +608,7 @@ type GetAllModulesRow struct { DisplayOrder int32 `json:"display_order"` IsActive bool `json:"is_active"` CreatedAt pgtype.Timestamptz `json:"created_at"` + IconUrl pgtype.Text `json:"icon_url"` } func (q *Queries) GetAllModules(ctx context.Context, arg GetAllModulesParams) ([]GetAllModulesRow, error) { @@ -510,6 +629,7 @@ func (q *Queries) GetAllModules(ctx context.Context, arg GetAllModulesParams) ([ &i.DisplayOrder, &i.IsActive, &i.CreatedAt, + &i.IconUrl, ); err != nil { return nil, err } @@ -524,7 +644,7 @@ func (q *Queries) GetAllModules(ctx context.Context, arg GetAllModulesParams) ([ const GetAllSubModules = `-- name: GetAllSubModules :many SELECT COUNT(*) OVER () AS total_count, - sm.id, sm.module_id, sm.title, sm.description, sm.display_order, sm.is_active, sm.created_at, sm.legacy_sub_course_id + sm.id, sm.module_id, sm.title, sm.description, sm.display_order, sm.is_active, sm.created_at, sm.legacy_sub_course_id, sm.thumbnail, sm.tips FROM sub_modules sm ORDER BY sm.display_order ASC, sm.id ASC LIMIT $2::INT @@ -546,6 +666,8 @@ type GetAllSubModulesRow struct { IsActive bool `json:"is_active"` CreatedAt pgtype.Timestamptz `json:"created_at"` LegacySubCourseID pgtype.Int8 `json:"legacy_sub_course_id"` + Thumbnail pgtype.Text `json:"thumbnail"` + Tips pgtype.Text `json:"tips"` } func (q *Queries) GetAllSubModules(ctx context.Context, arg GetAllSubModulesParams) ([]GetAllSubModulesRow, error) { @@ -567,6 +689,8 @@ func (q *Queries) GetAllSubModules(ctx context.Context, arg GetAllSubModulesPara &i.IsActive, &i.CreatedAt, &i.LegacySubCourseID, + &i.Thumbnail, + &i.Tips, ); err != nil { return nil, err } @@ -706,8 +830,13 @@ SELECT l.thumbnail AS level_thumbnail, m.id AS module_id, m.title AS module_title, + m.icon_url AS module_icon_url, sm.id AS sub_module_id, - sm.title AS sub_module_title + sm.title AS sub_module_title, + sm.description AS sub_module_description, + sm.thumbnail AS sub_module_thumbnail, + sm.tips AS sub_module_tips, + sm.display_order AS sub_module_display_order FROM courses c LEFT JOIN levels l ON l.course_id = c.id AND l.is_active = TRUE LEFT JOIN modules m ON m.level_id = l.id AND m.is_active = TRUE @@ -717,17 +846,22 @@ ORDER BY l.display_order, l.id, m.display_order, m.id, sm.display_order, sm.id ` type GetFullHierarchyByCourseIDRow struct { - CourseID int64 `json:"course_id"` - CourseTitle string `json:"course_title"` - LevelID pgtype.Int8 `json:"level_id"` - CefrLevel pgtype.Text `json:"cefr_level"` - LevelTitle pgtype.Text `json:"level_title"` - LevelDescription pgtype.Text `json:"level_description"` - LevelThumbnail pgtype.Text `json:"level_thumbnail"` - ModuleID pgtype.Int8 `json:"module_id"` - ModuleTitle pgtype.Text `json:"module_title"` - SubModuleID pgtype.Int8 `json:"sub_module_id"` - SubModuleTitle pgtype.Text `json:"sub_module_title"` + CourseID int64 `json:"course_id"` + CourseTitle string `json:"course_title"` + LevelID pgtype.Int8 `json:"level_id"` + CefrLevel pgtype.Text `json:"cefr_level"` + LevelTitle pgtype.Text `json:"level_title"` + LevelDescription pgtype.Text `json:"level_description"` + LevelThumbnail pgtype.Text `json:"level_thumbnail"` + ModuleID pgtype.Int8 `json:"module_id"` + ModuleTitle pgtype.Text `json:"module_title"` + ModuleIconUrl pgtype.Text `json:"module_icon_url"` + SubModuleID pgtype.Int8 `json:"sub_module_id"` + SubModuleTitle pgtype.Text `json:"sub_module_title"` + SubModuleDescription pgtype.Text `json:"sub_module_description"` + SubModuleThumbnail pgtype.Text `json:"sub_module_thumbnail"` + SubModuleTips pgtype.Text `json:"sub_module_tips"` + SubModuleDisplayOrder pgtype.Int4 `json:"sub_module_display_order"` } func (q *Queries) GetFullHierarchyByCourseID(ctx context.Context, id int64) ([]GetFullHierarchyByCourseIDRow, error) { @@ -749,8 +883,13 @@ func (q *Queries) GetFullHierarchyByCourseID(ctx context.Context, id int64) ([]G &i.LevelThumbnail, &i.ModuleID, &i.ModuleTitle, + &i.ModuleIconUrl, &i.SubModuleID, &i.SubModuleTitle, + &i.SubModuleDescription, + &i.SubModuleThumbnail, + &i.SubModuleTips, + &i.SubModuleDisplayOrder, ); err != nil { return nil, err } @@ -892,7 +1031,7 @@ func (q *Queries) GetLevelsByCourseID(ctx context.Context, courseID int64) ([]Le } const GetModuleByID = `-- name: GetModuleByID :one -SELECT id, level_id, title, description, display_order, is_active, created_at +SELECT id, level_id, title, description, display_order, is_active, created_at, icon_url FROM modules WHERE id = $1 ` @@ -908,12 +1047,157 @@ func (q *Queries) GetModuleByID(ctx context.Context, id int64) (Module, error) { &i.DisplayOrder, &i.IsActive, &i.CreatedAt, + &i.IconUrl, ) return i, err } +const GetModuleCapstoneByID = `-- name: GetModuleCapstoneByID :one +SELECT + mc.id, + mc.module_id, + mc.title, + mc.description, + mc.tips, + mc.thumbnail, + mc.question_set_id, + mc.display_order, + mc.is_active, + qs.status, + qs.set_type, + qs.time_limit_minutes, + qs.passing_score, + qs.shuffle_questions, + (SELECT COUNT(*) FROM question_set_items qsi WHERE qsi.set_id = qs.id) AS question_count +FROM module_capstones mc +JOIN question_sets qs ON qs.id = mc.question_set_id +WHERE mc.id = $1 + AND mc.is_active = TRUE + AND qs.set_type = 'CAPSTONE' +` + +type GetModuleCapstoneByIDRow struct { + ID int64 `json:"id"` + ModuleID int64 `json:"module_id"` + Title string `json:"title"` + Description pgtype.Text `json:"description"` + Tips pgtype.Text `json:"tips"` + Thumbnail pgtype.Text `json:"thumbnail"` + QuestionSetID int64 `json:"question_set_id"` + DisplayOrder int32 `json:"display_order"` + IsActive bool `json:"is_active"` + Status string `json:"status"` + SetType string `json:"set_type"` + TimeLimitMinutes pgtype.Int4 `json:"time_limit_minutes"` + PassingScore pgtype.Int4 `json:"passing_score"` + ShuffleQuestions bool `json:"shuffle_questions"` + QuestionCount int64 `json:"question_count"` +} + +func (q *Queries) GetModuleCapstoneByID(ctx context.Context, id int64) (GetModuleCapstoneByIDRow, error) { + row := q.db.QueryRow(ctx, GetModuleCapstoneByID, id) + var i GetModuleCapstoneByIDRow + err := row.Scan( + &i.ID, + &i.ModuleID, + &i.Title, + &i.Description, + &i.Tips, + &i.Thumbnail, + &i.QuestionSetID, + &i.DisplayOrder, + &i.IsActive, + &i.Status, + &i.SetType, + &i.TimeLimitMinutes, + &i.PassingScore, + &i.ShuffleQuestions, + &i.QuestionCount, + ) + return i, err +} + +const GetModuleCapstones = `-- name: GetModuleCapstones :many +SELECT + mc.id, + mc.module_id, + mc.title, + mc.description, + mc.tips, + mc.thumbnail, + mc.question_set_id, + mc.display_order, + mc.is_active, + qs.status, + qs.set_type, + qs.time_limit_minutes, + qs.passing_score, + qs.shuffle_questions, + (SELECT COUNT(*) FROM question_set_items qsi WHERE qsi.set_id = qs.id) AS question_count +FROM module_capstones mc +JOIN question_sets qs ON qs.id = mc.question_set_id +WHERE mc.module_id = $1 + AND mc.is_active = TRUE + AND qs.set_type = 'CAPSTONE' +ORDER BY mc.display_order ASC, mc.id ASC +` + +type GetModuleCapstonesRow struct { + ID int64 `json:"id"` + ModuleID int64 `json:"module_id"` + Title string `json:"title"` + Description pgtype.Text `json:"description"` + Tips pgtype.Text `json:"tips"` + Thumbnail pgtype.Text `json:"thumbnail"` + QuestionSetID int64 `json:"question_set_id"` + DisplayOrder int32 `json:"display_order"` + IsActive bool `json:"is_active"` + Status string `json:"status"` + SetType string `json:"set_type"` + TimeLimitMinutes pgtype.Int4 `json:"time_limit_minutes"` + PassingScore pgtype.Int4 `json:"passing_score"` + ShuffleQuestions bool `json:"shuffle_questions"` + QuestionCount int64 `json:"question_count"` +} + +func (q *Queries) GetModuleCapstones(ctx context.Context, moduleID int64) ([]GetModuleCapstonesRow, error) { + rows, err := q.db.Query(ctx, GetModuleCapstones, moduleID) + if err != nil { + return nil, err + } + defer rows.Close() + var items []GetModuleCapstonesRow + for rows.Next() { + var i GetModuleCapstonesRow + if err := rows.Scan( + &i.ID, + &i.ModuleID, + &i.Title, + &i.Description, + &i.Tips, + &i.Thumbnail, + &i.QuestionSetID, + &i.DisplayOrder, + &i.IsActive, + &i.Status, + &i.SetType, + &i.TimeLimitMinutes, + &i.PassingScore, + &i.ShuffleQuestions, + &i.QuestionCount, + ); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + const GetModulesByLevelID = `-- name: GetModulesByLevelID :many -SELECT id, level_id, title, description, display_order, is_active, created_at +SELECT id, level_id, title, description, display_order, is_active, created_at, icon_url FROM modules WHERE level_id = $1 AND is_active = TRUE @@ -937,6 +1221,7 @@ func (q *Queries) GetModulesByLevelID(ctx context.Context, levelID int64) ([]Mod &i.DisplayOrder, &i.IsActive, &i.CreatedAt, + &i.IconUrl, ); err != nil { return nil, err } @@ -949,7 +1234,7 @@ func (q *Queries) GetModulesByLevelID(ctx context.Context, levelID int64) ([]Mod } const GetSubModuleByID = `-- name: GetSubModuleByID :one -SELECT id, module_id, title, description, display_order, is_active, created_at, legacy_sub_course_id +SELECT id, module_id, title, description, display_order, is_active, created_at, legacy_sub_course_id, thumbnail, tips FROM sub_modules WHERE id = $1 ` @@ -966,10 +1251,156 @@ func (q *Queries) GetSubModuleByID(ctx context.Context, id int64) (SubModule, er &i.IsActive, &i.CreatedAt, &i.LegacySubCourseID, + &i.Thumbnail, + &i.Tips, ) return i, err } +const GetSubModuleCapstoneByID = `-- name: GetSubModuleCapstoneByID :one +SELECT + smc.id, + smc.sub_module_id, + smc.title, + smc.description, + smc.tips, + smc.thumbnail, + smc.question_set_id, + smc.display_order, + smc.is_active, + qs.status, + qs.set_type, + qs.time_limit_minutes, + qs.passing_score, + qs.shuffle_questions, + (SELECT COUNT(*) FROM question_set_items qsi WHERE qsi.set_id = qs.id) AS question_count +FROM sub_module_capstones smc +JOIN question_sets qs ON qs.id = smc.question_set_id +WHERE smc.id = $1 + AND smc.is_active = TRUE + AND qs.set_type = 'CAPSTONE' +` + +type GetSubModuleCapstoneByIDRow struct { + ID int64 `json:"id"` + SubModuleID int64 `json:"sub_module_id"` + Title string `json:"title"` + Description pgtype.Text `json:"description"` + Tips pgtype.Text `json:"tips"` + Thumbnail pgtype.Text `json:"thumbnail"` + QuestionSetID int64 `json:"question_set_id"` + DisplayOrder int32 `json:"display_order"` + IsActive bool `json:"is_active"` + Status string `json:"status"` + SetType string `json:"set_type"` + TimeLimitMinutes pgtype.Int4 `json:"time_limit_minutes"` + PassingScore pgtype.Int4 `json:"passing_score"` + ShuffleQuestions bool `json:"shuffle_questions"` + QuestionCount int64 `json:"question_count"` +} + +func (q *Queries) GetSubModuleCapstoneByID(ctx context.Context, id int64) (GetSubModuleCapstoneByIDRow, error) { + row := q.db.QueryRow(ctx, GetSubModuleCapstoneByID, id) + var i GetSubModuleCapstoneByIDRow + err := row.Scan( + &i.ID, + &i.SubModuleID, + &i.Title, + &i.Description, + &i.Tips, + &i.Thumbnail, + &i.QuestionSetID, + &i.DisplayOrder, + &i.IsActive, + &i.Status, + &i.SetType, + &i.TimeLimitMinutes, + &i.PassingScore, + &i.ShuffleQuestions, + &i.QuestionCount, + ) + return i, err +} + +const GetSubModuleCapstones = `-- name: GetSubModuleCapstones :many +SELECT + smc.id, + smc.sub_module_id, + smc.title, + smc.description, + smc.tips, + smc.thumbnail, + smc.question_set_id, + smc.display_order, + smc.is_active, + qs.status, + qs.set_type, + qs.time_limit_minutes, + qs.passing_score, + qs.shuffle_questions, + (SELECT COUNT(*) FROM question_set_items qsi WHERE qsi.set_id = qs.id) AS question_count +FROM sub_module_capstones smc +JOIN question_sets qs ON qs.id = smc.question_set_id +WHERE smc.sub_module_id = $1 + AND smc.is_active = TRUE + AND qs.set_type = 'CAPSTONE' +ORDER BY smc.display_order ASC, smc.id ASC +` + +type GetSubModuleCapstonesRow struct { + ID int64 `json:"id"` + SubModuleID int64 `json:"sub_module_id"` + Title string `json:"title"` + Description pgtype.Text `json:"description"` + Tips pgtype.Text `json:"tips"` + Thumbnail pgtype.Text `json:"thumbnail"` + QuestionSetID int64 `json:"question_set_id"` + DisplayOrder int32 `json:"display_order"` + IsActive bool `json:"is_active"` + Status string `json:"status"` + SetType string `json:"set_type"` + TimeLimitMinutes pgtype.Int4 `json:"time_limit_minutes"` + PassingScore pgtype.Int4 `json:"passing_score"` + ShuffleQuestions bool `json:"shuffle_questions"` + QuestionCount int64 `json:"question_count"` +} + +func (q *Queries) GetSubModuleCapstones(ctx context.Context, subModuleID int64) ([]GetSubModuleCapstonesRow, error) { + rows, err := q.db.Query(ctx, GetSubModuleCapstones, subModuleID) + if err != nil { + return nil, err + } + defer rows.Close() + var items []GetSubModuleCapstonesRow + for rows.Next() { + var i GetSubModuleCapstonesRow + if err := rows.Scan( + &i.ID, + &i.SubModuleID, + &i.Title, + &i.Description, + &i.Tips, + &i.Thumbnail, + &i.QuestionSetID, + &i.DisplayOrder, + &i.IsActive, + &i.Status, + &i.SetType, + &i.TimeLimitMinutes, + &i.PassingScore, + &i.ShuffleQuestions, + &i.QuestionCount, + ); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + const GetSubModuleLessonByID = `-- name: GetSubModuleLessonByID :one SELECT id, sub_module_id, display_order, is_active, created_at, title, description, thumbnail, teaching_text, teaching_image_url, teaching_audio_url, teaching_video_url FROM sub_module_lessons @@ -1214,7 +1645,7 @@ func (q *Queries) GetSubModuleVideos(ctx context.Context, subModuleID int64) ([] } const GetSubModulesByModuleID = `-- name: GetSubModulesByModuleID :many -SELECT id, module_id, title, description, display_order, is_active, created_at, legacy_sub_course_id +SELECT id, module_id, title, description, display_order, is_active, created_at, legacy_sub_course_id, thumbnail, tips FROM sub_modules WHERE module_id = $1 AND is_active = TRUE @@ -1239,6 +1670,8 @@ func (q *Queries) GetSubModulesByModuleID(ctx context.Context, moduleID int64) ( &i.IsActive, &i.CreatedAt, &i.LegacySubCourseID, + &i.Thumbnail, + &i.Tips, ); err != nil { return nil, err } @@ -1295,6 +1728,197 @@ func (q *Queries) UpdateLevel(ctx context.Context, arg UpdateLevelParams) (Level return i, err } +const UpdateModule = `-- name: UpdateModule :one +UPDATE modules +SET + title = $1, + description = $2, + icon_url = $3, + display_order = $4, + is_active = $5 +WHERE id = $6 +RETURNING id, level_id, title, description, display_order, is_active, created_at, icon_url +` + +type UpdateModuleParams struct { + Title string `json:"title"` + Description pgtype.Text `json:"description"` + IconUrl pgtype.Text `json:"icon_url"` + DisplayOrder int32 `json:"display_order"` + IsActive bool `json:"is_active"` + ID int64 `json:"id"` +} + +func (q *Queries) UpdateModule(ctx context.Context, arg UpdateModuleParams) (Module, error) { + row := q.db.QueryRow(ctx, UpdateModule, + arg.Title, + arg.Description, + arg.IconUrl, + arg.DisplayOrder, + arg.IsActive, + arg.ID, + ) + var i Module + err := row.Scan( + &i.ID, + &i.LevelID, + &i.Title, + &i.Description, + &i.DisplayOrder, + &i.IsActive, + &i.CreatedAt, + &i.IconUrl, + ) + return i, err +} + +const UpdateModuleCapstone = `-- name: UpdateModuleCapstone :one +UPDATE module_capstones +SET + title = $1, + description = $2, + tips = $3, + thumbnail = $4, + display_order = $5, + is_active = $6 +WHERE id = $7 +RETURNING id, module_id, title, description, tips, thumbnail, question_set_id, display_order, is_active, created_at +` + +type UpdateModuleCapstoneParams struct { + Title string `json:"title"` + Description pgtype.Text `json:"description"` + Tips pgtype.Text `json:"tips"` + Thumbnail pgtype.Text `json:"thumbnail"` + DisplayOrder int32 `json:"display_order"` + IsActive bool `json:"is_active"` + ID int64 `json:"id"` +} + +func (q *Queries) UpdateModuleCapstone(ctx context.Context, arg UpdateModuleCapstoneParams) (ModuleCapstone, error) { + row := q.db.QueryRow(ctx, UpdateModuleCapstone, + arg.Title, + arg.Description, + arg.Tips, + arg.Thumbnail, + arg.DisplayOrder, + arg.IsActive, + arg.ID, + ) + var i ModuleCapstone + err := row.Scan( + &i.ID, + &i.ModuleID, + &i.Title, + &i.Description, + &i.Tips, + &i.Thumbnail, + &i.QuestionSetID, + &i.DisplayOrder, + &i.IsActive, + &i.CreatedAt, + ) + return i, err +} + +const UpdateSubModule = `-- name: UpdateSubModule :one +UPDATE sub_modules +SET + title = $1, + description = $2, + thumbnail = $3, + tips = $4, + display_order = $5, + is_active = $6 +WHERE id = $7 +RETURNING id, module_id, title, description, display_order, is_active, created_at, legacy_sub_course_id, thumbnail, tips +` + +type UpdateSubModuleParams struct { + Title string `json:"title"` + Description pgtype.Text `json:"description"` + Thumbnail pgtype.Text `json:"thumbnail"` + Tips pgtype.Text `json:"tips"` + DisplayOrder int32 `json:"display_order"` + IsActive bool `json:"is_active"` + ID int64 `json:"id"` +} + +func (q *Queries) UpdateSubModule(ctx context.Context, arg UpdateSubModuleParams) (SubModule, error) { + row := q.db.QueryRow(ctx, UpdateSubModule, + arg.Title, + arg.Description, + arg.Thumbnail, + arg.Tips, + arg.DisplayOrder, + arg.IsActive, + arg.ID, + ) + var i SubModule + err := row.Scan( + &i.ID, + &i.ModuleID, + &i.Title, + &i.Description, + &i.DisplayOrder, + &i.IsActive, + &i.CreatedAt, + &i.LegacySubCourseID, + &i.Thumbnail, + &i.Tips, + ) + return i, err +} + +const UpdateSubModuleCapstone = `-- name: UpdateSubModuleCapstone :one +UPDATE sub_module_capstones +SET + title = $1, + description = $2, + tips = $3, + thumbnail = $4, + display_order = $5, + is_active = $6 +WHERE id = $7 +RETURNING id, sub_module_id, title, description, tips, thumbnail, question_set_id, display_order, is_active, created_at +` + +type UpdateSubModuleCapstoneParams struct { + Title string `json:"title"` + Description pgtype.Text `json:"description"` + Tips pgtype.Text `json:"tips"` + Thumbnail pgtype.Text `json:"thumbnail"` + DisplayOrder int32 `json:"display_order"` + IsActive bool `json:"is_active"` + ID int64 `json:"id"` +} + +func (q *Queries) UpdateSubModuleCapstone(ctx context.Context, arg UpdateSubModuleCapstoneParams) (SubModuleCapstone, error) { + row := q.db.QueryRow(ctx, UpdateSubModuleCapstone, + arg.Title, + arg.Description, + arg.Tips, + arg.Thumbnail, + arg.DisplayOrder, + arg.IsActive, + arg.ID, + ) + var i SubModuleCapstone + err := row.Scan( + &i.ID, + &i.SubModuleID, + &i.Title, + &i.Description, + &i.Tips, + &i.Thumbnail, + &i.QuestionSetID, + &i.DisplayOrder, + &i.IsActive, + &i.CreatedAt, + ) + return i, err +} + const UpdateSubModuleLesson = `-- name: UpdateSubModuleLesson :one UPDATE sub_module_lessons SET diff --git a/gen/db/models.go b/gen/db/models.go index cfcac33..ff48e2c 100644 --- a/gen/db/models.go +++ b/gen/db/models.go @@ -94,6 +94,20 @@ type Module struct { DisplayOrder int32 `json:"display_order"` IsActive bool `json:"is_active"` CreatedAt pgtype.Timestamptz `json:"created_at"` + IconUrl pgtype.Text `json:"icon_url"` +} + +type ModuleCapstone struct { + ID int64 `json:"id"` + ModuleID int64 `json:"module_id"` + Title string `json:"title"` + Description pgtype.Text `json:"description"` + Tips pgtype.Text `json:"tips"` + Thumbnail pgtype.Text `json:"thumbnail"` + QuestionSetID int64 `json:"question_set_id"` + DisplayOrder int32 `json:"display_order"` + IsActive bool `json:"is_active"` + CreatedAt pgtype.Timestamptz `json:"created_at"` } type ModuleToSubCourse struct { @@ -356,6 +370,21 @@ type SubModule struct { IsActive bool `json:"is_active"` CreatedAt pgtype.Timestamptz `json:"created_at"` LegacySubCourseID pgtype.Int8 `json:"legacy_sub_course_id"` + Thumbnail pgtype.Text `json:"thumbnail"` + Tips pgtype.Text `json:"tips"` +} + +type SubModuleCapstone struct { + ID int64 `json:"id"` + SubModuleID int64 `json:"sub_module_id"` + Title string `json:"title"` + Description pgtype.Text `json:"description"` + Tips pgtype.Text `json:"tips"` + Thumbnail pgtype.Text `json:"thumbnail"` + QuestionSetID int64 `json:"question_set_id"` + DisplayOrder int32 `json:"display_order"` + IsActive bool `json:"is_active"` + CreatedAt pgtype.Timestamptz `json:"created_at"` } type SubModuleLesson struct { diff --git a/internal/domain/course_management.go b/internal/domain/course_management.go index 91bad83..f94c24f 100644 --- a/internal/domain/course_management.go +++ b/internal/domain/course_management.go @@ -146,6 +146,7 @@ type LearningPathSubCourse struct { Title string `json:"title"` Description *string `json:"description,omitempty"` Thumbnail *string `json:"thumbnail,omitempty"` + Tips *string `json:"tips,omitempty"` DisplayOrder int32 `json:"display_order"` Level string `json:"level"` SubLevel string `json:"sub_level"` diff --git a/internal/domain/questions.go b/internal/domain/questions.go index 3e4670a..13bcf3b 100644 --- a/internal/domain/questions.go +++ b/internal/domain/questions.go @@ -27,6 +27,7 @@ const ( QuestionSetTypeQuiz QuestionSetType = "QUIZ" QuestionSetTypeExam QuestionSetType = "EXAM" QuestionSetTypeSurvey QuestionSetType = "SURVEY" + QuestionSetTypeCapstone QuestionSetType = "CAPSTONE" ) type PracticeAccessBlock struct { diff --git a/internal/web_server/handlers/hierarchy_handler.go b/internal/web_server/handlers/hierarchy_handler.go index 49dfefb..5f23866 100644 --- a/internal/web_server/handlers/hierarchy_handler.go +++ b/internal/web_server/handlers/hierarchy_handler.go @@ -67,6 +67,15 @@ type createModuleReq struct { LevelID int64 `json:"level_id"` Title string `json:"title"` Description *string `json:"description"` + IconURL *string `json:"icon_url"` + DisplayOrder *int32 `json:"display_order"` + IsActive *bool `json:"is_active"` +} + +type updateModuleReq struct { + Title *string `json:"title"` + Description *string `json:"description"` + IconURL *string `json:"icon_url"` DisplayOrder *int32 `json:"display_order"` IsActive *bool `json:"is_active"` } @@ -75,6 +84,8 @@ type createSubModuleReq struct { ModuleID int64 `json:"module_id"` Title string `json:"title"` Description *string `json:"description"` + Thumbnail *string `json:"thumbnail"` + Tips *string `json:"tips"` DisplayOrder *int32 `json:"display_order"` IsActive *bool `json:"is_active"` } @@ -94,9 +105,12 @@ type createSubModuleVideoReq struct { } type updateSubModuleReq struct { - Title *string `json:"title"` - Description *string `json:"description"` - IsActive *bool `json:"is_active"` + Title *string `json:"title"` + Description *string `json:"description"` + Thumbnail *string `json:"thumbnail"` + Tips *string `json:"tips"` + DisplayOrder *int32 `json:"display_order"` + IsActive *bool `json:"is_active"` } type updateSubModuleVideoReq struct { @@ -149,6 +163,69 @@ type createSubModulePracticeReq struct { IsActive *bool `json:"is_active"` } +type capstoneQuestionItem struct { + QuestionID int64 `json:"question_id"` + DisplayOrder *int32 `json:"display_order"` +} + +type createSubModuleCapstoneReq struct { + SubModuleID int64 `json:"sub_module_id"` + Title string `json:"title"` + Description *string `json:"description"` + Tips *string `json:"tips"` + Thumbnail *string `json:"thumbnail"` + TimeLimitMinutes *int32 `json:"time_limit_minutes"` + PassingScore *int32 `json:"passing_score"` + ShuffleQuestions *bool `json:"shuffle_questions"` + Status *string `json:"status"` + DisplayOrder *int32 `json:"display_order"` + IsActive *bool `json:"is_active"` + Questions []capstoneQuestionItem `json:"questions"` +} + +type updateSubModuleCapstoneReq struct { + Title *string `json:"title"` + Description *string `json:"description"` + Tips *string `json:"tips"` + Thumbnail *string `json:"thumbnail"` + TimeLimitMinutes *int32 `json:"time_limit_minutes"` + PassingScore *int32 `json:"passing_score"` + ShuffleQuestions *bool `json:"shuffle_questions"` + Status *string `json:"status"` + DisplayOrder *int32 `json:"display_order"` + IsActive *bool `json:"is_active"` + Questions []capstoneQuestionItem `json:"questions"` +} + +type createModuleCapstoneReq struct { + ModuleID int64 `json:"module_id"` + Title string `json:"title"` + Description *string `json:"description"` + Tips *string `json:"tips"` + Thumbnail *string `json:"thumbnail"` + TimeLimitMinutes *int32 `json:"time_limit_minutes"` + PassingScore *int32 `json:"passing_score"` + ShuffleQuestions *bool `json:"shuffle_questions"` + Status *string `json:"status"` + DisplayOrder *int32 `json:"display_order"` + IsActive *bool `json:"is_active"` + Questions []capstoneQuestionItem `json:"questions"` +} + +type updateModuleCapstoneReq struct { + Title *string `json:"title"` + Description *string `json:"description"` + Tips *string `json:"tips"` + Thumbnail *string `json:"thumbnail"` + TimeLimitMinutes *int32 `json:"time_limit_minutes"` + PassingScore *int32 `json:"passing_score"` + ShuffleQuestions *bool `json:"shuffle_questions"` + Status *string `json:"status"` + DisplayOrder *int32 `json:"display_order"` + IsActive *bool `json:"is_active"` + Questions []capstoneQuestionItem `json:"questions"` +} + type legacyHierarchyRow struct { CategoryID int64 `json:"category_id"` CategoryName string `json:"category_name"` @@ -175,6 +252,14 @@ func mergeTextField(current pgtype.Text, req *string) pgtype.Text { return pgtype.Text{String: *req, Valid: true} } +func stringPtrFromPgText(t pgtype.Text) *string { + if !t.Valid { + return nil + } + s := t.String + return &s +} + func toInt4(v *int32) pgtype.Int4 { if v == nil { return pgtype.Int4{Valid: false} @@ -1162,8 +1247,13 @@ func (h *Handler) UnifiedHierarchyByCourse(c *fiber.Ctx) error { "level_thumbnail": nil, "module_id": nil, "module_title": nil, - "sub_module_id": nil, - "sub_module_title": nil, + "module_icon_url": nil, + "sub_module_id": nil, + "sub_module_title": nil, + "sub_module_description": nil, + "sub_module_thumbnail": nil, + "sub_module_tips": nil, + "sub_module_display_order": nil, }, }, }) @@ -1222,10 +1312,17 @@ func (h *Handler) CourseLearningPath(c *fiber.Ctx) error { } else if row.CefrLevel.Valid { level = row.CefrLevel.String } + displayOrder := int32(len(subCourseOrder)) + if row.SubModuleDisplayOrder.Valid { + displayOrder = row.SubModuleDisplayOrder.Int32 + } subCourseByID[subModuleID] = &domain.LearningPathSubCourse{ ID: subModuleID, Title: title, - DisplayOrder: int32(len(subCourseOrder)), + Description: textPtr(row.SubModuleDescription), + Thumbnail: textPtr(row.SubModuleThumbnail), + Tips: textPtr(row.SubModuleTips), + DisplayOrder: displayOrder, Level: level, SubLevel: level, PrerequisiteCount: 0, @@ -1504,7 +1601,7 @@ func (h *Handler) UpdateLevel(c *fiber.Ctx) error { // CreateModule godoc // @Summary Create module -// @Description Creates a module under a level +// @Description Creates a module under a level; optional icon_url stores a module icon image URL // @Tags course-management // @Accept json // @Produce json @@ -1525,8 +1622,9 @@ func (h *Handler) CreateModule(c *fiber.Ctx) error { LevelID: req.LevelID, Title: req.Title, Description: toText(req.Description), - Column4: intOrNil(req.DisplayOrder), - Column5: boolOrNil(req.IsActive), + IconUrl: toText(req.IconURL), + Column5: intOrNil(req.DisplayOrder), + Column6: boolOrNil(req.IsActive), }) if err != nil { return c.Status(fiber.StatusInternalServerError).JSON(domain.ErrorResponse{Message: "Failed to create module", Error: err.Error()}) @@ -1534,9 +1632,70 @@ func (h *Handler) CreateModule(c *fiber.Ctx) error { return c.Status(fiber.StatusCreated).JSON(domain.Response{Message: "Module created", Data: created}) } +// UpdateModule godoc +// @Summary Update module +// @Description Updates module title, description, icon URL, display order, and active flag +// @Tags course-management +// @Accept json +// @Produce json +// @Param moduleId path int true "Module ID" +// @Param body body updateModuleReq true "Update module payload" +// @Success 200 {object} domain.Response +// @Failure 400 {object} domain.ErrorResponse +// @Failure 404 {object} domain.ErrorResponse +// @Failure 500 {object} domain.ErrorResponse +// @Router /api/v1/course-management/modules/{moduleId} [put] +func (h *Handler) UpdateModule(c *fiber.Ctx) error { + moduleID, err := strconv.ParseInt(c.Params("moduleId"), 10, 64) + if err != nil || moduleID <= 0 { + return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{Message: "Invalid module ID", Error: "moduleId must be a positive integer"}) + } + var req updateModuleReq + if err := c.BodyParser(&req); err != nil { + return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{Message: "Invalid request body", Error: err.Error()}) + } + + current, err := h.analyticsDB.GetModuleByID(c.Context(), moduleID) + if err != nil { + return c.Status(fiber.StatusNotFound).JSON(domain.ErrorResponse{Message: "Module not found", Error: err.Error()}) + } + + targetTitle := current.Title + if req.Title != nil { + t := strings.TrimSpace(*req.Title) + if t == "" { + return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{Message: "title cannot be empty"}) + } + targetTitle = t + } + targetDesc := mergeTextField(current.Description, req.Description) + targetIcon := mergeTextField(current.IconUrl, req.IconURL) + targetOrder := current.DisplayOrder + if req.DisplayOrder != nil { + targetOrder = *req.DisplayOrder + } + targetActive := current.IsActive + if req.IsActive != nil { + targetActive = *req.IsActive + } + + updated, err := h.analyticsDB.UpdateModule(c.Context(), dbgen.UpdateModuleParams{ + Title: targetTitle, + Description: targetDesc, + IconUrl: targetIcon, + DisplayOrder: targetOrder, + IsActive: targetActive, + ID: moduleID, + }) + if err != nil { + return c.Status(fiber.StatusInternalServerError).JSON(domain.ErrorResponse{Message: "Failed to update module", Error: err.Error()}) + } + return c.JSON(domain.Response{Message: "Module updated", Data: updated}) +} + // CreateSubModule godoc // @Summary Create sub-module -// @Description Creates a sub-module under a module +// @Description Creates a sub-module under a module; optional thumbnail (image URL) and tips text // @Tags course-management // @Accept json // @Produce json @@ -1557,8 +1716,10 @@ func (h *Handler) CreateSubModule(c *fiber.Ctx) error { ModuleID: req.ModuleID, Title: req.Title, Description: toText(req.Description), - Column4: intOrNil(req.DisplayOrder), - Column5: boolOrNil(req.IsActive), + Thumbnail: toText(req.Thumbnail), + Tips: toText(req.Tips), + Column6: intOrNil(req.DisplayOrder), + Column7: boolOrNil(req.IsActive), }) if err != nil { return c.Status(fiber.StatusInternalServerError).JSON(domain.ErrorResponse{Message: "Failed to create sub-module", Error: err.Error()}) @@ -1856,6 +2017,114 @@ func (h *Handler) CreateSubModulePractice(c *fiber.Ctx) error { return c.Status(fiber.StatusCreated).JSON(domain.Response{Message: "Practice created", Data: created}) } +// CreateSubModuleCapstone godoc +// @Summary Create capstone under sub-module +// @Description Creates a capstone assessment with a new CAPSTONE question set, metadata, and ordered questions +// @Tags course-management +// @Accept json +// @Produce json +// @Param body body createSubModuleCapstoneReq true "Create capstone payload" +// @Success 201 {object} domain.Response +// @Failure 400 {object} domain.ErrorResponse +// @Failure 500 {object} domain.ErrorResponse +// @Router /api/v1/course-management/sub-module-capstones [post] +func (h *Handler) CreateSubModuleCapstone(c *fiber.Ctx) error { + var req createSubModuleCapstoneReq + if err := c.BodyParser(&req); err != nil { + return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{Message: "Invalid request body", Error: err.Error()}) + } + if req.SubModuleID <= 0 || strings.TrimSpace(req.Title) == "" { + return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{Message: "sub_module_id and title are required"}) + } + if len(req.Questions) == 0 { + return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{Message: "At least one question is required"}) + } + seenQ := make(map[int64]struct{}, len(req.Questions)) + for _, q := range req.Questions { + if q.QuestionID <= 0 { + return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{Message: "Each question_id must be a positive integer"}) + } + if _, dup := seenQ[q.QuestionID]; dup { + return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{Message: "Duplicate question_id values are not allowed"}) + } + seenQ[q.QuestionID] = struct{}{} + if _, err := h.questionsSvc.GetQuestionByID(c.Context(), q.QuestionID); err != nil { + return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{Message: "Invalid question_id", Error: err.Error()}) + } + } + + ownerType := "SUB_MODULE" + shuffle := false + if req.ShuffleQuestions != nil { + shuffle = *req.ShuffleQuestions + } + status := "DRAFT" + if req.Status != nil && strings.TrimSpace(*req.Status) != "" { + status = strings.TrimSpace(*req.Status) + } + + title := strings.TrimSpace(req.Title) + createdSet, err := h.questionsSvc.CreateQuestionSet(c.Context(), domain.CreateQuestionSetInput{ + Title: title, + Description: req.Description, + SetType: string(domain.QuestionSetTypeCapstone), + OwnerType: &ownerType, + OwnerID: &req.SubModuleID, + BannerImage: req.Thumbnail, + TimeLimitMinutes: req.TimeLimitMinutes, + PassingScore: req.PassingScore, + ShuffleQuestions: &shuffle, + Status: &status, + }) + if err != nil { + return c.Status(fiber.StatusInternalServerError).JSON(domain.ErrorResponse{Message: "Failed to create capstone question set", Error: err.Error()}) + } + + capRow, err := h.analyticsDB.CreateSubModuleCapstone(c.Context(), dbgen.CreateSubModuleCapstoneParams{ + SubModuleID: req.SubModuleID, + Title: title, + Description: toText(req.Description), + Tips: toText(req.Tips), + Thumbnail: toText(req.Thumbnail), + QuestionSetID: createdSet.ID, + Column7: intOrNil(req.DisplayOrder), + Column8: boolOrNil(req.IsActive), + }) + if err != nil { + _ = h.questionsSvc.DeleteQuestionSet(c.Context(), createdSet.ID) + return c.Status(fiber.StatusInternalServerError).JSON(domain.ErrorResponse{Message: "Failed to create capstone", Error: err.Error()}) + } + + for idx, cq := range req.Questions { + order := cq.DisplayOrder + if order == nil { + o := int32(idx) + order = &o + } + if _, err := h.questionsSvc.AddQuestionToSet(c.Context(), createdSet.ID, cq.QuestionID, order); err != nil { + _ = h.questionsSvc.DeleteQuestionSet(c.Context(), createdSet.ID) + return c.Status(fiber.StatusInternalServerError).JSON(domain.ErrorResponse{Message: "Failed to attach capstone questions", Error: err.Error()}) + } + } + + detail, err := h.analyticsDB.GetSubModuleCapstoneByID(c.Context(), capRow.ID) + if err != nil { + return c.Status(fiber.StatusInternalServerError).JSON(domain.ErrorResponse{Message: "Capstone created but failed to load detail", Error: err.Error()}) + } + items, err := h.questionsSvc.GetQuestionSetItems(c.Context(), detail.QuestionSetID) + if err != nil { + return c.Status(fiber.StatusInternalServerError).JSON(domain.ErrorResponse{Message: "Capstone created but failed to load questions", Error: err.Error()}) + } + + return c.Status(fiber.StatusCreated).JSON(domain.Response{ + Message: "Capstone created", + Data: map[string]interface{}{ + "capstone": detail, + "questions": items, + }, + }) +} + // GetSubModulePractices godoc // @Summary Get practices under sub-module // @Description Returns all active practices attached to a sub-module @@ -1928,6 +2197,82 @@ func (h *Handler) GetSubModulePracticeByID(c *fiber.Ctx) error { }) } +// GetSubModuleCapstones godoc +// @Summary List capstones under sub-module +// @Description Returns active capstones for a sub-module with question-set settings and question counts +// @Tags course-management +// @Produce json +// @Param subModuleId path int true "Sub-module ID" +// @Success 200 {object} domain.Response +// @Failure 400 {object} domain.ErrorResponse +// @Failure 500 {object} domain.ErrorResponse +// @Router /api/v1/course-management/sub-modules/{subModuleId}/capstones [get] +func (h *Handler) GetSubModuleCapstones(c *fiber.Ctx) error { + subModuleID, err := strconv.ParseInt(c.Params("subModuleId"), 10, 64) + if err != nil || subModuleID <= 0 { + return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{ + Message: "Invalid sub-module ID", + Error: "subModuleId must be a valid positive integer", + }) + } + rows, err := h.analyticsDB.GetSubModuleCapstones(c.Context(), subModuleID) + if err != nil { + return c.Status(fiber.StatusInternalServerError).JSON(domain.ErrorResponse{ + Message: "Failed to load sub-module capstones", + Error: err.Error(), + }) + } + return c.JSON(domain.Response{ + Message: "Sub-module capstones retrieved successfully", + Data: map[string]interface{}{ + "capstones": rows, + "total_count": len(rows), + }, + }) +} + +// GetSubModuleCapstoneByID godoc +// @Summary Get capstone detail +// @Description Returns one capstone with question-set fields and the ordered question list +// @Tags course-management +// @Produce json +// @Param capstoneId path int true "Capstone ID" +// @Success 200 {object} domain.Response +// @Failure 400 {object} domain.ErrorResponse +// @Failure 404 {object} domain.ErrorResponse +// @Failure 500 {object} domain.ErrorResponse +// @Router /api/v1/course-management/capstones/{capstoneId} [get] +func (h *Handler) GetSubModuleCapstoneByID(c *fiber.Ctx) error { + capstoneID, err := strconv.ParseInt(c.Params("capstoneId"), 10, 64) + if err != nil || capstoneID <= 0 { + return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{ + Message: "Invalid capstone ID", + Error: "capstoneId must be a valid positive integer", + }) + } + detail, err := h.analyticsDB.GetSubModuleCapstoneByID(c.Context(), capstoneID) + if err != nil { + return c.Status(fiber.StatusNotFound).JSON(domain.ErrorResponse{ + Message: "Capstone not found", + Error: err.Error(), + }) + } + items, err := h.questionsSvc.GetQuestionSetItems(c.Context(), detail.QuestionSetID) + if err != nil { + return c.Status(fiber.StatusInternalServerError).JSON(domain.ErrorResponse{ + Message: "Failed to load capstone questions", + Error: err.Error(), + }) + } + return c.JSON(domain.Response{ + Message: "Capstone retrieved successfully", + Data: map[string]interface{}{ + "capstone": detail, + "questions": items, + }, + }) +} + func (h *Handler) GetSubModuleVideos(c *fiber.Ctx) error { subModuleID, err := strconv.ParseInt(c.Params("subModuleId"), 10, 64) if err != nil || subModuleID <= 0 { @@ -1954,9 +2299,9 @@ func (h *Handler) UpdateSubModule(c *fiber.Ctx) error { return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{Message: "Invalid sub-module ID", Error: "subModuleId must be a positive integer"}) } - existing, err := h.analyticsDB.GetSubModuleByIDCompat(c.Context(), subModuleID) + current, err := h.analyticsDB.GetSubModuleByID(c.Context(), subModuleID) if err != nil { - return c.Status(fiber.StatusInternalServerError).JSON(domain.ErrorResponse{Message: "Failed to load sub-module", Error: err.Error()}) + return c.Status(fiber.StatusNotFound).JSON(domain.ErrorResponse{Message: "Sub-module not found", Error: err.Error()}) } var req updateSubModuleReq @@ -1964,29 +2309,37 @@ func (h *Handler) UpdateSubModule(c *fiber.Ctx) error { return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{Message: "Invalid request body", Error: err.Error()}) } - title := existing.Title + targetTitle := current.Title if req.Title != nil { - title = *req.Title + t := strings.TrimSpace(*req.Title) + if t == "" { + return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{Message: "title cannot be empty"}) + } + targetTitle = t } - description := "" - if existing.Description.Valid { - description = existing.Description.String + targetDesc := mergeTextField(current.Description, req.Description) + targetThumb := mergeTextField(current.Thumbnail, req.Thumbnail) + targetTips := mergeTextField(current.Tips, req.Tips) + targetOrder := current.DisplayOrder + if req.DisplayOrder != nil { + targetOrder = *req.DisplayOrder } - if req.Description != nil { - description = *req.Description - } - isActive := existing.IsActive + targetActive := current.IsActive if req.IsActive != nil { - isActive = *req.IsActive + targetActive = *req.IsActive } - if err := h.analyticsDB.UpdateSubModuleCompat(c.Context(), subModuleID, title, description, isActive); err != nil { - return c.Status(fiber.StatusInternalServerError).JSON(domain.ErrorResponse{Message: "Failed to update sub-module", Error: err.Error()}) - } - - updated, err := h.analyticsDB.GetSubModuleByIDCompat(c.Context(), subModuleID) + updated, err := h.analyticsDB.UpdateSubModule(c.Context(), dbgen.UpdateSubModuleParams{ + Title: targetTitle, + Description: targetDesc, + Thumbnail: targetThumb, + Tips: targetTips, + DisplayOrder: targetOrder, + IsActive: targetActive, + ID: subModuleID, + }) if err != nil { - return c.Status(fiber.StatusInternalServerError).JSON(domain.ErrorResponse{Message: "Sub-module updated but failed to fetch latest record", Error: err.Error()}) + return c.Status(fiber.StatusInternalServerError).JSON(domain.ErrorResponse{Message: "Failed to update sub-module", Error: err.Error()}) } return c.JSON(domain.Response{Message: "Sub-module updated", Data: updated}) } @@ -2104,3 +2457,529 @@ func (h *Handler) DeletePractice(c *fiber.Ctx) error { return c.JSON(domain.Response{Message: "Practice deleted"}) } +// UpdateSubModuleCapstone godoc +// @Summary Update capstone +// @Description Updates capstone content, question-set assessment settings, and optionally replaces the question list +// @Tags course-management +// @Accept json +// @Produce json +// @Param capstoneId path int true "Capstone ID" +// @Param body body updateSubModuleCapstoneReq true "Update capstone payload" +// @Success 200 {object} domain.Response +// @Failure 400 {object} domain.ErrorResponse +// @Failure 404 {object} domain.ErrorResponse +// @Failure 500 {object} domain.ErrorResponse +// @Router /api/v1/course-management/capstones/{capstoneId} [put] +func (h *Handler) UpdateSubModuleCapstone(c *fiber.Ctx) error { + capstoneID, err := strconv.ParseInt(c.Params("capstoneId"), 10, 64) + if err != nil || capstoneID <= 0 { + return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{Message: "Invalid capstone ID", Error: "capstoneId must be a positive integer"}) + } + var req updateSubModuleCapstoneReq + if err := c.BodyParser(&req); err != nil { + return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{Message: "Invalid request body", Error: err.Error()}) + } + + cur, err := h.analyticsDB.GetSubModuleCapstoneByID(c.Context(), capstoneID) + if err != nil { + return c.Status(fiber.StatusNotFound).JSON(domain.ErrorResponse{Message: "Capstone not found", Error: err.Error()}) + } + + targetTitle := cur.Title + if req.Title != nil { + t := strings.TrimSpace(*req.Title) + if t == "" { + return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{Message: "title cannot be empty"}) + } + targetTitle = t + } + targetDesc := mergeTextField(cur.Description, req.Description) + targetTips := mergeTextField(cur.Tips, req.Tips) + targetThumb := mergeTextField(cur.Thumbnail, req.Thumbnail) + targetOrder := cur.DisplayOrder + if req.DisplayOrder != nil { + targetOrder = *req.DisplayOrder + } + targetActive := cur.IsActive + if req.IsActive != nil { + targetActive = *req.IsActive + } + + if _, err := h.analyticsDB.UpdateSubModuleCapstone(c.Context(), dbgen.UpdateSubModuleCapstoneParams{ + Title: targetTitle, + Description: targetDesc, + Tips: targetTips, + Thumbnail: targetThumb, + DisplayOrder: targetOrder, + IsActive: targetActive, + ID: capstoneID, + }); err != nil { + return c.Status(fiber.StatusInternalServerError).JSON(domain.ErrorResponse{Message: "Failed to update capstone", Error: err.Error()}) + } + + qs, err := h.questionsSvc.GetQuestionSetByID(c.Context(), cur.QuestionSetID) + if err != nil { + return c.Status(fiber.StatusInternalServerError).JSON(domain.ErrorResponse{Message: "Failed to load capstone question set", Error: err.Error()}) + } + + tlm := qs.TimeLimitMinutes + if req.TimeLimitMinutes != nil { + tlm = req.TimeLimitMinutes + } + ps := qs.PassingScore + if req.PassingScore != nil { + ps = req.PassingScore + } + sh := qs.ShuffleQuestions + if req.ShuffleQuestions != nil { + sh = *req.ShuffleQuestions + } + st := qs.Status + if req.Status != nil && strings.TrimSpace(*req.Status) != "" { + st = strings.TrimSpace(*req.Status) + } + + if err := h.questionsSvc.UpdateQuestionSet(c.Context(), cur.QuestionSetID, domain.CreateQuestionSetInput{ + Title: targetTitle, + Description: stringPtrFromPgText(targetDesc), + BannerImage: stringPtrFromPgText(targetThumb), + Persona: qs.Persona, + TimeLimitMinutes: tlm, + PassingScore: ps, + ShuffleQuestions: &sh, + Status: &st, + SubCourseVideoID: qs.SubCourseVideoID, + IntroVideoURL: qs.IntroVideoURL, + }); err != nil { + return c.Status(fiber.StatusInternalServerError).JSON(domain.ErrorResponse{Message: "Failed to update capstone question set", Error: err.Error()}) + } + + if req.Questions != nil { + seen := make(map[int64]struct{}, len(req.Questions)) + for idx, q := range req.Questions { + if q.QuestionID <= 0 { + return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{Message: "Each question_id must be a positive integer"}) + } + if _, exists := seen[q.QuestionID]; exists { + return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{Message: "Duplicate question_id values are not allowed"}) + } + seen[q.QuestionID] = struct{}{} + if _, err := h.questionsSvc.GetQuestionByID(c.Context(), q.QuestionID); err != nil { + return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{Message: "Invalid question_id in questions payload", Error: err.Error()}) + } + order := q.DisplayOrder + if order == nil { + o := int32(idx) + order = &o + } + if _, err := h.questionsSvc.AddQuestionToSet(c.Context(), cur.QuestionSetID, q.QuestionID, order); err != nil { + return c.Status(fiber.StatusInternalServerError).JSON(domain.ErrorResponse{Message: "Failed to upsert capstone question", Error: err.Error()}) + } + } + existingItems, err := h.questionsSvc.GetQuestionSetItems(c.Context(), cur.QuestionSetID) + if err != nil { + return c.Status(fiber.StatusInternalServerError).JSON(domain.ErrorResponse{Message: "Failed to load existing capstone questions", Error: err.Error()}) + } + for _, item := range existingItems { + if _, keep := seen[item.QuestionID]; keep { + continue + } + if err := h.questionsSvc.RemoveQuestionFromSet(c.Context(), cur.QuestionSetID, item.QuestionID); err != nil { + return c.Status(fiber.StatusInternalServerError).JSON(domain.ErrorResponse{Message: "Failed to remove capstone question", Error: err.Error()}) + } + } + } + + detail, err := h.analyticsDB.GetSubModuleCapstoneByID(c.Context(), capstoneID) + if err != nil { + return c.Status(fiber.StatusInternalServerError).JSON(domain.ErrorResponse{Message: "Capstone updated but failed to load detail", Error: err.Error()}) + } + items, err := h.questionsSvc.GetQuestionSetItems(c.Context(), detail.QuestionSetID) + if err != nil { + return c.Status(fiber.StatusInternalServerError).JSON(domain.ErrorResponse{Message: "Capstone updated but failed to load questions", Error: err.Error()}) + } + return c.JSON(domain.Response{ + Message: "Capstone updated successfully", + Data: map[string]interface{}{ + "capstone": detail, + "questions": items, + }, + }) +} + +// DeleteCapstone godoc +// @Summary Delete capstone +// @Description Deletes the capstone and its backing question set (and question items) +// @Tags course-management +// @Produce json +// @Param capstoneId path int true "Capstone ID" +// @Success 200 {object} domain.Response +// @Failure 400 {object} domain.ErrorResponse +// @Failure 500 {object} domain.ErrorResponse +// @Router /api/v1/course-management/capstones/{capstoneId} [delete] +func (h *Handler) DeleteCapstone(c *fiber.Ctx) error { + capstoneID, err := strconv.ParseInt(c.Params("capstoneId"), 10, 64) + if err != nil || capstoneID <= 0 { + return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{Message: "Invalid capstone ID", Error: "capstoneId must be a positive integer"}) + } + if err := h.analyticsDB.DeleteCapstoneCompat(c.Context(), capstoneID); err != nil { + return c.Status(fiber.StatusInternalServerError).JSON(domain.ErrorResponse{Message: "Failed to delete capstone", Error: err.Error()}) + } + return c.JSON(domain.Response{Message: "Capstone deleted"}) +} + +// CreateModuleCapstone godoc +// @Summary Create module capstone +// @Description Creates a module-level capstone with a new CAPSTONE question set and ordered questions +// @Tags course-management +// @Accept json +// @Produce json +// @Param body body createModuleCapstoneReq true "Create module capstone payload" +// @Success 201 {object} domain.Response +// @Failure 400 {object} domain.ErrorResponse +// @Failure 500 {object} domain.ErrorResponse +// @Router /api/v1/course-management/module-capstones [post] +func (h *Handler) CreateModuleCapstone(c *fiber.Ctx) error { + var req createModuleCapstoneReq + if err := c.BodyParser(&req); err != nil { + return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{Message: "Invalid request body", Error: err.Error()}) + } + if req.ModuleID <= 0 || strings.TrimSpace(req.Title) == "" { + return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{Message: "module_id and title are required"}) + } + if len(req.Questions) == 0 { + return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{Message: "At least one question is required"}) + } + seenQ := make(map[int64]struct{}, len(req.Questions)) + for _, q := range req.Questions { + if q.QuestionID <= 0 { + return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{Message: "Each question_id must be a positive integer"}) + } + if _, dup := seenQ[q.QuestionID]; dup { + return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{Message: "Duplicate question_id values are not allowed"}) + } + seenQ[q.QuestionID] = struct{}{} + if _, err := h.questionsSvc.GetQuestionByID(c.Context(), q.QuestionID); err != nil { + return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{Message: "Invalid question_id", Error: err.Error()}) + } + } + + ownerType := "MODULE" + shuffle := false + if req.ShuffleQuestions != nil { + shuffle = *req.ShuffleQuestions + } + status := "DRAFT" + if req.Status != nil && strings.TrimSpace(*req.Status) != "" { + status = strings.TrimSpace(*req.Status) + } + + title := strings.TrimSpace(req.Title) + createdSet, err := h.questionsSvc.CreateQuestionSet(c.Context(), domain.CreateQuestionSetInput{ + Title: title, + Description: req.Description, + SetType: string(domain.QuestionSetTypeCapstone), + OwnerType: &ownerType, + OwnerID: &req.ModuleID, + BannerImage: req.Thumbnail, + TimeLimitMinutes: req.TimeLimitMinutes, + PassingScore: req.PassingScore, + ShuffleQuestions: &shuffle, + Status: &status, + }) + if err != nil { + return c.Status(fiber.StatusInternalServerError).JSON(domain.ErrorResponse{Message: "Failed to create module capstone question set", Error: err.Error()}) + } + + capRow, err := h.analyticsDB.CreateModuleCapstone(c.Context(), dbgen.CreateModuleCapstoneParams{ + ModuleID: req.ModuleID, + Title: title, + Description: toText(req.Description), + Tips: toText(req.Tips), + Thumbnail: toText(req.Thumbnail), + QuestionSetID: createdSet.ID, + Column7: intOrNil(req.DisplayOrder), + Column8: boolOrNil(req.IsActive), + }) + if err != nil { + _ = h.questionsSvc.DeleteQuestionSet(c.Context(), createdSet.ID) + return c.Status(fiber.StatusInternalServerError).JSON(domain.ErrorResponse{Message: "Failed to create module capstone", Error: err.Error()}) + } + + for idx, cq := range req.Questions { + order := cq.DisplayOrder + if order == nil { + o := int32(idx) + order = &o + } + if _, err := h.questionsSvc.AddQuestionToSet(c.Context(), createdSet.ID, cq.QuestionID, order); err != nil { + _ = h.questionsSvc.DeleteQuestionSet(c.Context(), createdSet.ID) + return c.Status(fiber.StatusInternalServerError).JSON(domain.ErrorResponse{Message: "Failed to attach module capstone questions", Error: err.Error()}) + } + } + + detail, err := h.analyticsDB.GetModuleCapstoneByID(c.Context(), capRow.ID) + if err != nil { + return c.Status(fiber.StatusInternalServerError).JSON(domain.ErrorResponse{Message: "Module capstone created but failed to load detail", Error: err.Error()}) + } + items, err := h.questionsSvc.GetQuestionSetItems(c.Context(), detail.QuestionSetID) + if err != nil { + return c.Status(fiber.StatusInternalServerError).JSON(domain.ErrorResponse{Message: "Module capstone created but failed to load questions", Error: err.Error()}) + } + + return c.Status(fiber.StatusCreated).JSON(domain.Response{ + Message: "Module capstone created", + Data: map[string]interface{}{ + "capstone": detail, + "questions": items, + }, + }) +} + +// GetModuleCapstones godoc +// @Summary List capstones under module +// @Description Returns active module capstones with question-set settings and question counts +// @Tags course-management +// @Produce json +// @Param moduleId path int true "Module ID" +// @Success 200 {object} domain.Response +// @Failure 400 {object} domain.ErrorResponse +// @Failure 500 {object} domain.ErrorResponse +// @Router /api/v1/course-management/modules/{moduleId}/capstones [get] +func (h *Handler) GetModuleCapstones(c *fiber.Ctx) error { + moduleID, err := strconv.ParseInt(c.Params("moduleId"), 10, 64) + if err != nil || moduleID <= 0 { + return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{ + Message: "Invalid module ID", + Error: "moduleId must be a valid positive integer", + }) + } + rows, err := h.analyticsDB.GetModuleCapstones(c.Context(), moduleID) + if err != nil { + return c.Status(fiber.StatusInternalServerError).JSON(domain.ErrorResponse{ + Message: "Failed to load module capstones", + Error: err.Error(), + }) + } + return c.JSON(domain.Response{ + Message: "Module capstones retrieved successfully", + Data: map[string]interface{}{ + "capstones": rows, + "total_count": len(rows), + }, + }) +} + +// GetModuleCapstoneByID godoc +// @Summary Get module capstone detail +// @Description Returns one module capstone with question-set fields and the ordered question list +// @Tags course-management +// @Produce json +// @Param moduleCapstoneId path int true "Module capstone ID" +// @Success 200 {object} domain.Response +// @Failure 400 {object} domain.ErrorResponse +// @Failure 404 {object} domain.ErrorResponse +// @Failure 500 {object} domain.ErrorResponse +// @Router /api/v1/course-management/module-capstones/{moduleCapstoneId} [get] +func (h *Handler) GetModuleCapstoneByID(c *fiber.Ctx) error { + capstoneID, err := strconv.ParseInt(c.Params("moduleCapstoneId"), 10, 64) + if err != nil || capstoneID <= 0 { + return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{ + Message: "Invalid module capstone ID", + Error: "moduleCapstoneId must be a valid positive integer", + }) + } + detail, err := h.analyticsDB.GetModuleCapstoneByID(c.Context(), capstoneID) + if err != nil { + return c.Status(fiber.StatusNotFound).JSON(domain.ErrorResponse{ + Message: "Module capstone not found", + Error: err.Error(), + }) + } + items, err := h.questionsSvc.GetQuestionSetItems(c.Context(), detail.QuestionSetID) + if err != nil { + return c.Status(fiber.StatusInternalServerError).JSON(domain.ErrorResponse{ + Message: "Failed to load module capstone questions", + Error: err.Error(), + }) + } + return c.JSON(domain.Response{ + Message: "Module capstone retrieved successfully", + Data: map[string]interface{}{ + "capstone": detail, + "questions": items, + }, + }) +} + +// UpdateModuleCapstone godoc +// @Summary Update module capstone +// @Description Updates module capstone content, question-set assessment settings, and optionally replaces the question list +// @Tags course-management +// @Accept json +// @Produce json +// @Param moduleCapstoneId path int true "Module capstone ID" +// @Param body body updateModuleCapstoneReq true "Update module capstone payload" +// @Success 200 {object} domain.Response +// @Failure 400 {object} domain.ErrorResponse +// @Failure 404 {object} domain.ErrorResponse +// @Failure 500 {object} domain.ErrorResponse +// @Router /api/v1/course-management/module-capstones/{moduleCapstoneId} [put] +func (h *Handler) UpdateModuleCapstone(c *fiber.Ctx) error { + capstoneID, err := strconv.ParseInt(c.Params("moduleCapstoneId"), 10, 64) + if err != nil || capstoneID <= 0 { + return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{Message: "Invalid module capstone ID", Error: "moduleCapstoneId must be a positive integer"}) + } + var req updateModuleCapstoneReq + if err := c.BodyParser(&req); err != nil { + return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{Message: "Invalid request body", Error: err.Error()}) + } + + cur, err := h.analyticsDB.GetModuleCapstoneByID(c.Context(), capstoneID) + if err != nil { + return c.Status(fiber.StatusNotFound).JSON(domain.ErrorResponse{Message: "Module capstone not found", Error: err.Error()}) + } + + targetTitle := cur.Title + if req.Title != nil { + t := strings.TrimSpace(*req.Title) + if t == "" { + return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{Message: "title cannot be empty"}) + } + targetTitle = t + } + targetDesc := mergeTextField(cur.Description, req.Description) + targetTips := mergeTextField(cur.Tips, req.Tips) + targetThumb := mergeTextField(cur.Thumbnail, req.Thumbnail) + targetOrder := cur.DisplayOrder + if req.DisplayOrder != nil { + targetOrder = *req.DisplayOrder + } + targetActive := cur.IsActive + if req.IsActive != nil { + targetActive = *req.IsActive + } + + if _, err := h.analyticsDB.UpdateModuleCapstone(c.Context(), dbgen.UpdateModuleCapstoneParams{ + Title: targetTitle, + Description: targetDesc, + Tips: targetTips, + Thumbnail: targetThumb, + DisplayOrder: targetOrder, + IsActive: targetActive, + ID: capstoneID, + }); err != nil { + return c.Status(fiber.StatusInternalServerError).JSON(domain.ErrorResponse{Message: "Failed to update module capstone", Error: err.Error()}) + } + + qs, err := h.questionsSvc.GetQuestionSetByID(c.Context(), cur.QuestionSetID) + if err != nil { + return c.Status(fiber.StatusInternalServerError).JSON(domain.ErrorResponse{Message: "Failed to load module capstone question set", Error: err.Error()}) + } + + tlm := qs.TimeLimitMinutes + if req.TimeLimitMinutes != nil { + tlm = req.TimeLimitMinutes + } + ps := qs.PassingScore + if req.PassingScore != nil { + ps = req.PassingScore + } + sh := qs.ShuffleQuestions + if req.ShuffleQuestions != nil { + sh = *req.ShuffleQuestions + } + st := qs.Status + if req.Status != nil && strings.TrimSpace(*req.Status) != "" { + st = strings.TrimSpace(*req.Status) + } + + if err := h.questionsSvc.UpdateQuestionSet(c.Context(), cur.QuestionSetID, domain.CreateQuestionSetInput{ + Title: targetTitle, + Description: stringPtrFromPgText(targetDesc), + BannerImage: stringPtrFromPgText(targetThumb), + Persona: qs.Persona, + TimeLimitMinutes: tlm, + PassingScore: ps, + ShuffleQuestions: &sh, + Status: &st, + SubCourseVideoID: qs.SubCourseVideoID, + IntroVideoURL: qs.IntroVideoURL, + }); err != nil { + return c.Status(fiber.StatusInternalServerError).JSON(domain.ErrorResponse{Message: "Failed to update module capstone question set", Error: err.Error()}) + } + + if req.Questions != nil { + seen := make(map[int64]struct{}, len(req.Questions)) + for idx, q := range req.Questions { + if q.QuestionID <= 0 { + return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{Message: "Each question_id must be a positive integer"}) + } + if _, exists := seen[q.QuestionID]; exists { + return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{Message: "Duplicate question_id values are not allowed"}) + } + seen[q.QuestionID] = struct{}{} + if _, err := h.questionsSvc.GetQuestionByID(c.Context(), q.QuestionID); err != nil { + return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{Message: "Invalid question_id in questions payload", Error: err.Error()}) + } + order := q.DisplayOrder + if order == nil { + o := int32(idx) + order = &o + } + if _, err := h.questionsSvc.AddQuestionToSet(c.Context(), cur.QuestionSetID, q.QuestionID, order); err != nil { + return c.Status(fiber.StatusInternalServerError).JSON(domain.ErrorResponse{Message: "Failed to upsert module capstone question", Error: err.Error()}) + } + } + existingItems, err := h.questionsSvc.GetQuestionSetItems(c.Context(), cur.QuestionSetID) + if err != nil { + return c.Status(fiber.StatusInternalServerError).JSON(domain.ErrorResponse{Message: "Failed to load existing module capstone questions", Error: err.Error()}) + } + for _, item := range existingItems { + if _, keep := seen[item.QuestionID]; keep { + continue + } + if err := h.questionsSvc.RemoveQuestionFromSet(c.Context(), cur.QuestionSetID, item.QuestionID); err != nil { + return c.Status(fiber.StatusInternalServerError).JSON(domain.ErrorResponse{Message: "Failed to remove module capstone question", Error: err.Error()}) + } + } + } + + detail, err := h.analyticsDB.GetModuleCapstoneByID(c.Context(), capstoneID) + if err != nil { + return c.Status(fiber.StatusInternalServerError).JSON(domain.ErrorResponse{Message: "Module capstone updated but failed to load detail", Error: err.Error()}) + } + items, err := h.questionsSvc.GetQuestionSetItems(c.Context(), detail.QuestionSetID) + if err != nil { + return c.Status(fiber.StatusInternalServerError).JSON(domain.ErrorResponse{Message: "Module capstone updated but failed to load questions", Error: err.Error()}) + } + return c.JSON(domain.Response{ + Message: "Module capstone updated successfully", + Data: map[string]interface{}{ + "capstone": detail, + "questions": items, + }, + }) +} + +// DeleteModuleCapstone godoc +// @Summary Delete module capstone +// @Description Deletes the module capstone and its backing question set +// @Tags course-management +// @Produce json +// @Param moduleCapstoneId path int true "Module capstone ID" +// @Success 200 {object} domain.Response +// @Failure 400 {object} domain.ErrorResponse +// @Failure 500 {object} domain.ErrorResponse +// @Router /api/v1/course-management/module-capstones/{moduleCapstoneId} [delete] +func (h *Handler) DeleteModuleCapstone(c *fiber.Ctx) error { + capstoneID, err := strconv.ParseInt(c.Params("moduleCapstoneId"), 10, 64) + if err != nil || capstoneID <= 0 { + return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{Message: "Invalid module capstone ID", Error: "moduleCapstoneId must be a positive integer"}) + } + if err := h.analyticsDB.DeleteModuleCapstoneCompat(c.Context(), capstoneID); err != nil { + return c.Status(fiber.StatusInternalServerError).JSON(domain.ErrorResponse{Message: "Failed to delete module capstone", Error: err.Error()}) + } + return c.JSON(domain.Response{Message: "Module capstone deleted"}) +} + diff --git a/internal/web_server/handlers/questions.go b/internal/web_server/handlers/questions.go index e6715a6..09e0d7d 100644 --- a/internal/web_server/handlers/questions.go +++ b/internal/web_server/handlers/questions.go @@ -515,7 +515,7 @@ func (h *Handler) DeleteQuestion(c *fiber.Ctx) error { type createQuestionSetReq struct { Title string `json:"title" validate:"required"` Description *string `json:"description"` - SetType string `json:"set_type" validate:"required,oneof=PRACTICE INITIAL_ASSESSMENT QUIZ EXAM SURVEY"` + SetType string `json:"set_type" validate:"required,oneof=PRACTICE INITIAL_ASSESSMENT QUIZ EXAM SURVEY CAPSTONE"` OwnerType *string `json:"owner_type"` OwnerID *int64 `json:"owner_id"` BannerImage *string `json:"banner_image"` @@ -791,7 +791,7 @@ func (h *Handler) GetQuestionSetByID(c *fiber.Ctx) error { // @Description Returns a paginated list of question sets filtered by type // @Tags question-sets // @Produce json -// @Param set_type query string true "Set type (PRACTICE, INITIAL_ASSESSMENT, QUIZ, EXAM, SURVEY)" +// @Param set_type query string true "Set type (PRACTICE, INITIAL_ASSESSMENT, QUIZ, EXAM, SURVEY, CAPSTONE)" // @Param limit query int false "Limit" default(10) // @Param offset query int false "Offset" default(0) // @Success 200 {object} domain.Response diff --git a/internal/web_server/routes.go b/internal/web_server/routes.go index 169e6cd..02c6577 100644 --- a/internal/web_server/routes.go +++ b/internal/web_server/routes.go @@ -110,6 +110,7 @@ func (a *App) initAppRoutes() { groupV1.Post("/course-management/levels", a.authMiddleware, a.RequirePermission("subcourses.create"), h.CreateLevel) groupV1.Put("/course-management/levels/:levelId", a.authMiddleware, a.RequirePermission("subcourses.update"), h.UpdateLevel) groupV1.Post("/course-management/modules", a.authMiddleware, a.RequirePermission("subcourses.create"), h.CreateModule) + groupV1.Put("/course-management/modules/:moduleId", a.authMiddleware, a.RequirePermission("subcourses.update"), h.UpdateModule) groupV1.Delete("/course-management/modules/:moduleId", a.authMiddleware, a.RequirePermission("subcourses.delete"), h.DeleteModule) groupV1.Post("/course-management/sub-modules", a.authMiddleware, a.RequirePermission("subcourses.create"), h.CreateSubModule) groupV1.Put("/course-management/sub-modules/:subModuleId", a.authMiddleware, a.RequirePermission("subcourses.update"), h.UpdateSubModule) @@ -126,6 +127,16 @@ func (a *App) initAppRoutes() { groupV1.Post("/course-management/sub-module-practices", a.authMiddleware, a.RequirePermission("question_sets.update"), h.CreateSubModulePractice) groupV1.Put("/course-management/practices/:practiceId", a.authMiddleware, a.RequirePermission("question_sets.update"), h.UpdatePractice) groupV1.Delete("/course-management/practices/:practiceId", a.authMiddleware, a.RequirePermission("question_sets.delete"), h.DeletePractice) + groupV1.Get("/course-management/sub-modules/:subModuleId/capstones", a.authMiddleware, a.RequirePermission("question_sets.list"), h.GetSubModuleCapstones) + groupV1.Get("/course-management/capstones/:capstoneId", a.authMiddleware, a.RequirePermission("question_sets.get"), h.GetSubModuleCapstoneByID) + groupV1.Post("/course-management/sub-module-capstones", a.authMiddleware, a.RequirePermission("question_sets.update"), h.CreateSubModuleCapstone) + groupV1.Put("/course-management/capstones/:capstoneId", a.authMiddleware, a.RequirePermission("question_sets.update"), h.UpdateSubModuleCapstone) + groupV1.Delete("/course-management/capstones/:capstoneId", a.authMiddleware, a.RequirePermission("question_sets.delete"), h.DeleteCapstone) + groupV1.Get("/course-management/modules/:moduleId/capstones", a.authMiddleware, a.RequirePermission("question_sets.list"), h.GetModuleCapstones) + groupV1.Get("/course-management/module-capstones/:moduleCapstoneId", a.authMiddleware, a.RequirePermission("question_sets.get"), h.GetModuleCapstoneByID) + groupV1.Post("/course-management/module-capstones", a.authMiddleware, a.RequirePermission("question_sets.update"), h.CreateModuleCapstone) + groupV1.Put("/course-management/module-capstones/:moduleCapstoneId", a.authMiddleware, a.RequirePermission("question_sets.update"), h.UpdateModuleCapstone) + groupV1.Delete("/course-management/module-capstones/:moduleCapstoneId", a.authMiddleware, a.RequirePermission("question_sets.delete"), h.DeleteModuleCapstone) // Questions groupV1.Post("/questions", a.authMiddleware, a.RequirePermission("questions.create"), h.CreateQuestion)