diff --git a/db/migrations/000058_dynamic_question_builder_runtime.down.sql b/db/migrations/000058_dynamic_question_builder_runtime.down.sql new file mode 100644 index 0000000..3dfe854 --- /dev/null +++ b/db/migrations/000058_dynamic_question_builder_runtime.down.sql @@ -0,0 +1,13 @@ +ALTER TABLE question_type_definitions + DROP COLUMN IF EXISTS stimulus_schema, + DROP COLUMN IF EXISTS response_schema; + +ALTER TABLE questions + DROP COLUMN IF EXISTS dynamic_payload; + +ALTER TABLE questions + DROP CONSTRAINT IF EXISTS questions_question_type_check; + +ALTER TABLE questions + ADD CONSTRAINT questions_question_type_check + CHECK (question_type IN ('MCQ', 'TRUE_FALSE', 'SHORT_ANSWER', 'AUDIO')); diff --git a/db/migrations/000058_dynamic_question_builder_runtime.up.sql b/db/migrations/000058_dynamic_question_builder_runtime.up.sql new file mode 100644 index 0000000..1770dcd --- /dev/null +++ b/db/migrations/000058_dynamic_question_builder_runtime.up.sql @@ -0,0 +1,13 @@ +ALTER TABLE questions + DROP CONSTRAINT IF EXISTS questions_question_type_check; + +ALTER TABLE questions + ADD CONSTRAINT questions_question_type_check + CHECK (question_type IN ('MCQ', 'TRUE_FALSE', 'SHORT_ANSWER', 'AUDIO', 'DYNAMIC')); + +ALTER TABLE questions + ADD COLUMN IF NOT EXISTS dynamic_payload JSONB NULL; + +ALTER TABLE question_type_definitions + ADD COLUMN IF NOT EXISTS stimulus_schema JSONB NOT NULL DEFAULT '[]'::jsonb, + ADD COLUMN IF NOT EXISTS response_schema JSONB NOT NULL DEFAULT '[]'::jsonb; diff --git a/db/query/question_set_items.sql b/db/query/question_set_items.sql index 7421371..5efa846 100644 --- a/db/query/question_set_items.sql +++ b/db/query/question_set_items.sql @@ -16,6 +16,7 @@ SELECT qsi.display_order, q.question_text, q.question_type, + q.dynamic_payload, q.difficulty_level, q.points, q.explanation, @@ -41,6 +42,7 @@ SELECT qsi.display_order, q.question_text, q.question_type, + q.dynamic_payload, q.difficulty_level, q.points, q.explanation, @@ -68,6 +70,7 @@ SELECT qsi.display_order, q.question_text, q.question_type, + q.dynamic_payload, q.difficulty_level, q.points, q.explanation, diff --git a/db/query/questions.sql b/db/query/questions.sql index c72cd9c..040b9c2 100644 --- a/db/query/questions.sql +++ b/db/query/questions.sql @@ -9,9 +9,10 @@ INSERT INTO questions ( voice_prompt, sample_answer_voice_prompt, image_url, - status + status, + dynamic_payload ) -VALUES ($1, $2, $3, COALESCE($4, 1), $5, $6, $7, $8, $9, COALESCE($10, 'DRAFT')) +VALUES ($1, $2, $3, COALESCE($4, 1), $5, $6, $7, $8, $9, COALESCE($10, 'DRAFT'), $11::jsonb) RETURNING *; -- name: GetQuestionByID :one @@ -62,8 +63,9 @@ SET sample_answer_voice_prompt = COALESCE($8, sample_answer_voice_prompt), image_url = COALESCE($9, image_url), status = COALESCE($10, status), + dynamic_payload = COALESCE($11::jsonb, dynamic_payload), updated_at = CURRENT_TIMESTAMP -WHERE id = $11; +WHERE id = $12; -- name: ArchiveQuestion :exec UPDATE questions diff --git a/docs/docs.go b/docs/docs.go index 52e3be7..14c36a9 100644 --- a/docs/docs.go +++ b/docs/docs.go @@ -4560,7 +4560,7 @@ const docTemplate = `{ } }, "post": { - "description": "Creates a new question with options (for MCQ/TRUE_FALSE) or short answers (for SHORT_ANSWER)", + "description": "Creates a new question with options (for MCQ/TRUE_FALSE) or short answers (for SHORT_ANSWER). Supports question_type_definition_id for dynamic builder-linked questions.", "consumes": [ "application/json" ], @@ -4721,9 +4721,221 @@ const docTemplate = `{ } } }, + "/api/v1/questions/type-definitions": { + "get": { + "produces": [ + "application/json" + ], + "tags": [ + "questions" + ], + "summary": "List reusable question-type definitions", + "parameters": [ + { + "type": "string", + "description": "Filter by status (ACTIVE, INACTIVE)", + "name": "status", + "in": "query" + }, + { + "type": "boolean", + "description": "Include system seeded definitions", + "name": "include_system", + "in": "query" + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/domain.Response" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/domain.ErrorResponse" + } + } + } + }, + "post": { + "description": "Stores a reusable dynamic question-type definition for future question construction. Only runtime-mappable definitions are persisted.", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "questions" + ], + "summary": "Create reusable question-type definition", + "parameters": [ + { + "description": "Question type definition payload", + "name": "body", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/handlers.createQuestionTypeDefinitionReq" + } + } + ], + "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/questions/type-definitions/{id}": { + "get": { + "produces": [ + "application/json" + ], + "tags": [ + "questions" + ], + "summary": "Get reusable question-type definition by id", + "parameters": [ + { + "type": "integer", + "description": "Question type definition id", + "name": "id", + "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" + } + } + } + }, + "put": { + "description": "Updates a reusable dynamic question-type definition. Updated definitions must remain runtime-mappable.", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "questions" + ], + "summary": "Update reusable question-type definition", + "parameters": [ + { + "type": "integer", + "description": "Question type definition id", + "name": "id", + "in": "path", + "required": true + }, + { + "description": "Update question type definition payload", + "name": "body", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/handlers.updateQuestionTypeDefinitionReq" + } + } + ], + "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" + } + } + } + }, + "delete": { + "produces": [ + "application/json" + ], + "tags": [ + "questions" + ], + "summary": "Delete reusable question-type definition", + "parameters": [ + { + "type": "integer", + "description": "Question type definition id", + "name": "id", + "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/questions/validate-question-type-definition": { "post": { - "description": "Validates selected stimulus and response component kinds for temporary question-type definitions", + "description": "Validates selected stimulus and response component kinds for temporary question-type definitions (component-level validation only)", "consumes": [ "application/json" ], @@ -4802,7 +5014,7 @@ const docTemplate = `{ } }, "put": { - "description": "Updates a question and optionally replaces its options/short answers", + "description": "Updates a question and optionally replaces its options/short answers. Supports question_type_definition_id for dynamic builder-linked questions.", "consumes": [ "application/json" ], @@ -9452,6 +9664,10 @@ const docTemplate = `{ "questionType": { "type": "string" }, + "questionTypeDefinitionID": { + "type": "integer", + "format": "int64" + }, "sampleAnswerVoicePrompt": { "type": "string" }, @@ -10702,8 +10918,7 @@ const docTemplate = `{ "handlers.createQuestionReq": { "type": "object", "required": [ - "question_text", - "question_type" + "question_text" ], "properties": { "audio_correct_answer_text": { @@ -10731,13 +10946,10 @@ const docTemplate = `{ "type": "string" }, "question_type": { - "type": "string", - "enum": [ - "MCQ", - "TRUE_FALSE", - "SHORT_ANSWER", - "AUDIO" - ] + "type": "string" + }, + "question_type_definition_id": { + "type": "integer" }, "sample_answer_voice_prompt": { "type": "string" @@ -10812,6 +11024,39 @@ const docTemplate = `{ } } }, + "handlers.createQuestionTypeDefinitionReq": { + "type": "object", + "required": [ + "display_name", + "key" + ], + "properties": { + "description": { + "type": "string" + }, + "display_name": { + "type": "string" + }, + "key": { + "type": "string" + }, + "response_component_kinds": { + "type": "array", + "items": { + "type": "string" + } + }, + "status": { + "type": "string" + }, + "stimulus_component_kinds": { + "type": "array", + "items": { + "type": "string" + } + } + } + }, "handlers.initiateDirectPaymentReq": { "type": "object", "required": [ @@ -11185,6 +11430,9 @@ const docTemplate = `{ "question_type": { "type": "string" }, + "question_type_definition_id": { + "type": "integer" + }, "sample_answer_voice_prompt": { "type": "string" }, @@ -11237,6 +11485,32 @@ const docTemplate = `{ } } }, + "handlers.updateQuestionTypeDefinitionReq": { + "type": "object", + "properties": { + "description": { + "type": "string" + }, + "display_name": { + "type": "string" + }, + "response_component_kinds": { + "type": "array", + "items": { + "type": "string" + } + }, + "status": { + "type": "string" + }, + "stimulus_component_kinds": { + "type": "array", + "items": { + "type": "string" + } + } + } + }, "handlers.validateQuestionTypeDefinitionReq": { "type": "object", "properties": { diff --git a/docs/swagger.json b/docs/swagger.json index 2e9d160..6381314 100644 --- a/docs/swagger.json +++ b/docs/swagger.json @@ -4552,7 +4552,7 @@ } }, "post": { - "description": "Creates a new question with options (for MCQ/TRUE_FALSE) or short answers (for SHORT_ANSWER)", + "description": "Creates a new question with options (for MCQ/TRUE_FALSE) or short answers (for SHORT_ANSWER). Supports question_type_definition_id for dynamic builder-linked questions.", "consumes": [ "application/json" ], @@ -4713,9 +4713,221 @@ } } }, + "/api/v1/questions/type-definitions": { + "get": { + "produces": [ + "application/json" + ], + "tags": [ + "questions" + ], + "summary": "List reusable question-type definitions", + "parameters": [ + { + "type": "string", + "description": "Filter by status (ACTIVE, INACTIVE)", + "name": "status", + "in": "query" + }, + { + "type": "boolean", + "description": "Include system seeded definitions", + "name": "include_system", + "in": "query" + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/domain.Response" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/domain.ErrorResponse" + } + } + } + }, + "post": { + "description": "Stores a reusable dynamic question-type definition for future question construction. Only runtime-mappable definitions are persisted.", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "questions" + ], + "summary": "Create reusable question-type definition", + "parameters": [ + { + "description": "Question type definition payload", + "name": "body", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/handlers.createQuestionTypeDefinitionReq" + } + } + ], + "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/questions/type-definitions/{id}": { + "get": { + "produces": [ + "application/json" + ], + "tags": [ + "questions" + ], + "summary": "Get reusable question-type definition by id", + "parameters": [ + { + "type": "integer", + "description": "Question type definition id", + "name": "id", + "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" + } + } + } + }, + "put": { + "description": "Updates a reusable dynamic question-type definition. Updated definitions must remain runtime-mappable.", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "questions" + ], + "summary": "Update reusable question-type definition", + "parameters": [ + { + "type": "integer", + "description": "Question type definition id", + "name": "id", + "in": "path", + "required": true + }, + { + "description": "Update question type definition payload", + "name": "body", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/handlers.updateQuestionTypeDefinitionReq" + } + } + ], + "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" + } + } + } + }, + "delete": { + "produces": [ + "application/json" + ], + "tags": [ + "questions" + ], + "summary": "Delete reusable question-type definition", + "parameters": [ + { + "type": "integer", + "description": "Question type definition id", + "name": "id", + "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/questions/validate-question-type-definition": { "post": { - "description": "Validates selected stimulus and response component kinds for temporary question-type definitions", + "description": "Validates selected stimulus and response component kinds for temporary question-type definitions (component-level validation only)", "consumes": [ "application/json" ], @@ -4794,7 +5006,7 @@ } }, "put": { - "description": "Updates a question and optionally replaces its options/short answers", + "description": "Updates a question and optionally replaces its options/short answers. Supports question_type_definition_id for dynamic builder-linked questions.", "consumes": [ "application/json" ], @@ -9444,6 +9656,10 @@ "questionType": { "type": "string" }, + "questionTypeDefinitionID": { + "type": "integer", + "format": "int64" + }, "sampleAnswerVoicePrompt": { "type": "string" }, @@ -10694,8 +10910,7 @@ "handlers.createQuestionReq": { "type": "object", "required": [ - "question_text", - "question_type" + "question_text" ], "properties": { "audio_correct_answer_text": { @@ -10723,13 +10938,10 @@ "type": "string" }, "question_type": { - "type": "string", - "enum": [ - "MCQ", - "TRUE_FALSE", - "SHORT_ANSWER", - "AUDIO" - ] + "type": "string" + }, + "question_type_definition_id": { + "type": "integer" }, "sample_answer_voice_prompt": { "type": "string" @@ -10804,6 +11016,39 @@ } } }, + "handlers.createQuestionTypeDefinitionReq": { + "type": "object", + "required": [ + "display_name", + "key" + ], + "properties": { + "description": { + "type": "string" + }, + "display_name": { + "type": "string" + }, + "key": { + "type": "string" + }, + "response_component_kinds": { + "type": "array", + "items": { + "type": "string" + } + }, + "status": { + "type": "string" + }, + "stimulus_component_kinds": { + "type": "array", + "items": { + "type": "string" + } + } + } + }, "handlers.initiateDirectPaymentReq": { "type": "object", "required": [ @@ -11177,6 +11422,9 @@ "question_type": { "type": "string" }, + "question_type_definition_id": { + "type": "integer" + }, "sample_answer_voice_prompt": { "type": "string" }, @@ -11229,6 +11477,32 @@ } } }, + "handlers.updateQuestionTypeDefinitionReq": { + "type": "object", + "properties": { + "description": { + "type": "string" + }, + "display_name": { + "type": "string" + }, + "response_component_kinds": { + "type": "array", + "items": { + "type": "string" + } + }, + "status": { + "type": "string" + }, + "stimulus_component_kinds": { + "type": "array", + "items": { + "type": "string" + } + } + } + }, "handlers.validateQuestionTypeDefinitionReq": { "type": "object", "properties": { diff --git a/docs/swagger.yaml b/docs/swagger.yaml index e28947b..072aac3 100644 --- a/docs/swagger.yaml +++ b/docs/swagger.yaml @@ -389,6 +389,9 @@ definitions: type: string questionType: type: string + questionTypeDefinitionID: + format: int64 + type: integer sampleAnswerVoicePrompt: type: string shortAnswers: @@ -1250,12 +1253,9 @@ definitions: question_text: type: string question_type: - enum: - - MCQ - - TRUE_FALSE - - SHORT_ANSWER - - AUDIO type: string + question_type_definition_id: + type: integer sample_answer_voice_prompt: type: string short_answers: @@ -1270,7 +1270,6 @@ definitions: type: string required: - question_text - - question_type type: object handlers.createQuestionSetReq: properties: @@ -1309,6 +1308,28 @@ definitions: - set_type - title type: object + handlers.createQuestionTypeDefinitionReq: + properties: + description: + type: string + display_name: + type: string + key: + type: string + response_component_kinds: + items: + type: string + type: array + status: + type: string + stimulus_component_kinds: + items: + type: string + type: array + required: + - display_name + - key + type: object handlers.initiateDirectPaymentReq: properties: email: @@ -1560,6 +1581,8 @@ definitions: type: string question_type: type: string + question_type_definition_id: + type: integer sample_answer_voice_prompt: type: string short_answers: @@ -1594,6 +1617,23 @@ definitions: title: type: string type: object + handlers.updateQuestionTypeDefinitionReq: + properties: + description: + type: string + display_name: + type: string + response_component_kinds: + items: + type: string + type: array + status: + type: string + stimulus_component_kinds: + items: + type: string + type: array + type: object handlers.validateQuestionTypeDefinitionReq: properties: response_component_kinds: @@ -5025,7 +5065,8 @@ paths: consumes: - application/json description: Creates a new question with options (for MCQ/TRUE_FALSE) or short - answers (for SHORT_ANSWER) + answers (for SHORT_ANSWER). Supports question_type_definition_id for dynamic + builder-linked questions. parameters: - description: Create question payload in: body @@ -5107,7 +5148,8 @@ paths: put: consumes: - application/json - description: Updates a question and optionally replaces its options/short answers + description: Updates a question and optionally replaces its options/short answers. + Supports question_type_definition_id for dynamic builder-linked questions. parameters: - description: Question ID in: path @@ -5217,12 +5259,153 @@ paths: summary: Search questions tags: - questions + /api/v1/questions/type-definitions: + get: + parameters: + - description: Filter by status (ACTIVE, INACTIVE) + in: query + name: status + type: string + - description: Include system seeded definitions + in: query + name: include_system + type: boolean + produces: + - application/json + responses: + "200": + description: OK + schema: + $ref: '#/definitions/domain.Response' + "500": + description: Internal Server Error + schema: + $ref: '#/definitions/domain.ErrorResponse' + summary: List reusable question-type definitions + tags: + - questions + post: + consumes: + - application/json + description: Stores a reusable dynamic question-type definition for future question + construction. Only runtime-mappable definitions are persisted. + parameters: + - description: Question type definition payload + in: body + name: body + required: true + schema: + $ref: '#/definitions/handlers.createQuestionTypeDefinitionReq' + 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 reusable question-type definition + tags: + - questions + /api/v1/questions/type-definitions/{id}: + delete: + parameters: + - description: Question type definition id + in: path + name: id + 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 reusable question-type definition + tags: + - questions + get: + parameters: + - description: Question type definition id + in: path + name: id + 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' + summary: Get reusable question-type definition by id + tags: + - questions + put: + consumes: + - application/json + description: Updates a reusable dynamic question-type definition. Updated definitions + must remain runtime-mappable. + parameters: + - description: Question type definition id + in: path + name: id + required: true + type: integer + - description: Update question type definition payload + in: body + name: body + required: true + schema: + $ref: '#/definitions/handlers.updateQuestionTypeDefinitionReq' + 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: Update reusable question-type definition + tags: + - questions /api/v1/questions/validate-question-type-definition: post: consumes: - application/json description: Validates selected stimulus and response component kinds for temporary - question-type definitions + question-type definitions (component-level validation only) parameters: - description: Stimulus and response component kinds in: body diff --git a/gen/db/models.go b/gen/db/models.go index e77c390..b288ac1 100644 --- a/gen/db/models.go +++ b/gen/db/models.go @@ -248,19 +248,21 @@ type Program struct { } type Question struct { - ID int64 `json:"id"` - QuestionText string `json:"question_text"` - QuestionType string `json:"question_type"` - DifficultyLevel pgtype.Text `json:"difficulty_level"` - Points int32 `json:"points"` - Explanation pgtype.Text `json:"explanation"` - Tips pgtype.Text `json:"tips"` - VoicePrompt pgtype.Text `json:"voice_prompt"` - SampleAnswerVoicePrompt pgtype.Text `json:"sample_answer_voice_prompt"` - Status string `json:"status"` - CreatedAt pgtype.Timestamptz `json:"created_at"` - UpdatedAt pgtype.Timestamptz `json:"updated_at"` - ImageUrl pgtype.Text `json:"image_url"` + ID int64 `json:"id"` + QuestionText string `json:"question_text"` + QuestionType string `json:"question_type"` + DifficultyLevel pgtype.Text `json:"difficulty_level"` + Points int32 `json:"points"` + Explanation pgtype.Text `json:"explanation"` + Tips pgtype.Text `json:"tips"` + VoicePrompt pgtype.Text `json:"voice_prompt"` + SampleAnswerVoicePrompt pgtype.Text `json:"sample_answer_voice_prompt"` + Status string `json:"status"` + CreatedAt pgtype.Timestamptz `json:"created_at"` + UpdatedAt pgtype.Timestamptz `json:"updated_at"` + ImageUrl pgtype.Text `json:"image_url"` + QuestionTypeDefinitionID pgtype.Int8 `json:"question_type_definition_id"` + DynamicPayload []byte `json:"dynamic_payload"` } type QuestionAudioAnswer struct { @@ -322,6 +324,21 @@ type QuestionShortAnswer struct { CreatedAt pgtype.Timestamptz `json:"created_at"` } +type QuestionTypeDefinition struct { + ID int64 `json:"id"` + Key string `json:"key"` + DisplayName string `json:"display_name"` + Description pgtype.Text `json:"description"` + StimulusComponentKinds []string `json:"stimulus_component_kinds"` + ResponseComponentKinds []string `json:"response_component_kinds"` + IsSystem bool `json:"is_system"` + Status string `json:"status"` + CreatedAt pgtype.Timestamptz `json:"created_at"` + UpdatedAt pgtype.Timestamptz `json:"updated_at"` + StimulusSchema []byte `json:"stimulus_schema"` + ResponseSchema []byte `json:"response_schema"` +} + type Rating struct { ID int64 `json:"id"` UserID int64 `json:"user_id"` diff --git a/gen/db/question_set_items.sql.go b/gen/db/question_set_items.sql.go index 21f9b1b..08e75e9 100644 --- a/gen/db/question_set_items.sql.go +++ b/gen/db/question_set_items.sql.go @@ -64,6 +64,7 @@ SELECT qsi.display_order, q.question_text, q.question_type, + q.dynamic_payload, q.difficulty_level, q.points, q.explanation, @@ -87,6 +88,7 @@ type GetPublishedQuestionsInSetRow struct { DisplayOrder int32 `json:"display_order"` QuestionText string `json:"question_text"` QuestionType string `json:"question_type"` + DynamicPayload []byte `json:"dynamic_payload"` DifficultyLevel pgtype.Text `json:"difficulty_level"` Points int32 `json:"points"` Explanation pgtype.Text `json:"explanation"` @@ -113,6 +115,7 @@ func (q *Queries) GetPublishedQuestionsInSet(ctx context.Context, setID int64) ( &i.DisplayOrder, &i.QuestionText, &i.QuestionType, + &i.DynamicPayload, &i.DifficultyLevel, &i.Points, &i.Explanation, @@ -140,6 +143,7 @@ SELECT qsi.display_order, q.question_text, q.question_type, + q.dynamic_payload, q.difficulty_level, q.points, q.explanation, @@ -164,6 +168,7 @@ type GetQuestionSetItemsRow struct { DisplayOrder int32 `json:"display_order"` QuestionText string `json:"question_text"` QuestionType string `json:"question_type"` + DynamicPayload []byte `json:"dynamic_payload"` DifficultyLevel pgtype.Text `json:"difficulty_level"` Points int32 `json:"points"` Explanation pgtype.Text `json:"explanation"` @@ -191,6 +196,7 @@ func (q *Queries) GetQuestionSetItems(ctx context.Context, setID int64) ([]GetQu &i.DisplayOrder, &i.QuestionText, &i.QuestionType, + &i.DynamicPayload, &i.DifficultyLevel, &i.Points, &i.Explanation, @@ -220,6 +226,7 @@ SELECT qsi.display_order, q.question_text, q.question_type, + q.dynamic_payload, q.difficulty_level, q.points, q.explanation, @@ -255,6 +262,7 @@ type GetQuestionSetItemsPaginatedRow struct { DisplayOrder int32 `json:"display_order"` QuestionText string `json:"question_text"` QuestionType string `json:"question_type"` + DynamicPayload []byte `json:"dynamic_payload"` DifficultyLevel pgtype.Text `json:"difficulty_level"` Points int32 `json:"points"` Explanation pgtype.Text `json:"explanation"` @@ -288,6 +296,7 @@ func (q *Queries) GetQuestionSetItemsPaginated(ctx context.Context, arg GetQuest &i.DisplayOrder, &i.QuestionText, &i.QuestionType, + &i.DynamicPayload, &i.DifficultyLevel, &i.Points, &i.Explanation, diff --git a/gen/db/questions.sql.go b/gen/db/questions.sql.go index 7b023af..0c8f625 100644 --- a/gen/db/questions.sql.go +++ b/gen/db/questions.sql.go @@ -33,10 +33,11 @@ INSERT INTO questions ( voice_prompt, sample_answer_voice_prompt, image_url, - status + status, + dynamic_payload ) -VALUES ($1, $2, $3, COALESCE($4, 1), $5, $6, $7, $8, $9, COALESCE($10, 'DRAFT')) -RETURNING id, question_text, question_type, difficulty_level, points, explanation, tips, voice_prompt, sample_answer_voice_prompt, status, created_at, updated_at, image_url +VALUES ($1, $2, $3, COALESCE($4, 1), $5, $6, $7, $8, $9, COALESCE($10, 'DRAFT'), $11::jsonb) +RETURNING id, question_text, question_type, difficulty_level, points, explanation, tips, voice_prompt, sample_answer_voice_prompt, status, created_at, updated_at, image_url, question_type_definition_id, dynamic_payload ` type CreateQuestionParams struct { @@ -50,6 +51,7 @@ type CreateQuestionParams struct { SampleAnswerVoicePrompt pgtype.Text `json:"sample_answer_voice_prompt"` ImageUrl pgtype.Text `json:"image_url"` Column10 interface{} `json:"column_10"` + Column11 []byte `json:"column_11"` } func (q *Queries) CreateQuestion(ctx context.Context, arg CreateQuestionParams) (Question, error) { @@ -64,6 +66,7 @@ func (q *Queries) CreateQuestion(ctx context.Context, arg CreateQuestionParams) arg.SampleAnswerVoicePrompt, arg.ImageUrl, arg.Column10, + arg.Column11, ) var i Question err := row.Scan( @@ -80,6 +83,8 @@ func (q *Queries) CreateQuestion(ctx context.Context, arg CreateQuestionParams) &i.CreatedAt, &i.UpdatedAt, &i.ImageUrl, + &i.QuestionTypeDefinitionID, + &i.DynamicPayload, ) return i, err } @@ -95,7 +100,7 @@ func (q *Queries) DeleteQuestion(ctx context.Context, id int64) error { } const GetQuestionByID = `-- name: GetQuestionByID :one -SELECT id, question_text, question_type, difficulty_level, points, explanation, tips, voice_prompt, sample_answer_voice_prompt, status, created_at, updated_at, image_url +SELECT id, question_text, question_type, difficulty_level, points, explanation, tips, voice_prompt, sample_answer_voice_prompt, status, created_at, updated_at, image_url, question_type_definition_id, dynamic_payload FROM questions WHERE id = $1 ` @@ -117,6 +122,8 @@ func (q *Queries) GetQuestionByID(ctx context.Context, id int64) (Question, erro &i.CreatedAt, &i.UpdatedAt, &i.ImageUrl, + &i.QuestionTypeDefinitionID, + &i.DynamicPayload, ) return i, err } @@ -193,7 +200,7 @@ func (q *Queries) GetQuestionWithOptions(ctx context.Context, id int64) ([]GetQu } const GetQuestionsByIDs = `-- name: GetQuestionsByIDs :many -SELECT id, question_text, question_type, difficulty_level, points, explanation, tips, voice_prompt, sample_answer_voice_prompt, status, created_at, updated_at, image_url +SELECT id, question_text, question_type, difficulty_level, points, explanation, tips, voice_prompt, sample_answer_voice_prompt, status, created_at, updated_at, image_url, question_type_definition_id, dynamic_payload FROM questions WHERE id = ANY($1::BIGINT[]) ORDER BY id @@ -222,6 +229,8 @@ func (q *Queries) GetQuestionsByIDs(ctx context.Context, dollar_1 []int64) ([]Qu &i.CreatedAt, &i.UpdatedAt, &i.ImageUrl, + &i.QuestionTypeDefinitionID, + &i.DynamicPayload, ); err != nil { return nil, err } @@ -236,7 +245,7 @@ func (q *Queries) GetQuestionsByIDs(ctx context.Context, dollar_1 []int64) ([]Qu const ListQuestions = `-- name: ListQuestions :many SELECT COUNT(*) OVER () AS total_count, - q.id, q.question_text, q.question_type, q.difficulty_level, q.points, q.explanation, q.tips, q.voice_prompt, q.sample_answer_voice_prompt, q.status, q.created_at, q.updated_at, q.image_url + q.id, q.question_text, q.question_type, q.difficulty_level, q.points, q.explanation, q.tips, q.voice_prompt, q.sample_answer_voice_prompt, q.status, q.created_at, q.updated_at, q.image_url, q.question_type_definition_id, q.dynamic_payload FROM questions q WHERE status != 'ARCHIVED' AND ($1::VARCHAR IS NULL OR $1 = '' OR question_type = $1) @@ -256,20 +265,22 @@ type ListQuestionsParams struct { } type ListQuestionsRow struct { - TotalCount int64 `json:"total_count"` - ID int64 `json:"id"` - QuestionText string `json:"question_text"` - QuestionType string `json:"question_type"` - DifficultyLevel pgtype.Text `json:"difficulty_level"` - Points int32 `json:"points"` - Explanation pgtype.Text `json:"explanation"` - Tips pgtype.Text `json:"tips"` - VoicePrompt pgtype.Text `json:"voice_prompt"` - SampleAnswerVoicePrompt pgtype.Text `json:"sample_answer_voice_prompt"` - Status string `json:"status"` - CreatedAt pgtype.Timestamptz `json:"created_at"` - UpdatedAt pgtype.Timestamptz `json:"updated_at"` - ImageUrl pgtype.Text `json:"image_url"` + TotalCount int64 `json:"total_count"` + ID int64 `json:"id"` + QuestionText string `json:"question_text"` + QuestionType string `json:"question_type"` + DifficultyLevel pgtype.Text `json:"difficulty_level"` + Points int32 `json:"points"` + Explanation pgtype.Text `json:"explanation"` + Tips pgtype.Text `json:"tips"` + VoicePrompt pgtype.Text `json:"voice_prompt"` + SampleAnswerVoicePrompt pgtype.Text `json:"sample_answer_voice_prompt"` + Status string `json:"status"` + CreatedAt pgtype.Timestamptz `json:"created_at"` + UpdatedAt pgtype.Timestamptz `json:"updated_at"` + ImageUrl pgtype.Text `json:"image_url"` + QuestionTypeDefinitionID pgtype.Int8 `json:"question_type_definition_id"` + DynamicPayload []byte `json:"dynamic_payload"` } func (q *Queries) ListQuestions(ctx context.Context, arg ListQuestionsParams) ([]ListQuestionsRow, error) { @@ -302,6 +313,8 @@ func (q *Queries) ListQuestions(ctx context.Context, arg ListQuestionsParams) ([ &i.CreatedAt, &i.UpdatedAt, &i.ImageUrl, + &i.QuestionTypeDefinitionID, + &i.DynamicPayload, ); err != nil { return nil, err } @@ -316,7 +329,7 @@ func (q *Queries) ListQuestions(ctx context.Context, arg ListQuestionsParams) ([ const SearchQuestions = `-- name: SearchQuestions :many SELECT COUNT(*) OVER () AS total_count, - q.id, q.question_text, q.question_type, q.difficulty_level, q.points, q.explanation, q.tips, q.voice_prompt, q.sample_answer_voice_prompt, q.status, q.created_at, q.updated_at, q.image_url + q.id, q.question_text, q.question_type, q.difficulty_level, q.points, q.explanation, q.tips, q.voice_prompt, q.sample_answer_voice_prompt, q.status, q.created_at, q.updated_at, q.image_url, q.question_type_definition_id, q.dynamic_payload FROM questions q WHERE status != 'ARCHIVED' AND question_text ILIKE '%' || $1 || '%' @@ -332,20 +345,22 @@ type SearchQuestionsParams struct { } type SearchQuestionsRow struct { - TotalCount int64 `json:"total_count"` - ID int64 `json:"id"` - QuestionText string `json:"question_text"` - QuestionType string `json:"question_type"` - DifficultyLevel pgtype.Text `json:"difficulty_level"` - Points int32 `json:"points"` - Explanation pgtype.Text `json:"explanation"` - Tips pgtype.Text `json:"tips"` - VoicePrompt pgtype.Text `json:"voice_prompt"` - SampleAnswerVoicePrompt pgtype.Text `json:"sample_answer_voice_prompt"` - Status string `json:"status"` - CreatedAt pgtype.Timestamptz `json:"created_at"` - UpdatedAt pgtype.Timestamptz `json:"updated_at"` - ImageUrl pgtype.Text `json:"image_url"` + TotalCount int64 `json:"total_count"` + ID int64 `json:"id"` + QuestionText string `json:"question_text"` + QuestionType string `json:"question_type"` + DifficultyLevel pgtype.Text `json:"difficulty_level"` + Points int32 `json:"points"` + Explanation pgtype.Text `json:"explanation"` + Tips pgtype.Text `json:"tips"` + VoicePrompt pgtype.Text `json:"voice_prompt"` + SampleAnswerVoicePrompt pgtype.Text `json:"sample_answer_voice_prompt"` + Status string `json:"status"` + CreatedAt pgtype.Timestamptz `json:"created_at"` + UpdatedAt pgtype.Timestamptz `json:"updated_at"` + ImageUrl pgtype.Text `json:"image_url"` + QuestionTypeDefinitionID pgtype.Int8 `json:"question_type_definition_id"` + DynamicPayload []byte `json:"dynamic_payload"` } func (q *Queries) SearchQuestions(ctx context.Context, arg SearchQuestionsParams) ([]SearchQuestionsRow, error) { @@ -372,6 +387,8 @@ func (q *Queries) SearchQuestions(ctx context.Context, arg SearchQuestionsParams &i.CreatedAt, &i.UpdatedAt, &i.ImageUrl, + &i.QuestionTypeDefinitionID, + &i.DynamicPayload, ); err != nil { return nil, err } @@ -396,8 +413,9 @@ SET sample_answer_voice_prompt = COALESCE($8, sample_answer_voice_prompt), image_url = COALESCE($9, image_url), status = COALESCE($10, status), + dynamic_payload = COALESCE($11::jsonb, dynamic_payload), updated_at = CURRENT_TIMESTAMP -WHERE id = $11 +WHERE id = $12 ` type UpdateQuestionParams struct { @@ -411,6 +429,7 @@ type UpdateQuestionParams struct { SampleAnswerVoicePrompt pgtype.Text `json:"sample_answer_voice_prompt"` ImageUrl pgtype.Text `json:"image_url"` Status string `json:"status"` + Column11 []byte `json:"column_11"` ID int64 `json:"id"` } @@ -426,6 +445,7 @@ func (q *Queries) UpdateQuestion(ctx context.Context, arg UpdateQuestionParams) arg.SampleAnswerVoicePrompt, arg.ImageUrl, arg.Status, + arg.Column11, arg.ID, ) return err diff --git a/internal/domain/question_type_builder.go b/internal/domain/question_type_builder.go index 84d8cad..13d1594 100644 --- a/internal/domain/question_type_builder.go +++ b/internal/domain/question_type_builder.go @@ -10,11 +10,14 @@ import ( type StimulusComponentKind string const ( + StimulusQuestionText StimulusComponentKind = "QUESTION_TEXT" StimulusPrepTime StimulusComponentKind = "PREP_TIME" StimulusInstruction StimulusComponentKind = "INSTRUCTION" + StimulusAudioPrompt StimulusComponentKind = "AUDIO_PROMPT" StimulusAudioClip StimulusComponentKind = "AUDIO_CLIP" StimulusTextPassage StimulusComponentKind = "TEXT_PASSAGE" StimulusImage StimulusComponentKind = "IMAGE" + StimulusChart StimulusComponentKind = "CHART" StimulusMatchingInputs StimulusComponentKind = "MATCHING_INPUTS" StimulusSelectMissingWords StimulusComponentKind = "SELECT_MISSING_WORDS" StimulusTable StimulusComponentKind = "TABLE" @@ -25,24 +28,49 @@ const ( type ResponseComponentKind string const ( - ResponseAudioResponse ResponseComponentKind = "AUDIO_RESPONSE" - ResponseTextInput ResponseComponentKind = "TEXT_INPUT" - ResponseShortAnswer ResponseComponentKind = "SHORT_ANSWER" - ResponseMultipleChoice ResponseComponentKind = "MULTIPLE_CHOICE" - ResponseAnswerTimer ResponseComponentKind = "ANSWER_TIMER" - ResponseSelectMissingWords ResponseComponentKind = "SELECT_MISSING_WORDS" - ResponsePDFUpload ResponseComponentKind = "PDF_UPLOAD" - ResponseMatchingAnswer ResponseComponentKind = "MATCHING_ANSWER" - ResponseLabelSelection ResponseComponentKind = "LABEL_SELECTION" + ResponseAudioResponse ResponseComponentKind = "AUDIO_RESPONSE" + ResponseTextInput ResponseComponentKind = "TEXT_INPUT" + ResponseShortAnswer ResponseComponentKind = "SHORT_ANSWER" + ResponseMultipleChoice ResponseComponentKind = "MULTIPLE_CHOICE" + ResponseOption ResponseComponentKind = "OPTION" + ResponseAnswerTimer ResponseComponentKind = "ANSWER_TIMER" + ResponseSelectMissingWords ResponseComponentKind = "SELECT_MISSING_WORDS" + ResponsePDFUpload ResponseComponentKind = "PDF_UPLOAD" + ResponseMatchingAnswer ResponseComponentKind = "MATCHING_ANSWER" + ResponseLabelSelection ResponseComponentKind = "LABEL_SELECTION" + ResponseSequenceOrder ResponseComponentKind = "SEQUENCE_ORDER" ) +type DynamicElementDefinition struct { + ID string `json:"id"` + Kind string `json:"kind"` + Label *string `json:"label,omitempty"` + Required bool `json:"required"` + Config map[string]interface{} `json:"config,omitempty"` +} + +type DynamicElementInstance struct { + ID string `json:"id"` + Kind string `json:"kind"` + Value interface{} `json:"value,omitempty"` + Meta map[string]interface{} `json:"meta,omitempty"` +} + +type DynamicQuestionPayload struct { + Stimulus []DynamicElementInstance `json:"stimulus"` + Response []DynamicElementInstance `json:"response"` +} + var ( stimulusCatalog = []StimulusComponentKind{ + StimulusQuestionText, StimulusPrepTime, StimulusInstruction, + StimulusAudioPrompt, StimulusAudioClip, StimulusTextPassage, StimulusImage, + StimulusChart, StimulusMatchingInputs, StimulusSelectMissingWords, StimulusTable, @@ -55,11 +83,13 @@ var ( ResponseTextInput, ResponseShortAnswer, ResponseMultipleChoice, + ResponseOption, ResponseAnswerTimer, ResponseSelectMissingWords, ResponsePDFUpload, ResponseMatchingAnswer, ResponseLabelSelection, + ResponseSequenceOrder, } responseSet map[string]struct{} @@ -183,6 +213,178 @@ func ValidateDynamicQuestionTypeDefinition(stimulusKinds, responseKinds []string return fmt.Errorf("%s", strings.Join(errs, "; ")) } +// ResolveRuntimeQuestionTypeFromDefinition derives the legacy runtime question_type code used by +// existing question execution paths. Empty string means the definition cannot be executed yet. +func ResolveRuntimeQuestionTypeFromDefinition(key string, responseKinds []string) string { + normalizedKey := strings.ToLower(strings.TrimSpace(key)) + if normalizedKey == "true_false" || normalizedKey == "true-false" { + return "TRUE_FALSE" + } + + for _, kind := range normalizeKindList(responseKinds) { + switch kind { + case string(ResponseAudioResponse): + return "AUDIO" + case string(ResponseMultipleChoice): + return "MCQ" + case string(ResponseShortAnswer), + string(ResponseTextInput), + string(ResponseSelectMissingWords), + string(ResponseMatchingAnswer), + string(ResponseLabelSelection), + string(ResponsePDFUpload): + return "SHORT_ANSWER" + } + } + + return "" +} + +// ValidatePersistableQuestionTypeDefinition ensures definitions that pass component-level validation +// are also mappable to the currently supported runtime question_type set. +func ValidatePersistableQuestionTypeDefinition(key string, responseKinds []string) error { + if ResolveRuntimeQuestionTypeFromDefinition(key, responseKinds) == "" { + return fmt.Errorf("unable to map definition to runtime question_type") + } + return nil +} + +func ValidateDefinitionSchemas(stimulusSchema, responseSchema []DynamicElementDefinition) error { + var errs []string + stimulusIDs := make(map[string]struct{}) + responseIDs := make(map[string]struct{}) + + for i, el := range stimulusSchema { + id := strings.TrimSpace(el.ID) + kind := strings.TrimSpace(el.Kind) + + if id == "" { + errs = append(errs, fmt.Sprintf("stimulus schema [%d]: id is required", i)) + } else if _, exists := stimulusIDs[id]; exists { + errs = append(errs, fmt.Sprintf("stimulus schema: duplicate id %q", id)) + } else { + stimulusIDs[id] = struct{}{} + } + + if !IsValidStimulusComponentKind(kind) { + errs = append(errs, fmt.Sprintf("stimulus schema: unknown kind %q", kind)) + } + } + + for i, el := range responseSchema { + id := strings.TrimSpace(el.ID) + kind := strings.TrimSpace(el.Kind) + + if id == "" { + errs = append(errs, fmt.Sprintf("response schema [%d]: id is required", i)) + } else if _, exists := responseIDs[id]; exists { + errs = append(errs, fmt.Sprintf("response schema: duplicate id %q", id)) + } else { + responseIDs[id] = struct{}{} + } + + if !IsValidResponseComponentKind(kind) { + errs = append(errs, fmt.Sprintf("response schema: unknown kind %q", kind)) + } + } + + if len(errs) == 0 { + return nil + } + + return fmt.Errorf("%s", strings.Join(errs, "; ")) +} + +func ValidateDynamicPayloadAgainstDefinition(payload DynamicQuestionPayload, def QuestionTypeDefinition) error { + var errs []string + if len(payload.Stimulus) == 0 { + errs = append(errs, "dynamic_payload.stimulus must contain at least one element") + } + if len(payload.Response) == 0 { + errs = append(errs, "dynamic_payload.response must contain at least one element") + } + + allowedStimulus := make(map[string]struct{}) + allowedResponse := make(map[string]struct{}) + for _, k := range def.StimulusComponentKinds { + allowedStimulus[strings.TrimSpace(k)] = struct{}{} + } + for _, k := range def.ResponseComponentKinds { + allowedResponse[strings.TrimSpace(k)] = struct{}{} + } + + requiredStimulusIDs := make(map[string]struct{}) + requiredResponseIDs := make(map[string]struct{}) + for _, el := range def.StimulusSchema { + if el.Required { + requiredStimulusIDs[strings.TrimSpace(el.ID)] = struct{}{} + } + } + for _, el := range def.ResponseSchema { + if el.Required { + requiredResponseIDs[strings.TrimSpace(el.ID)] = struct{}{} + } + } + + seenStimulusIDs := make(map[string]struct{}) + seenResponseIDs := make(map[string]struct{}) + + for i, el := range payload.Stimulus { + id := strings.TrimSpace(el.ID) + kind := strings.TrimSpace(el.Kind) + + if id == "" { + errs = append(errs, fmt.Sprintf("dynamic_payload.stimulus[%d]: id is required", i)) + } else { + seenStimulusIDs[id] = struct{}{} + } + + if !IsValidStimulusComponentKind(kind) { + errs = append(errs, fmt.Sprintf("dynamic_payload.stimulus[%d]: invalid kind %q", i, kind)) + continue + } + if _, ok := allowedStimulus[kind]; !ok { + errs = append(errs, fmt.Sprintf("dynamic_payload.stimulus[%d]: kind %q is not allowed by selected definition", i, kind)) + } + } + + for i, el := range payload.Response { + id := strings.TrimSpace(el.ID) + kind := strings.TrimSpace(el.Kind) + + if id == "" { + errs = append(errs, fmt.Sprintf("dynamic_payload.response[%d]: id is required", i)) + } else { + seenResponseIDs[id] = struct{}{} + } + + if !IsValidResponseComponentKind(kind) { + errs = append(errs, fmt.Sprintf("dynamic_payload.response[%d]: invalid kind %q", i, kind)) + continue + } + if _, ok := allowedResponse[kind]; !ok { + errs = append(errs, fmt.Sprintf("dynamic_payload.response[%d]: kind %q is not allowed by selected definition", i, kind)) + } + } + + for id := range requiredStimulusIDs { + if _, ok := seenStimulusIDs[id]; !ok { + errs = append(errs, fmt.Sprintf("dynamic_payload.stimulus: required element id %q is missing", id)) + } + } + for id := range requiredResponseIDs { + if _, ok := seenResponseIDs[id]; !ok { + errs = append(errs, fmt.Sprintf("dynamic_payload.response: required element id %q is missing", id)) + } + } + + if len(errs) == 0 { + return nil + } + + return fmt.Errorf("%s", strings.Join(errs, "; ")) +} + func normalizeKindList(in []string) []string { var out []string for _, s := range in { diff --git a/internal/domain/question_type_builder_test.go b/internal/domain/question_type_builder_test.go index 770bd29..1d3e45d 100644 --- a/internal/domain/question_type_builder_test.go +++ b/internal/domain/question_type_builder_test.go @@ -64,3 +64,71 @@ func TestValidateDynamicQuestionTypeDefinition_twoPrepTimes(t *testing.T) { t.Fatalf("expected at most one PREP_TIME, got %v", err) } } + +func TestResolveRuntimeQuestionTypeFromDefinition_trueFalseKey(t *testing.T) { + got := ResolveRuntimeQuestionTypeFromDefinition("true_false", []string{"MULTIPLE_CHOICE"}) + if got != "TRUE_FALSE" { + t.Fatalf("expected TRUE_FALSE, got %q", got) + } +} + +func TestResolveRuntimeQuestionTypeFromDefinition_pdfUpload(t *testing.T) { + got := ResolveRuntimeQuestionTypeFromDefinition("doc_upload", []string{"PDF_UPLOAD"}) + if got != "SHORT_ANSWER" { + t.Fatalf("expected SHORT_ANSWER, got %q", got) + } +} + +func TestResolveRuntimeQuestionTypeFromDefinition_skipsAuxiliaryKinds(t *testing.T) { + got := ResolveRuntimeQuestionTypeFromDefinition("timed_mcq", []string{"ANSWER_TIMER", "MULTIPLE_CHOICE"}) + if got != "MCQ" { + t.Fatalf("expected MCQ, got %q", got) + } +} + +func TestValidatePersistableQuestionTypeDefinition_unmappable(t *testing.T) { + err := ValidatePersistableQuestionTypeDefinition("custom", []string{"ANSWER_TIMER"}) + if err == nil || !strings.Contains(err.Error(), "unable to map definition") { + t.Fatalf("expected unmappable definition error, got %v", err) + } +} + +func TestValidateDefinitionSchemas_valid(t *testing.T) { + err := ValidateDefinitionSchemas( + []DynamicElementDefinition{ + {ID: "stimulus_1", Kind: "QUESTION_TEXT", Required: true}, + {ID: "stimulus_2", Kind: "IMAGE", Required: false}, + }, + []DynamicElementDefinition{ + {ID: "response_1", Kind: "OPTION", Required: true}, + }, + ) + if err != nil { + t.Fatalf("expected nil, got %v", err) + } +} + +func TestValidateDynamicPayloadAgainstDefinition_requiredMissing(t *testing.T) { + def := QuestionTypeDefinition{ + StimulusComponentKinds: []string{"QUESTION_TEXT"}, + ResponseComponentKinds: []string{"OPTION"}, + StimulusSchema: []DynamicElementDefinition{ + {ID: "stimulus_1", Kind: "QUESTION_TEXT", Required: true}, + }, + ResponseSchema: []DynamicElementDefinition{ + {ID: "response_1", Kind: "OPTION", Required: true}, + }, + } + + payload := DynamicQuestionPayload{ + Stimulus: []DynamicElementInstance{ + {ID: "stimulus_1", Kind: "QUESTION_TEXT"}, + }, + Response: []DynamicElementInstance{}, + } + + err := ValidateDynamicPayloadAgainstDefinition(payload, def) + if err == nil || !strings.Contains(err.Error(), "required element id") { + t.Fatalf("expected required element error, got %v", err) + } +} diff --git a/internal/domain/question_type_definitions.go b/internal/domain/question_type_definitions.go index 0c1f526..6dd3cb1 100644 --- a/internal/domain/question_type_definitions.go +++ b/internal/domain/question_type_definitions.go @@ -9,6 +9,8 @@ type QuestionTypeDefinition struct { Description *string StimulusComponentKinds []string ResponseComponentKinds []string + StimulusSchema []DynamicElementDefinition + ResponseSchema []DynamicElementDefinition IsSystem bool Status string CreatedAt time.Time @@ -21,6 +23,8 @@ type CreateQuestionTypeDefinitionInput struct { Description *string StimulusComponentKinds []string ResponseComponentKinds []string + StimulusSchema []DynamicElementDefinition + ResponseSchema []DynamicElementDefinition IsSystem bool Status *string } @@ -30,5 +34,7 @@ type UpdateQuestionTypeDefinitionInput struct { Description *string StimulusComponentKinds []string ResponseComponentKinds []string + StimulusSchema []DynamicElementDefinition + ResponseSchema []DynamicElementDefinition Status *string } diff --git a/internal/domain/questions.go b/internal/domain/questions.go index 2ede2c4..cec5944 100644 --- a/internal/domain/questions.go +++ b/internal/domain/questions.go @@ -49,6 +49,7 @@ type Question struct { QuestionText string QuestionType string QuestionTypeDefinitionID *int64 + DynamicPayload *DynamicQuestionPayload DifficultyLevel *string Points int32 Explanation *string @@ -123,6 +124,7 @@ type QuestionSetItemWithQuestion struct { QuestionSetItem QuestionText string QuestionType string + DynamicPayload *DynamicQuestionPayload DifficultyLevel *string Points int32 Explanation *string @@ -138,6 +140,7 @@ type CreateQuestionInput struct { QuestionText string QuestionType string QuestionTypeDefinitionID *int64 + DynamicPayload *DynamicQuestionPayload DifficultyLevel *string Points *int32 Explanation *string diff --git a/internal/repository/questions.go b/internal/repository/questions.go index fda8a74..814cade 100644 --- a/internal/repository/questions.go +++ b/internal/repository/questions.go @@ -2,6 +2,7 @@ package repository import ( "context" + "encoding/json" "errors" "strings" "time" @@ -64,22 +65,46 @@ func timePtr(t pgtype.Timestamptz) *time.Time { func questionToDomain(q dbgen.Question) domain.Question { return domain.Question{ - ID: q.ID, - QuestionText: q.QuestionText, - QuestionType: q.QuestionType, - DifficultyLevel: fromPgText(q.DifficultyLevel), - Points: q.Points, - Explanation: fromPgText(q.Explanation), - Tips: fromPgText(q.Tips), - VoicePrompt: fromPgText(q.VoicePrompt), - SampleAnswerVoicePrompt: fromPgText(q.SampleAnswerVoicePrompt), - ImageURL: fromPgText(q.ImageUrl), - Status: q.Status, - CreatedAt: q.CreatedAt.Time, - UpdatedAt: timePtr(q.UpdatedAt), + ID: q.ID, + QuestionText: q.QuestionText, + QuestionType: q.QuestionType, + QuestionTypeDefinitionID: fromPgInt8(q.QuestionTypeDefinitionID), + DynamicPayload: parseDynamicPayload(q.DynamicPayload), + DifficultyLevel: fromPgText(q.DifficultyLevel), + Points: q.Points, + Explanation: fromPgText(q.Explanation), + Tips: fromPgText(q.Tips), + VoicePrompt: fromPgText(q.VoicePrompt), + SampleAnswerVoicePrompt: fromPgText(q.SampleAnswerVoicePrompt), + ImageURL: fromPgText(q.ImageUrl), + Status: q.Status, + CreatedAt: q.CreatedAt.Time, + UpdatedAt: timePtr(q.UpdatedAt), } } +func parseDynamicPayload(raw []byte) *domain.DynamicQuestionPayload { + if len(raw) == 0 { + return nil + } + var payload domain.DynamicQuestionPayload + if err := json.Unmarshal(raw, &payload); err != nil { + return nil + } + return &payload +} + +func encodeDynamicPayload(payload *domain.DynamicQuestionPayload) []byte { + if payload == nil { + return nil + } + b, err := json.Marshal(payload) + if err != nil { + return nil + } + return b +} + func (s *Store) setQuestionTypeDefinitionID(ctx context.Context, questionID int64, definitionID *int64) error { _, err := s.conn.Exec(ctx, ` UPDATE questions @@ -156,11 +181,22 @@ func questionTypeDefinitionToDomain( description pgtype.Text, stimulusKinds []string, responseKinds []string, + stimulusSchema []byte, + responseSchema []byte, isSystem bool, status string, createdAt time.Time, updatedAt pgtype.Timestamptz, ) domain.QuestionTypeDefinition { + var stimulusSchemaDef []domain.DynamicElementDefinition + var responseSchemaDef []domain.DynamicElementDefinition + if len(stimulusSchema) > 0 { + _ = json.Unmarshal(stimulusSchema, &stimulusSchemaDef) + } + if len(responseSchema) > 0 { + _ = json.Unmarshal(responseSchema, &responseSchemaDef) + } + return domain.QuestionTypeDefinition{ ID: id, Key: key, @@ -168,6 +204,8 @@ func questionTypeDefinitionToDomain( Description: fromPgText(description), StimulusComponentKinds: stimulusKinds, ResponseComponentKinds: responseKinds, + StimulusSchema: stimulusSchemaDef, + ResponseSchema: responseSchemaDef, IsSystem: isSystem, Status: status, CreatedAt: createdAt, @@ -200,24 +238,85 @@ func normalizeDefinitionKey(key string) string { return key } +func kindsFromStimulusSchema(schema []domain.DynamicElementDefinition) []string { + seen := make(map[string]struct{}) + out := make([]string, 0, len(schema)) + for _, el := range schema { + kind := strings.TrimSpace(el.Kind) + if kind == "" { + continue + } + if _, exists := seen[kind]; exists { + continue + } + seen[kind] = struct{}{} + out = append(out, kind) + } + return out +} + +func kindsFromResponseSchema(schema []domain.DynamicElementDefinition) []string { + seen := make(map[string]struct{}) + out := make([]string, 0, len(schema)) + for _, el := range schema { + kind := strings.TrimSpace(el.Kind) + if kind == "" { + continue + } + if _, exists := seen[kind]; exists { + continue + } + seen[kind] = struct{}{} + out = append(out, kind) + } + return out +} + +func mustJSON(v interface{}) []byte { + b, err := json.Marshal(v) + if err != nil { + return []byte("[]") + } + return b +} + func (s *Store) CreateQuestionTypeDefinition(ctx context.Context, input domain.CreateQuestionTypeDefinitionInput) (domain.QuestionTypeDefinition, error) { + normalizedKey := normalizeDefinitionKey(input.Key) stimulusKinds := normalizeDefinitionKinds(input.StimulusComponentKinds) responseKinds := normalizeDefinitionKinds(input.ResponseComponentKinds) + stimulusSchema := input.StimulusSchema + responseSchema := input.ResponseSchema + + if len(stimulusKinds) == 0 && len(stimulusSchema) > 0 { + stimulusKinds = kindsFromStimulusSchema(stimulusSchema) + } + if len(responseKinds) == 0 && len(responseSchema) > 0 { + responseKinds = kindsFromResponseSchema(responseSchema) + } + if err := domain.ValidateDynamicQuestionTypeDefinition(stimulusKinds, responseKinds); err != nil { return domain.QuestionTypeDefinition{}, err } + if err := domain.ValidateDefinitionSchemas(stimulusSchema, responseSchema); err != nil { + return domain.QuestionTypeDefinition{}, err + } + if err := domain.ValidatePersistableQuestionTypeDefinition(normalizedKey, responseKinds); err != nil { + return domain.QuestionTypeDefinition{}, err + } row := s.conn.QueryRow(ctx, ` INSERT INTO question_type_definitions - (key, display_name, description, stimulus_component_kinds, response_component_kinds, is_system, status) - VALUES ($1, $2, $3, $4, $5, $6, $7) - RETURNING id, key, display_name, description, stimulus_component_kinds, response_component_kinds, is_system, status, created_at, updated_at + (key, display_name, description, stimulus_component_kinds, response_component_kinds, stimulus_schema, response_schema, is_system, status) + VALUES ($1, $2, $3, $4, $5, $6::jsonb, $7::jsonb, $8, $9) + RETURNING id, key, display_name, description, stimulus_component_kinds, response_component_kinds, stimulus_schema, response_schema, is_system, status, created_at, updated_at `, - normalizeDefinitionKey(input.Key), + normalizedKey, strings.TrimSpace(input.DisplayName), toPgText(input.Description), stimulusKinds, responseKinds, + mustJSON(stimulusSchema), + mustJSON(responseSchema), input.IsSystem, normalizeDefinitionStatus(input.Status), ) @@ -229,21 +328,23 @@ func (s *Store) CreateQuestionTypeDefinition(ctx context.Context, input domain.C description pgtype.Text stimulus []string response []string + stimulusSch []byte + responseSch []byte isSystem bool status string createdAt time.Time updatedAt pgtype.Timestamptz ) - if err := row.Scan(&id, &key, &displayName, &description, &stimulus, &response, &isSystem, &status, &createdAt, &updatedAt); err != nil { + if err := row.Scan(&id, &key, &displayName, &description, &stimulus, &response, &stimulusSch, &responseSch, &isSystem, &status, &createdAt, &updatedAt); err != nil { return domain.QuestionTypeDefinition{}, err } - return questionTypeDefinitionToDomain(id, key, displayName, description, stimulus, response, isSystem, status, createdAt, updatedAt), nil + return questionTypeDefinitionToDomain(id, key, displayName, description, stimulus, response, stimulusSch, responseSch, isSystem, status, createdAt, updatedAt), nil } func (s *Store) GetQuestionTypeDefinitionByID(ctx context.Context, id int64) (domain.QuestionTypeDefinition, error) { row := s.conn.QueryRow(ctx, ` - SELECT id, key, display_name, description, stimulus_component_kinds, response_component_kinds, is_system, status, created_at, updated_at + SELECT id, key, display_name, description, stimulus_component_kinds, response_component_kinds, stimulus_schema, response_schema, is_system, status, created_at, updated_at FROM question_type_definitions WHERE id = $1 `, id) @@ -254,20 +355,22 @@ func (s *Store) GetQuestionTypeDefinitionByID(ctx context.Context, id int64) (do description pgtype.Text stimulus []string response []string + stimulusSch []byte + responseSch []byte isSystem bool status string createdAt time.Time updatedAt pgtype.Timestamptz ) - if err := row.Scan(&id, &key, &displayName, &description, &stimulus, &response, &isSystem, &status, &createdAt, &updatedAt); err != nil { + if err := row.Scan(&id, &key, &displayName, &description, &stimulus, &response, &stimulusSch, &responseSch, &isSystem, &status, &createdAt, &updatedAt); err != nil { return domain.QuestionTypeDefinition{}, err } - return questionTypeDefinitionToDomain(id, key, displayName, description, stimulus, response, isSystem, status, createdAt, updatedAt), nil + return questionTypeDefinitionToDomain(id, key, displayName, description, stimulus, response, stimulusSch, responseSch, isSystem, status, createdAt, updatedAt), nil } func (s *Store) ListQuestionTypeDefinitions(ctx context.Context, status *string, includeSystem bool) ([]domain.QuestionTypeDefinition, error) { rows, err := s.conn.Query(ctx, ` - SELECT id, key, display_name, description, stimulus_component_kinds, response_component_kinds, is_system, status, created_at, updated_at + SELECT id, key, display_name, description, stimulus_component_kinds, response_component_kinds, stimulus_schema, response_schema, is_system, status, created_at, updated_at FROM question_type_definitions WHERE ($1::VARCHAR IS NULL OR status = $1) AND ($2::BOOLEAN = TRUE OR is_system = FALSE) @@ -287,15 +390,17 @@ func (s *Store) ListQuestionTypeDefinitions(ctx context.Context, status *string, description pgtype.Text stimulus []string response []string + stimulusSch []byte + responseSch []byte isSystem bool defStatus string createdAt time.Time updatedAt pgtype.Timestamptz ) - if err := rows.Scan(&id, &key, &displayName, &description, &stimulus, &response, &isSystem, &defStatus, &createdAt, &updatedAt); err != nil { + if err := rows.Scan(&id, &key, &displayName, &description, &stimulus, &response, &stimulusSch, &responseSch, &isSystem, &defStatus, &createdAt, &updatedAt); err != nil { return nil, err } - out = append(out, questionTypeDefinitionToDomain(id, key, displayName, description, stimulus, response, isSystem, defStatus, createdAt, updatedAt)) + out = append(out, questionTypeDefinitionToDomain(id, key, displayName, description, stimulus, response, stimulusSch, responseSch, isSystem, defStatus, createdAt, updatedAt)) } return out, rows.Err() @@ -326,10 +431,31 @@ func (s *Store) UpdateQuestionTypeDefinition(ctx context.Context, id int64, inpu if input.ResponseComponentKinds != nil { responseKinds = normalizeDefinitionKinds(input.ResponseComponentKinds) } + stimulusSchema := existing.StimulusSchema + if input.StimulusSchema != nil { + stimulusSchema = input.StimulusSchema + } + responseSchema := existing.ResponseSchema + if input.ResponseSchema != nil { + responseSchema = input.ResponseSchema + } + + if len(stimulusKinds) == 0 && len(stimulusSchema) > 0 { + stimulusKinds = kindsFromStimulusSchema(stimulusSchema) + } + if len(responseKinds) == 0 && len(responseSchema) > 0 { + responseKinds = kindsFromResponseSchema(responseSchema) + } if err := domain.ValidateDynamicQuestionTypeDefinition(stimulusKinds, responseKinds); err != nil { return err } + if err := domain.ValidateDefinitionSchemas(stimulusSchema, responseSchema); err != nil { + return err + } + if err := domain.ValidatePersistableQuestionTypeDefinition(existing.Key, responseKinds); err != nil { + return err + } status := existing.Status if input.Status != nil { @@ -342,10 +468,12 @@ func (s *Store) UpdateQuestionTypeDefinition(ctx context.Context, id int64, inpu description = $3, stimulus_component_kinds = $4, response_component_kinds = $5, - status = $6, + stimulus_schema = $6::jsonb, + response_schema = $7::jsonb, + status = $8, updated_at = NOW() WHERE id = $1 - `, id, displayName, toPgText(description), stimulusKinds, responseKinds, status) + `, id, displayName, toPgText(description), stimulusKinds, responseKinds, mustJSON(stimulusSchema), mustJSON(responseSchema), status) return err } @@ -396,6 +524,7 @@ func (s *Store) CreateQuestion(ctx context.Context, input domain.CreateQuestionI SampleAnswerVoicePrompt: toPgText(input.SampleAnswerVoicePrompt), ImageUrl: toPgText(input.ImageURL), Column10: status, + Column11: encodeDynamicPayload(input.DynamicPayload), }) if err != nil { return domain.Question{}, err @@ -450,7 +579,9 @@ func (s *Store) CreateQuestion(ctx context.Context, input domain.CreateQuestionI return domain.Question{}, err } - return questionToDomain(question), nil + created := questionToDomain(question) + created.QuestionTypeDefinitionID = input.QuestionTypeDefinitionID + return created, nil } func (s *Store) GetQuestionByID(ctx context.Context, id int64) (domain.Question, error) { @@ -532,19 +663,21 @@ func (s *Store) ListQuestions(ctx context.Context, questionType, difficulty, sta totalCount = r.TotalCount } questions[i] = domain.Question{ - ID: r.ID, - QuestionText: r.QuestionText, - QuestionType: r.QuestionType, - DifficultyLevel: fromPgText(r.DifficultyLevel), - Points: r.Points, - Explanation: fromPgText(r.Explanation), - Tips: fromPgText(r.Tips), - VoicePrompt: fromPgText(r.VoicePrompt), - SampleAnswerVoicePrompt: fromPgText(r.SampleAnswerVoicePrompt), - ImageURL: fromPgText(r.ImageUrl), - Status: r.Status, - CreatedAt: r.CreatedAt.Time, - UpdatedAt: timePtr(r.UpdatedAt), + ID: r.ID, + QuestionText: r.QuestionText, + QuestionType: r.QuestionType, + QuestionTypeDefinitionID: fromPgInt8(r.QuestionTypeDefinitionID), + DynamicPayload: parseDynamicPayload(r.DynamicPayload), + DifficultyLevel: fromPgText(r.DifficultyLevel), + Points: r.Points, + Explanation: fromPgText(r.Explanation), + Tips: fromPgText(r.Tips), + VoicePrompt: fromPgText(r.VoicePrompt), + SampleAnswerVoicePrompt: fromPgText(r.SampleAnswerVoicePrompt), + ImageURL: fromPgText(r.ImageUrl), + Status: r.Status, + CreatedAt: r.CreatedAt.Time, + UpdatedAt: timePtr(r.UpdatedAt), } } @@ -568,19 +701,21 @@ func (s *Store) SearchQuestions(ctx context.Context, query string, limit, offset totalCount = r.TotalCount } questions[i] = domain.Question{ - ID: r.ID, - QuestionText: r.QuestionText, - QuestionType: r.QuestionType, - DifficultyLevel: fromPgText(r.DifficultyLevel), - Points: r.Points, - Explanation: fromPgText(r.Explanation), - Tips: fromPgText(r.Tips), - VoicePrompt: fromPgText(r.VoicePrompt), - SampleAnswerVoicePrompt: fromPgText(r.SampleAnswerVoicePrompt), - ImageURL: fromPgText(r.ImageUrl), - Status: r.Status, - CreatedAt: r.CreatedAt.Time, - UpdatedAt: timePtr(r.UpdatedAt), + ID: r.ID, + QuestionText: r.QuestionText, + QuestionType: r.QuestionType, + QuestionTypeDefinitionID: fromPgInt8(r.QuestionTypeDefinitionID), + DynamicPayload: parseDynamicPayload(r.DynamicPayload), + DifficultyLevel: fromPgText(r.DifficultyLevel), + Points: r.Points, + Explanation: fromPgText(r.Explanation), + Tips: fromPgText(r.Tips), + VoicePrompt: fromPgText(r.VoicePrompt), + SampleAnswerVoicePrompt: fromPgText(r.SampleAnswerVoicePrompt), + ImageURL: fromPgText(r.ImageUrl), + Status: r.Status, + CreatedAt: r.CreatedAt.Time, + UpdatedAt: timePtr(r.UpdatedAt), } } @@ -609,6 +744,7 @@ func (s *Store) UpdateQuestion(ctx context.Context, id int64, input domain.Creat SampleAnswerVoicePrompt: toPgText(input.SampleAnswerVoicePrompt), ImageUrl: toPgText(input.ImageURL), Status: status, + Column11: encodeDynamicPayload(input.DynamicPayload), }) if err != nil { return err @@ -979,6 +1115,7 @@ func (s *Store) GetQuestionSetItems(ctx context.Context, setID int64) ([]domain. }, QuestionText: r.QuestionText, QuestionType: r.QuestionType, + DynamicPayload: parseDynamicPayload(r.DynamicPayload), DifficultyLevel: fromPgText(r.DifficultyLevel), Points: r.Points, Explanation: fromPgText(r.Explanation), @@ -1023,6 +1160,7 @@ func (s *Store) GetQuestionSetItemsPaginated(ctx context.Context, setID int64, q }, QuestionText: r.QuestionText, QuestionType: r.QuestionType, + DynamicPayload: parseDynamicPayload(r.DynamicPayload), DifficultyLevel: fromPgText(r.DifficultyLevel), Points: r.Points, Explanation: fromPgText(r.Explanation), @@ -1054,6 +1192,7 @@ func (s *Store) GetPublishedQuestionsInSet(ctx context.Context, setID int64) ([] }, QuestionText: r.QuestionText, QuestionType: r.QuestionType, + DynamicPayload: parseDynamicPayload(r.DynamicPayload), DifficultyLevel: fromPgText(r.DifficultyLevel), Points: r.Points, Explanation: fromPgText(r.Explanation), diff --git a/internal/web_server/handlers/question_type_builder.go b/internal/web_server/handlers/question_type_builder.go index 6da9bbc..3b411b8 100644 --- a/internal/web_server/handlers/question_type_builder.go +++ b/internal/web_server/handlers/question_type_builder.go @@ -19,20 +19,24 @@ type validateQuestionTypeDefinitionReq struct { } type createQuestionTypeDefinitionReq struct { - Key string `json:"key" validate:"required"` - DisplayName string `json:"display_name" validate:"required"` - Description *string `json:"description"` - StimulusComponentKinds []string `json:"stimulus_component_kinds"` - ResponseComponentKinds []string `json:"response_component_kinds"` - Status *string `json:"status"` + Key string `json:"key" validate:"required"` + DisplayName string `json:"display_name" validate:"required"` + Description *string `json:"description"` + StimulusComponentKinds []string `json:"stimulus_component_kinds"` + ResponseComponentKinds []string `json:"response_component_kinds"` + StimulusSchema []domain.DynamicElementDefinition `json:"stimulus_schema"` + ResponseSchema []domain.DynamicElementDefinition `json:"response_schema"` + Status *string `json:"status"` } type updateQuestionTypeDefinitionReq struct { - DisplayName *string `json:"display_name"` - Description *string `json:"description"` - StimulusComponentKinds []string `json:"stimulus_component_kinds"` - ResponseComponentKinds []string `json:"response_component_kinds"` - Status *string `json:"status"` + DisplayName *string `json:"display_name"` + Description *string `json:"description"` + StimulusComponentKinds []string `json:"stimulus_component_kinds"` + ResponseComponentKinds []string `json:"response_component_kinds"` + StimulusSchema []domain.DynamicElementDefinition `json:"stimulus_schema"` + ResponseSchema []domain.DynamicElementDefinition `json:"response_schema"` + Status *string `json:"status"` } // GetQuestionTypeComponentCatalog godoc @@ -54,7 +58,7 @@ func (h *Handler) GetQuestionTypeComponentCatalog(c *fiber.Ctx) error { // ValidateQuestionTypeDefinition godoc // @Summary Validate dynamic question-type definition -// @Description Validates selected stimulus and response component kinds for temporary question-type definitions +// @Description Validates selected stimulus and response component kinds for temporary question-type definitions (component-level validation only) // @Tags questions // @Accept json // @Produce json @@ -86,7 +90,7 @@ func (h *Handler) ValidateQuestionTypeDefinition(c *fiber.Ctx) error { // CreateQuestionTypeDefinition godoc // @Summary Create reusable question-type definition -// @Description Stores a reusable dynamic question-type definition for future question construction +// @Description Stores a reusable dynamic question-type definition for future question construction. Only runtime-mappable definitions are persisted. // @Tags questions // @Accept json // @Produce json @@ -116,6 +120,8 @@ func (h *Handler) CreateQuestionTypeDefinition(c *fiber.Ctx) error { Description: req.Description, StimulusComponentKinds: req.StimulusComponentKinds, ResponseComponentKinds: req.ResponseComponentKinds, + StimulusSchema: req.StimulusSchema, + ResponseSchema: req.ResponseSchema, Status: req.Status, IsSystem: false, }) @@ -197,6 +203,7 @@ func (h *Handler) GetQuestionTypeDefinitionByID(c *fiber.Ctx) error { // UpdateQuestionTypeDefinition godoc // @Summary Update reusable question-type definition +// @Description Updates a reusable dynamic question-type definition. Updated definitions must remain runtime-mappable. // @Tags questions // @Accept json // @Produce json @@ -233,6 +240,8 @@ func (h *Handler) UpdateQuestionTypeDefinition(c *fiber.Ctx) error { Description: req.Description, StimulusComponentKinds: req.StimulusComponentKinds, ResponseComponentKinds: req.ResponseComponentKinds, + StimulusSchema: req.StimulusSchema, + ResponseSchema: req.ResponseSchema, Status: req.Status, }) if err != nil { diff --git a/internal/web_server/handlers/questions.go b/internal/web_server/handlers/questions.go index 83235aa..58552d0 100644 --- a/internal/web_server/handlers/questions.go +++ b/internal/web_server/handlers/questions.go @@ -25,20 +25,21 @@ type shortAnswerInput struct { } type createQuestionReq struct { - QuestionText string `json:"question_text" validate:"required"` - QuestionType string `json:"question_type"` - QuestionTypeDefinitionID *int64 `json:"question_type_definition_id"` - DifficultyLevel *string `json:"difficulty_level"` - Points *int32 `json:"points"` - Explanation *string `json:"explanation"` - Tips *string `json:"tips"` - VoicePrompt *string `json:"voice_prompt"` - SampleAnswerVoicePrompt *string `json:"sample_answer_voice_prompt"` - ImageURL *string `json:"image_url"` - Status *string `json:"status"` - Options []optionInput `json:"options"` - ShortAnswers []shortAnswerInput `json:"short_answers"` - AudioCorrectAnswerText *string `json:"audio_correct_answer_text"` + QuestionText string `json:"question_text" validate:"required"` + QuestionType string `json:"question_type"` + QuestionTypeDefinitionID *int64 `json:"question_type_definition_id"` + DynamicPayload *domain.DynamicQuestionPayload `json:"dynamic_payload"` + DifficultyLevel *string `json:"difficulty_level"` + Points *int32 `json:"points"` + Explanation *string `json:"explanation"` + Tips *string `json:"tips"` + VoicePrompt *string `json:"voice_prompt"` + SampleAnswerVoicePrompt *string `json:"sample_answer_voice_prompt"` + ImageURL *string `json:"image_url"` + Status *string `json:"status"` + Options []optionInput `json:"options"` + ShortAnswers []shortAnswerInput `json:"short_answers"` + AudioCorrectAnswerText *string `json:"audio_correct_answer_text"` } type optionRes struct { @@ -55,21 +56,23 @@ type shortAnswerRes struct { } type questionRes struct { - ID int64 `json:"id"` - QuestionText string `json:"question_text"` - QuestionType string `json:"question_type"` - DifficultyLevel *string `json:"difficulty_level,omitempty"` - Points int32 `json:"points"` - Explanation *string `json:"explanation,omitempty"` - Tips *string `json:"tips,omitempty"` - VoicePrompt *string `json:"voice_prompt,omitempty"` - SampleAnswerVoicePrompt *string `json:"sample_answer_voice_prompt,omitempty"` - ImageURL *string `json:"image_url,omitempty"` - Status string `json:"status"` - CreatedAt string `json:"created_at"` - Options []optionRes `json:"options,omitempty"` - ShortAnswers []shortAnswerRes `json:"short_answers,omitempty"` - AudioCorrectAnswerText *string `json:"audio_correct_answer_text,omitempty"` + ID int64 `json:"id"` + QuestionText string `json:"question_text"` + QuestionType string `json:"question_type"` + QuestionTypeDefinitionID *int64 `json:"question_type_definition_id,omitempty"` + DynamicPayload *domain.DynamicQuestionPayload `json:"dynamic_payload,omitempty"` + DifficultyLevel *string `json:"difficulty_level,omitempty"` + Points int32 `json:"points"` + Explanation *string `json:"explanation,omitempty"` + Tips *string `json:"tips,omitempty"` + VoicePrompt *string `json:"voice_prompt,omitempty"` + SampleAnswerVoicePrompt *string `json:"sample_answer_voice_prompt,omitempty"` + ImageURL *string `json:"image_url,omitempty"` + Status string `json:"status"` + CreatedAt string `json:"created_at"` + Options []optionRes `json:"options,omitempty"` + ShortAnswers []shortAnswerRes `json:"short_answers,omitempty"` + AudioCorrectAnswerText *string `json:"audio_correct_answer_text,omitempty"` } type listQuestionsRes struct { @@ -78,22 +81,7 @@ type listQuestionsRes struct { } func resolveQuestionTypeFromDefinition(def domain.QuestionTypeDefinition) string { - key := strings.ToLower(strings.TrimSpace(def.Key)) - if key == "true_false" || key == "true-false" { - return "TRUE_FALSE" - } - - for _, kind := range def.ResponseComponentKinds { - switch strings.TrimSpace(kind) { - case "AUDIO_RESPONSE": - return "AUDIO" - case "MULTIPLE_CHOICE": - return "MCQ" - case "SHORT_ANSWER", "TEXT_INPUT", "SELECT_MISSING_WORDS", "MATCHING_ANSWER", "LABEL_SELECTION": - return "SHORT_ANSWER" - } - } - return "" + return domain.ResolveRuntimeQuestionTypeFromDefinition(def.Key, def.ResponseComponentKinds) } func normalizeRuntimeQuestionType(v string) string { @@ -102,7 +90,7 @@ func normalizeRuntimeQuestionType(v string) string { // CreateQuestion godoc // @Summary Create a new question -// @Description Creates a new question with options (for MCQ/TRUE_FALSE) or short answers (for SHORT_ANSWER) +// @Description Creates a new question with options (for MCQ/TRUE_FALSE) or short answers (for SHORT_ANSWER). Supports question_type_definition_id for dynamic builder-linked questions. // @Tags questions // @Accept json // @Produce json @@ -129,22 +117,33 @@ func (h *Handler) CreateQuestion(c *fiber.Ctx) error { Error: err.Error(), }) } - inferred := resolveQuestionTypeFromDefinition(def) - if inferred == "" { - return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{ - Message: "Unsupported dynamic question type definition", - Error: "unable to map definition to runtime question_type", - }) - } if questionType == "" { - questionType = inferred + questionType = "DYNAMIC" } - if questionType != inferred { + if questionType != "DYNAMIC" { return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{ - Message: "Mismatched question_type and definition", - Error: "question_type must match the selected question_type_definition_id", + Message: "Invalid question_type for dynamic definition", + Error: "question_type must be DYNAMIC when question_type_definition_id is provided", }) } + if req.DynamicPayload == nil { + return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{ + Message: "Missing dynamic_payload", + Error: "dynamic_payload is required when question_type_definition_id is provided", + }) + } + if err := domain.ValidateDynamicPayloadAgainstDefinition(*req.DynamicPayload, def); err != nil { + return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{ + Message: "Invalid dynamic_payload", + Error: err.Error(), + }) + } + } + if req.QuestionTypeDefinitionID == nil && req.DynamicPayload != nil { + return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{ + Message: "Invalid dynamic_payload usage", + Error: "dynamic_payload requires question_type_definition_id", + }) } if questionType == "" { return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{ @@ -153,11 +152,17 @@ func (h *Handler) CreateQuestion(c *fiber.Ctx) error { }) } switch questionType { - case "MCQ", "TRUE_FALSE", "SHORT_ANSWER", "AUDIO": + case "MCQ", "TRUE_FALSE", "SHORT_ANSWER", "AUDIO", "DYNAMIC": default: return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{ Message: "Invalid question_type", - Error: "question_type must be one of MCQ, TRUE_FALSE, SHORT_ANSWER, AUDIO", + Error: "question_type must be one of MCQ, TRUE_FALSE, SHORT_ANSWER, AUDIO, DYNAMIC", + }) + } + if questionType == "DYNAMIC" && req.QuestionTypeDefinitionID == nil { + return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{ + Message: "Invalid dynamic question", + Error: "question_type_definition_id is required for DYNAMIC question_type", }) } @@ -184,6 +189,7 @@ func (h *Handler) CreateQuestion(c *fiber.Ctx) error { QuestionText: req.QuestionText, QuestionType: questionType, QuestionTypeDefinitionID: req.QuestionTypeDefinitionID, + DynamicPayload: req.DynamicPayload, DifficultyLevel: req.DifficultyLevel, Points: req.Points, Explanation: req.Explanation, @@ -218,18 +224,20 @@ func (h *Handler) CreateQuestion(c *fiber.Ctx) error { return c.Status(fiber.StatusCreated).JSON(domain.Response{ Message: "Question created successfully", Data: questionRes{ - ID: question.ID, - QuestionText: question.QuestionText, - QuestionType: question.QuestionType, - DifficultyLevel: question.DifficultyLevel, - Points: question.Points, - Explanation: question.Explanation, - Tips: question.Tips, - VoicePrompt: question.VoicePrompt, - SampleAnswerVoicePrompt: question.SampleAnswerVoicePrompt, - ImageURL: question.ImageURL, - Status: question.Status, - CreatedAt: question.CreatedAt.String(), + ID: question.ID, + QuestionText: question.QuestionText, + QuestionType: question.QuestionType, + QuestionTypeDefinitionID: question.QuestionTypeDefinitionID, + DynamicPayload: question.DynamicPayload, + DifficultyLevel: question.DifficultyLevel, + Points: question.Points, + Explanation: question.Explanation, + Tips: question.Tips, + VoicePrompt: question.VoicePrompt, + SampleAnswerVoicePrompt: question.SampleAnswerVoicePrompt, + ImageURL: question.ImageURL, + Status: question.Status, + CreatedAt: question.CreatedAt.String(), }, }) } @@ -289,21 +297,23 @@ func (h *Handler) GetQuestionByID(c *fiber.Ctx) error { return c.JSON(domain.Response{ Message: "Question retrieved successfully", Data: questionRes{ - ID: question.ID, - QuestionText: question.QuestionText, - QuestionType: question.QuestionType, - DifficultyLevel: question.DifficultyLevel, - Points: question.Points, - Explanation: question.Explanation, - Tips: question.Tips, - VoicePrompt: question.VoicePrompt, - SampleAnswerVoicePrompt: question.SampleAnswerVoicePrompt, - ImageURL: question.ImageURL, - Status: question.Status, - CreatedAt: question.CreatedAt.String(), - Options: options, - ShortAnswers: shortAnswers, - AudioCorrectAnswerText: audioCorrectAnswerText, + ID: question.ID, + QuestionText: question.QuestionText, + QuestionType: question.QuestionType, + QuestionTypeDefinitionID: question.QuestionTypeDefinitionID, + DynamicPayload: question.DynamicPayload, + DifficultyLevel: question.DifficultyLevel, + Points: question.Points, + Explanation: question.Explanation, + Tips: question.Tips, + VoicePrompt: question.VoicePrompt, + SampleAnswerVoicePrompt: question.SampleAnswerVoicePrompt, + ImageURL: question.ImageURL, + Status: question.Status, + CreatedAt: question.CreatedAt.String(), + Options: options, + ShortAnswers: shortAnswers, + AudioCorrectAnswerText: audioCorrectAnswerText, }, }) } @@ -354,16 +364,18 @@ func (h *Handler) ListQuestions(c *fiber.Ctx) error { var questionResponses []questionRes for _, q := range questions { questionResponses = append(questionResponses, questionRes{ - ID: q.ID, - QuestionText: q.QuestionText, - QuestionType: q.QuestionType, - DifficultyLevel: q.DifficultyLevel, - Points: q.Points, - Explanation: q.Explanation, - Tips: q.Tips, - VoicePrompt: q.VoicePrompt, - Status: q.Status, - CreatedAt: q.CreatedAt.String(), + ID: q.ID, + QuestionText: q.QuestionText, + QuestionType: q.QuestionType, + QuestionTypeDefinitionID: q.QuestionTypeDefinitionID, + DynamicPayload: q.DynamicPayload, + DifficultyLevel: q.DifficultyLevel, + Points: q.Points, + Explanation: q.Explanation, + Tips: q.Tips, + VoicePrompt: q.VoicePrompt, + Status: q.Status, + CreatedAt: q.CreatedAt.String(), }) } @@ -412,13 +424,15 @@ func (h *Handler) SearchQuestions(c *fiber.Ctx) error { var questionResponses []questionRes for _, q := range questions { questionResponses = append(questionResponses, questionRes{ - ID: q.ID, - QuestionText: q.QuestionText, - QuestionType: q.QuestionType, - DifficultyLevel: q.DifficultyLevel, - Points: q.Points, - Status: q.Status, - CreatedAt: q.CreatedAt.String(), + ID: q.ID, + QuestionText: q.QuestionText, + QuestionType: q.QuestionType, + QuestionTypeDefinitionID: q.QuestionTypeDefinitionID, + DynamicPayload: q.DynamicPayload, + DifficultyLevel: q.DifficultyLevel, + Points: q.Points, + Status: q.Status, + CreatedAt: q.CreatedAt.String(), }) } @@ -432,25 +446,26 @@ func (h *Handler) SearchQuestions(c *fiber.Ctx) error { } type updateQuestionReq struct { - QuestionText *string `json:"question_text"` - QuestionType *string `json:"question_type"` - QuestionTypeDefinitionID *int64 `json:"question_type_definition_id"` - DifficultyLevel *string `json:"difficulty_level"` - Points *int32 `json:"points"` - Explanation *string `json:"explanation"` - Tips *string `json:"tips"` - VoicePrompt *string `json:"voice_prompt"` - SampleAnswerVoicePrompt *string `json:"sample_answer_voice_prompt"` - ImageURL *string `json:"image_url"` - Status *string `json:"status"` - Options []optionInput `json:"options"` - ShortAnswers []shortAnswerInput `json:"short_answers"` - AudioCorrectAnswerText *string `json:"audio_correct_answer_text"` + QuestionText *string `json:"question_text"` + QuestionType *string `json:"question_type"` + QuestionTypeDefinitionID *int64 `json:"question_type_definition_id"` + DynamicPayload *domain.DynamicQuestionPayload `json:"dynamic_payload"` + DifficultyLevel *string `json:"difficulty_level"` + Points *int32 `json:"points"` + Explanation *string `json:"explanation"` + Tips *string `json:"tips"` + VoicePrompt *string `json:"voice_prompt"` + SampleAnswerVoicePrompt *string `json:"sample_answer_voice_prompt"` + ImageURL *string `json:"image_url"` + Status *string `json:"status"` + Options []optionInput `json:"options"` + ShortAnswers []shortAnswerInput `json:"short_answers"` + AudioCorrectAnswerText *string `json:"audio_correct_answer_text"` } // UpdateQuestion godoc // @Summary Update a question -// @Description Updates a question and optionally replaces its options/short answers +// @Description Updates a question and optionally replaces its options/short answers. Supports question_type_definition_id for dynamic builder-linked questions. // @Tags questions // @Accept json // @Produce json @@ -478,6 +493,14 @@ func (h *Handler) UpdateQuestion(c *fiber.Ctx) error { }) } + existingQuestion, err := h.questionsSvc.GetQuestionByID(c.Context(), id) + if err != nil { + return c.Status(fiber.StatusNotFound).JSON(domain.ErrorResponse{ + Message: "Question not found", + Error: err.Error(), + }) + } + var options []domain.CreateQuestionOptionInput for _, opt := range req.Options { options = append(options, domain.CreateQuestionOptionInput{ @@ -495,44 +518,80 @@ func (h *Handler) UpdateQuestion(c *fiber.Ctx) error { }) } - questionText := "" + questionText := existingQuestion.QuestionText if req.QuestionText != nil { questionText = *req.QuestionText } - questionType := "" + questionType := normalizeRuntimeQuestionType(existingQuestion.QuestionType) if req.QuestionType != nil { questionType = normalizeRuntimeQuestionType(*req.QuestionType) } + effectiveDefinitionID := existingQuestion.QuestionTypeDefinitionID if req.QuestionTypeDefinitionID != nil { - def, err := h.questionsSvc.GetQuestionTypeDefinitionByID(c.Context(), *req.QuestionTypeDefinitionID) + effectiveDefinitionID = req.QuestionTypeDefinitionID + } + effectiveDynamicPayload := existingQuestion.DynamicPayload + if req.DynamicPayload != nil { + effectiveDynamicPayload = req.DynamicPayload + } + + if effectiveDefinitionID != nil { + def, err := h.questionsSvc.GetQuestionTypeDefinitionByID(c.Context(), *effectiveDefinitionID) if err != nil { return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{ Message: "Invalid question_type_definition_id", Error: err.Error(), }) } - inferred := resolveQuestionTypeFromDefinition(def) - if inferred == "" { - return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{ - Message: "Unsupported dynamic question type definition", - Error: "unable to map definition to runtime question_type", - }) - } if questionType == "" { - questionType = inferred + questionType = "DYNAMIC" } - if questionType != inferred { + if questionType != "DYNAMIC" { return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{ - Message: "Mismatched question_type and definition", - Error: "question_type must match the selected question_type_definition_id", + Message: "Invalid question_type for dynamic definition", + Error: "question_type must be DYNAMIC when question_type_definition_id is provided", }) } + if effectiveDynamicPayload == nil { + return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{ + Message: "Missing dynamic_payload", + Error: "dynamic_payload is required for dynamic questions", + }) + } + if err := domain.ValidateDynamicPayloadAgainstDefinition(*effectiveDynamicPayload, def); err != nil { + return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{ + Message: "Invalid dynamic_payload", + Error: err.Error(), + }) + } + } + if effectiveDefinitionID == nil && req.DynamicPayload != nil { + return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{ + Message: "Invalid dynamic_payload usage", + Error: "dynamic_payload requires question_type_definition_id", + }) + } + + switch questionType { + case "MCQ", "TRUE_FALSE", "SHORT_ANSWER", "AUDIO", "DYNAMIC": + default: + return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{ + Message: "Invalid question_type", + Error: "question_type must be one of MCQ, TRUE_FALSE, SHORT_ANSWER, AUDIO, DYNAMIC", + }) + } + if questionType == "DYNAMIC" && effectiveDefinitionID == nil { + return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{ + Message: "Invalid dynamic question", + Error: "question_type_definition_id is required for DYNAMIC question_type", + }) } input := domain.CreateQuestionInput{ QuestionText: questionText, QuestionType: questionType, - QuestionTypeDefinitionID: req.QuestionTypeDefinitionID, + QuestionTypeDefinitionID: effectiveDefinitionID, + DynamicPayload: effectiveDynamicPayload, DifficultyLevel: req.DifficultyLevel, Points: req.Points, Explanation: req.Explanation, @@ -1152,21 +1211,22 @@ type addQuestionToSetReq struct { } type questionSetItemRes struct { - ID int64 `json:"id"` - SetID int64 `json:"set_id"` - QuestionID int64 `json:"question_id"` - DisplayOrder int32 `json:"display_order"` - QuestionText string `json:"question_text"` - QuestionType string `json:"question_type"` - DifficultyLevel *string `json:"difficulty_level,omitempty"` - Points int32 `json:"points"` - Explanation *string `json:"explanation,omitempty"` - Tips *string `json:"tips,omitempty"` - VoicePrompt *string `json:"voice_prompt,omitempty"` - SampleAnswerVoicePrompt *string `json:"sample_answer_voice_prompt,omitempty"` - ImageURL *string `json:"image_url,omitempty"` - AudioCorrectAnswerText *string `json:"audio_correct_answer_text,omitempty"` - QuestionStatus string `json:"question_status"` + ID int64 `json:"id"` + SetID int64 `json:"set_id"` + QuestionID int64 `json:"question_id"` + DisplayOrder int32 `json:"display_order"` + QuestionText string `json:"question_text"` + QuestionType string `json:"question_type"` + DynamicPayload *domain.DynamicQuestionPayload `json:"dynamic_payload,omitempty"` + DifficultyLevel *string `json:"difficulty_level,omitempty"` + Points int32 `json:"points"` + Explanation *string `json:"explanation,omitempty"` + Tips *string `json:"tips,omitempty"` + VoicePrompt *string `json:"voice_prompt,omitempty"` + SampleAnswerVoicePrompt *string `json:"sample_answer_voice_prompt,omitempty"` + ImageURL *string `json:"image_url,omitempty"` + AudioCorrectAnswerText *string `json:"audio_correct_answer_text,omitempty"` + QuestionStatus string `json:"question_status"` } type paginatedQuestionSetItemsRes struct { @@ -1186,6 +1246,7 @@ func questionSetItemsToRes(items []domain.QuestionSetItemWithQuestion) []questio DisplayOrder: item.DisplayOrder, QuestionText: item.QuestionText, QuestionType: item.QuestionType, + DynamicPayload: item.DynamicPayload, DifficultyLevel: item.DifficultyLevel, Points: item.Points, Explanation: item.Explanation, @@ -1331,21 +1392,23 @@ func (h *Handler) GetQuestionsInSet(c *fiber.Ctx) error { } questionResponses = append(questionResponses, questionRes{ - ID: question.ID, - QuestionText: question.QuestionText, - QuestionType: question.QuestionType, - DifficultyLevel: question.DifficultyLevel, - Points: question.Points, - Explanation: question.Explanation, - Tips: question.Tips, - VoicePrompt: question.VoicePrompt, - SampleAnswerVoicePrompt: question.SampleAnswerVoicePrompt, - ImageURL: question.ImageURL, - Status: question.Status, - CreatedAt: question.CreatedAt.String(), - Options: options, - ShortAnswers: shortAnswers, - AudioCorrectAnswerText: audioCorrectAnswerText, + ID: question.ID, + QuestionText: question.QuestionText, + QuestionType: question.QuestionType, + QuestionTypeDefinitionID: question.QuestionTypeDefinitionID, + DynamicPayload: question.DynamicPayload, + DifficultyLevel: question.DifficultyLevel, + Points: question.Points, + Explanation: question.Explanation, + Tips: question.Tips, + VoicePrompt: question.VoicePrompt, + SampleAnswerVoicePrompt: question.SampleAnswerVoicePrompt, + ImageURL: question.ImageURL, + Status: question.Status, + CreatedAt: question.CreatedAt.String(), + Options: options, + ShortAnswers: shortAnswers, + AudioCorrectAnswerText: audioCorrectAnswerText, }) } diff --git a/postman/Duolingo-ExamPrep.postman_collection.json b/postman/Duolingo-ExamPrep.postman_collection.json deleted file mode 100644 index 13cad41..0000000 --- a/postman/Duolingo-ExamPrep.postman_collection.json +++ /dev/null @@ -1,457 +0,0 @@ -{ - "info": { - "_postman_id": "f7c2e4a1-8b3d-4e9f-a2c6-11dd99ee5501", - "name": "Yimaru Exam Prep (Duolingo)", - "description": "Exam-prep tree API (`/api/v1/exam-prep/...`): catalog courses → units → modules → lessons → practices. Requires Bearer token.\n\n**Courses** = `catalog-courses` in the backend. Set collection variables before chaining requests.", - "schema": "https://schema.getpostman.com/json/collection/v2.1.0/collection.json" - }, - "auth": { - "type": "bearer", - "bearer": [ - { - "key": "token", - "value": "{{accessToken}}", - "type": "string" - } - ] - }, - "variable": [ - { - "key": "baseUrl", - "value": "http://localhost:8080" - }, - { - "key": "accessToken", - "value": "" - }, - { - "key": "catalogCourseId", - "value": "1" - }, - { - "key": "unitId", - "value": "1" - }, - { - "key": "moduleId", - "value": "1" - }, - { - "key": "lessonId", - "value": "1" - }, - { - "key": "practiceId", - "value": "1" - } - ], - "item": [ - { - "name": "Duolingo", - "item": [ - { - "name": "Courses", - "description": "Backend route group: **`catalog-courses`** (`exam_prep.catalog_courses.*`)", - "item": [ - { - "name": "Create catalog course", - "request": { - "method": "POST", - "header": [ - { - "key": "Content-Type", - "value": "application/json" - } - ], - "body": { - "mode": "raw", - "raw": "{\n \"name\": \"IELTS Prep\",\n \"description\": \"Optional description\",\n \"thumbnail\": null\n}" - }, - "url": "{{baseUrl}}/api/v1/exam-prep/catalog-courses", - "description": "Permission: `exam_prep.catalog_courses.create`" - } - }, - { - "name": "List catalog courses", - "request": { - "method": "GET", - "url": "{{baseUrl}}/api/v1/exam-prep/catalog-courses?limit=20&offset=0", - "description": "Permission: `exam_prep.catalog_courses.list`" - } - }, - { - "name": "Reorder catalog courses", - "request": { - "method": "PUT", - "header": [ - { - "key": "Content-Type", - "value": "application/json" - } - ], - "body": { - "mode": "raw", - "raw": "{\n \"ordered_ids\": [1, 2, 3]\n}" - }, - "url": "{{baseUrl}}/api/v1/exam-prep/catalog-courses/reorder", - "description": "Permission: `exam_prep.catalog_courses.reorder`. Must include every id in scope exactly once." - } - }, - { - "name": "Get catalog course by ID", - "request": { - "method": "GET", - "url": "{{baseUrl}}/api/v1/exam-prep/catalog-courses/{{catalogCourseId}}", - "description": "Permission: `exam_prep.catalog_courses.get`" - } - }, - { - "name": "Update catalog course", - "request": { - "method": "PUT", - "header": [ - { - "key": "Content-Type", - "value": "application/json" - } - ], - "body": { - "mode": "raw", - "raw": "{\n \"name\": \"Updated name\",\n \"description\": null,\n \"thumbnail\": null,\n \"sort_order\": 1\n}" - }, - "url": "{{baseUrl}}/api/v1/exam-prep/catalog-courses/{{catalogCourseId}}", - "description": "Permission: `exam_prep.catalog_courses.update`" - } - }, - { - "name": "Delete catalog course", - "request": { - "method": "DELETE", - "url": "{{baseUrl}}/api/v1/exam-prep/catalog-courses/{{catalogCourseId}}", - "description": "Permission: `exam_prep.catalog_courses.delete`" - } - } - ] - }, - { - "name": "Units", - "description": "Nested under catalog course (`exam_prep.units.*`)", - "item": [ - { - "name": "Create unit", - "request": { - "method": "POST", - "header": [ - { - "key": "Content-Type", - "value": "application/json" - } - ], - "body": { - "mode": "raw", - "raw": "{\n \"name\": \"Grammar foundations\",\n \"description\": null,\n \"thumbnail\": null\n}" - }, - "url": "{{baseUrl}}/api/v1/exam-prep/catalog-courses/{{catalogCourseId}}/units", - "description": "Permission: `exam_prep.units.create`" - } - }, - { - "name": "List units by catalog course", - "request": { - "method": "GET", - "url": "{{baseUrl}}/api/v1/exam-prep/catalog-courses/{{catalogCourseId}}/units?limit=20&offset=0", - "description": "Permission: `exam_prep.units.list`" - } - }, - { - "name": "Reorder units in catalog course", - "request": { - "method": "PUT", - "header": [ - { - "key": "Content-Type", - "value": "application/json" - } - ], - "body": { - "mode": "raw", - "raw": "{\n \"ordered_ids\": [1, 2, 3]\n}" - }, - "url": "{{baseUrl}}/api/v1/exam-prep/catalog-courses/{{catalogCourseId}}/units/reorder", - "description": "Permission: `exam_prep.units.reorder`" - } - }, - { - "name": "Get unit by ID", - "request": { - "method": "GET", - "url": "{{baseUrl}}/api/v1/exam-prep/units/{{unitId}}", - "description": "Permission: `exam_prep.units.get`" - } - }, - { - "name": "Update unit", - "request": { - "method": "PUT", - "header": [ - { - "key": "Content-Type", - "value": "application/json" - } - ], - "body": { - "mode": "raw", - "raw": "{\n \"name\": \"Updated unit\",\n \"description\": null,\n \"thumbnail\": null,\n \"sort_order\": 1\n}" - }, - "url": "{{baseUrl}}/api/v1/exam-prep/units/{{unitId}}", - "description": "Permission: `exam_prep.units.update`" - } - }, - { - "name": "Delete unit", - "request": { - "method": "DELETE", - "url": "{{baseUrl}}/api/v1/exam-prep/units/{{unitId}}", - "description": "Permission: `exam_prep.units.delete`" - } - } - ] - }, - { - "name": "Modules", - "description": "Exam-prep **`unit_modules`** (`exam_prep.modules.*`)", - "item": [ - { - "name": "Create module", - "request": { - "method": "POST", - "header": [ - { - "key": "Content-Type", - "value": "application/json" - } - ], - "body": { - "mode": "raw", - "raw": "{\n \"name\": \"Present tense\",\n \"description\": null,\n \"thumbnail\": null,\n \"icon\": null\n}" - }, - "url": "{{baseUrl}}/api/v1/exam-prep/units/{{unitId}}/modules", - "description": "Permission: `exam_prep.modules.create`" - } - }, - { - "name": "List modules by unit", - "request": { - "method": "GET", - "url": "{{baseUrl}}/api/v1/exam-prep/units/{{unitId}}/modules?limit=20&offset=0", - "description": "Permission: `exam_prep.modules.list`" - } - }, - { - "name": "Reorder modules in unit", - "request": { - "method": "PUT", - "header": [ - { - "key": "Content-Type", - "value": "application/json" - } - ], - "body": { - "mode": "raw", - "raw": "{\n \"ordered_ids\": [1, 2, 3]\n}" - }, - "url": "{{baseUrl}}/api/v1/exam-prep/units/{{unitId}}/modules/reorder", - "description": "Permission: `exam_prep.modules.reorder`" - } - }, - { - "name": "Get module by ID", - "request": { - "method": "GET", - "url": "{{baseUrl}}/api/v1/exam-prep/modules/{{moduleId}}", - "description": "Permission: `exam_prep.modules.get`" - } - }, - { - "name": "Update module", - "request": { - "method": "PUT", - "header": [ - { - "key": "Content-Type", - "value": "application/json" - } - ], - "body": { - "mode": "raw", - "raw": "{\n \"name\": \"Updated module\",\n \"description\": null,\n \"thumbnail\": null,\n \"icon\": null,\n \"sort_order\": 1\n}" - }, - "url": "{{baseUrl}}/api/v1/exam-prep/modules/{{moduleId}}", - "description": "Permission: `exam_prep.modules.update`" - } - }, - { - "name": "Delete module", - "request": { - "method": "DELETE", - "url": "{{baseUrl}}/api/v1/exam-prep/modules/{{moduleId}}", - "description": "Permission: `exam_prep.modules.delete`" - } - } - ] - }, - { - "name": "Lessons", - "description": "`exam_prep.lessons.*`", - "item": [ - { - "name": "Create lesson", - "request": { - "method": "POST", - "header": [ - { - "key": "Content-Type", - "value": "application/json" - } - ], - "body": { - "mode": "raw", - "raw": "{\n \"title\": \"Intro video\",\n \"video_url\": \"https://example.com/video\",\n \"thumbnail\": null,\n \"description\": null\n}" - }, - "url": "{{baseUrl}}/api/v1/exam-prep/modules/{{moduleId}}/lessons", - "description": "Permission: `exam_prep.lessons.create`" - } - }, - { - "name": "List lessons by module", - "request": { - "method": "GET", - "url": "{{baseUrl}}/api/v1/exam-prep/modules/{{moduleId}}/lessons?limit=20&offset=0", - "description": "Permission: `exam_prep.lessons.list_by_module`" - } - }, - { - "name": "Reorder lessons in module", - "request": { - "method": "PUT", - "header": [ - { - "key": "Content-Type", - "value": "application/json" - } - ], - "body": { - "mode": "raw", - "raw": "{\n \"ordered_ids\": [1, 2, 3]\n}" - }, - "url": "{{baseUrl}}/api/v1/exam-prep/modules/{{moduleId}}/lessons/reorder", - "description": "Permission: `exam_prep.lessons.reorder`" - } - }, - { - "name": "Get lesson by ID", - "request": { - "method": "GET", - "url": "{{baseUrl}}/api/v1/exam-prep/lessons/{{lessonId}}", - "description": "Permission: `exam_prep.lessons.get`" - } - }, - { - "name": "Update lesson", - "request": { - "method": "PUT", - "header": [ - { - "key": "Content-Type", - "value": "application/json" - } - ], - "body": { - "mode": "raw", - "raw": "{\n \"title\": \"Updated lesson\",\n \"video_url\": null,\n \"thumbnail\": null,\n \"description\": null,\n \"sort_order\": 1\n}" - }, - "url": "{{baseUrl}}/api/v1/exam-prep/lessons/{{lessonId}}", - "description": "Permission: `exam_prep.lessons.update`" - } - }, - { - "name": "Delete lesson", - "request": { - "method": "DELETE", - "url": "{{baseUrl}}/api/v1/exam-prep/lessons/{{lessonId}}", - "description": "Permission: `exam_prep.lessons.delete`" - } - } - ] - }, - { - "name": "Practices", - "description": "Tied to lesson; **`question_set_id`** references shared `question_sets`. `exam_prep.practices.*`", - "item": [ - { - "name": "Create practice", - "request": { - "method": "POST", - "header": [ - { - "key": "Content-Type", - "value": "application/json" - } - ], - "body": { - "mode": "raw", - "raw": "{\n \"title\": \"Drill: articles\",\n \"story_description\": null,\n \"story_image\": null,\n \"persona_id\": null,\n \"question_set_id\": 1,\n \"quick_tips\": null\n}" - }, - "url": "{{baseUrl}}/api/v1/exam-prep/lessons/{{lessonId}}/practices", - "description": "Permission: `exam_prep.practices.create`" - } - }, - { - "name": "List practices by lesson", - "request": { - "method": "GET", - "url": "{{baseUrl}}/api/v1/exam-prep/lessons/{{lessonId}}/practices?limit=20&offset=0", - "description": "Permission: `exam_prep.practices.list_by_lesson`" - } - }, - { - "name": "Get practice by ID", - "request": { - "method": "GET", - "url": "{{baseUrl}}/api/v1/exam-prep/practices/{{practiceId}}", - "description": "Permission: `exam_prep.practices.get`" - } - }, - { - "name": "Update practice", - "request": { - "method": "PUT", - "header": [ - { - "key": "Content-Type", - "value": "application/json" - } - ], - "body": { - "mode": "raw", - "raw": "{\n \"title\": \"Updated practice\",\n \"story_description\": null,\n \"story_image\": null,\n \"persona_id\": null,\n \"question_set_id\": 1,\n \"quick_tips\": null\n}" - }, - "url": "{{baseUrl}}/api/v1/exam-prep/practices/{{practiceId}}", - "description": "Permission: `exam_prep.practices.update`. Omit fields you do not change." - } - }, - { - "name": "Delete practice", - "request": { - "method": "DELETE", - "url": "{{baseUrl}}/api/v1/exam-prep/practices/{{practiceId}}", - "description": "Permission: `exam_prep.practices.delete`" - } - } - ] - } - ] - } - ] -} diff --git a/postman/Dynamic-Question-Type-Builder.postman_collection.json b/postman/Dynamic-Question-Type-Builder.postman_collection.json index e2e6ebb..1201e1e 100644 --- a/postman/Dynamic-Question-Type-Builder.postman_collection.json +++ b/postman/Dynamic-Question-Type-Builder.postman_collection.json @@ -1,418 +1,663 @@ { "info": { - "name": "Yimaru Dynamic Question Type Builder API", - "_postman_id": "f0f9c795-09aa-4f5a-9cc0-1f2fcb0f1b01", - "description": "Complete Postman collection for the dynamic question type builder feature, including catalog, validation, reusable type-definition CRUD, and question create/update using question_type_definition_id.", + "_postman_id": "c9e5e08f-e878-4d8a-b24c-5f866eb4c735", + "name": "Dynamic Question Type Builder - Runtime Complete", + "description": "Complete collection for the new dynamic question type builder flow: component catalog, definition schema CRUD, dynamic payload validation, and DYNAMIC question lifecycle.", "schema": "https://schema.getpostman.com/json/collection/v2.1.0/collection.json" }, - "variable": [ - { - "key": "baseUrl", - "value": "http://localhost:8080" - }, - { - "key": "apiPrefix", - "value": "/api/v1" - }, - { - "key": "token", - "value": "" - }, - { - "key": "questionTypeDefinitionId", - "value": "" - }, - { - "key": "questionId", - "value": "" - } - ], "auth": { "type": "bearer", "bearer": [ { "key": "token", - "value": "{{token}}", + "value": "{{access_token}}", "type": "string" } ] }, + "variable": [ + { + "key": "base_url", + "value": "http://localhost:8080" + }, + { + "key": "access_token", + "value": "" + }, + { + "key": "definition_key", + "value": "dynamic_builder_{{$timestamp}}" + }, + { + "key": "dynamic_definition_id", + "value": "" + }, + { + "key": "dynamic_question_id", + "value": "" + } + ], "item": [ { - "name": "01 - Builder Component Catalog", - "request": { - "method": "GET", - "header": [], - "url": { - "raw": "{{baseUrl}}{{apiPrefix}}/questions/component-catalog", - "host": [ - "{{baseUrl}}" - ], - "path": [ - "{{apiPrefix}}", - "questions", - "component-catalog" - ] - }, - "description": "Returns supported stimulus and response component kinds for dynamic type definitions." - }, - "response": [] - }, - { - "name": "02 - Validate Dynamic Type Definition", - "request": { - "method": "POST", - "header": [ - { - "key": "Content-Type", - "value": "application/json" - } - ], - "body": { - "mode": "raw", - "raw": "{\n \"stimulus_component_kinds\": [\"INSTRUCTION\", \"TEXT_PASSAGE\"],\n \"response_component_kinds\": [\"MULTIPLE_CHOICE\"]\n}" - }, - "url": { - "raw": "{{baseUrl}}{{apiPrefix}}/questions/validate-question-type-definition", - "host": [ - "{{baseUrl}}" - ], - "path": [ - "{{apiPrefix}}", - "questions", - "validate-question-type-definition" - ] - }, - "description": "Validates a candidate dynamic question-type definition before saving." - }, - "response": [] - }, - { - "name": "03 - Create Question Type Definition (MCQ Dynamic)", - "request": { - "method": "POST", - "header": [ - { - "key": "Content-Type", - "value": "application/json" - } - ], - "body": { - "mode": "raw", - "raw": "{\n \"key\": \"mcq_dynamic_vocab\",\n \"display_name\": \"MCQ Dynamic Vocabulary\",\n \"description\": \"Dynamic multiple-choice template for vocabulary checks.\",\n \"stimulus_component_kinds\": [\"INSTRUCTION\", \"TEXT_PASSAGE\"],\n \"response_component_kinds\": [\"MULTIPLE_CHOICE\"],\n \"status\": \"ACTIVE\"\n}" - }, - "url": { - "raw": "{{baseUrl}}{{apiPrefix}}/questions/type-definitions", - "host": [ - "{{baseUrl}}" - ], - "path": [ - "{{apiPrefix}}", - "questions", - "type-definitions" - ] - }, - "description": "Creates a reusable dynamic question-type definition." - }, - "event": [ + "name": "01 - Builder Catalog and Validation", + "item": [ { - "listen": "test", - "script": { - "exec": [ - "pm.test('Status is 201', function () { pm.response.to.have.status(201); });", - "var json = pm.response.json();", - "if (json && json.data && json.data.id) {", - " pm.collectionVariables.set('questionTypeDefinitionId', json.data.id);", - "}" - ], - "type": "text/javascript" - } - } - ], - "response": [] - }, - { - "name": "04 - List Question Type Definitions (Include System)", - "request": { - "method": "GET", - "header": [], - "url": { - "raw": "{{baseUrl}}{{apiPrefix}}/questions/type-definitions?include_system=true&status=ACTIVE", - "host": [ - "{{baseUrl}}" - ], - "path": [ - "{{apiPrefix}}", - "questions", - "type-definitions" - ], - "query": [ - { - "key": "include_system", - "value": "true" - }, - { - "key": "status", - "value": "ACTIVE" + "name": "Get Component Catalog", + "request": { + "method": "GET", + "header": [], + "url": { + "raw": "{{base_url}}/api/v1/questions/component-catalog", + "host": [ + "{{base_url}}" + ], + "path": [ + "api", + "v1", + "questions", + "component-catalog" + ] } - ] + }, + "response": [] }, - "description": "Lists reusable dynamic definitions (system + custom)." - }, - "response": [] - }, - { - "name": "05 - Get Question Type Definition By ID", - "request": { - "method": "GET", - "header": [], - "url": { - "raw": "{{baseUrl}}{{apiPrefix}}/questions/type-definitions/{{questionTypeDefinitionId}}", - "host": [ - "{{baseUrl}}" - ], - "path": [ - "{{apiPrefix}}", - "questions", - "type-definitions", - "{{questionTypeDefinitionId}}" - ] - }, - "description": "Fetches one dynamic type-definition by ID." - }, - "response": [] - }, - { - "name": "06 - Update Question Type Definition", - "request": { - "method": "PUT", - "header": [ - { - "key": "Content-Type", - "value": "application/json" - } - ], - "body": { - "mode": "raw", - "raw": "{\n \"display_name\": \"MCQ Dynamic Vocabulary (Updated)\",\n \"description\": \"Updated dynamic MCQ template.\",\n \"stimulus_component_kinds\": [\"INSTRUCTION\", \"TEXT_PASSAGE\", \"IMAGE\"],\n \"response_component_kinds\": [\"MULTIPLE_CHOICE\", \"ANSWER_TIMER\"],\n \"status\": \"ACTIVE\"\n}" - }, - "url": { - "raw": "{{baseUrl}}{{apiPrefix}}/questions/type-definitions/{{questionTypeDefinitionId}}", - "host": [ - "{{baseUrl}}" - ], - "path": [ - "{{apiPrefix}}", - "questions", - "type-definitions", - "{{questionTypeDefinitionId}}" - ] - }, - "description": "Updates dynamic definition (except key/system flag)." - }, - "response": [] - }, - { - "name": "07 - Create Question Using question_type_definition_id", - "request": { - "method": "POST", - "header": [ - { - "key": "Content-Type", - "value": "application/json" - } - ], - "body": { - "mode": "raw", - "raw": "{\n \"question_text\": \"Choose the correct synonym for 'rapid'.\",\n \"question_type_definition_id\": {{questionTypeDefinitionId}},\n \"difficulty_level\": \"EASY\",\n \"points\": 1,\n \"status\": \"PUBLISHED\",\n \"options\": [\n { \"option_text\": \"Slow\", \"option_order\": 1, \"is_correct\": false },\n { \"option_text\": \"Quick\", \"option_order\": 2, \"is_correct\": true },\n { \"option_text\": \"Heavy\", \"option_order\": 3, \"is_correct\": false },\n { \"option_text\": \"Late\", \"option_order\": 4, \"is_correct\": false }\n ]\n}" - }, - "url": { - "raw": "{{baseUrl}}{{apiPrefix}}/questions", - "host": [ - "{{baseUrl}}" - ], - "path": [ - "{{apiPrefix}}", - "questions" - ] - }, - "description": "Creates a question by binding to a dynamic definition. Backend infers runtime question_type from the definition." - }, - "event": [ { - "listen": "test", - "script": { - "exec": [ - "pm.test('Status is 201', function () { pm.response.to.have.status(201); });", - "var json = pm.response.json();", - "if (json && json.data && json.data.id) {", - " pm.collectionVariables.set('questionId', json.data.id);", - "}" - ], - "type": "text/javascript" - } - } - ], - "response": [] - }, - { - "name": "08 - Create Question (Explicit question_type + Definition)", - "request": { - "method": "POST", - "header": [ - { - "key": "Content-Type", - "value": "application/json" - } - ], - "body": { - "mode": "raw", - "raw": "{\n \"question_text\": \"Pick the antonym of 'expand'.\",\n \"question_type\": \"MCQ\",\n \"question_type_definition_id\": {{questionTypeDefinitionId}},\n \"difficulty_level\": \"MEDIUM\",\n \"points\": 2,\n \"options\": [\n { \"option_text\": \"Increase\", \"option_order\": 1, \"is_correct\": false },\n { \"option_text\": \"Contract\", \"option_order\": 2, \"is_correct\": true },\n { \"option_text\": \"Stretch\", \"option_order\": 3, \"is_correct\": false },\n { \"option_text\": \"Grow\", \"option_order\": 4, \"is_correct\": false }\n ]\n}" - }, - "url": { - "raw": "{{baseUrl}}{{apiPrefix}}/questions", - "host": [ - "{{baseUrl}}" - ], - "path": [ - "{{apiPrefix}}", - "questions" - ] - }, - "description": "Valid combination: explicit question_type must match the type inferred from the selected definition." - }, - "response": [] - }, - { - "name": "09 - Update Question (Switch/Attach Definition)", - "request": { - "method": "PUT", - "header": [ - { - "key": "Content-Type", - "value": "application/json" - } - ], - "body": { - "mode": "raw", - "raw": "{\n \"question_type_definition_id\": {{questionTypeDefinitionId}},\n \"question_type\": \"MCQ\",\n \"question_text\": \"Choose the best definition of 'meticulous'.\",\n \"options\": [\n { \"option_text\": \"Careless\", \"option_order\": 1, \"is_correct\": false },\n { \"option_text\": \"Very careful and precise\", \"option_order\": 2, \"is_correct\": true },\n { \"option_text\": \"Quickly done\", \"option_order\": 3, \"is_correct\": false }\n ],\n \"status\": \"PUBLISHED\"\n}" - }, - "url": { - "raw": "{{baseUrl}}{{apiPrefix}}/questions/{{questionId}}", - "host": [ - "{{baseUrl}}" - ], - "path": [ - "{{apiPrefix}}", - "questions", - "{{questionId}}" - ] - }, - "description": "Updates a question and links (or re-links) it to a dynamic definition." - }, - "response": [] - }, - { - "name": "10 - Get Question By ID", - "request": { - "method": "GET", - "header": [], - "url": { - "raw": "{{baseUrl}}{{apiPrefix}}/questions/{{questionId}}", - "host": [ - "{{baseUrl}}" - ], - "path": [ - "{{apiPrefix}}", - "questions", - "{{questionId}}" - ] - }, - "description": "Returns question details (options/short_answers/audio fields as applicable)." - }, - "response": [] - }, - { - "name": "11 - List Questions", - "request": { - "method": "GET", - "header": [], - "url": { - "raw": "{{baseUrl}}{{apiPrefix}}/questions?question_type=MCQ&limit=10&offset=0", - "host": [ - "{{baseUrl}}" - ], - "path": [ - "{{apiPrefix}}", - "questions" - ], - "query": [ + "name": "Validate Definition (Valid)", + "event": [ { - "key": "question_type", - "value": "MCQ" - }, - { - "key": "limit", - "value": "10" - }, - { - "key": "offset", - "value": "0" + "listen": "test", + "script": { + "exec": [ + "pm.test(\"Status code is 200\", function () {", + " pm.response.to.have.status(200);", + "});", + "const body = pm.response.json();", + "pm.test(\"valid = true\", function () {", + " pm.expect(body.data.valid).to.eql(true);", + "});" + ], + "type": "text/javascript" + } } - ] + ], + "request": { + "method": "POST", + "header": [ + { + "key": "Content-Type", + "value": "application/json", + "type": "text" + } + ], + "body": { + "mode": "raw", + "raw": "{\n \"stimulus_component_kinds\": [\"QUESTION_TEXT\", \"IMAGE\", \"TABLE\"],\n \"response_component_kinds\": [\"OPTION\", \"ANSWER_TIMER\"]\n}" + }, + "url": { + "raw": "{{base_url}}/api/v1/questions/validate-question-type-definition", + "host": [ + "{{base_url}}" + ], + "path": [ + "api", + "v1", + "questions", + "validate-question-type-definition" + ] + } + }, + "response": [] }, - "description": "Lists questions filtered by runtime question_type." - }, - "response": [] + { + "name": "Validate Definition (Invalid - Timer Only)", + "event": [ + { + "listen": "test", + "script": { + "exec": [ + "pm.test(\"Status code is 400\", function () {", + " pm.response.to.have.status(400);", + "});" + ], + "type": "text/javascript" + } + } + ], + "request": { + "method": "POST", + "header": [ + { + "key": "Content-Type", + "value": "application/json", + "type": "text" + } + ], + "body": { + "mode": "raw", + "raw": "{\n \"stimulus_component_kinds\": [\"QUESTION_TEXT\"],\n \"response_component_kinds\": [\"ANSWER_TIMER\"]\n}" + }, + "url": { + "raw": "{{base_url}}/api/v1/questions/validate-question-type-definition", + "host": [ + "{{base_url}}" + ], + "path": [ + "api", + "v1", + "questions", + "validate-question-type-definition" + ] + } + }, + "response": [] + } + ] }, { - "name": "12 - Negative Test: Mismatched Type and Definition", - "request": { - "method": "POST", - "header": [ - { - "key": "Content-Type", - "value": "application/json" - } - ], - "body": { - "mode": "raw", - "raw": "{\n \"question_text\": \"This should fail.\",\n \"question_type\": \"AUDIO\",\n \"question_type_definition_id\": {{questionTypeDefinitionId}},\n \"options\": [\n { \"option_text\": \"A\", \"option_order\": 1, \"is_correct\": true }\n ]\n}" - }, - "url": { - "raw": "{{baseUrl}}{{apiPrefix}}/questions", - "host": [ - "{{baseUrl}}" + "name": "02 - Definition CRUD (Schema-Driven)", + "item": [ + { + "name": "Create Dynamic Definition (with schema)", + "event": [ + { + "listen": "test", + "script": { + "exec": [ + "pm.test(\"Status code is 201\", function () {", + " pm.response.to.have.status(201);", + "});", + "const body = pm.response.json();", + "pm.collectionVariables.set(\"dynamic_definition_id\", body.data.id);" + ], + "type": "text/javascript" + } + } ], - "path": [ - "{{apiPrefix}}", - "questions" - ] + "request": { + "method": "POST", + "header": [ + { + "key": "Content-Type", + "value": "application/json", + "type": "text" + } + ], + "body": { + "mode": "raw", + "raw": "{\n \"key\": \"{{definition_key}}\",\n \"display_name\": \"Dynamic Grammar + Visual MCQ\",\n \"description\": \"Reusable dynamic question type with question text, image, table, and option responses.\",\n \"stimulus_component_kinds\": [\"QUESTION_TEXT\", \"IMAGE\", \"TABLE\"],\n \"response_component_kinds\": [\"OPTION\", \"ANSWER_TIMER\"],\n \"stimulus_schema\": [\n {\n \"id\": \"prompt\",\n \"kind\": \"QUESTION_TEXT\",\n \"label\": \"Prompt\",\n \"required\": true,\n \"config\": {\n \"max_length\": 1000\n }\n },\n {\n \"id\": \"illustration\",\n \"kind\": \"IMAGE\",\n \"label\": \"Supporting Image\",\n \"required\": false,\n \"config\": {\n \"allowed_formats\": [\"png\", \"jpg\", \"webp\"]\n }\n },\n {\n \"id\": \"data_table\",\n \"kind\": \"TABLE\",\n \"label\": \"Reference Table\",\n \"required\": false,\n \"config\": {\n \"max_rows\": 20,\n \"max_columns\": 8\n }\n }\n ],\n \"response_schema\": [\n {\n \"id\": \"choices\",\n \"kind\": \"OPTION\",\n \"label\": \"Answer Options\",\n \"required\": true,\n \"config\": {\n \"min_options\": 2,\n \"max_options\": 6,\n \"allow_multiple\": false\n }\n },\n {\n \"id\": \"timer\",\n \"kind\": \"ANSWER_TIMER\",\n \"label\": \"Answer Time Limit\",\n \"required\": false,\n \"config\": {\n \"min_seconds\": 5,\n \"max_seconds\": 180\n }\n }\n ],\n \"status\": \"ACTIVE\"\n}" + }, + "url": { + "raw": "{{base_url}}/api/v1/questions/type-definitions", + "host": [ + "{{base_url}}" + ], + "path": [ + "api", + "v1", + "questions", + "type-definitions" + ] + } + }, + "response": [] }, - "description": "Expected 400 because explicit question_type does not match inferred type from definition." - }, - "response": [] + { + "name": "Get Definition By ID", + "request": { + "method": "GET", + "header": [], + "url": { + "raw": "{{base_url}}/api/v1/questions/type-definitions/{{dynamic_definition_id}}", + "host": [ + "{{base_url}}" + ], + "path": [ + "api", + "v1", + "questions", + "type-definitions", + "{{dynamic_definition_id}}" + ] + } + }, + "response": [] + }, + { + "name": "List Definitions (include system)", + "request": { + "method": "GET", + "header": [], + "url": { + "raw": "{{base_url}}/api/v1/questions/type-definitions?include_system=true&status=ACTIVE", + "host": [ + "{{base_url}}" + ], + "path": [ + "api", + "v1", + "questions", + "type-definitions" + ], + "query": [ + { + "key": "include_system", + "value": "true" + }, + { + "key": "status", + "value": "ACTIVE" + } + ] + } + }, + "response": [] + }, + { + "name": "Update Definition (schema evolution)", + "event": [ + { + "listen": "test", + "script": { + "exec": [ + "pm.test(\"Status code is 200\", function () {", + " pm.response.to.have.status(200);", + "});" + ], + "type": "text/javascript" + } + } + ], + "request": { + "method": "PUT", + "header": [ + { + "key": "Content-Type", + "value": "application/json", + "type": "text" + } + ], + "body": { + "mode": "raw", + "raw": "{\n \"display_name\": \"Dynamic Grammar + Visual MCQ (Updated)\",\n \"response_component_kinds\": [\"OPTION\", \"ANSWER_TIMER\", \"SEQUENCE_ORDER\"],\n \"response_schema\": [\n {\n \"id\": \"choices\",\n \"kind\": \"OPTION\",\n \"label\": \"Answer Options\",\n \"required\": true,\n \"config\": {\n \"min_options\": 2,\n \"max_options\": 6,\n \"allow_multiple\": false\n }\n },\n {\n \"id\": \"timer\",\n \"kind\": \"ANSWER_TIMER\",\n \"label\": \"Answer Time Limit\",\n \"required\": false,\n \"config\": {\n \"min_seconds\": 5,\n \"max_seconds\": 180\n }\n },\n {\n \"id\": \"ordering\",\n \"kind\": \"SEQUENCE_ORDER\",\n \"label\": \"Optional Sequence Order\",\n \"required\": false,\n \"config\": {\n \"max_items\": 8\n }\n }\n ]\n}" + }, + "url": { + "raw": "{{base_url}}/api/v1/questions/type-definitions/{{dynamic_definition_id}}", + "host": [ + "{{base_url}}" + ], + "path": [ + "api", + "v1", + "questions", + "type-definitions", + "{{dynamic_definition_id}}" + ] + } + }, + "response": [] + }, + { + "name": "Update Definition (Invalid Schema Kind) - Expect 400", + "event": [ + { + "listen": "test", + "script": { + "exec": [ + "pm.test(\"Status code is 400\", function () {", + " pm.response.to.have.status(400);", + "});" + ], + "type": "text/javascript" + } + } + ], + "request": { + "method": "PUT", + "header": [ + { + "key": "Content-Type", + "value": "application/json", + "type": "text" + } + ], + "body": { + "mode": "raw", + "raw": "{\n \"response_schema\": [\n {\n \"id\": \"bad\",\n \"kind\": \"NON_EXISTENT_KIND\",\n \"required\": true\n }\n ]\n}" + }, + "url": { + "raw": "{{base_url}}/api/v1/questions/type-definitions/{{dynamic_definition_id}}", + "host": [ + "{{base_url}}" + ], + "path": [ + "api", + "v1", + "questions", + "type-definitions", + "{{dynamic_definition_id}}" + ] + } + }, + "response": [] + } + ] }, { - "name": "13 - Delete Custom Question Type Definition", - "request": { - "method": "DELETE", - "header": [], - "url": { - "raw": "{{baseUrl}}{{apiPrefix}}/questions/type-definitions/{{questionTypeDefinitionId}}", - "host": [ - "{{baseUrl}}" + "name": "03 - Dynamic Question Runtime", + "item": [ + { + "name": "Create Dynamic Question (valid payload)", + "event": [ + { + "listen": "test", + "script": { + "exec": [ + "pm.test(\"Status code is 201\", function () {", + " pm.response.to.have.status(201);", + "});", + "const body = pm.response.json();", + "pm.collectionVariables.set(\"dynamic_question_id\", body.data.id);", + "pm.test(\"Question type is DYNAMIC\", function () {", + " pm.expect(body.data.question_type).to.eql(\"DYNAMIC\");", + "});" + ], + "type": "text/javascript" + } + } ], - "path": [ - "{{apiPrefix}}", - "questions", - "type-definitions", - "{{questionTypeDefinitionId}}" - ] + "request": { + "method": "POST", + "header": [ + { + "key": "Content-Type", + "value": "application/json", + "type": "text" + } + ], + "body": { + "mode": "raw", + "raw": "{\n \"question_text\": \"Complete the sentence using the table and image.\",\n \"question_type\": \"DYNAMIC\",\n \"question_type_definition_id\": {{dynamic_definition_id}},\n \"difficulty_level\": \"MEDIUM\",\n \"points\": 2,\n \"status\": \"DRAFT\",\n \"dynamic_payload\": {\n \"stimulus\": [\n {\n \"id\": \"prompt\",\n \"kind\": \"QUESTION_TEXT\",\n \"value\": \"Select the best completion based on the visual and table.\"\n },\n {\n \"id\": \"illustration\",\n \"kind\": \"IMAGE\",\n \"value\": \"https://cdn.example.com/images/sample-grammar-scene.jpg\"\n },\n {\n \"id\": \"data_table\",\n \"kind\": \"TABLE\",\n \"value\": {\n \"columns\": [\"Verb\", \"Past Form\"],\n \"rows\": [\n [\"go\", \"went\"],\n [\"write\", \"wrote\"]\n ]\n }\n }\n ],\n \"response\": [\n {\n \"id\": \"choices\",\n \"kind\": \"OPTION\",\n \"value\": {\n \"options\": [\n {\"id\": \"a\", \"text\": \"I goed to school\", \"is_correct\": false},\n {\"id\": \"b\", \"text\": \"I went to school\", \"is_correct\": true}\n ]\n }\n },\n {\n \"id\": \"timer\",\n \"kind\": \"ANSWER_TIMER\",\n \"value\": {\n \"seconds\": 30\n }\n }\n ]\n }\n}" + }, + "url": { + "raw": "{{base_url}}/api/v1/questions", + "host": [ + "{{base_url}}" + ], + "path": [ + "api", + "v1", + "questions" + ] + } + }, + "response": [] }, - "description": "Deletes a custom definition. System definitions cannot be deleted." - }, - "response": [] + { + "name": "Get Dynamic Question By ID", + "request": { + "method": "GET", + "header": [], + "url": { + "raw": "{{base_url}}/api/v1/questions/{{dynamic_question_id}}", + "host": [ + "{{base_url}}" + ], + "path": [ + "api", + "v1", + "questions", + "{{dynamic_question_id}}" + ] + } + }, + "response": [] + }, + { + "name": "Update Dynamic Question (valid payload)", + "event": [ + { + "listen": "test", + "script": { + "exec": [ + "pm.test(\"Status code is 200\", function () {", + " pm.response.to.have.status(200);", + "});" + ], + "type": "text/javascript" + } + } + ], + "request": { + "method": "PUT", + "header": [ + { + "key": "Content-Type", + "value": "application/json", + "type": "text" + } + ], + "body": { + "mode": "raw", + "raw": "{\n \"question_text\": \"Updated: choose the best sentence.\",\n \"question_type_definition_id\": {{dynamic_definition_id}},\n \"question_type\": \"DYNAMIC\",\n \"status\": \"PUBLISHED\",\n \"dynamic_payload\": {\n \"stimulus\": [\n {\n \"id\": \"prompt\",\n \"kind\": \"QUESTION_TEXT\",\n \"value\": \"Pick the grammatically correct option.\"\n }\n ],\n \"response\": [\n {\n \"id\": \"choices\",\n \"kind\": \"OPTION\",\n \"value\": {\n \"options\": [\n {\"id\": \"a\", \"text\": \"He go home yesterday\", \"is_correct\": false},\n {\"id\": \"b\", \"text\": \"He went home yesterday\", \"is_correct\": true}\n ]\n }\n }\n ]\n }\n}" + }, + "url": { + "raw": "{{base_url}}/api/v1/questions/{{dynamic_question_id}}", + "host": [ + "{{base_url}}" + ], + "path": [ + "api", + "v1", + "questions", + "{{dynamic_question_id}}" + ] + } + }, + "response": [] + }, + { + "name": "List Questions (question_type=DYNAMIC)", + "request": { + "method": "GET", + "header": [], + "url": { + "raw": "{{base_url}}/api/v1/questions?question_type=DYNAMIC&limit=20&offset=0", + "host": [ + "{{base_url}}" + ], + "path": [ + "api", + "v1", + "questions" + ], + "query": [ + { + "key": "question_type", + "value": "DYNAMIC" + }, + { + "key": "limit", + "value": "20" + }, + { + "key": "offset", + "value": "0" + } + ] + } + }, + "response": [] + }, + { + "name": "Search Questions", + "request": { + "method": "GET", + "header": [], + "url": { + "raw": "{{base_url}}/api/v1/questions/search?q=choose&limit=20&offset=0", + "host": [ + "{{base_url}}" + ], + "path": [ + "api", + "v1", + "questions", + "search" + ], + "query": [ + { + "key": "q", + "value": "choose" + }, + { + "key": "limit", + "value": "20" + }, + { + "key": "offset", + "value": "0" + } + ] + } + }, + "response": [] + } + ] + }, + { + "name": "04 - Negative Runtime Validation Cases", + "item": [ + { + "name": "Create Dynamic Question without dynamic_payload - Expect 400", + "event": [ + { + "listen": "test", + "script": { + "exec": [ + "pm.test(\"Status code is 400\", function () {", + " pm.response.to.have.status(400);", + "});" + ], + "type": "text/javascript" + } + } + ], + "request": { + "method": "POST", + "header": [ + { + "key": "Content-Type", + "value": "application/json", + "type": "text" + } + ], + "body": { + "mode": "raw", + "raw": "{\n \"question_text\": \"Should fail\",\n \"question_type\": \"DYNAMIC\",\n \"question_type_definition_id\": {{dynamic_definition_id}},\n \"difficulty_level\": \"EASY\"\n}" + }, + "url": { + "raw": "{{base_url}}/api/v1/questions", + "host": [ + "{{base_url}}" + ], + "path": [ + "api", + "v1", + "questions" + ] + } + }, + "response": [] + }, + { + "name": "Create Dynamic Question with disallowed element kind - Expect 400", + "event": [ + { + "listen": "test", + "script": { + "exec": [ + "pm.test(\"Status code is 400\", function () {", + " pm.response.to.have.status(400);", + "});" + ], + "type": "text/javascript" + } + } + ], + "request": { + "method": "POST", + "header": [ + { + "key": "Content-Type", + "value": "application/json", + "type": "text" + } + ], + "body": { + "mode": "raw", + "raw": "{\n \"question_text\": \"Should fail because AUDIO_CLIP is not allowed by selected definition\",\n \"question_type\": \"DYNAMIC\",\n \"question_type_definition_id\": {{dynamic_definition_id}},\n \"dynamic_payload\": {\n \"stimulus\": [\n {\n \"id\": \"audio1\",\n \"kind\": \"AUDIO_CLIP\",\n \"value\": \"https://cdn.example.com/audio/not-allowed.mp3\"\n }\n ],\n \"response\": [\n {\n \"id\": \"choices\",\n \"kind\": \"OPTION\",\n \"value\": {\n \"options\": [\n {\"id\": \"a\", \"text\": \"A\", \"is_correct\": true},\n {\"id\": \"b\", \"text\": \"B\", \"is_correct\": false}\n ]\n }\n }\n ]\n }\n}" + }, + "url": { + "raw": "{{base_url}}/api/v1/questions", + "host": [ + "{{base_url}}" + ], + "path": [ + "api", + "v1", + "questions" + ] + } + }, + "response": [] + } + ] + }, + { + "name": "05 - Cleanup", + "item": [ + { + "name": "Delete Dynamic Question", + "request": { + "method": "DELETE", + "header": [], + "url": { + "raw": "{{base_url}}/api/v1/questions/{{dynamic_question_id}}", + "host": [ + "{{base_url}}" + ], + "path": [ + "api", + "v1", + "questions", + "{{dynamic_question_id}}" + ] + } + }, + "response": [] + }, + { + "name": "Delete Dynamic Definition", + "request": { + "method": "DELETE", + "header": [], + "url": { + "raw": "{{base_url}}/api/v1/questions/type-definitions/{{dynamic_definition_id}}", + "host": [ + "{{base_url}}" + ], + "path": [ + "api", + "v1", + "questions", + "type-definitions", + "{{dynamic_definition_id}}" + ] + } + }, + "response": [] + } + ] } ] } diff --git a/postman/Yimaru-Local-Dynamic-Builder.postman_environment.json b/postman/Yimaru-Local-Dynamic-Builder.postman_environment.json deleted file mode 100644 index 0d082e3..0000000 --- a/postman/Yimaru-Local-Dynamic-Builder.postman_environment.json +++ /dev/null @@ -1,39 +0,0 @@ -{ - "id": "2e932640-c314-4f18-b273-3a55fcb9384e", - "name": "Yimaru Local Dynamic Builder", - "values": [ - { - "key": "baseUrl", - "value": "http://localhost:8080", - "type": "default", - "enabled": true - }, - { - "key": "apiPrefix", - "value": "/api/v1", - "type": "default", - "enabled": true - }, - { - "key": "token", - "value": "", - "type": "secret", - "enabled": true - }, - { - "key": "questionTypeDefinitionId", - "value": "", - "type": "default", - "enabled": true - }, - { - "key": "questionId", - "value": "", - "type": "default", - "enabled": true - } - ], - "_postman_variable_scope": "environment", - "_postman_exported_at": "2026-05-07T08:41:00.000Z", - "_postman_exported_using": "Cursor Agent" -}