dynamic question type builder completion

This commit is contained in:
Yared Yemane 2026-05-08 10:12:02 -07:00
parent 9a17f0b3c4
commit 3d1b3ad9b8
20 changed files with 2252 additions and 1205 deletions

View File

@ -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'));

View File

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

View File

@ -16,6 +16,7 @@ SELECT
qsi.display_order, qsi.display_order,
q.question_text, q.question_text,
q.question_type, q.question_type,
q.dynamic_payload,
q.difficulty_level, q.difficulty_level,
q.points, q.points,
q.explanation, q.explanation,
@ -41,6 +42,7 @@ SELECT
qsi.display_order, qsi.display_order,
q.question_text, q.question_text,
q.question_type, q.question_type,
q.dynamic_payload,
q.difficulty_level, q.difficulty_level,
q.points, q.points,
q.explanation, q.explanation,
@ -68,6 +70,7 @@ SELECT
qsi.display_order, qsi.display_order,
q.question_text, q.question_text,
q.question_type, q.question_type,
q.dynamic_payload,
q.difficulty_level, q.difficulty_level,
q.points, q.points,
q.explanation, q.explanation,

View File

@ -9,9 +9,10 @@ INSERT INTO questions (
voice_prompt, voice_prompt,
sample_answer_voice_prompt, sample_answer_voice_prompt,
image_url, 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 *; RETURNING *;
-- name: GetQuestionByID :one -- name: GetQuestionByID :one
@ -62,8 +63,9 @@ SET
sample_answer_voice_prompt = COALESCE($8, sample_answer_voice_prompt), sample_answer_voice_prompt = COALESCE($8, sample_answer_voice_prompt),
image_url = COALESCE($9, image_url), image_url = COALESCE($9, image_url),
status = COALESCE($10, status), status = COALESCE($10, status),
dynamic_payload = COALESCE($11::jsonb, dynamic_payload),
updated_at = CURRENT_TIMESTAMP updated_at = CURRENT_TIMESTAMP
WHERE id = $11; WHERE id = $12;
-- name: ArchiveQuestion :exec -- name: ArchiveQuestion :exec
UPDATE questions UPDATE questions

View File

@ -4560,7 +4560,7 @@ const docTemplate = `{
} }
}, },
"post": { "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": [ "consumes": [
"application/json" "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": { "/api/v1/questions/validate-question-type-definition": {
"post": { "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": [ "consumes": [
"application/json" "application/json"
], ],
@ -4802,7 +5014,7 @@ const docTemplate = `{
} }
}, },
"put": { "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": [ "consumes": [
"application/json" "application/json"
], ],
@ -9452,6 +9664,10 @@ const docTemplate = `{
"questionType": { "questionType": {
"type": "string" "type": "string"
}, },
"questionTypeDefinitionID": {
"type": "integer",
"format": "int64"
},
"sampleAnswerVoicePrompt": { "sampleAnswerVoicePrompt": {
"type": "string" "type": "string"
}, },
@ -10702,8 +10918,7 @@ const docTemplate = `{
"handlers.createQuestionReq": { "handlers.createQuestionReq": {
"type": "object", "type": "object",
"required": [ "required": [
"question_text", "question_text"
"question_type"
], ],
"properties": { "properties": {
"audio_correct_answer_text": { "audio_correct_answer_text": {
@ -10731,13 +10946,10 @@ const docTemplate = `{
"type": "string" "type": "string"
}, },
"question_type": { "question_type": {
"type": "string", "type": "string"
"enum": [ },
"MCQ", "question_type_definition_id": {
"TRUE_FALSE", "type": "integer"
"SHORT_ANSWER",
"AUDIO"
]
}, },
"sample_answer_voice_prompt": { "sample_answer_voice_prompt": {
"type": "string" "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": { "handlers.initiateDirectPaymentReq": {
"type": "object", "type": "object",
"required": [ "required": [
@ -11185,6 +11430,9 @@ const docTemplate = `{
"question_type": { "question_type": {
"type": "string" "type": "string"
}, },
"question_type_definition_id": {
"type": "integer"
},
"sample_answer_voice_prompt": { "sample_answer_voice_prompt": {
"type": "string" "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": { "handlers.validateQuestionTypeDefinitionReq": {
"type": "object", "type": "object",
"properties": { "properties": {

View File

@ -4552,7 +4552,7 @@
} }
}, },
"post": { "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": [ "consumes": [
"application/json" "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": { "/api/v1/questions/validate-question-type-definition": {
"post": { "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": [ "consumes": [
"application/json" "application/json"
], ],
@ -4794,7 +5006,7 @@
} }
}, },
"put": { "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": [ "consumes": [
"application/json" "application/json"
], ],
@ -9444,6 +9656,10 @@
"questionType": { "questionType": {
"type": "string" "type": "string"
}, },
"questionTypeDefinitionID": {
"type": "integer",
"format": "int64"
},
"sampleAnswerVoicePrompt": { "sampleAnswerVoicePrompt": {
"type": "string" "type": "string"
}, },
@ -10694,8 +10910,7 @@
"handlers.createQuestionReq": { "handlers.createQuestionReq": {
"type": "object", "type": "object",
"required": [ "required": [
"question_text", "question_text"
"question_type"
], ],
"properties": { "properties": {
"audio_correct_answer_text": { "audio_correct_answer_text": {
@ -10723,13 +10938,10 @@
"type": "string" "type": "string"
}, },
"question_type": { "question_type": {
"type": "string", "type": "string"
"enum": [ },
"MCQ", "question_type_definition_id": {
"TRUE_FALSE", "type": "integer"
"SHORT_ANSWER",
"AUDIO"
]
}, },
"sample_answer_voice_prompt": { "sample_answer_voice_prompt": {
"type": "string" "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": { "handlers.initiateDirectPaymentReq": {
"type": "object", "type": "object",
"required": [ "required": [
@ -11177,6 +11422,9 @@
"question_type": { "question_type": {
"type": "string" "type": "string"
}, },
"question_type_definition_id": {
"type": "integer"
},
"sample_answer_voice_prompt": { "sample_answer_voice_prompt": {
"type": "string" "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": { "handlers.validateQuestionTypeDefinitionReq": {
"type": "object", "type": "object",
"properties": { "properties": {

View File

@ -389,6 +389,9 @@ definitions:
type: string type: string
questionType: questionType:
type: string type: string
questionTypeDefinitionID:
format: int64
type: integer
sampleAnswerVoicePrompt: sampleAnswerVoicePrompt:
type: string type: string
shortAnswers: shortAnswers:
@ -1250,12 +1253,9 @@ definitions:
question_text: question_text:
type: string type: string
question_type: question_type:
enum:
- MCQ
- TRUE_FALSE
- SHORT_ANSWER
- AUDIO
type: string type: string
question_type_definition_id:
type: integer
sample_answer_voice_prompt: sample_answer_voice_prompt:
type: string type: string
short_answers: short_answers:
@ -1270,7 +1270,6 @@ definitions:
type: string type: string
required: required:
- question_text - question_text
- question_type
type: object type: object
handlers.createQuestionSetReq: handlers.createQuestionSetReq:
properties: properties:
@ -1309,6 +1308,28 @@ definitions:
- set_type - set_type
- title - title
type: object 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: handlers.initiateDirectPaymentReq:
properties: properties:
email: email:
@ -1560,6 +1581,8 @@ definitions:
type: string type: string
question_type: question_type:
type: string type: string
question_type_definition_id:
type: integer
sample_answer_voice_prompt: sample_answer_voice_prompt:
type: string type: string
short_answers: short_answers:
@ -1594,6 +1617,23 @@ definitions:
title: title:
type: string type: string
type: object 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: handlers.validateQuestionTypeDefinitionReq:
properties: properties:
response_component_kinds: response_component_kinds:
@ -5025,7 +5065,8 @@ paths:
consumes: consumes:
- application/json - application/json
description: Creates a new question with options (for MCQ/TRUE_FALSE) or short 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: parameters:
- description: Create question payload - description: Create question payload
in: body in: body
@ -5107,7 +5148,8 @@ paths:
put: put:
consumes: consumes:
- application/json - 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: parameters:
- description: Question ID - description: Question ID
in: path in: path
@ -5217,12 +5259,153 @@ paths:
summary: Search questions summary: Search questions
tags: tags:
- questions - 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: /api/v1/questions/validate-question-type-definition:
post: post:
consumes: consumes:
- application/json - application/json
description: Validates selected stimulus and response component kinds for temporary description: Validates selected stimulus and response component kinds for temporary
question-type definitions question-type definitions (component-level validation only)
parameters: parameters:
- description: Stimulus and response component kinds - description: Stimulus and response component kinds
in: body in: body

View File

@ -248,19 +248,21 @@ type Program struct {
} }
type Question struct { type Question struct {
ID int64 `json:"id"` ID int64 `json:"id"`
QuestionText string `json:"question_text"` QuestionText string `json:"question_text"`
QuestionType string `json:"question_type"` QuestionType string `json:"question_type"`
DifficultyLevel pgtype.Text `json:"difficulty_level"` DifficultyLevel pgtype.Text `json:"difficulty_level"`
Points int32 `json:"points"` Points int32 `json:"points"`
Explanation pgtype.Text `json:"explanation"` Explanation pgtype.Text `json:"explanation"`
Tips pgtype.Text `json:"tips"` Tips pgtype.Text `json:"tips"`
VoicePrompt pgtype.Text `json:"voice_prompt"` VoicePrompt pgtype.Text `json:"voice_prompt"`
SampleAnswerVoicePrompt pgtype.Text `json:"sample_answer_voice_prompt"` SampleAnswerVoicePrompt pgtype.Text `json:"sample_answer_voice_prompt"`
Status string `json:"status"` Status string `json:"status"`
CreatedAt pgtype.Timestamptz `json:"created_at"` CreatedAt pgtype.Timestamptz `json:"created_at"`
UpdatedAt pgtype.Timestamptz `json:"updated_at"` UpdatedAt pgtype.Timestamptz `json:"updated_at"`
ImageUrl pgtype.Text `json:"image_url"` ImageUrl pgtype.Text `json:"image_url"`
QuestionTypeDefinitionID pgtype.Int8 `json:"question_type_definition_id"`
DynamicPayload []byte `json:"dynamic_payload"`
} }
type QuestionAudioAnswer struct { type QuestionAudioAnswer struct {
@ -322,6 +324,21 @@ type QuestionShortAnswer struct {
CreatedAt pgtype.Timestamptz `json:"created_at"` 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 { type Rating struct {
ID int64 `json:"id"` ID int64 `json:"id"`
UserID int64 `json:"user_id"` UserID int64 `json:"user_id"`

View File

@ -64,6 +64,7 @@ SELECT
qsi.display_order, qsi.display_order,
q.question_text, q.question_text,
q.question_type, q.question_type,
q.dynamic_payload,
q.difficulty_level, q.difficulty_level,
q.points, q.points,
q.explanation, q.explanation,
@ -87,6 +88,7 @@ type GetPublishedQuestionsInSetRow struct {
DisplayOrder int32 `json:"display_order"` DisplayOrder int32 `json:"display_order"`
QuestionText string `json:"question_text"` QuestionText string `json:"question_text"`
QuestionType string `json:"question_type"` QuestionType string `json:"question_type"`
DynamicPayload []byte `json:"dynamic_payload"`
DifficultyLevel pgtype.Text `json:"difficulty_level"` DifficultyLevel pgtype.Text `json:"difficulty_level"`
Points int32 `json:"points"` Points int32 `json:"points"`
Explanation pgtype.Text `json:"explanation"` Explanation pgtype.Text `json:"explanation"`
@ -113,6 +115,7 @@ func (q *Queries) GetPublishedQuestionsInSet(ctx context.Context, setID int64) (
&i.DisplayOrder, &i.DisplayOrder,
&i.QuestionText, &i.QuestionText,
&i.QuestionType, &i.QuestionType,
&i.DynamicPayload,
&i.DifficultyLevel, &i.DifficultyLevel,
&i.Points, &i.Points,
&i.Explanation, &i.Explanation,
@ -140,6 +143,7 @@ SELECT
qsi.display_order, qsi.display_order,
q.question_text, q.question_text,
q.question_type, q.question_type,
q.dynamic_payload,
q.difficulty_level, q.difficulty_level,
q.points, q.points,
q.explanation, q.explanation,
@ -164,6 +168,7 @@ type GetQuestionSetItemsRow struct {
DisplayOrder int32 `json:"display_order"` DisplayOrder int32 `json:"display_order"`
QuestionText string `json:"question_text"` QuestionText string `json:"question_text"`
QuestionType string `json:"question_type"` QuestionType string `json:"question_type"`
DynamicPayload []byte `json:"dynamic_payload"`
DifficultyLevel pgtype.Text `json:"difficulty_level"` DifficultyLevel pgtype.Text `json:"difficulty_level"`
Points int32 `json:"points"` Points int32 `json:"points"`
Explanation pgtype.Text `json:"explanation"` Explanation pgtype.Text `json:"explanation"`
@ -191,6 +196,7 @@ func (q *Queries) GetQuestionSetItems(ctx context.Context, setID int64) ([]GetQu
&i.DisplayOrder, &i.DisplayOrder,
&i.QuestionText, &i.QuestionText,
&i.QuestionType, &i.QuestionType,
&i.DynamicPayload,
&i.DifficultyLevel, &i.DifficultyLevel,
&i.Points, &i.Points,
&i.Explanation, &i.Explanation,
@ -220,6 +226,7 @@ SELECT
qsi.display_order, qsi.display_order,
q.question_text, q.question_text,
q.question_type, q.question_type,
q.dynamic_payload,
q.difficulty_level, q.difficulty_level,
q.points, q.points,
q.explanation, q.explanation,
@ -255,6 +262,7 @@ type GetQuestionSetItemsPaginatedRow struct {
DisplayOrder int32 `json:"display_order"` DisplayOrder int32 `json:"display_order"`
QuestionText string `json:"question_text"` QuestionText string `json:"question_text"`
QuestionType string `json:"question_type"` QuestionType string `json:"question_type"`
DynamicPayload []byte `json:"dynamic_payload"`
DifficultyLevel pgtype.Text `json:"difficulty_level"` DifficultyLevel pgtype.Text `json:"difficulty_level"`
Points int32 `json:"points"` Points int32 `json:"points"`
Explanation pgtype.Text `json:"explanation"` Explanation pgtype.Text `json:"explanation"`
@ -288,6 +296,7 @@ func (q *Queries) GetQuestionSetItemsPaginated(ctx context.Context, arg GetQuest
&i.DisplayOrder, &i.DisplayOrder,
&i.QuestionText, &i.QuestionText,
&i.QuestionType, &i.QuestionType,
&i.DynamicPayload,
&i.DifficultyLevel, &i.DifficultyLevel,
&i.Points, &i.Points,
&i.Explanation, &i.Explanation,

View File

@ -33,10 +33,11 @@ INSERT INTO questions (
voice_prompt, voice_prompt,
sample_answer_voice_prompt, sample_answer_voice_prompt,
image_url, 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 id, question_text, question_type, difficulty_level, points, explanation, tips, voice_prompt, sample_answer_voice_prompt, status, created_at, updated_at, image_url 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 { type CreateQuestionParams struct {
@ -50,6 +51,7 @@ type CreateQuestionParams struct {
SampleAnswerVoicePrompt pgtype.Text `json:"sample_answer_voice_prompt"` SampleAnswerVoicePrompt pgtype.Text `json:"sample_answer_voice_prompt"`
ImageUrl pgtype.Text `json:"image_url"` ImageUrl pgtype.Text `json:"image_url"`
Column10 interface{} `json:"column_10"` Column10 interface{} `json:"column_10"`
Column11 []byte `json:"column_11"`
} }
func (q *Queries) CreateQuestion(ctx context.Context, arg CreateQuestionParams) (Question, error) { 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.SampleAnswerVoicePrompt,
arg.ImageUrl, arg.ImageUrl,
arg.Column10, arg.Column10,
arg.Column11,
) )
var i Question var i Question
err := row.Scan( err := row.Scan(
@ -80,6 +83,8 @@ func (q *Queries) CreateQuestion(ctx context.Context, arg CreateQuestionParams)
&i.CreatedAt, &i.CreatedAt,
&i.UpdatedAt, &i.UpdatedAt,
&i.ImageUrl, &i.ImageUrl,
&i.QuestionTypeDefinitionID,
&i.DynamicPayload,
) )
return i, err return i, err
} }
@ -95,7 +100,7 @@ func (q *Queries) DeleteQuestion(ctx context.Context, id int64) error {
} }
const GetQuestionByID = `-- name: GetQuestionByID :one 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 FROM questions
WHERE id = $1 WHERE id = $1
` `
@ -117,6 +122,8 @@ func (q *Queries) GetQuestionByID(ctx context.Context, id int64) (Question, erro
&i.CreatedAt, &i.CreatedAt,
&i.UpdatedAt, &i.UpdatedAt,
&i.ImageUrl, &i.ImageUrl,
&i.QuestionTypeDefinitionID,
&i.DynamicPayload,
) )
return i, err return i, err
} }
@ -193,7 +200,7 @@ func (q *Queries) GetQuestionWithOptions(ctx context.Context, id int64) ([]GetQu
} }
const GetQuestionsByIDs = `-- name: GetQuestionsByIDs :many 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 FROM questions
WHERE id = ANY($1::BIGINT[]) WHERE id = ANY($1::BIGINT[])
ORDER BY id ORDER BY id
@ -222,6 +229,8 @@ func (q *Queries) GetQuestionsByIDs(ctx context.Context, dollar_1 []int64) ([]Qu
&i.CreatedAt, &i.CreatedAt,
&i.UpdatedAt, &i.UpdatedAt,
&i.ImageUrl, &i.ImageUrl,
&i.QuestionTypeDefinitionID,
&i.DynamicPayload,
); err != nil { ); err != nil {
return nil, err return nil, err
} }
@ -236,7 +245,7 @@ func (q *Queries) GetQuestionsByIDs(ctx context.Context, dollar_1 []int64) ([]Qu
const ListQuestions = `-- name: ListQuestions :many const ListQuestions = `-- name: ListQuestions :many
SELECT SELECT
COUNT(*) OVER () AS total_count, 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 FROM questions q
WHERE status != 'ARCHIVED' WHERE status != 'ARCHIVED'
AND ($1::VARCHAR IS NULL OR $1 = '' OR question_type = $1) AND ($1::VARCHAR IS NULL OR $1 = '' OR question_type = $1)
@ -256,20 +265,22 @@ type ListQuestionsParams struct {
} }
type ListQuestionsRow struct { type ListQuestionsRow struct {
TotalCount int64 `json:"total_count"` TotalCount int64 `json:"total_count"`
ID int64 `json:"id"` ID int64 `json:"id"`
QuestionText string `json:"question_text"` QuestionText string `json:"question_text"`
QuestionType string `json:"question_type"` QuestionType string `json:"question_type"`
DifficultyLevel pgtype.Text `json:"difficulty_level"` DifficultyLevel pgtype.Text `json:"difficulty_level"`
Points int32 `json:"points"` Points int32 `json:"points"`
Explanation pgtype.Text `json:"explanation"` Explanation pgtype.Text `json:"explanation"`
Tips pgtype.Text `json:"tips"` Tips pgtype.Text `json:"tips"`
VoicePrompt pgtype.Text `json:"voice_prompt"` VoicePrompt pgtype.Text `json:"voice_prompt"`
SampleAnswerVoicePrompt pgtype.Text `json:"sample_answer_voice_prompt"` SampleAnswerVoicePrompt pgtype.Text `json:"sample_answer_voice_prompt"`
Status string `json:"status"` Status string `json:"status"`
CreatedAt pgtype.Timestamptz `json:"created_at"` CreatedAt pgtype.Timestamptz `json:"created_at"`
UpdatedAt pgtype.Timestamptz `json:"updated_at"` UpdatedAt pgtype.Timestamptz `json:"updated_at"`
ImageUrl pgtype.Text `json:"image_url"` 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) { 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.CreatedAt,
&i.UpdatedAt, &i.UpdatedAt,
&i.ImageUrl, &i.ImageUrl,
&i.QuestionTypeDefinitionID,
&i.DynamicPayload,
); err != nil { ); err != nil {
return nil, err return nil, err
} }
@ -316,7 +329,7 @@ func (q *Queries) ListQuestions(ctx context.Context, arg ListQuestionsParams) ([
const SearchQuestions = `-- name: SearchQuestions :many const SearchQuestions = `-- name: SearchQuestions :many
SELECT SELECT
COUNT(*) OVER () AS total_count, 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 FROM questions q
WHERE status != 'ARCHIVED' WHERE status != 'ARCHIVED'
AND question_text ILIKE '%' || $1 || '%' AND question_text ILIKE '%' || $1 || '%'
@ -332,20 +345,22 @@ type SearchQuestionsParams struct {
} }
type SearchQuestionsRow struct { type SearchQuestionsRow struct {
TotalCount int64 `json:"total_count"` TotalCount int64 `json:"total_count"`
ID int64 `json:"id"` ID int64 `json:"id"`
QuestionText string `json:"question_text"` QuestionText string `json:"question_text"`
QuestionType string `json:"question_type"` QuestionType string `json:"question_type"`
DifficultyLevel pgtype.Text `json:"difficulty_level"` DifficultyLevel pgtype.Text `json:"difficulty_level"`
Points int32 `json:"points"` Points int32 `json:"points"`
Explanation pgtype.Text `json:"explanation"` Explanation pgtype.Text `json:"explanation"`
Tips pgtype.Text `json:"tips"` Tips pgtype.Text `json:"tips"`
VoicePrompt pgtype.Text `json:"voice_prompt"` VoicePrompt pgtype.Text `json:"voice_prompt"`
SampleAnswerVoicePrompt pgtype.Text `json:"sample_answer_voice_prompt"` SampleAnswerVoicePrompt pgtype.Text `json:"sample_answer_voice_prompt"`
Status string `json:"status"` Status string `json:"status"`
CreatedAt pgtype.Timestamptz `json:"created_at"` CreatedAt pgtype.Timestamptz `json:"created_at"`
UpdatedAt pgtype.Timestamptz `json:"updated_at"` UpdatedAt pgtype.Timestamptz `json:"updated_at"`
ImageUrl pgtype.Text `json:"image_url"` 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) { 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.CreatedAt,
&i.UpdatedAt, &i.UpdatedAt,
&i.ImageUrl, &i.ImageUrl,
&i.QuestionTypeDefinitionID,
&i.DynamicPayload,
); err != nil { ); err != nil {
return nil, err return nil, err
} }
@ -396,8 +413,9 @@ SET
sample_answer_voice_prompt = COALESCE($8, sample_answer_voice_prompt), sample_answer_voice_prompt = COALESCE($8, sample_answer_voice_prompt),
image_url = COALESCE($9, image_url), image_url = COALESCE($9, image_url),
status = COALESCE($10, status), status = COALESCE($10, status),
dynamic_payload = COALESCE($11::jsonb, dynamic_payload),
updated_at = CURRENT_TIMESTAMP updated_at = CURRENT_TIMESTAMP
WHERE id = $11 WHERE id = $12
` `
type UpdateQuestionParams struct { type UpdateQuestionParams struct {
@ -411,6 +429,7 @@ type UpdateQuestionParams struct {
SampleAnswerVoicePrompt pgtype.Text `json:"sample_answer_voice_prompt"` SampleAnswerVoicePrompt pgtype.Text `json:"sample_answer_voice_prompt"`
ImageUrl pgtype.Text `json:"image_url"` ImageUrl pgtype.Text `json:"image_url"`
Status string `json:"status"` Status string `json:"status"`
Column11 []byte `json:"column_11"`
ID int64 `json:"id"` ID int64 `json:"id"`
} }
@ -426,6 +445,7 @@ func (q *Queries) UpdateQuestion(ctx context.Context, arg UpdateQuestionParams)
arg.SampleAnswerVoicePrompt, arg.SampleAnswerVoicePrompt,
arg.ImageUrl, arg.ImageUrl,
arg.Status, arg.Status,
arg.Column11,
arg.ID, arg.ID,
) )
return err return err

View File

@ -10,11 +10,14 @@ import (
type StimulusComponentKind string type StimulusComponentKind string
const ( const (
StimulusQuestionText StimulusComponentKind = "QUESTION_TEXT"
StimulusPrepTime StimulusComponentKind = "PREP_TIME" StimulusPrepTime StimulusComponentKind = "PREP_TIME"
StimulusInstruction StimulusComponentKind = "INSTRUCTION" StimulusInstruction StimulusComponentKind = "INSTRUCTION"
StimulusAudioPrompt StimulusComponentKind = "AUDIO_PROMPT"
StimulusAudioClip StimulusComponentKind = "AUDIO_CLIP" StimulusAudioClip StimulusComponentKind = "AUDIO_CLIP"
StimulusTextPassage StimulusComponentKind = "TEXT_PASSAGE" StimulusTextPassage StimulusComponentKind = "TEXT_PASSAGE"
StimulusImage StimulusComponentKind = "IMAGE" StimulusImage StimulusComponentKind = "IMAGE"
StimulusChart StimulusComponentKind = "CHART"
StimulusMatchingInputs StimulusComponentKind = "MATCHING_INPUTS" StimulusMatchingInputs StimulusComponentKind = "MATCHING_INPUTS"
StimulusSelectMissingWords StimulusComponentKind = "SELECT_MISSING_WORDS" StimulusSelectMissingWords StimulusComponentKind = "SELECT_MISSING_WORDS"
StimulusTable StimulusComponentKind = "TABLE" StimulusTable StimulusComponentKind = "TABLE"
@ -25,24 +28,49 @@ const (
type ResponseComponentKind string type ResponseComponentKind string
const ( const (
ResponseAudioResponse ResponseComponentKind = "AUDIO_RESPONSE" ResponseAudioResponse ResponseComponentKind = "AUDIO_RESPONSE"
ResponseTextInput ResponseComponentKind = "TEXT_INPUT" ResponseTextInput ResponseComponentKind = "TEXT_INPUT"
ResponseShortAnswer ResponseComponentKind = "SHORT_ANSWER" ResponseShortAnswer ResponseComponentKind = "SHORT_ANSWER"
ResponseMultipleChoice ResponseComponentKind = "MULTIPLE_CHOICE" ResponseMultipleChoice ResponseComponentKind = "MULTIPLE_CHOICE"
ResponseAnswerTimer ResponseComponentKind = "ANSWER_TIMER" ResponseOption ResponseComponentKind = "OPTION"
ResponseSelectMissingWords ResponseComponentKind = "SELECT_MISSING_WORDS" ResponseAnswerTimer ResponseComponentKind = "ANSWER_TIMER"
ResponsePDFUpload ResponseComponentKind = "PDF_UPLOAD" ResponseSelectMissingWords ResponseComponentKind = "SELECT_MISSING_WORDS"
ResponseMatchingAnswer ResponseComponentKind = "MATCHING_ANSWER" ResponsePDFUpload ResponseComponentKind = "PDF_UPLOAD"
ResponseLabelSelection ResponseComponentKind = "LABEL_SELECTION" 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 ( var (
stimulusCatalog = []StimulusComponentKind{ stimulusCatalog = []StimulusComponentKind{
StimulusQuestionText,
StimulusPrepTime, StimulusPrepTime,
StimulusInstruction, StimulusInstruction,
StimulusAudioPrompt,
StimulusAudioClip, StimulusAudioClip,
StimulusTextPassage, StimulusTextPassage,
StimulusImage, StimulusImage,
StimulusChart,
StimulusMatchingInputs, StimulusMatchingInputs,
StimulusSelectMissingWords, StimulusSelectMissingWords,
StimulusTable, StimulusTable,
@ -55,11 +83,13 @@ var (
ResponseTextInput, ResponseTextInput,
ResponseShortAnswer, ResponseShortAnswer,
ResponseMultipleChoice, ResponseMultipleChoice,
ResponseOption,
ResponseAnswerTimer, ResponseAnswerTimer,
ResponseSelectMissingWords, ResponseSelectMissingWords,
ResponsePDFUpload, ResponsePDFUpload,
ResponseMatchingAnswer, ResponseMatchingAnswer,
ResponseLabelSelection, ResponseLabelSelection,
ResponseSequenceOrder,
} }
responseSet map[string]struct{} responseSet map[string]struct{}
@ -183,6 +213,178 @@ func ValidateDynamicQuestionTypeDefinition(stimulusKinds, responseKinds []string
return fmt.Errorf("%s", strings.Join(errs, "; ")) 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 { func normalizeKindList(in []string) []string {
var out []string var out []string
for _, s := range in { for _, s := range in {

View File

@ -64,3 +64,71 @@ func TestValidateDynamicQuestionTypeDefinition_twoPrepTimes(t *testing.T) {
t.Fatalf("expected at most one PREP_TIME, got %v", err) 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)
}
}

View File

@ -9,6 +9,8 @@ type QuestionTypeDefinition struct {
Description *string Description *string
StimulusComponentKinds []string StimulusComponentKinds []string
ResponseComponentKinds []string ResponseComponentKinds []string
StimulusSchema []DynamicElementDefinition
ResponseSchema []DynamicElementDefinition
IsSystem bool IsSystem bool
Status string Status string
CreatedAt time.Time CreatedAt time.Time
@ -21,6 +23,8 @@ type CreateQuestionTypeDefinitionInput struct {
Description *string Description *string
StimulusComponentKinds []string StimulusComponentKinds []string
ResponseComponentKinds []string ResponseComponentKinds []string
StimulusSchema []DynamicElementDefinition
ResponseSchema []DynamicElementDefinition
IsSystem bool IsSystem bool
Status *string Status *string
} }
@ -30,5 +34,7 @@ type UpdateQuestionTypeDefinitionInput struct {
Description *string Description *string
StimulusComponentKinds []string StimulusComponentKinds []string
ResponseComponentKinds []string ResponseComponentKinds []string
StimulusSchema []DynamicElementDefinition
ResponseSchema []DynamicElementDefinition
Status *string Status *string
} }

View File

@ -49,6 +49,7 @@ type Question struct {
QuestionText string QuestionText string
QuestionType string QuestionType string
QuestionTypeDefinitionID *int64 QuestionTypeDefinitionID *int64
DynamicPayload *DynamicQuestionPayload
DifficultyLevel *string DifficultyLevel *string
Points int32 Points int32
Explanation *string Explanation *string
@ -123,6 +124,7 @@ type QuestionSetItemWithQuestion struct {
QuestionSetItem QuestionSetItem
QuestionText string QuestionText string
QuestionType string QuestionType string
DynamicPayload *DynamicQuestionPayload
DifficultyLevel *string DifficultyLevel *string
Points int32 Points int32
Explanation *string Explanation *string
@ -138,6 +140,7 @@ type CreateQuestionInput struct {
QuestionText string QuestionText string
QuestionType string QuestionType string
QuestionTypeDefinitionID *int64 QuestionTypeDefinitionID *int64
DynamicPayload *DynamicQuestionPayload
DifficultyLevel *string DifficultyLevel *string
Points *int32 Points *int32
Explanation *string Explanation *string

View File

@ -2,6 +2,7 @@ package repository
import ( import (
"context" "context"
"encoding/json"
"errors" "errors"
"strings" "strings"
"time" "time"
@ -64,22 +65,46 @@ func timePtr(t pgtype.Timestamptz) *time.Time {
func questionToDomain(q dbgen.Question) domain.Question { func questionToDomain(q dbgen.Question) domain.Question {
return domain.Question{ return domain.Question{
ID: q.ID, ID: q.ID,
QuestionText: q.QuestionText, QuestionText: q.QuestionText,
QuestionType: q.QuestionType, QuestionType: q.QuestionType,
DifficultyLevel: fromPgText(q.DifficultyLevel), QuestionTypeDefinitionID: fromPgInt8(q.QuestionTypeDefinitionID),
Points: q.Points, DynamicPayload: parseDynamicPayload(q.DynamicPayload),
Explanation: fromPgText(q.Explanation), DifficultyLevel: fromPgText(q.DifficultyLevel),
Tips: fromPgText(q.Tips), Points: q.Points,
VoicePrompt: fromPgText(q.VoicePrompt), Explanation: fromPgText(q.Explanation),
SampleAnswerVoicePrompt: fromPgText(q.SampleAnswerVoicePrompt), Tips: fromPgText(q.Tips),
ImageURL: fromPgText(q.ImageUrl), VoicePrompt: fromPgText(q.VoicePrompt),
Status: q.Status, SampleAnswerVoicePrompt: fromPgText(q.SampleAnswerVoicePrompt),
CreatedAt: q.CreatedAt.Time, ImageURL: fromPgText(q.ImageUrl),
UpdatedAt: timePtr(q.UpdatedAt), 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 { func (s *Store) setQuestionTypeDefinitionID(ctx context.Context, questionID int64, definitionID *int64) error {
_, err := s.conn.Exec(ctx, ` _, err := s.conn.Exec(ctx, `
UPDATE questions UPDATE questions
@ -156,11 +181,22 @@ func questionTypeDefinitionToDomain(
description pgtype.Text, description pgtype.Text,
stimulusKinds []string, stimulusKinds []string,
responseKinds []string, responseKinds []string,
stimulusSchema []byte,
responseSchema []byte,
isSystem bool, isSystem bool,
status string, status string,
createdAt time.Time, createdAt time.Time,
updatedAt pgtype.Timestamptz, updatedAt pgtype.Timestamptz,
) domain.QuestionTypeDefinition { ) 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{ return domain.QuestionTypeDefinition{
ID: id, ID: id,
Key: key, Key: key,
@ -168,6 +204,8 @@ func questionTypeDefinitionToDomain(
Description: fromPgText(description), Description: fromPgText(description),
StimulusComponentKinds: stimulusKinds, StimulusComponentKinds: stimulusKinds,
ResponseComponentKinds: responseKinds, ResponseComponentKinds: responseKinds,
StimulusSchema: stimulusSchemaDef,
ResponseSchema: responseSchemaDef,
IsSystem: isSystem, IsSystem: isSystem,
Status: status, Status: status,
CreatedAt: createdAt, CreatedAt: createdAt,
@ -200,24 +238,85 @@ func normalizeDefinitionKey(key string) string {
return key 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) { func (s *Store) CreateQuestionTypeDefinition(ctx context.Context, input domain.CreateQuestionTypeDefinitionInput) (domain.QuestionTypeDefinition, error) {
normalizedKey := normalizeDefinitionKey(input.Key)
stimulusKinds := normalizeDefinitionKinds(input.StimulusComponentKinds) stimulusKinds := normalizeDefinitionKinds(input.StimulusComponentKinds)
responseKinds := normalizeDefinitionKinds(input.ResponseComponentKinds) 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 { if err := domain.ValidateDynamicQuestionTypeDefinition(stimulusKinds, responseKinds); err != nil {
return domain.QuestionTypeDefinition{}, err 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, ` row := s.conn.QueryRow(ctx, `
INSERT INTO question_type_definitions INSERT INTO question_type_definitions
(key, display_name, description, stimulus_component_kinds, response_component_kinds, is_system, status) (key, display_name, description, stimulus_component_kinds, response_component_kinds, stimulus_schema, response_schema, is_system, status)
VALUES ($1, $2, $3, $4, $5, $6, $7) VALUES ($1, $2, $3, $4, $5, $6::jsonb, $7::jsonb, $8, $9)
RETURNING id, key, display_name, description, stimulus_component_kinds, response_component_kinds, is_system, status, created_at, updated_at 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), strings.TrimSpace(input.DisplayName),
toPgText(input.Description), toPgText(input.Description),
stimulusKinds, stimulusKinds,
responseKinds, responseKinds,
mustJSON(stimulusSchema),
mustJSON(responseSchema),
input.IsSystem, input.IsSystem,
normalizeDefinitionStatus(input.Status), normalizeDefinitionStatus(input.Status),
) )
@ -229,21 +328,23 @@ func (s *Store) CreateQuestionTypeDefinition(ctx context.Context, input domain.C
description pgtype.Text description pgtype.Text
stimulus []string stimulus []string
response []string response []string
stimulusSch []byte
responseSch []byte
isSystem bool isSystem bool
status string status string
createdAt time.Time createdAt time.Time
updatedAt pgtype.Timestamptz 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 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) { func (s *Store) GetQuestionTypeDefinitionByID(ctx context.Context, id int64) (domain.QuestionTypeDefinition, error) {
row := s.conn.QueryRow(ctx, ` 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 FROM question_type_definitions
WHERE id = $1 WHERE id = $1
`, id) `, id)
@ -254,20 +355,22 @@ func (s *Store) GetQuestionTypeDefinitionByID(ctx context.Context, id int64) (do
description pgtype.Text description pgtype.Text
stimulus []string stimulus []string
response []string response []string
stimulusSch []byte
responseSch []byte
isSystem bool isSystem bool
status string status string
createdAt time.Time createdAt time.Time
updatedAt pgtype.Timestamptz 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 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) { func (s *Store) ListQuestionTypeDefinitions(ctx context.Context, status *string, includeSystem bool) ([]domain.QuestionTypeDefinition, error) {
rows, err := s.conn.Query(ctx, ` 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 FROM question_type_definitions
WHERE ($1::VARCHAR IS NULL OR status = $1) WHERE ($1::VARCHAR IS NULL OR status = $1)
AND ($2::BOOLEAN = TRUE OR is_system = FALSE) AND ($2::BOOLEAN = TRUE OR is_system = FALSE)
@ -287,15 +390,17 @@ func (s *Store) ListQuestionTypeDefinitions(ctx context.Context, status *string,
description pgtype.Text description pgtype.Text
stimulus []string stimulus []string
response []string response []string
stimulusSch []byte
responseSch []byte
isSystem bool isSystem bool
defStatus string defStatus string
createdAt time.Time createdAt time.Time
updatedAt pgtype.Timestamptz 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 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() return out, rows.Err()
@ -326,10 +431,31 @@ func (s *Store) UpdateQuestionTypeDefinition(ctx context.Context, id int64, inpu
if input.ResponseComponentKinds != nil { if input.ResponseComponentKinds != nil {
responseKinds = normalizeDefinitionKinds(input.ResponseComponentKinds) 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 { if err := domain.ValidateDynamicQuestionTypeDefinition(stimulusKinds, responseKinds); err != nil {
return err 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 status := existing.Status
if input.Status != nil { if input.Status != nil {
@ -342,10 +468,12 @@ func (s *Store) UpdateQuestionTypeDefinition(ctx context.Context, id int64, inpu
description = $3, description = $3,
stimulus_component_kinds = $4, stimulus_component_kinds = $4,
response_component_kinds = $5, response_component_kinds = $5,
status = $6, stimulus_schema = $6::jsonb,
response_schema = $7::jsonb,
status = $8,
updated_at = NOW() updated_at = NOW()
WHERE id = $1 WHERE id = $1
`, id, displayName, toPgText(description), stimulusKinds, responseKinds, status) `, id, displayName, toPgText(description), stimulusKinds, responseKinds, mustJSON(stimulusSchema), mustJSON(responseSchema), status)
return err return err
} }
@ -396,6 +524,7 @@ func (s *Store) CreateQuestion(ctx context.Context, input domain.CreateQuestionI
SampleAnswerVoicePrompt: toPgText(input.SampleAnswerVoicePrompt), SampleAnswerVoicePrompt: toPgText(input.SampleAnswerVoicePrompt),
ImageUrl: toPgText(input.ImageURL), ImageUrl: toPgText(input.ImageURL),
Column10: status, Column10: status,
Column11: encodeDynamicPayload(input.DynamicPayload),
}) })
if err != nil { if err != nil {
return domain.Question{}, err return domain.Question{}, err
@ -450,7 +579,9 @@ func (s *Store) CreateQuestion(ctx context.Context, input domain.CreateQuestionI
return domain.Question{}, err 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) { 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 totalCount = r.TotalCount
} }
questions[i] = domain.Question{ questions[i] = domain.Question{
ID: r.ID, ID: r.ID,
QuestionText: r.QuestionText, QuestionText: r.QuestionText,
QuestionType: r.QuestionType, QuestionType: r.QuestionType,
DifficultyLevel: fromPgText(r.DifficultyLevel), QuestionTypeDefinitionID: fromPgInt8(r.QuestionTypeDefinitionID),
Points: r.Points, DynamicPayload: parseDynamicPayload(r.DynamicPayload),
Explanation: fromPgText(r.Explanation), DifficultyLevel: fromPgText(r.DifficultyLevel),
Tips: fromPgText(r.Tips), Points: r.Points,
VoicePrompt: fromPgText(r.VoicePrompt), Explanation: fromPgText(r.Explanation),
SampleAnswerVoicePrompt: fromPgText(r.SampleAnswerVoicePrompt), Tips: fromPgText(r.Tips),
ImageURL: fromPgText(r.ImageUrl), VoicePrompt: fromPgText(r.VoicePrompt),
Status: r.Status, SampleAnswerVoicePrompt: fromPgText(r.SampleAnswerVoicePrompt),
CreatedAt: r.CreatedAt.Time, ImageURL: fromPgText(r.ImageUrl),
UpdatedAt: timePtr(r.UpdatedAt), 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 totalCount = r.TotalCount
} }
questions[i] = domain.Question{ questions[i] = domain.Question{
ID: r.ID, ID: r.ID,
QuestionText: r.QuestionText, QuestionText: r.QuestionText,
QuestionType: r.QuestionType, QuestionType: r.QuestionType,
DifficultyLevel: fromPgText(r.DifficultyLevel), QuestionTypeDefinitionID: fromPgInt8(r.QuestionTypeDefinitionID),
Points: r.Points, DynamicPayload: parseDynamicPayload(r.DynamicPayload),
Explanation: fromPgText(r.Explanation), DifficultyLevel: fromPgText(r.DifficultyLevel),
Tips: fromPgText(r.Tips), Points: r.Points,
VoicePrompt: fromPgText(r.VoicePrompt), Explanation: fromPgText(r.Explanation),
SampleAnswerVoicePrompt: fromPgText(r.SampleAnswerVoicePrompt), Tips: fromPgText(r.Tips),
ImageURL: fromPgText(r.ImageUrl), VoicePrompt: fromPgText(r.VoicePrompt),
Status: r.Status, SampleAnswerVoicePrompt: fromPgText(r.SampleAnswerVoicePrompt),
CreatedAt: r.CreatedAt.Time, ImageURL: fromPgText(r.ImageUrl),
UpdatedAt: timePtr(r.UpdatedAt), 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), SampleAnswerVoicePrompt: toPgText(input.SampleAnswerVoicePrompt),
ImageUrl: toPgText(input.ImageURL), ImageUrl: toPgText(input.ImageURL),
Status: status, Status: status,
Column11: encodeDynamicPayload(input.DynamicPayload),
}) })
if err != nil { if err != nil {
return err return err
@ -979,6 +1115,7 @@ func (s *Store) GetQuestionSetItems(ctx context.Context, setID int64) ([]domain.
}, },
QuestionText: r.QuestionText, QuestionText: r.QuestionText,
QuestionType: r.QuestionType, QuestionType: r.QuestionType,
DynamicPayload: parseDynamicPayload(r.DynamicPayload),
DifficultyLevel: fromPgText(r.DifficultyLevel), DifficultyLevel: fromPgText(r.DifficultyLevel),
Points: r.Points, Points: r.Points,
Explanation: fromPgText(r.Explanation), Explanation: fromPgText(r.Explanation),
@ -1023,6 +1160,7 @@ func (s *Store) GetQuestionSetItemsPaginated(ctx context.Context, setID int64, q
}, },
QuestionText: r.QuestionText, QuestionText: r.QuestionText,
QuestionType: r.QuestionType, QuestionType: r.QuestionType,
DynamicPayload: parseDynamicPayload(r.DynamicPayload),
DifficultyLevel: fromPgText(r.DifficultyLevel), DifficultyLevel: fromPgText(r.DifficultyLevel),
Points: r.Points, Points: r.Points,
Explanation: fromPgText(r.Explanation), Explanation: fromPgText(r.Explanation),
@ -1054,6 +1192,7 @@ func (s *Store) GetPublishedQuestionsInSet(ctx context.Context, setID int64) ([]
}, },
QuestionText: r.QuestionText, QuestionText: r.QuestionText,
QuestionType: r.QuestionType, QuestionType: r.QuestionType,
DynamicPayload: parseDynamicPayload(r.DynamicPayload),
DifficultyLevel: fromPgText(r.DifficultyLevel), DifficultyLevel: fromPgText(r.DifficultyLevel),
Points: r.Points, Points: r.Points,
Explanation: fromPgText(r.Explanation), Explanation: fromPgText(r.Explanation),

View File

@ -19,20 +19,24 @@ type validateQuestionTypeDefinitionReq struct {
} }
type createQuestionTypeDefinitionReq struct { type createQuestionTypeDefinitionReq struct {
Key string `json:"key" validate:"required"` Key string `json:"key" validate:"required"`
DisplayName string `json:"display_name" validate:"required"` DisplayName string `json:"display_name" validate:"required"`
Description *string `json:"description"` Description *string `json:"description"`
StimulusComponentKinds []string `json:"stimulus_component_kinds"` StimulusComponentKinds []string `json:"stimulus_component_kinds"`
ResponseComponentKinds []string `json:"response_component_kinds"` ResponseComponentKinds []string `json:"response_component_kinds"`
Status *string `json:"status"` StimulusSchema []domain.DynamicElementDefinition `json:"stimulus_schema"`
ResponseSchema []domain.DynamicElementDefinition `json:"response_schema"`
Status *string `json:"status"`
} }
type updateQuestionTypeDefinitionReq struct { type updateQuestionTypeDefinitionReq struct {
DisplayName *string `json:"display_name"` DisplayName *string `json:"display_name"`
Description *string `json:"description"` Description *string `json:"description"`
StimulusComponentKinds []string `json:"stimulus_component_kinds"` StimulusComponentKinds []string `json:"stimulus_component_kinds"`
ResponseComponentKinds []string `json:"response_component_kinds"` ResponseComponentKinds []string `json:"response_component_kinds"`
Status *string `json:"status"` StimulusSchema []domain.DynamicElementDefinition `json:"stimulus_schema"`
ResponseSchema []domain.DynamicElementDefinition `json:"response_schema"`
Status *string `json:"status"`
} }
// GetQuestionTypeComponentCatalog godoc // GetQuestionTypeComponentCatalog godoc
@ -54,7 +58,7 @@ func (h *Handler) GetQuestionTypeComponentCatalog(c *fiber.Ctx) error {
// ValidateQuestionTypeDefinition godoc // ValidateQuestionTypeDefinition godoc
// @Summary Validate dynamic question-type definition // @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 // @Tags questions
// @Accept json // @Accept json
// @Produce json // @Produce json
@ -86,7 +90,7 @@ func (h *Handler) ValidateQuestionTypeDefinition(c *fiber.Ctx) error {
// CreateQuestionTypeDefinition godoc // CreateQuestionTypeDefinition godoc
// @Summary Create reusable question-type definition // @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 // @Tags questions
// @Accept json // @Accept json
// @Produce json // @Produce json
@ -116,6 +120,8 @@ func (h *Handler) CreateQuestionTypeDefinition(c *fiber.Ctx) error {
Description: req.Description, Description: req.Description,
StimulusComponentKinds: req.StimulusComponentKinds, StimulusComponentKinds: req.StimulusComponentKinds,
ResponseComponentKinds: req.ResponseComponentKinds, ResponseComponentKinds: req.ResponseComponentKinds,
StimulusSchema: req.StimulusSchema,
ResponseSchema: req.ResponseSchema,
Status: req.Status, Status: req.Status,
IsSystem: false, IsSystem: false,
}) })
@ -197,6 +203,7 @@ func (h *Handler) GetQuestionTypeDefinitionByID(c *fiber.Ctx) error {
// UpdateQuestionTypeDefinition godoc // UpdateQuestionTypeDefinition godoc
// @Summary Update reusable question-type definition // @Summary Update reusable question-type definition
// @Description Updates a reusable dynamic question-type definition. Updated definitions must remain runtime-mappable.
// @Tags questions // @Tags questions
// @Accept json // @Accept json
// @Produce json // @Produce json
@ -233,6 +240,8 @@ func (h *Handler) UpdateQuestionTypeDefinition(c *fiber.Ctx) error {
Description: req.Description, Description: req.Description,
StimulusComponentKinds: req.StimulusComponentKinds, StimulusComponentKinds: req.StimulusComponentKinds,
ResponseComponentKinds: req.ResponseComponentKinds, ResponseComponentKinds: req.ResponseComponentKinds,
StimulusSchema: req.StimulusSchema,
ResponseSchema: req.ResponseSchema,
Status: req.Status, Status: req.Status,
}) })
if err != nil { if err != nil {

View File

@ -25,20 +25,21 @@ type shortAnswerInput struct {
} }
type createQuestionReq struct { type createQuestionReq struct {
QuestionText string `json:"question_text" validate:"required"` QuestionText string `json:"question_text" validate:"required"`
QuestionType string `json:"question_type"` QuestionType string `json:"question_type"`
QuestionTypeDefinitionID *int64 `json:"question_type_definition_id"` QuestionTypeDefinitionID *int64 `json:"question_type_definition_id"`
DifficultyLevel *string `json:"difficulty_level"` DynamicPayload *domain.DynamicQuestionPayload `json:"dynamic_payload"`
Points *int32 `json:"points"` DifficultyLevel *string `json:"difficulty_level"`
Explanation *string `json:"explanation"` Points *int32 `json:"points"`
Tips *string `json:"tips"` Explanation *string `json:"explanation"`
VoicePrompt *string `json:"voice_prompt"` Tips *string `json:"tips"`
SampleAnswerVoicePrompt *string `json:"sample_answer_voice_prompt"` VoicePrompt *string `json:"voice_prompt"`
ImageURL *string `json:"image_url"` SampleAnswerVoicePrompt *string `json:"sample_answer_voice_prompt"`
Status *string `json:"status"` ImageURL *string `json:"image_url"`
Options []optionInput `json:"options"` Status *string `json:"status"`
ShortAnswers []shortAnswerInput `json:"short_answers"` Options []optionInput `json:"options"`
AudioCorrectAnswerText *string `json:"audio_correct_answer_text"` ShortAnswers []shortAnswerInput `json:"short_answers"`
AudioCorrectAnswerText *string `json:"audio_correct_answer_text"`
} }
type optionRes struct { type optionRes struct {
@ -55,21 +56,23 @@ type shortAnswerRes struct {
} }
type questionRes struct { type questionRes struct {
ID int64 `json:"id"` ID int64 `json:"id"`
QuestionText string `json:"question_text"` QuestionText string `json:"question_text"`
QuestionType string `json:"question_type"` QuestionType string `json:"question_type"`
DifficultyLevel *string `json:"difficulty_level,omitempty"` QuestionTypeDefinitionID *int64 `json:"question_type_definition_id,omitempty"`
Points int32 `json:"points"` DynamicPayload *domain.DynamicQuestionPayload `json:"dynamic_payload,omitempty"`
Explanation *string `json:"explanation,omitempty"` DifficultyLevel *string `json:"difficulty_level,omitempty"`
Tips *string `json:"tips,omitempty"` Points int32 `json:"points"`
VoicePrompt *string `json:"voice_prompt,omitempty"` Explanation *string `json:"explanation,omitempty"`
SampleAnswerVoicePrompt *string `json:"sample_answer_voice_prompt,omitempty"` Tips *string `json:"tips,omitempty"`
ImageURL *string `json:"image_url,omitempty"` VoicePrompt *string `json:"voice_prompt,omitempty"`
Status string `json:"status"` SampleAnswerVoicePrompt *string `json:"sample_answer_voice_prompt,omitempty"`
CreatedAt string `json:"created_at"` ImageURL *string `json:"image_url,omitempty"`
Options []optionRes `json:"options,omitempty"` Status string `json:"status"`
ShortAnswers []shortAnswerRes `json:"short_answers,omitempty"` CreatedAt string `json:"created_at"`
AudioCorrectAnswerText *string `json:"audio_correct_answer_text,omitempty"` Options []optionRes `json:"options,omitempty"`
ShortAnswers []shortAnswerRes `json:"short_answers,omitempty"`
AudioCorrectAnswerText *string `json:"audio_correct_answer_text,omitempty"`
} }
type listQuestionsRes struct { type listQuestionsRes struct {
@ -78,22 +81,7 @@ type listQuestionsRes struct {
} }
func resolveQuestionTypeFromDefinition(def domain.QuestionTypeDefinition) string { func resolveQuestionTypeFromDefinition(def domain.QuestionTypeDefinition) string {
key := strings.ToLower(strings.TrimSpace(def.Key)) return domain.ResolveRuntimeQuestionTypeFromDefinition(def.Key, def.ResponseComponentKinds)
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 ""
} }
func normalizeRuntimeQuestionType(v string) string { func normalizeRuntimeQuestionType(v string) string {
@ -102,7 +90,7 @@ func normalizeRuntimeQuestionType(v string) string {
// CreateQuestion godoc // CreateQuestion godoc
// @Summary Create a new question // @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 // @Tags questions
// @Accept json // @Accept json
// @Produce json // @Produce json
@ -129,22 +117,33 @@ func (h *Handler) CreateQuestion(c *fiber.Ctx) error {
Error: err.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 == "" { if questionType == "" {
questionType = inferred questionType = "DYNAMIC"
} }
if questionType != inferred { if questionType != "DYNAMIC" {
return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{ return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{
Message: "Mismatched question_type and definition", Message: "Invalid question_type for dynamic definition",
Error: "question_type must match the selected question_type_definition_id", 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 == "" { if questionType == "" {
return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{ return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{
@ -153,11 +152,17 @@ func (h *Handler) CreateQuestion(c *fiber.Ctx) error {
}) })
} }
switch questionType { switch questionType {
case "MCQ", "TRUE_FALSE", "SHORT_ANSWER", "AUDIO": case "MCQ", "TRUE_FALSE", "SHORT_ANSWER", "AUDIO", "DYNAMIC":
default: default:
return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{ return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{
Message: "Invalid question_type", 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, QuestionText: req.QuestionText,
QuestionType: questionType, QuestionType: questionType,
QuestionTypeDefinitionID: req.QuestionTypeDefinitionID, QuestionTypeDefinitionID: req.QuestionTypeDefinitionID,
DynamicPayload: req.DynamicPayload,
DifficultyLevel: req.DifficultyLevel, DifficultyLevel: req.DifficultyLevel,
Points: req.Points, Points: req.Points,
Explanation: req.Explanation, Explanation: req.Explanation,
@ -218,18 +224,20 @@ func (h *Handler) CreateQuestion(c *fiber.Ctx) error {
return c.Status(fiber.StatusCreated).JSON(domain.Response{ return c.Status(fiber.StatusCreated).JSON(domain.Response{
Message: "Question created successfully", Message: "Question created successfully",
Data: questionRes{ Data: questionRes{
ID: question.ID, ID: question.ID,
QuestionText: question.QuestionText, QuestionText: question.QuestionText,
QuestionType: question.QuestionType, QuestionType: question.QuestionType,
DifficultyLevel: question.DifficultyLevel, QuestionTypeDefinitionID: question.QuestionTypeDefinitionID,
Points: question.Points, DynamicPayload: question.DynamicPayload,
Explanation: question.Explanation, DifficultyLevel: question.DifficultyLevel,
Tips: question.Tips, Points: question.Points,
VoicePrompt: question.VoicePrompt, Explanation: question.Explanation,
SampleAnswerVoicePrompt: question.SampleAnswerVoicePrompt, Tips: question.Tips,
ImageURL: question.ImageURL, VoicePrompt: question.VoicePrompt,
Status: question.Status, SampleAnswerVoicePrompt: question.SampleAnswerVoicePrompt,
CreatedAt: question.CreatedAt.String(), 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{ return c.JSON(domain.Response{
Message: "Question retrieved successfully", Message: "Question retrieved successfully",
Data: questionRes{ Data: questionRes{
ID: question.ID, ID: question.ID,
QuestionText: question.QuestionText, QuestionText: question.QuestionText,
QuestionType: question.QuestionType, QuestionType: question.QuestionType,
DifficultyLevel: question.DifficultyLevel, QuestionTypeDefinitionID: question.QuestionTypeDefinitionID,
Points: question.Points, DynamicPayload: question.DynamicPayload,
Explanation: question.Explanation, DifficultyLevel: question.DifficultyLevel,
Tips: question.Tips, Points: question.Points,
VoicePrompt: question.VoicePrompt, Explanation: question.Explanation,
SampleAnswerVoicePrompt: question.SampleAnswerVoicePrompt, Tips: question.Tips,
ImageURL: question.ImageURL, VoicePrompt: question.VoicePrompt,
Status: question.Status, SampleAnswerVoicePrompt: question.SampleAnswerVoicePrompt,
CreatedAt: question.CreatedAt.String(), ImageURL: question.ImageURL,
Options: options, Status: question.Status,
ShortAnswers: shortAnswers, CreatedAt: question.CreatedAt.String(),
AudioCorrectAnswerText: audioCorrectAnswerText, Options: options,
ShortAnswers: shortAnswers,
AudioCorrectAnswerText: audioCorrectAnswerText,
}, },
}) })
} }
@ -354,16 +364,18 @@ func (h *Handler) ListQuestions(c *fiber.Ctx) error {
var questionResponses []questionRes var questionResponses []questionRes
for _, q := range questions { for _, q := range questions {
questionResponses = append(questionResponses, questionRes{ questionResponses = append(questionResponses, questionRes{
ID: q.ID, ID: q.ID,
QuestionText: q.QuestionText, QuestionText: q.QuestionText,
QuestionType: q.QuestionType, QuestionType: q.QuestionType,
DifficultyLevel: q.DifficultyLevel, QuestionTypeDefinitionID: q.QuestionTypeDefinitionID,
Points: q.Points, DynamicPayload: q.DynamicPayload,
Explanation: q.Explanation, DifficultyLevel: q.DifficultyLevel,
Tips: q.Tips, Points: q.Points,
VoicePrompt: q.VoicePrompt, Explanation: q.Explanation,
Status: q.Status, Tips: q.Tips,
CreatedAt: q.CreatedAt.String(), 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 var questionResponses []questionRes
for _, q := range questions { for _, q := range questions {
questionResponses = append(questionResponses, questionRes{ questionResponses = append(questionResponses, questionRes{
ID: q.ID, ID: q.ID,
QuestionText: q.QuestionText, QuestionText: q.QuestionText,
QuestionType: q.QuestionType, QuestionType: q.QuestionType,
DifficultyLevel: q.DifficultyLevel, QuestionTypeDefinitionID: q.QuestionTypeDefinitionID,
Points: q.Points, DynamicPayload: q.DynamicPayload,
Status: q.Status, DifficultyLevel: q.DifficultyLevel,
CreatedAt: q.CreatedAt.String(), 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 { type updateQuestionReq struct {
QuestionText *string `json:"question_text"` QuestionText *string `json:"question_text"`
QuestionType *string `json:"question_type"` QuestionType *string `json:"question_type"`
QuestionTypeDefinitionID *int64 `json:"question_type_definition_id"` QuestionTypeDefinitionID *int64 `json:"question_type_definition_id"`
DifficultyLevel *string `json:"difficulty_level"` DynamicPayload *domain.DynamicQuestionPayload `json:"dynamic_payload"`
Points *int32 `json:"points"` DifficultyLevel *string `json:"difficulty_level"`
Explanation *string `json:"explanation"` Points *int32 `json:"points"`
Tips *string `json:"tips"` Explanation *string `json:"explanation"`
VoicePrompt *string `json:"voice_prompt"` Tips *string `json:"tips"`
SampleAnswerVoicePrompt *string `json:"sample_answer_voice_prompt"` VoicePrompt *string `json:"voice_prompt"`
ImageURL *string `json:"image_url"` SampleAnswerVoicePrompt *string `json:"sample_answer_voice_prompt"`
Status *string `json:"status"` ImageURL *string `json:"image_url"`
Options []optionInput `json:"options"` Status *string `json:"status"`
ShortAnswers []shortAnswerInput `json:"short_answers"` Options []optionInput `json:"options"`
AudioCorrectAnswerText *string `json:"audio_correct_answer_text"` ShortAnswers []shortAnswerInput `json:"short_answers"`
AudioCorrectAnswerText *string `json:"audio_correct_answer_text"`
} }
// UpdateQuestion godoc // UpdateQuestion godoc
// @Summary Update a question // @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 // @Tags questions
// @Accept json // @Accept json
// @Produce 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 var options []domain.CreateQuestionOptionInput
for _, opt := range req.Options { for _, opt := range req.Options {
options = append(options, domain.CreateQuestionOptionInput{ 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 { if req.QuestionText != nil {
questionText = *req.QuestionText questionText = *req.QuestionText
} }
questionType := "" questionType := normalizeRuntimeQuestionType(existingQuestion.QuestionType)
if req.QuestionType != nil { if req.QuestionType != nil {
questionType = normalizeRuntimeQuestionType(*req.QuestionType) questionType = normalizeRuntimeQuestionType(*req.QuestionType)
} }
effectiveDefinitionID := existingQuestion.QuestionTypeDefinitionID
if req.QuestionTypeDefinitionID != nil { 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 { if err != nil {
return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{ return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{
Message: "Invalid question_type_definition_id", Message: "Invalid question_type_definition_id",
Error: err.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 == "" { if questionType == "" {
questionType = inferred questionType = "DYNAMIC"
} }
if questionType != inferred { if questionType != "DYNAMIC" {
return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{ return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{
Message: "Mismatched question_type and definition", Message: "Invalid question_type for dynamic definition",
Error: "question_type must match the selected question_type_definition_id", 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{ input := domain.CreateQuestionInput{
QuestionText: questionText, QuestionText: questionText,
QuestionType: questionType, QuestionType: questionType,
QuestionTypeDefinitionID: req.QuestionTypeDefinitionID, QuestionTypeDefinitionID: effectiveDefinitionID,
DynamicPayload: effectiveDynamicPayload,
DifficultyLevel: req.DifficultyLevel, DifficultyLevel: req.DifficultyLevel,
Points: req.Points, Points: req.Points,
Explanation: req.Explanation, Explanation: req.Explanation,
@ -1152,21 +1211,22 @@ type addQuestionToSetReq struct {
} }
type questionSetItemRes struct { type questionSetItemRes struct {
ID int64 `json:"id"` ID int64 `json:"id"`
SetID int64 `json:"set_id"` SetID int64 `json:"set_id"`
QuestionID int64 `json:"question_id"` QuestionID int64 `json:"question_id"`
DisplayOrder int32 `json:"display_order"` DisplayOrder int32 `json:"display_order"`
QuestionText string `json:"question_text"` QuestionText string `json:"question_text"`
QuestionType string `json:"question_type"` QuestionType string `json:"question_type"`
DifficultyLevel *string `json:"difficulty_level,omitempty"` DynamicPayload *domain.DynamicQuestionPayload `json:"dynamic_payload,omitempty"`
Points int32 `json:"points"` DifficultyLevel *string `json:"difficulty_level,omitempty"`
Explanation *string `json:"explanation,omitempty"` Points int32 `json:"points"`
Tips *string `json:"tips,omitempty"` Explanation *string `json:"explanation,omitempty"`
VoicePrompt *string `json:"voice_prompt,omitempty"` Tips *string `json:"tips,omitempty"`
SampleAnswerVoicePrompt *string `json:"sample_answer_voice_prompt,omitempty"` VoicePrompt *string `json:"voice_prompt,omitempty"`
ImageURL *string `json:"image_url,omitempty"` SampleAnswerVoicePrompt *string `json:"sample_answer_voice_prompt,omitempty"`
AudioCorrectAnswerText *string `json:"audio_correct_answer_text,omitempty"` ImageURL *string `json:"image_url,omitempty"`
QuestionStatus string `json:"question_status"` AudioCorrectAnswerText *string `json:"audio_correct_answer_text,omitempty"`
QuestionStatus string `json:"question_status"`
} }
type paginatedQuestionSetItemsRes struct { type paginatedQuestionSetItemsRes struct {
@ -1186,6 +1246,7 @@ func questionSetItemsToRes(items []domain.QuestionSetItemWithQuestion) []questio
DisplayOrder: item.DisplayOrder, DisplayOrder: item.DisplayOrder,
QuestionText: item.QuestionText, QuestionText: item.QuestionText,
QuestionType: item.QuestionType, QuestionType: item.QuestionType,
DynamicPayload: item.DynamicPayload,
DifficultyLevel: item.DifficultyLevel, DifficultyLevel: item.DifficultyLevel,
Points: item.Points, Points: item.Points,
Explanation: item.Explanation, Explanation: item.Explanation,
@ -1331,21 +1392,23 @@ func (h *Handler) GetQuestionsInSet(c *fiber.Ctx) error {
} }
questionResponses = append(questionResponses, questionRes{ questionResponses = append(questionResponses, questionRes{
ID: question.ID, ID: question.ID,
QuestionText: question.QuestionText, QuestionText: question.QuestionText,
QuestionType: question.QuestionType, QuestionType: question.QuestionType,
DifficultyLevel: question.DifficultyLevel, QuestionTypeDefinitionID: question.QuestionTypeDefinitionID,
Points: question.Points, DynamicPayload: question.DynamicPayload,
Explanation: question.Explanation, DifficultyLevel: question.DifficultyLevel,
Tips: question.Tips, Points: question.Points,
VoicePrompt: question.VoicePrompt, Explanation: question.Explanation,
SampleAnswerVoicePrompt: question.SampleAnswerVoicePrompt, Tips: question.Tips,
ImageURL: question.ImageURL, VoicePrompt: question.VoicePrompt,
Status: question.Status, SampleAnswerVoicePrompt: question.SampleAnswerVoicePrompt,
CreatedAt: question.CreatedAt.String(), ImageURL: question.ImageURL,
Options: options, Status: question.Status,
ShortAnswers: shortAnswers, CreatedAt: question.CreatedAt.String(),
AudioCorrectAnswerText: audioCorrectAnswerText, Options: options,
ShortAnswers: shortAnswers,
AudioCorrectAnswerText: audioCorrectAnswerText,
}) })
} }

View File

@ -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`"
}
}
]
}
]
}
]
}

File diff suppressed because it is too large Load Diff

View File

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