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,
q.question_text,
q.question_type,
q.dynamic_payload,
q.difficulty_level,
q.points,
q.explanation,
@ -41,6 +42,7 @@ SELECT
qsi.display_order,
q.question_text,
q.question_type,
q.dynamic_payload,
q.difficulty_level,
q.points,
q.explanation,
@ -68,6 +70,7 @@ SELECT
qsi.display_order,
q.question_text,
q.question_type,
q.dynamic_payload,
q.difficulty_level,
q.points,
q.explanation,

View File

@ -9,9 +9,10 @@ INSERT INTO questions (
voice_prompt,
sample_answer_voice_prompt,
image_url,
status
status,
dynamic_payload
)
VALUES ($1, $2, $3, COALESCE($4, 1), $5, $6, $7, $8, $9, COALESCE($10, 'DRAFT'))
VALUES ($1, $2, $3, COALESCE($4, 1), $5, $6, $7, $8, $9, COALESCE($10, 'DRAFT'), $11::jsonb)
RETURNING *;
-- name: GetQuestionByID :one
@ -62,8 +63,9 @@ SET
sample_answer_voice_prompt = COALESCE($8, sample_answer_voice_prompt),
image_url = COALESCE($9, image_url),
status = COALESCE($10, status),
dynamic_payload = COALESCE($11::jsonb, dynamic_payload),
updated_at = CURRENT_TIMESTAMP
WHERE id = $11;
WHERE id = $12;
-- name: ArchiveQuestion :exec
UPDATE questions

View File

@ -4560,7 +4560,7 @@ const docTemplate = `{
}
},
"post": {
"description": "Creates a new question with options (for MCQ/TRUE_FALSE) or short answers (for SHORT_ANSWER)",
"description": "Creates a new question with options (for MCQ/TRUE_FALSE) or short answers (for SHORT_ANSWER). Supports question_type_definition_id for dynamic builder-linked questions.",
"consumes": [
"application/json"
],
@ -4721,9 +4721,221 @@ const docTemplate = `{
}
}
},
"/api/v1/questions/type-definitions": {
"get": {
"produces": [
"application/json"
],
"tags": [
"questions"
],
"summary": "List reusable question-type definitions",
"parameters": [
{
"type": "string",
"description": "Filter by status (ACTIVE, INACTIVE)",
"name": "status",
"in": "query"
},
{
"type": "boolean",
"description": "Include system seeded definitions",
"name": "include_system",
"in": "query"
}
],
"responses": {
"200": {
"description": "OK",
"schema": {
"$ref": "#/definitions/domain.Response"
}
},
"500": {
"description": "Internal Server Error",
"schema": {
"$ref": "#/definitions/domain.ErrorResponse"
}
}
}
},
"post": {
"description": "Stores a reusable dynamic question-type definition for future question construction. Only runtime-mappable definitions are persisted.",
"consumes": [
"application/json"
],
"produces": [
"application/json"
],
"tags": [
"questions"
],
"summary": "Create reusable question-type definition",
"parameters": [
{
"description": "Question type definition payload",
"name": "body",
"in": "body",
"required": true,
"schema": {
"$ref": "#/definitions/handlers.createQuestionTypeDefinitionReq"
}
}
],
"responses": {
"201": {
"description": "Created",
"schema": {
"$ref": "#/definitions/domain.Response"
}
},
"400": {
"description": "Bad Request",
"schema": {
"$ref": "#/definitions/domain.ErrorResponse"
}
},
"500": {
"description": "Internal Server Error",
"schema": {
"$ref": "#/definitions/domain.ErrorResponse"
}
}
}
}
},
"/api/v1/questions/type-definitions/{id}": {
"get": {
"produces": [
"application/json"
],
"tags": [
"questions"
],
"summary": "Get reusable question-type definition by id",
"parameters": [
{
"type": "integer",
"description": "Question type definition id",
"name": "id",
"in": "path",
"required": true
}
],
"responses": {
"200": {
"description": "OK",
"schema": {
"$ref": "#/definitions/domain.Response"
}
},
"400": {
"description": "Bad Request",
"schema": {
"$ref": "#/definitions/domain.ErrorResponse"
}
},
"404": {
"description": "Not Found",
"schema": {
"$ref": "#/definitions/domain.ErrorResponse"
}
}
}
},
"put": {
"description": "Updates a reusable dynamic question-type definition. Updated definitions must remain runtime-mappable.",
"consumes": [
"application/json"
],
"produces": [
"application/json"
],
"tags": [
"questions"
],
"summary": "Update reusable question-type definition",
"parameters": [
{
"type": "integer",
"description": "Question type definition id",
"name": "id",
"in": "path",
"required": true
},
{
"description": "Update question type definition payload",
"name": "body",
"in": "body",
"required": true,
"schema": {
"$ref": "#/definitions/handlers.updateQuestionTypeDefinitionReq"
}
}
],
"responses": {
"200": {
"description": "OK",
"schema": {
"$ref": "#/definitions/domain.Response"
}
},
"400": {
"description": "Bad Request",
"schema": {
"$ref": "#/definitions/domain.ErrorResponse"
}
},
"500": {
"description": "Internal Server Error",
"schema": {
"$ref": "#/definitions/domain.ErrorResponse"
}
}
}
},
"delete": {
"produces": [
"application/json"
],
"tags": [
"questions"
],
"summary": "Delete reusable question-type definition",
"parameters": [
{
"type": "integer",
"description": "Question type definition id",
"name": "id",
"in": "path",
"required": true
}
],
"responses": {
"200": {
"description": "OK",
"schema": {
"$ref": "#/definitions/domain.Response"
}
},
"400": {
"description": "Bad Request",
"schema": {
"$ref": "#/definitions/domain.ErrorResponse"
}
},
"500": {
"description": "Internal Server Error",
"schema": {
"$ref": "#/definitions/domain.ErrorResponse"
}
}
}
}
},
"/api/v1/questions/validate-question-type-definition": {
"post": {
"description": "Validates selected stimulus and response component kinds for temporary question-type definitions",
"description": "Validates selected stimulus and response component kinds for temporary question-type definitions (component-level validation only)",
"consumes": [
"application/json"
],
@ -4802,7 +5014,7 @@ const docTemplate = `{
}
},
"put": {
"description": "Updates a question and optionally replaces its options/short answers",
"description": "Updates a question and optionally replaces its options/short answers. Supports question_type_definition_id for dynamic builder-linked questions.",
"consumes": [
"application/json"
],
@ -9452,6 +9664,10 @@ const docTemplate = `{
"questionType": {
"type": "string"
},
"questionTypeDefinitionID": {
"type": "integer",
"format": "int64"
},
"sampleAnswerVoicePrompt": {
"type": "string"
},
@ -10702,8 +10918,7 @@ const docTemplate = `{
"handlers.createQuestionReq": {
"type": "object",
"required": [
"question_text",
"question_type"
"question_text"
],
"properties": {
"audio_correct_answer_text": {
@ -10731,13 +10946,10 @@ const docTemplate = `{
"type": "string"
},
"question_type": {
"type": "string",
"enum": [
"MCQ",
"TRUE_FALSE",
"SHORT_ANSWER",
"AUDIO"
]
"type": "string"
},
"question_type_definition_id": {
"type": "integer"
},
"sample_answer_voice_prompt": {
"type": "string"
@ -10812,6 +11024,39 @@ const docTemplate = `{
}
}
},
"handlers.createQuestionTypeDefinitionReq": {
"type": "object",
"required": [
"display_name",
"key"
],
"properties": {
"description": {
"type": "string"
},
"display_name": {
"type": "string"
},
"key": {
"type": "string"
},
"response_component_kinds": {
"type": "array",
"items": {
"type": "string"
}
},
"status": {
"type": "string"
},
"stimulus_component_kinds": {
"type": "array",
"items": {
"type": "string"
}
}
}
},
"handlers.initiateDirectPaymentReq": {
"type": "object",
"required": [
@ -11185,6 +11430,9 @@ const docTemplate = `{
"question_type": {
"type": "string"
},
"question_type_definition_id": {
"type": "integer"
},
"sample_answer_voice_prompt": {
"type": "string"
},
@ -11237,6 +11485,32 @@ const docTemplate = `{
}
}
},
"handlers.updateQuestionTypeDefinitionReq": {
"type": "object",
"properties": {
"description": {
"type": "string"
},
"display_name": {
"type": "string"
},
"response_component_kinds": {
"type": "array",
"items": {
"type": "string"
}
},
"status": {
"type": "string"
},
"stimulus_component_kinds": {
"type": "array",
"items": {
"type": "string"
}
}
}
},
"handlers.validateQuestionTypeDefinitionReq": {
"type": "object",
"properties": {

View File

@ -4552,7 +4552,7 @@
}
},
"post": {
"description": "Creates a new question with options (for MCQ/TRUE_FALSE) or short answers (for SHORT_ANSWER)",
"description": "Creates a new question with options (for MCQ/TRUE_FALSE) or short answers (for SHORT_ANSWER). Supports question_type_definition_id for dynamic builder-linked questions.",
"consumes": [
"application/json"
],
@ -4713,9 +4713,221 @@
}
}
},
"/api/v1/questions/type-definitions": {
"get": {
"produces": [
"application/json"
],
"tags": [
"questions"
],
"summary": "List reusable question-type definitions",
"parameters": [
{
"type": "string",
"description": "Filter by status (ACTIVE, INACTIVE)",
"name": "status",
"in": "query"
},
{
"type": "boolean",
"description": "Include system seeded definitions",
"name": "include_system",
"in": "query"
}
],
"responses": {
"200": {
"description": "OK",
"schema": {
"$ref": "#/definitions/domain.Response"
}
},
"500": {
"description": "Internal Server Error",
"schema": {
"$ref": "#/definitions/domain.ErrorResponse"
}
}
}
},
"post": {
"description": "Stores a reusable dynamic question-type definition for future question construction. Only runtime-mappable definitions are persisted.",
"consumes": [
"application/json"
],
"produces": [
"application/json"
],
"tags": [
"questions"
],
"summary": "Create reusable question-type definition",
"parameters": [
{
"description": "Question type definition payload",
"name": "body",
"in": "body",
"required": true,
"schema": {
"$ref": "#/definitions/handlers.createQuestionTypeDefinitionReq"
}
}
],
"responses": {
"201": {
"description": "Created",
"schema": {
"$ref": "#/definitions/domain.Response"
}
},
"400": {
"description": "Bad Request",
"schema": {
"$ref": "#/definitions/domain.ErrorResponse"
}
},
"500": {
"description": "Internal Server Error",
"schema": {
"$ref": "#/definitions/domain.ErrorResponse"
}
}
}
}
},
"/api/v1/questions/type-definitions/{id}": {
"get": {
"produces": [
"application/json"
],
"tags": [
"questions"
],
"summary": "Get reusable question-type definition by id",
"parameters": [
{
"type": "integer",
"description": "Question type definition id",
"name": "id",
"in": "path",
"required": true
}
],
"responses": {
"200": {
"description": "OK",
"schema": {
"$ref": "#/definitions/domain.Response"
}
},
"400": {
"description": "Bad Request",
"schema": {
"$ref": "#/definitions/domain.ErrorResponse"
}
},
"404": {
"description": "Not Found",
"schema": {
"$ref": "#/definitions/domain.ErrorResponse"
}
}
}
},
"put": {
"description": "Updates a reusable dynamic question-type definition. Updated definitions must remain runtime-mappable.",
"consumes": [
"application/json"
],
"produces": [
"application/json"
],
"tags": [
"questions"
],
"summary": "Update reusable question-type definition",
"parameters": [
{
"type": "integer",
"description": "Question type definition id",
"name": "id",
"in": "path",
"required": true
},
{
"description": "Update question type definition payload",
"name": "body",
"in": "body",
"required": true,
"schema": {
"$ref": "#/definitions/handlers.updateQuestionTypeDefinitionReq"
}
}
],
"responses": {
"200": {
"description": "OK",
"schema": {
"$ref": "#/definitions/domain.Response"
}
},
"400": {
"description": "Bad Request",
"schema": {
"$ref": "#/definitions/domain.ErrorResponse"
}
},
"500": {
"description": "Internal Server Error",
"schema": {
"$ref": "#/definitions/domain.ErrorResponse"
}
}
}
},
"delete": {
"produces": [
"application/json"
],
"tags": [
"questions"
],
"summary": "Delete reusable question-type definition",
"parameters": [
{
"type": "integer",
"description": "Question type definition id",
"name": "id",
"in": "path",
"required": true
}
],
"responses": {
"200": {
"description": "OK",
"schema": {
"$ref": "#/definitions/domain.Response"
}
},
"400": {
"description": "Bad Request",
"schema": {
"$ref": "#/definitions/domain.ErrorResponse"
}
},
"500": {
"description": "Internal Server Error",
"schema": {
"$ref": "#/definitions/domain.ErrorResponse"
}
}
}
}
},
"/api/v1/questions/validate-question-type-definition": {
"post": {
"description": "Validates selected stimulus and response component kinds for temporary question-type definitions",
"description": "Validates selected stimulus and response component kinds for temporary question-type definitions (component-level validation only)",
"consumes": [
"application/json"
],
@ -4794,7 +5006,7 @@
}
},
"put": {
"description": "Updates a question and optionally replaces its options/short answers",
"description": "Updates a question and optionally replaces its options/short answers. Supports question_type_definition_id for dynamic builder-linked questions.",
"consumes": [
"application/json"
],
@ -9444,6 +9656,10 @@
"questionType": {
"type": "string"
},
"questionTypeDefinitionID": {
"type": "integer",
"format": "int64"
},
"sampleAnswerVoicePrompt": {
"type": "string"
},
@ -10694,8 +10910,7 @@
"handlers.createQuestionReq": {
"type": "object",
"required": [
"question_text",
"question_type"
"question_text"
],
"properties": {
"audio_correct_answer_text": {
@ -10723,13 +10938,10 @@
"type": "string"
},
"question_type": {
"type": "string",
"enum": [
"MCQ",
"TRUE_FALSE",
"SHORT_ANSWER",
"AUDIO"
]
"type": "string"
},
"question_type_definition_id": {
"type": "integer"
},
"sample_answer_voice_prompt": {
"type": "string"
@ -10804,6 +11016,39 @@
}
}
},
"handlers.createQuestionTypeDefinitionReq": {
"type": "object",
"required": [
"display_name",
"key"
],
"properties": {
"description": {
"type": "string"
},
"display_name": {
"type": "string"
},
"key": {
"type": "string"
},
"response_component_kinds": {
"type": "array",
"items": {
"type": "string"
}
},
"status": {
"type": "string"
},
"stimulus_component_kinds": {
"type": "array",
"items": {
"type": "string"
}
}
}
},
"handlers.initiateDirectPaymentReq": {
"type": "object",
"required": [
@ -11177,6 +11422,9 @@
"question_type": {
"type": "string"
},
"question_type_definition_id": {
"type": "integer"
},
"sample_answer_voice_prompt": {
"type": "string"
},
@ -11229,6 +11477,32 @@
}
}
},
"handlers.updateQuestionTypeDefinitionReq": {
"type": "object",
"properties": {
"description": {
"type": "string"
},
"display_name": {
"type": "string"
},
"response_component_kinds": {
"type": "array",
"items": {
"type": "string"
}
},
"status": {
"type": "string"
},
"stimulus_component_kinds": {
"type": "array",
"items": {
"type": "string"
}
}
}
},
"handlers.validateQuestionTypeDefinitionReq": {
"type": "object",
"properties": {

View File

@ -389,6 +389,9 @@ definitions:
type: string
questionType:
type: string
questionTypeDefinitionID:
format: int64
type: integer
sampleAnswerVoicePrompt:
type: string
shortAnswers:
@ -1250,12 +1253,9 @@ definitions:
question_text:
type: string
question_type:
enum:
- MCQ
- TRUE_FALSE
- SHORT_ANSWER
- AUDIO
type: string
question_type_definition_id:
type: integer
sample_answer_voice_prompt:
type: string
short_answers:
@ -1270,7 +1270,6 @@ definitions:
type: string
required:
- question_text
- question_type
type: object
handlers.createQuestionSetReq:
properties:
@ -1309,6 +1308,28 @@ definitions:
- set_type
- title
type: object
handlers.createQuestionTypeDefinitionReq:
properties:
description:
type: string
display_name:
type: string
key:
type: string
response_component_kinds:
items:
type: string
type: array
status:
type: string
stimulus_component_kinds:
items:
type: string
type: array
required:
- display_name
- key
type: object
handlers.initiateDirectPaymentReq:
properties:
email:
@ -1560,6 +1581,8 @@ definitions:
type: string
question_type:
type: string
question_type_definition_id:
type: integer
sample_answer_voice_prompt:
type: string
short_answers:
@ -1594,6 +1617,23 @@ definitions:
title:
type: string
type: object
handlers.updateQuestionTypeDefinitionReq:
properties:
description:
type: string
display_name:
type: string
response_component_kinds:
items:
type: string
type: array
status:
type: string
stimulus_component_kinds:
items:
type: string
type: array
type: object
handlers.validateQuestionTypeDefinitionReq:
properties:
response_component_kinds:
@ -5025,7 +5065,8 @@ paths:
consumes:
- application/json
description: Creates a new question with options (for MCQ/TRUE_FALSE) or short
answers (for SHORT_ANSWER)
answers (for SHORT_ANSWER). Supports question_type_definition_id for dynamic
builder-linked questions.
parameters:
- description: Create question payload
in: body
@ -5107,7 +5148,8 @@ paths:
put:
consumes:
- application/json
description: Updates a question and optionally replaces its options/short answers
description: Updates a question and optionally replaces its options/short answers.
Supports question_type_definition_id for dynamic builder-linked questions.
parameters:
- description: Question ID
in: path
@ -5217,12 +5259,153 @@ paths:
summary: Search questions
tags:
- questions
/api/v1/questions/type-definitions:
get:
parameters:
- description: Filter by status (ACTIVE, INACTIVE)
in: query
name: status
type: string
- description: Include system seeded definitions
in: query
name: include_system
type: boolean
produces:
- application/json
responses:
"200":
description: OK
schema:
$ref: '#/definitions/domain.Response'
"500":
description: Internal Server Error
schema:
$ref: '#/definitions/domain.ErrorResponse'
summary: List reusable question-type definitions
tags:
- questions
post:
consumes:
- application/json
description: Stores a reusable dynamic question-type definition for future question
construction. Only runtime-mappable definitions are persisted.
parameters:
- description: Question type definition payload
in: body
name: body
required: true
schema:
$ref: '#/definitions/handlers.createQuestionTypeDefinitionReq'
produces:
- application/json
responses:
"201":
description: Created
schema:
$ref: '#/definitions/domain.Response'
"400":
description: Bad Request
schema:
$ref: '#/definitions/domain.ErrorResponse'
"500":
description: Internal Server Error
schema:
$ref: '#/definitions/domain.ErrorResponse'
summary: Create reusable question-type definition
tags:
- questions
/api/v1/questions/type-definitions/{id}:
delete:
parameters:
- description: Question type definition id
in: path
name: id
required: true
type: integer
produces:
- application/json
responses:
"200":
description: OK
schema:
$ref: '#/definitions/domain.Response'
"400":
description: Bad Request
schema:
$ref: '#/definitions/domain.ErrorResponse'
"500":
description: Internal Server Error
schema:
$ref: '#/definitions/domain.ErrorResponse'
summary: Delete reusable question-type definition
tags:
- questions
get:
parameters:
- description: Question type definition id
in: path
name: id
required: true
type: integer
produces:
- application/json
responses:
"200":
description: OK
schema:
$ref: '#/definitions/domain.Response'
"400":
description: Bad Request
schema:
$ref: '#/definitions/domain.ErrorResponse'
"404":
description: Not Found
schema:
$ref: '#/definitions/domain.ErrorResponse'
summary: Get reusable question-type definition by id
tags:
- questions
put:
consumes:
- application/json
description: Updates a reusable dynamic question-type definition. Updated definitions
must remain runtime-mappable.
parameters:
- description: Question type definition id
in: path
name: id
required: true
type: integer
- description: Update question type definition payload
in: body
name: body
required: true
schema:
$ref: '#/definitions/handlers.updateQuestionTypeDefinitionReq'
produces:
- application/json
responses:
"200":
description: OK
schema:
$ref: '#/definitions/domain.Response'
"400":
description: Bad Request
schema:
$ref: '#/definitions/domain.ErrorResponse'
"500":
description: Internal Server Error
schema:
$ref: '#/definitions/domain.ErrorResponse'
summary: Update reusable question-type definition
tags:
- questions
/api/v1/questions/validate-question-type-definition:
post:
consumes:
- application/json
description: Validates selected stimulus and response component kinds for temporary
question-type definitions
question-type definitions (component-level validation only)
parameters:
- description: Stimulus and response component kinds
in: body

View File

@ -261,6 +261,8 @@ type Question struct {
CreatedAt pgtype.Timestamptz `json:"created_at"`
UpdatedAt pgtype.Timestamptz `json:"updated_at"`
ImageUrl pgtype.Text `json:"image_url"`
QuestionTypeDefinitionID pgtype.Int8 `json:"question_type_definition_id"`
DynamicPayload []byte `json:"dynamic_payload"`
}
type QuestionAudioAnswer struct {
@ -322,6 +324,21 @@ type QuestionShortAnswer struct {
CreatedAt pgtype.Timestamptz `json:"created_at"`
}
type QuestionTypeDefinition struct {
ID int64 `json:"id"`
Key string `json:"key"`
DisplayName string `json:"display_name"`
Description pgtype.Text `json:"description"`
StimulusComponentKinds []string `json:"stimulus_component_kinds"`
ResponseComponentKinds []string `json:"response_component_kinds"`
IsSystem bool `json:"is_system"`
Status string `json:"status"`
CreatedAt pgtype.Timestamptz `json:"created_at"`
UpdatedAt pgtype.Timestamptz `json:"updated_at"`
StimulusSchema []byte `json:"stimulus_schema"`
ResponseSchema []byte `json:"response_schema"`
}
type Rating struct {
ID int64 `json:"id"`
UserID int64 `json:"user_id"`

View File

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

View File

@ -33,10 +33,11 @@ INSERT INTO questions (
voice_prompt,
sample_answer_voice_prompt,
image_url,
status
status,
dynamic_payload
)
VALUES ($1, $2, $3, COALESCE($4, 1), $5, $6, $7, $8, $9, COALESCE($10, 'DRAFT'))
RETURNING id, question_text, question_type, difficulty_level, points, explanation, tips, voice_prompt, sample_answer_voice_prompt, status, created_at, updated_at, image_url
VALUES ($1, $2, $3, COALESCE($4, 1), $5, $6, $7, $8, $9, COALESCE($10, 'DRAFT'), $11::jsonb)
RETURNING id, question_text, question_type, difficulty_level, points, explanation, tips, voice_prompt, sample_answer_voice_prompt, status, created_at, updated_at, image_url, question_type_definition_id, dynamic_payload
`
type CreateQuestionParams struct {
@ -50,6 +51,7 @@ type CreateQuestionParams struct {
SampleAnswerVoicePrompt pgtype.Text `json:"sample_answer_voice_prompt"`
ImageUrl pgtype.Text `json:"image_url"`
Column10 interface{} `json:"column_10"`
Column11 []byte `json:"column_11"`
}
func (q *Queries) CreateQuestion(ctx context.Context, arg CreateQuestionParams) (Question, error) {
@ -64,6 +66,7 @@ func (q *Queries) CreateQuestion(ctx context.Context, arg CreateQuestionParams)
arg.SampleAnswerVoicePrompt,
arg.ImageUrl,
arg.Column10,
arg.Column11,
)
var i Question
err := row.Scan(
@ -80,6 +83,8 @@ func (q *Queries) CreateQuestion(ctx context.Context, arg CreateQuestionParams)
&i.CreatedAt,
&i.UpdatedAt,
&i.ImageUrl,
&i.QuestionTypeDefinitionID,
&i.DynamicPayload,
)
return i, err
}
@ -95,7 +100,7 @@ func (q *Queries) DeleteQuestion(ctx context.Context, id int64) error {
}
const GetQuestionByID = `-- name: GetQuestionByID :one
SELECT id, question_text, question_type, difficulty_level, points, explanation, tips, voice_prompt, sample_answer_voice_prompt, status, created_at, updated_at, image_url
SELECT id, question_text, question_type, difficulty_level, points, explanation, tips, voice_prompt, sample_answer_voice_prompt, status, created_at, updated_at, image_url, question_type_definition_id, dynamic_payload
FROM questions
WHERE id = $1
`
@ -117,6 +122,8 @@ func (q *Queries) GetQuestionByID(ctx context.Context, id int64) (Question, erro
&i.CreatedAt,
&i.UpdatedAt,
&i.ImageUrl,
&i.QuestionTypeDefinitionID,
&i.DynamicPayload,
)
return i, err
}
@ -193,7 +200,7 @@ func (q *Queries) GetQuestionWithOptions(ctx context.Context, id int64) ([]GetQu
}
const GetQuestionsByIDs = `-- name: GetQuestionsByIDs :many
SELECT id, question_text, question_type, difficulty_level, points, explanation, tips, voice_prompt, sample_answer_voice_prompt, status, created_at, updated_at, image_url
SELECT id, question_text, question_type, difficulty_level, points, explanation, tips, voice_prompt, sample_answer_voice_prompt, status, created_at, updated_at, image_url, question_type_definition_id, dynamic_payload
FROM questions
WHERE id = ANY($1::BIGINT[])
ORDER BY id
@ -222,6 +229,8 @@ func (q *Queries) GetQuestionsByIDs(ctx context.Context, dollar_1 []int64) ([]Qu
&i.CreatedAt,
&i.UpdatedAt,
&i.ImageUrl,
&i.QuestionTypeDefinitionID,
&i.DynamicPayload,
); err != nil {
return nil, err
}
@ -236,7 +245,7 @@ func (q *Queries) GetQuestionsByIDs(ctx context.Context, dollar_1 []int64) ([]Qu
const ListQuestions = `-- name: ListQuestions :many
SELECT
COUNT(*) OVER () AS total_count,
q.id, q.question_text, q.question_type, q.difficulty_level, q.points, q.explanation, q.tips, q.voice_prompt, q.sample_answer_voice_prompt, q.status, q.created_at, q.updated_at, q.image_url
q.id, q.question_text, q.question_type, q.difficulty_level, q.points, q.explanation, q.tips, q.voice_prompt, q.sample_answer_voice_prompt, q.status, q.created_at, q.updated_at, q.image_url, q.question_type_definition_id, q.dynamic_payload
FROM questions q
WHERE status != 'ARCHIVED'
AND ($1::VARCHAR IS NULL OR $1 = '' OR question_type = $1)
@ -270,6 +279,8 @@ type ListQuestionsRow struct {
CreatedAt pgtype.Timestamptz `json:"created_at"`
UpdatedAt pgtype.Timestamptz `json:"updated_at"`
ImageUrl pgtype.Text `json:"image_url"`
QuestionTypeDefinitionID pgtype.Int8 `json:"question_type_definition_id"`
DynamicPayload []byte `json:"dynamic_payload"`
}
func (q *Queries) ListQuestions(ctx context.Context, arg ListQuestionsParams) ([]ListQuestionsRow, error) {
@ -302,6 +313,8 @@ func (q *Queries) ListQuestions(ctx context.Context, arg ListQuestionsParams) ([
&i.CreatedAt,
&i.UpdatedAt,
&i.ImageUrl,
&i.QuestionTypeDefinitionID,
&i.DynamicPayload,
); err != nil {
return nil, err
}
@ -316,7 +329,7 @@ func (q *Queries) ListQuestions(ctx context.Context, arg ListQuestionsParams) ([
const SearchQuestions = `-- name: SearchQuestions :many
SELECT
COUNT(*) OVER () AS total_count,
q.id, q.question_text, q.question_type, q.difficulty_level, q.points, q.explanation, q.tips, q.voice_prompt, q.sample_answer_voice_prompt, q.status, q.created_at, q.updated_at, q.image_url
q.id, q.question_text, q.question_type, q.difficulty_level, q.points, q.explanation, q.tips, q.voice_prompt, q.sample_answer_voice_prompt, q.status, q.created_at, q.updated_at, q.image_url, q.question_type_definition_id, q.dynamic_payload
FROM questions q
WHERE status != 'ARCHIVED'
AND question_text ILIKE '%' || $1 || '%'
@ -346,6 +359,8 @@ type SearchQuestionsRow struct {
CreatedAt pgtype.Timestamptz `json:"created_at"`
UpdatedAt pgtype.Timestamptz `json:"updated_at"`
ImageUrl pgtype.Text `json:"image_url"`
QuestionTypeDefinitionID pgtype.Int8 `json:"question_type_definition_id"`
DynamicPayload []byte `json:"dynamic_payload"`
}
func (q *Queries) SearchQuestions(ctx context.Context, arg SearchQuestionsParams) ([]SearchQuestionsRow, error) {
@ -372,6 +387,8 @@ func (q *Queries) SearchQuestions(ctx context.Context, arg SearchQuestionsParams
&i.CreatedAt,
&i.UpdatedAt,
&i.ImageUrl,
&i.QuestionTypeDefinitionID,
&i.DynamicPayload,
); err != nil {
return nil, err
}
@ -396,8 +413,9 @@ SET
sample_answer_voice_prompt = COALESCE($8, sample_answer_voice_prompt),
image_url = COALESCE($9, image_url),
status = COALESCE($10, status),
dynamic_payload = COALESCE($11::jsonb, dynamic_payload),
updated_at = CURRENT_TIMESTAMP
WHERE id = $11
WHERE id = $12
`
type UpdateQuestionParams struct {
@ -411,6 +429,7 @@ type UpdateQuestionParams struct {
SampleAnswerVoicePrompt pgtype.Text `json:"sample_answer_voice_prompt"`
ImageUrl pgtype.Text `json:"image_url"`
Status string `json:"status"`
Column11 []byte `json:"column_11"`
ID int64 `json:"id"`
}
@ -426,6 +445,7 @@ func (q *Queries) UpdateQuestion(ctx context.Context, arg UpdateQuestionParams)
arg.SampleAnswerVoicePrompt,
arg.ImageUrl,
arg.Status,
arg.Column11,
arg.ID,
)
return err

View File

@ -10,11 +10,14 @@ import (
type StimulusComponentKind string
const (
StimulusQuestionText StimulusComponentKind = "QUESTION_TEXT"
StimulusPrepTime StimulusComponentKind = "PREP_TIME"
StimulusInstruction StimulusComponentKind = "INSTRUCTION"
StimulusAudioPrompt StimulusComponentKind = "AUDIO_PROMPT"
StimulusAudioClip StimulusComponentKind = "AUDIO_CLIP"
StimulusTextPassage StimulusComponentKind = "TEXT_PASSAGE"
StimulusImage StimulusComponentKind = "IMAGE"
StimulusChart StimulusComponentKind = "CHART"
StimulusMatchingInputs StimulusComponentKind = "MATCHING_INPUTS"
StimulusSelectMissingWords StimulusComponentKind = "SELECT_MISSING_WORDS"
StimulusTable StimulusComponentKind = "TABLE"
@ -29,20 +32,45 @@ const (
ResponseTextInput ResponseComponentKind = "TEXT_INPUT"
ResponseShortAnswer ResponseComponentKind = "SHORT_ANSWER"
ResponseMultipleChoice ResponseComponentKind = "MULTIPLE_CHOICE"
ResponseOption ResponseComponentKind = "OPTION"
ResponseAnswerTimer ResponseComponentKind = "ANSWER_TIMER"
ResponseSelectMissingWords ResponseComponentKind = "SELECT_MISSING_WORDS"
ResponsePDFUpload ResponseComponentKind = "PDF_UPLOAD"
ResponseMatchingAnswer ResponseComponentKind = "MATCHING_ANSWER"
ResponseLabelSelection ResponseComponentKind = "LABEL_SELECTION"
ResponseSequenceOrder ResponseComponentKind = "SEQUENCE_ORDER"
)
type DynamicElementDefinition struct {
ID string `json:"id"`
Kind string `json:"kind"`
Label *string `json:"label,omitempty"`
Required bool `json:"required"`
Config map[string]interface{} `json:"config,omitempty"`
}
type DynamicElementInstance struct {
ID string `json:"id"`
Kind string `json:"kind"`
Value interface{} `json:"value,omitempty"`
Meta map[string]interface{} `json:"meta,omitempty"`
}
type DynamicQuestionPayload struct {
Stimulus []DynamicElementInstance `json:"stimulus"`
Response []DynamicElementInstance `json:"response"`
}
var (
stimulusCatalog = []StimulusComponentKind{
StimulusQuestionText,
StimulusPrepTime,
StimulusInstruction,
StimulusAudioPrompt,
StimulusAudioClip,
StimulusTextPassage,
StimulusImage,
StimulusChart,
StimulusMatchingInputs,
StimulusSelectMissingWords,
StimulusTable,
@ -55,11 +83,13 @@ var (
ResponseTextInput,
ResponseShortAnswer,
ResponseMultipleChoice,
ResponseOption,
ResponseAnswerTimer,
ResponseSelectMissingWords,
ResponsePDFUpload,
ResponseMatchingAnswer,
ResponseLabelSelection,
ResponseSequenceOrder,
}
responseSet map[string]struct{}
@ -183,6 +213,178 @@ func ValidateDynamicQuestionTypeDefinition(stimulusKinds, responseKinds []string
return fmt.Errorf("%s", strings.Join(errs, "; "))
}
// ResolveRuntimeQuestionTypeFromDefinition derives the legacy runtime question_type code used by
// existing question execution paths. Empty string means the definition cannot be executed yet.
func ResolveRuntimeQuestionTypeFromDefinition(key string, responseKinds []string) string {
normalizedKey := strings.ToLower(strings.TrimSpace(key))
if normalizedKey == "true_false" || normalizedKey == "true-false" {
return "TRUE_FALSE"
}
for _, kind := range normalizeKindList(responseKinds) {
switch kind {
case string(ResponseAudioResponse):
return "AUDIO"
case string(ResponseMultipleChoice):
return "MCQ"
case string(ResponseShortAnswer),
string(ResponseTextInput),
string(ResponseSelectMissingWords),
string(ResponseMatchingAnswer),
string(ResponseLabelSelection),
string(ResponsePDFUpload):
return "SHORT_ANSWER"
}
}
return ""
}
// ValidatePersistableQuestionTypeDefinition ensures definitions that pass component-level validation
// are also mappable to the currently supported runtime question_type set.
func ValidatePersistableQuestionTypeDefinition(key string, responseKinds []string) error {
if ResolveRuntimeQuestionTypeFromDefinition(key, responseKinds) == "" {
return fmt.Errorf("unable to map definition to runtime question_type")
}
return nil
}
func ValidateDefinitionSchemas(stimulusSchema, responseSchema []DynamicElementDefinition) error {
var errs []string
stimulusIDs := make(map[string]struct{})
responseIDs := make(map[string]struct{})
for i, el := range stimulusSchema {
id := strings.TrimSpace(el.ID)
kind := strings.TrimSpace(el.Kind)
if id == "" {
errs = append(errs, fmt.Sprintf("stimulus schema [%d]: id is required", i))
} else if _, exists := stimulusIDs[id]; exists {
errs = append(errs, fmt.Sprintf("stimulus schema: duplicate id %q", id))
} else {
stimulusIDs[id] = struct{}{}
}
if !IsValidStimulusComponentKind(kind) {
errs = append(errs, fmt.Sprintf("stimulus schema: unknown kind %q", kind))
}
}
for i, el := range responseSchema {
id := strings.TrimSpace(el.ID)
kind := strings.TrimSpace(el.Kind)
if id == "" {
errs = append(errs, fmt.Sprintf("response schema [%d]: id is required", i))
} else if _, exists := responseIDs[id]; exists {
errs = append(errs, fmt.Sprintf("response schema: duplicate id %q", id))
} else {
responseIDs[id] = struct{}{}
}
if !IsValidResponseComponentKind(kind) {
errs = append(errs, fmt.Sprintf("response schema: unknown kind %q", kind))
}
}
if len(errs) == 0 {
return nil
}
return fmt.Errorf("%s", strings.Join(errs, "; "))
}
func ValidateDynamicPayloadAgainstDefinition(payload DynamicQuestionPayload, def QuestionTypeDefinition) error {
var errs []string
if len(payload.Stimulus) == 0 {
errs = append(errs, "dynamic_payload.stimulus must contain at least one element")
}
if len(payload.Response) == 0 {
errs = append(errs, "dynamic_payload.response must contain at least one element")
}
allowedStimulus := make(map[string]struct{})
allowedResponse := make(map[string]struct{})
for _, k := range def.StimulusComponentKinds {
allowedStimulus[strings.TrimSpace(k)] = struct{}{}
}
for _, k := range def.ResponseComponentKinds {
allowedResponse[strings.TrimSpace(k)] = struct{}{}
}
requiredStimulusIDs := make(map[string]struct{})
requiredResponseIDs := make(map[string]struct{})
for _, el := range def.StimulusSchema {
if el.Required {
requiredStimulusIDs[strings.TrimSpace(el.ID)] = struct{}{}
}
}
for _, el := range def.ResponseSchema {
if el.Required {
requiredResponseIDs[strings.TrimSpace(el.ID)] = struct{}{}
}
}
seenStimulusIDs := make(map[string]struct{})
seenResponseIDs := make(map[string]struct{})
for i, el := range payload.Stimulus {
id := strings.TrimSpace(el.ID)
kind := strings.TrimSpace(el.Kind)
if id == "" {
errs = append(errs, fmt.Sprintf("dynamic_payload.stimulus[%d]: id is required", i))
} else {
seenStimulusIDs[id] = struct{}{}
}
if !IsValidStimulusComponentKind(kind) {
errs = append(errs, fmt.Sprintf("dynamic_payload.stimulus[%d]: invalid kind %q", i, kind))
continue
}
if _, ok := allowedStimulus[kind]; !ok {
errs = append(errs, fmt.Sprintf("dynamic_payload.stimulus[%d]: kind %q is not allowed by selected definition", i, kind))
}
}
for i, el := range payload.Response {
id := strings.TrimSpace(el.ID)
kind := strings.TrimSpace(el.Kind)
if id == "" {
errs = append(errs, fmt.Sprintf("dynamic_payload.response[%d]: id is required", i))
} else {
seenResponseIDs[id] = struct{}{}
}
if !IsValidResponseComponentKind(kind) {
errs = append(errs, fmt.Sprintf("dynamic_payload.response[%d]: invalid kind %q", i, kind))
continue
}
if _, ok := allowedResponse[kind]; !ok {
errs = append(errs, fmt.Sprintf("dynamic_payload.response[%d]: kind %q is not allowed by selected definition", i, kind))
}
}
for id := range requiredStimulusIDs {
if _, ok := seenStimulusIDs[id]; !ok {
errs = append(errs, fmt.Sprintf("dynamic_payload.stimulus: required element id %q is missing", id))
}
}
for id := range requiredResponseIDs {
if _, ok := seenResponseIDs[id]; !ok {
errs = append(errs, fmt.Sprintf("dynamic_payload.response: required element id %q is missing", id))
}
}
if len(errs) == 0 {
return nil
}
return fmt.Errorf("%s", strings.Join(errs, "; "))
}
func normalizeKindList(in []string) []string {
var out []string
for _, s := range in {

View File

@ -64,3 +64,71 @@ func TestValidateDynamicQuestionTypeDefinition_twoPrepTimes(t *testing.T) {
t.Fatalf("expected at most one PREP_TIME, got %v", err)
}
}
func TestResolveRuntimeQuestionTypeFromDefinition_trueFalseKey(t *testing.T) {
got := ResolveRuntimeQuestionTypeFromDefinition("true_false", []string{"MULTIPLE_CHOICE"})
if got != "TRUE_FALSE" {
t.Fatalf("expected TRUE_FALSE, got %q", got)
}
}
func TestResolveRuntimeQuestionTypeFromDefinition_pdfUpload(t *testing.T) {
got := ResolveRuntimeQuestionTypeFromDefinition("doc_upload", []string{"PDF_UPLOAD"})
if got != "SHORT_ANSWER" {
t.Fatalf("expected SHORT_ANSWER, got %q", got)
}
}
func TestResolveRuntimeQuestionTypeFromDefinition_skipsAuxiliaryKinds(t *testing.T) {
got := ResolveRuntimeQuestionTypeFromDefinition("timed_mcq", []string{"ANSWER_TIMER", "MULTIPLE_CHOICE"})
if got != "MCQ" {
t.Fatalf("expected MCQ, got %q", got)
}
}
func TestValidatePersistableQuestionTypeDefinition_unmappable(t *testing.T) {
err := ValidatePersistableQuestionTypeDefinition("custom", []string{"ANSWER_TIMER"})
if err == nil || !strings.Contains(err.Error(), "unable to map definition") {
t.Fatalf("expected unmappable definition error, got %v", err)
}
}
func TestValidateDefinitionSchemas_valid(t *testing.T) {
err := ValidateDefinitionSchemas(
[]DynamicElementDefinition{
{ID: "stimulus_1", Kind: "QUESTION_TEXT", Required: true},
{ID: "stimulus_2", Kind: "IMAGE", Required: false},
},
[]DynamicElementDefinition{
{ID: "response_1", Kind: "OPTION", Required: true},
},
)
if err != nil {
t.Fatalf("expected nil, got %v", err)
}
}
func TestValidateDynamicPayloadAgainstDefinition_requiredMissing(t *testing.T) {
def := QuestionTypeDefinition{
StimulusComponentKinds: []string{"QUESTION_TEXT"},
ResponseComponentKinds: []string{"OPTION"},
StimulusSchema: []DynamicElementDefinition{
{ID: "stimulus_1", Kind: "QUESTION_TEXT", Required: true},
},
ResponseSchema: []DynamicElementDefinition{
{ID: "response_1", Kind: "OPTION", Required: true},
},
}
payload := DynamicQuestionPayload{
Stimulus: []DynamicElementInstance{
{ID: "stimulus_1", Kind: "QUESTION_TEXT"},
},
Response: []DynamicElementInstance{},
}
err := ValidateDynamicPayloadAgainstDefinition(payload, def)
if err == nil || !strings.Contains(err.Error(), "required element id") {
t.Fatalf("expected required element error, got %v", err)
}
}

View File

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

View File

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

View File

@ -2,6 +2,7 @@ package repository
import (
"context"
"encoding/json"
"errors"
"strings"
"time"
@ -67,6 +68,8 @@ func questionToDomain(q dbgen.Question) domain.Question {
ID: q.ID,
QuestionText: q.QuestionText,
QuestionType: q.QuestionType,
QuestionTypeDefinitionID: fromPgInt8(q.QuestionTypeDefinitionID),
DynamicPayload: parseDynamicPayload(q.DynamicPayload),
DifficultyLevel: fromPgText(q.DifficultyLevel),
Points: q.Points,
Explanation: fromPgText(q.Explanation),
@ -80,6 +83,28 @@ func questionToDomain(q dbgen.Question) domain.Question {
}
}
func parseDynamicPayload(raw []byte) *domain.DynamicQuestionPayload {
if len(raw) == 0 {
return nil
}
var payload domain.DynamicQuestionPayload
if err := json.Unmarshal(raw, &payload); err != nil {
return nil
}
return &payload
}
func encodeDynamicPayload(payload *domain.DynamicQuestionPayload) []byte {
if payload == nil {
return nil
}
b, err := json.Marshal(payload)
if err != nil {
return nil
}
return b
}
func (s *Store) setQuestionTypeDefinitionID(ctx context.Context, questionID int64, definitionID *int64) error {
_, err := s.conn.Exec(ctx, `
UPDATE questions
@ -156,11 +181,22 @@ func questionTypeDefinitionToDomain(
description pgtype.Text,
stimulusKinds []string,
responseKinds []string,
stimulusSchema []byte,
responseSchema []byte,
isSystem bool,
status string,
createdAt time.Time,
updatedAt pgtype.Timestamptz,
) domain.QuestionTypeDefinition {
var stimulusSchemaDef []domain.DynamicElementDefinition
var responseSchemaDef []domain.DynamicElementDefinition
if len(stimulusSchema) > 0 {
_ = json.Unmarshal(stimulusSchema, &stimulusSchemaDef)
}
if len(responseSchema) > 0 {
_ = json.Unmarshal(responseSchema, &responseSchemaDef)
}
return domain.QuestionTypeDefinition{
ID: id,
Key: key,
@ -168,6 +204,8 @@ func questionTypeDefinitionToDomain(
Description: fromPgText(description),
StimulusComponentKinds: stimulusKinds,
ResponseComponentKinds: responseKinds,
StimulusSchema: stimulusSchemaDef,
ResponseSchema: responseSchemaDef,
IsSystem: isSystem,
Status: status,
CreatedAt: createdAt,
@ -200,24 +238,85 @@ func normalizeDefinitionKey(key string) string {
return key
}
func kindsFromStimulusSchema(schema []domain.DynamicElementDefinition) []string {
seen := make(map[string]struct{})
out := make([]string, 0, len(schema))
for _, el := range schema {
kind := strings.TrimSpace(el.Kind)
if kind == "" {
continue
}
if _, exists := seen[kind]; exists {
continue
}
seen[kind] = struct{}{}
out = append(out, kind)
}
return out
}
func kindsFromResponseSchema(schema []domain.DynamicElementDefinition) []string {
seen := make(map[string]struct{})
out := make([]string, 0, len(schema))
for _, el := range schema {
kind := strings.TrimSpace(el.Kind)
if kind == "" {
continue
}
if _, exists := seen[kind]; exists {
continue
}
seen[kind] = struct{}{}
out = append(out, kind)
}
return out
}
func mustJSON(v interface{}) []byte {
b, err := json.Marshal(v)
if err != nil {
return []byte("[]")
}
return b
}
func (s *Store) CreateQuestionTypeDefinition(ctx context.Context, input domain.CreateQuestionTypeDefinitionInput) (domain.QuestionTypeDefinition, error) {
normalizedKey := normalizeDefinitionKey(input.Key)
stimulusKinds := normalizeDefinitionKinds(input.StimulusComponentKinds)
responseKinds := normalizeDefinitionKinds(input.ResponseComponentKinds)
stimulusSchema := input.StimulusSchema
responseSchema := input.ResponseSchema
if len(stimulusKinds) == 0 && len(stimulusSchema) > 0 {
stimulusKinds = kindsFromStimulusSchema(stimulusSchema)
}
if len(responseKinds) == 0 && len(responseSchema) > 0 {
responseKinds = kindsFromResponseSchema(responseSchema)
}
if err := domain.ValidateDynamicQuestionTypeDefinition(stimulusKinds, responseKinds); err != nil {
return domain.QuestionTypeDefinition{}, err
}
if err := domain.ValidateDefinitionSchemas(stimulusSchema, responseSchema); err != nil {
return domain.QuestionTypeDefinition{}, err
}
if err := domain.ValidatePersistableQuestionTypeDefinition(normalizedKey, responseKinds); err != nil {
return domain.QuestionTypeDefinition{}, err
}
row := s.conn.QueryRow(ctx, `
INSERT INTO question_type_definitions
(key, display_name, description, stimulus_component_kinds, response_component_kinds, is_system, status)
VALUES ($1, $2, $3, $4, $5, $6, $7)
RETURNING id, key, display_name, description, stimulus_component_kinds, response_component_kinds, is_system, status, created_at, updated_at
(key, display_name, description, stimulus_component_kinds, response_component_kinds, stimulus_schema, response_schema, is_system, status)
VALUES ($1, $2, $3, $4, $5, $6::jsonb, $7::jsonb, $8, $9)
RETURNING id, key, display_name, description, stimulus_component_kinds, response_component_kinds, stimulus_schema, response_schema, is_system, status, created_at, updated_at
`,
normalizeDefinitionKey(input.Key),
normalizedKey,
strings.TrimSpace(input.DisplayName),
toPgText(input.Description),
stimulusKinds,
responseKinds,
mustJSON(stimulusSchema),
mustJSON(responseSchema),
input.IsSystem,
normalizeDefinitionStatus(input.Status),
)
@ -229,21 +328,23 @@ func (s *Store) CreateQuestionTypeDefinition(ctx context.Context, input domain.C
description pgtype.Text
stimulus []string
response []string
stimulusSch []byte
responseSch []byte
isSystem bool
status string
createdAt time.Time
updatedAt pgtype.Timestamptz
)
if err := row.Scan(&id, &key, &displayName, &description, &stimulus, &response, &isSystem, &status, &createdAt, &updatedAt); err != nil {
if err := row.Scan(&id, &key, &displayName, &description, &stimulus, &response, &stimulusSch, &responseSch, &isSystem, &status, &createdAt, &updatedAt); err != nil {
return domain.QuestionTypeDefinition{}, err
}
return questionTypeDefinitionToDomain(id, key, displayName, description, stimulus, response, isSystem, status, createdAt, updatedAt), nil
return questionTypeDefinitionToDomain(id, key, displayName, description, stimulus, response, stimulusSch, responseSch, isSystem, status, createdAt, updatedAt), nil
}
func (s *Store) GetQuestionTypeDefinitionByID(ctx context.Context, id int64) (domain.QuestionTypeDefinition, error) {
row := s.conn.QueryRow(ctx, `
SELECT id, key, display_name, description, stimulus_component_kinds, response_component_kinds, is_system, status, created_at, updated_at
SELECT id, key, display_name, description, stimulus_component_kinds, response_component_kinds, stimulus_schema, response_schema, is_system, status, created_at, updated_at
FROM question_type_definitions
WHERE id = $1
`, id)
@ -254,20 +355,22 @@ func (s *Store) GetQuestionTypeDefinitionByID(ctx context.Context, id int64) (do
description pgtype.Text
stimulus []string
response []string
stimulusSch []byte
responseSch []byte
isSystem bool
status string
createdAt time.Time
updatedAt pgtype.Timestamptz
)
if err := row.Scan(&id, &key, &displayName, &description, &stimulus, &response, &isSystem, &status, &createdAt, &updatedAt); err != nil {
if err := row.Scan(&id, &key, &displayName, &description, &stimulus, &response, &stimulusSch, &responseSch, &isSystem, &status, &createdAt, &updatedAt); err != nil {
return domain.QuestionTypeDefinition{}, err
}
return questionTypeDefinitionToDomain(id, key, displayName, description, stimulus, response, isSystem, status, createdAt, updatedAt), nil
return questionTypeDefinitionToDomain(id, key, displayName, description, stimulus, response, stimulusSch, responseSch, isSystem, status, createdAt, updatedAt), nil
}
func (s *Store) ListQuestionTypeDefinitions(ctx context.Context, status *string, includeSystem bool) ([]domain.QuestionTypeDefinition, error) {
rows, err := s.conn.Query(ctx, `
SELECT id, key, display_name, description, stimulus_component_kinds, response_component_kinds, is_system, status, created_at, updated_at
SELECT id, key, display_name, description, stimulus_component_kinds, response_component_kinds, stimulus_schema, response_schema, is_system, status, created_at, updated_at
FROM question_type_definitions
WHERE ($1::VARCHAR IS NULL OR status = $1)
AND ($2::BOOLEAN = TRUE OR is_system = FALSE)
@ -287,15 +390,17 @@ func (s *Store) ListQuestionTypeDefinitions(ctx context.Context, status *string,
description pgtype.Text
stimulus []string
response []string
stimulusSch []byte
responseSch []byte
isSystem bool
defStatus string
createdAt time.Time
updatedAt pgtype.Timestamptz
)
if err := rows.Scan(&id, &key, &displayName, &description, &stimulus, &response, &isSystem, &defStatus, &createdAt, &updatedAt); err != nil {
if err := rows.Scan(&id, &key, &displayName, &description, &stimulus, &response, &stimulusSch, &responseSch, &isSystem, &defStatus, &createdAt, &updatedAt); err != nil {
return nil, err
}
out = append(out, questionTypeDefinitionToDomain(id, key, displayName, description, stimulus, response, isSystem, defStatus, createdAt, updatedAt))
out = append(out, questionTypeDefinitionToDomain(id, key, displayName, description, stimulus, response, stimulusSch, responseSch, isSystem, defStatus, createdAt, updatedAt))
}
return out, rows.Err()
@ -326,10 +431,31 @@ func (s *Store) UpdateQuestionTypeDefinition(ctx context.Context, id int64, inpu
if input.ResponseComponentKinds != nil {
responseKinds = normalizeDefinitionKinds(input.ResponseComponentKinds)
}
stimulusSchema := existing.StimulusSchema
if input.StimulusSchema != nil {
stimulusSchema = input.StimulusSchema
}
responseSchema := existing.ResponseSchema
if input.ResponseSchema != nil {
responseSchema = input.ResponseSchema
}
if len(stimulusKinds) == 0 && len(stimulusSchema) > 0 {
stimulusKinds = kindsFromStimulusSchema(stimulusSchema)
}
if len(responseKinds) == 0 && len(responseSchema) > 0 {
responseKinds = kindsFromResponseSchema(responseSchema)
}
if err := domain.ValidateDynamicQuestionTypeDefinition(stimulusKinds, responseKinds); err != nil {
return err
}
if err := domain.ValidateDefinitionSchemas(stimulusSchema, responseSchema); err != nil {
return err
}
if err := domain.ValidatePersistableQuestionTypeDefinition(existing.Key, responseKinds); err != nil {
return err
}
status := existing.Status
if input.Status != nil {
@ -342,10 +468,12 @@ func (s *Store) UpdateQuestionTypeDefinition(ctx context.Context, id int64, inpu
description = $3,
stimulus_component_kinds = $4,
response_component_kinds = $5,
status = $6,
stimulus_schema = $6::jsonb,
response_schema = $7::jsonb,
status = $8,
updated_at = NOW()
WHERE id = $1
`, id, displayName, toPgText(description), stimulusKinds, responseKinds, status)
`, id, displayName, toPgText(description), stimulusKinds, responseKinds, mustJSON(stimulusSchema), mustJSON(responseSchema), status)
return err
}
@ -396,6 +524,7 @@ func (s *Store) CreateQuestion(ctx context.Context, input domain.CreateQuestionI
SampleAnswerVoicePrompt: toPgText(input.SampleAnswerVoicePrompt),
ImageUrl: toPgText(input.ImageURL),
Column10: status,
Column11: encodeDynamicPayload(input.DynamicPayload),
})
if err != nil {
return domain.Question{}, err
@ -450,7 +579,9 @@ func (s *Store) CreateQuestion(ctx context.Context, input domain.CreateQuestionI
return domain.Question{}, err
}
return questionToDomain(question), nil
created := questionToDomain(question)
created.QuestionTypeDefinitionID = input.QuestionTypeDefinitionID
return created, nil
}
func (s *Store) GetQuestionByID(ctx context.Context, id int64) (domain.Question, error) {
@ -535,6 +666,8 @@ func (s *Store) ListQuestions(ctx context.Context, questionType, difficulty, sta
ID: r.ID,
QuestionText: r.QuestionText,
QuestionType: r.QuestionType,
QuestionTypeDefinitionID: fromPgInt8(r.QuestionTypeDefinitionID),
DynamicPayload: parseDynamicPayload(r.DynamicPayload),
DifficultyLevel: fromPgText(r.DifficultyLevel),
Points: r.Points,
Explanation: fromPgText(r.Explanation),
@ -571,6 +704,8 @@ func (s *Store) SearchQuestions(ctx context.Context, query string, limit, offset
ID: r.ID,
QuestionText: r.QuestionText,
QuestionType: r.QuestionType,
QuestionTypeDefinitionID: fromPgInt8(r.QuestionTypeDefinitionID),
DynamicPayload: parseDynamicPayload(r.DynamicPayload),
DifficultyLevel: fromPgText(r.DifficultyLevel),
Points: r.Points,
Explanation: fromPgText(r.Explanation),
@ -609,6 +744,7 @@ func (s *Store) UpdateQuestion(ctx context.Context, id int64, input domain.Creat
SampleAnswerVoicePrompt: toPgText(input.SampleAnswerVoicePrompt),
ImageUrl: toPgText(input.ImageURL),
Status: status,
Column11: encodeDynamicPayload(input.DynamicPayload),
})
if err != nil {
return err
@ -979,6 +1115,7 @@ func (s *Store) GetQuestionSetItems(ctx context.Context, setID int64) ([]domain.
},
QuestionText: r.QuestionText,
QuestionType: r.QuestionType,
DynamicPayload: parseDynamicPayload(r.DynamicPayload),
DifficultyLevel: fromPgText(r.DifficultyLevel),
Points: r.Points,
Explanation: fromPgText(r.Explanation),
@ -1023,6 +1160,7 @@ func (s *Store) GetQuestionSetItemsPaginated(ctx context.Context, setID int64, q
},
QuestionText: r.QuestionText,
QuestionType: r.QuestionType,
DynamicPayload: parseDynamicPayload(r.DynamicPayload),
DifficultyLevel: fromPgText(r.DifficultyLevel),
Points: r.Points,
Explanation: fromPgText(r.Explanation),
@ -1054,6 +1192,7 @@ func (s *Store) GetPublishedQuestionsInSet(ctx context.Context, setID int64) ([]
},
QuestionText: r.QuestionText,
QuestionType: r.QuestionType,
DynamicPayload: parseDynamicPayload(r.DynamicPayload),
DifficultyLevel: fromPgText(r.DifficultyLevel),
Points: r.Points,
Explanation: fromPgText(r.Explanation),

View File

@ -24,6 +24,8 @@ type createQuestionTypeDefinitionReq struct {
Description *string `json:"description"`
StimulusComponentKinds []string `json:"stimulus_component_kinds"`
ResponseComponentKinds []string `json:"response_component_kinds"`
StimulusSchema []domain.DynamicElementDefinition `json:"stimulus_schema"`
ResponseSchema []domain.DynamicElementDefinition `json:"response_schema"`
Status *string `json:"status"`
}
@ -32,6 +34,8 @@ type updateQuestionTypeDefinitionReq struct {
Description *string `json:"description"`
StimulusComponentKinds []string `json:"stimulus_component_kinds"`
ResponseComponentKinds []string `json:"response_component_kinds"`
StimulusSchema []domain.DynamicElementDefinition `json:"stimulus_schema"`
ResponseSchema []domain.DynamicElementDefinition `json:"response_schema"`
Status *string `json:"status"`
}
@ -54,7 +58,7 @@ func (h *Handler) GetQuestionTypeComponentCatalog(c *fiber.Ctx) error {
// ValidateQuestionTypeDefinition godoc
// @Summary Validate dynamic question-type definition
// @Description Validates selected stimulus and response component kinds for temporary question-type definitions
// @Description Validates selected stimulus and response component kinds for temporary question-type definitions (component-level validation only)
// @Tags questions
// @Accept json
// @Produce json
@ -86,7 +90,7 @@ func (h *Handler) ValidateQuestionTypeDefinition(c *fiber.Ctx) error {
// CreateQuestionTypeDefinition godoc
// @Summary Create reusable question-type definition
// @Description Stores a reusable dynamic question-type definition for future question construction
// @Description Stores a reusable dynamic question-type definition for future question construction. Only runtime-mappable definitions are persisted.
// @Tags questions
// @Accept json
// @Produce json
@ -116,6 +120,8 @@ func (h *Handler) CreateQuestionTypeDefinition(c *fiber.Ctx) error {
Description: req.Description,
StimulusComponentKinds: req.StimulusComponentKinds,
ResponseComponentKinds: req.ResponseComponentKinds,
StimulusSchema: req.StimulusSchema,
ResponseSchema: req.ResponseSchema,
Status: req.Status,
IsSystem: false,
})
@ -197,6 +203,7 @@ func (h *Handler) GetQuestionTypeDefinitionByID(c *fiber.Ctx) error {
// UpdateQuestionTypeDefinition godoc
// @Summary Update reusable question-type definition
// @Description Updates a reusable dynamic question-type definition. Updated definitions must remain runtime-mappable.
// @Tags questions
// @Accept json
// @Produce json
@ -233,6 +240,8 @@ func (h *Handler) UpdateQuestionTypeDefinition(c *fiber.Ctx) error {
Description: req.Description,
StimulusComponentKinds: req.StimulusComponentKinds,
ResponseComponentKinds: req.ResponseComponentKinds,
StimulusSchema: req.StimulusSchema,
ResponseSchema: req.ResponseSchema,
Status: req.Status,
})
if err != nil {

View File

@ -28,6 +28,7 @@ type createQuestionReq struct {
QuestionText string `json:"question_text" validate:"required"`
QuestionType string `json:"question_type"`
QuestionTypeDefinitionID *int64 `json:"question_type_definition_id"`
DynamicPayload *domain.DynamicQuestionPayload `json:"dynamic_payload"`
DifficultyLevel *string `json:"difficulty_level"`
Points *int32 `json:"points"`
Explanation *string `json:"explanation"`
@ -58,6 +59,8 @@ type questionRes struct {
ID int64 `json:"id"`
QuestionText string `json:"question_text"`
QuestionType string `json:"question_type"`
QuestionTypeDefinitionID *int64 `json:"question_type_definition_id,omitempty"`
DynamicPayload *domain.DynamicQuestionPayload `json:"dynamic_payload,omitempty"`
DifficultyLevel *string `json:"difficulty_level,omitempty"`
Points int32 `json:"points"`
Explanation *string `json:"explanation,omitempty"`
@ -78,22 +81,7 @@ type listQuestionsRes struct {
}
func resolveQuestionTypeFromDefinition(def domain.QuestionTypeDefinition) string {
key := strings.ToLower(strings.TrimSpace(def.Key))
if key == "true_false" || key == "true-false" {
return "TRUE_FALSE"
}
for _, kind := range def.ResponseComponentKinds {
switch strings.TrimSpace(kind) {
case "AUDIO_RESPONSE":
return "AUDIO"
case "MULTIPLE_CHOICE":
return "MCQ"
case "SHORT_ANSWER", "TEXT_INPUT", "SELECT_MISSING_WORDS", "MATCHING_ANSWER", "LABEL_SELECTION":
return "SHORT_ANSWER"
}
}
return ""
return domain.ResolveRuntimeQuestionTypeFromDefinition(def.Key, def.ResponseComponentKinds)
}
func normalizeRuntimeQuestionType(v string) string {
@ -102,7 +90,7 @@ func normalizeRuntimeQuestionType(v string) string {
// CreateQuestion godoc
// @Summary Create a new question
// @Description Creates a new question with options (for MCQ/TRUE_FALSE) or short answers (for SHORT_ANSWER)
// @Description Creates a new question with options (for MCQ/TRUE_FALSE) or short answers (for SHORT_ANSWER). Supports question_type_definition_id for dynamic builder-linked questions.
// @Tags questions
// @Accept json
// @Produce json
@ -129,22 +117,33 @@ func (h *Handler) CreateQuestion(c *fiber.Ctx) error {
Error: err.Error(),
})
}
inferred := resolveQuestionTypeFromDefinition(def)
if inferred == "" {
return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{
Message: "Unsupported dynamic question type definition",
Error: "unable to map definition to runtime question_type",
})
}
if questionType == "" {
questionType = inferred
questionType = "DYNAMIC"
}
if questionType != inferred {
if questionType != "DYNAMIC" {
return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{
Message: "Mismatched question_type and definition",
Error: "question_type must match the selected question_type_definition_id",
Message: "Invalid question_type for dynamic definition",
Error: "question_type must be DYNAMIC when question_type_definition_id is provided",
})
}
if req.DynamicPayload == nil {
return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{
Message: "Missing dynamic_payload",
Error: "dynamic_payload is required when question_type_definition_id is provided",
})
}
if err := domain.ValidateDynamicPayloadAgainstDefinition(*req.DynamicPayload, def); err != nil {
return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{
Message: "Invalid dynamic_payload",
Error: err.Error(),
})
}
}
if req.QuestionTypeDefinitionID == nil && req.DynamicPayload != nil {
return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{
Message: "Invalid dynamic_payload usage",
Error: "dynamic_payload requires question_type_definition_id",
})
}
if questionType == "" {
return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{
@ -153,11 +152,17 @@ func (h *Handler) CreateQuestion(c *fiber.Ctx) error {
})
}
switch questionType {
case "MCQ", "TRUE_FALSE", "SHORT_ANSWER", "AUDIO":
case "MCQ", "TRUE_FALSE", "SHORT_ANSWER", "AUDIO", "DYNAMIC":
default:
return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{
Message: "Invalid question_type",
Error: "question_type must be one of MCQ, TRUE_FALSE, SHORT_ANSWER, AUDIO",
Error: "question_type must be one of MCQ, TRUE_FALSE, SHORT_ANSWER, AUDIO, DYNAMIC",
})
}
if questionType == "DYNAMIC" && req.QuestionTypeDefinitionID == nil {
return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{
Message: "Invalid dynamic question",
Error: "question_type_definition_id is required for DYNAMIC question_type",
})
}
@ -184,6 +189,7 @@ func (h *Handler) CreateQuestion(c *fiber.Ctx) error {
QuestionText: req.QuestionText,
QuestionType: questionType,
QuestionTypeDefinitionID: req.QuestionTypeDefinitionID,
DynamicPayload: req.DynamicPayload,
DifficultyLevel: req.DifficultyLevel,
Points: req.Points,
Explanation: req.Explanation,
@ -221,6 +227,8 @@ func (h *Handler) CreateQuestion(c *fiber.Ctx) error {
ID: question.ID,
QuestionText: question.QuestionText,
QuestionType: question.QuestionType,
QuestionTypeDefinitionID: question.QuestionTypeDefinitionID,
DynamicPayload: question.DynamicPayload,
DifficultyLevel: question.DifficultyLevel,
Points: question.Points,
Explanation: question.Explanation,
@ -292,6 +300,8 @@ func (h *Handler) GetQuestionByID(c *fiber.Ctx) error {
ID: question.ID,
QuestionText: question.QuestionText,
QuestionType: question.QuestionType,
QuestionTypeDefinitionID: question.QuestionTypeDefinitionID,
DynamicPayload: question.DynamicPayload,
DifficultyLevel: question.DifficultyLevel,
Points: question.Points,
Explanation: question.Explanation,
@ -357,6 +367,8 @@ func (h *Handler) ListQuestions(c *fiber.Ctx) error {
ID: q.ID,
QuestionText: q.QuestionText,
QuestionType: q.QuestionType,
QuestionTypeDefinitionID: q.QuestionTypeDefinitionID,
DynamicPayload: q.DynamicPayload,
DifficultyLevel: q.DifficultyLevel,
Points: q.Points,
Explanation: q.Explanation,
@ -415,6 +427,8 @@ func (h *Handler) SearchQuestions(c *fiber.Ctx) error {
ID: q.ID,
QuestionText: q.QuestionText,
QuestionType: q.QuestionType,
QuestionTypeDefinitionID: q.QuestionTypeDefinitionID,
DynamicPayload: q.DynamicPayload,
DifficultyLevel: q.DifficultyLevel,
Points: q.Points,
Status: q.Status,
@ -435,6 +449,7 @@ type updateQuestionReq struct {
QuestionText *string `json:"question_text"`
QuestionType *string `json:"question_type"`
QuestionTypeDefinitionID *int64 `json:"question_type_definition_id"`
DynamicPayload *domain.DynamicQuestionPayload `json:"dynamic_payload"`
DifficultyLevel *string `json:"difficulty_level"`
Points *int32 `json:"points"`
Explanation *string `json:"explanation"`
@ -450,7 +465,7 @@ type updateQuestionReq struct {
// UpdateQuestion godoc
// @Summary Update a question
// @Description Updates a question and optionally replaces its options/short answers
// @Description Updates a question and optionally replaces its options/short answers. Supports question_type_definition_id for dynamic builder-linked questions.
// @Tags questions
// @Accept json
// @Produce json
@ -478,6 +493,14 @@ func (h *Handler) UpdateQuestion(c *fiber.Ctx) error {
})
}
existingQuestion, err := h.questionsSvc.GetQuestionByID(c.Context(), id)
if err != nil {
return c.Status(fiber.StatusNotFound).JSON(domain.ErrorResponse{
Message: "Question not found",
Error: err.Error(),
})
}
var options []domain.CreateQuestionOptionInput
for _, opt := range req.Options {
options = append(options, domain.CreateQuestionOptionInput{
@ -495,44 +518,80 @@ func (h *Handler) UpdateQuestion(c *fiber.Ctx) error {
})
}
questionText := ""
questionText := existingQuestion.QuestionText
if req.QuestionText != nil {
questionText = *req.QuestionText
}
questionType := ""
questionType := normalizeRuntimeQuestionType(existingQuestion.QuestionType)
if req.QuestionType != nil {
questionType = normalizeRuntimeQuestionType(*req.QuestionType)
}
effectiveDefinitionID := existingQuestion.QuestionTypeDefinitionID
if req.QuestionTypeDefinitionID != nil {
def, err := h.questionsSvc.GetQuestionTypeDefinitionByID(c.Context(), *req.QuestionTypeDefinitionID)
effectiveDefinitionID = req.QuestionTypeDefinitionID
}
effectiveDynamicPayload := existingQuestion.DynamicPayload
if req.DynamicPayload != nil {
effectiveDynamicPayload = req.DynamicPayload
}
if effectiveDefinitionID != nil {
def, err := h.questionsSvc.GetQuestionTypeDefinitionByID(c.Context(), *effectiveDefinitionID)
if err != nil {
return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{
Message: "Invalid question_type_definition_id",
Error: err.Error(),
})
}
inferred := resolveQuestionTypeFromDefinition(def)
if inferred == "" {
return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{
Message: "Unsupported dynamic question type definition",
Error: "unable to map definition to runtime question_type",
})
}
if questionType == "" {
questionType = inferred
questionType = "DYNAMIC"
}
if questionType != inferred {
if questionType != "DYNAMIC" {
return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{
Message: "Mismatched question_type and definition",
Error: "question_type must match the selected question_type_definition_id",
Message: "Invalid question_type for dynamic definition",
Error: "question_type must be DYNAMIC when question_type_definition_id is provided",
})
}
if effectiveDynamicPayload == nil {
return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{
Message: "Missing dynamic_payload",
Error: "dynamic_payload is required for dynamic questions",
})
}
if err := domain.ValidateDynamicPayloadAgainstDefinition(*effectiveDynamicPayload, def); err != nil {
return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{
Message: "Invalid dynamic_payload",
Error: err.Error(),
})
}
}
if effectiveDefinitionID == nil && req.DynamicPayload != nil {
return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{
Message: "Invalid dynamic_payload usage",
Error: "dynamic_payload requires question_type_definition_id",
})
}
switch questionType {
case "MCQ", "TRUE_FALSE", "SHORT_ANSWER", "AUDIO", "DYNAMIC":
default:
return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{
Message: "Invalid question_type",
Error: "question_type must be one of MCQ, TRUE_FALSE, SHORT_ANSWER, AUDIO, DYNAMIC",
})
}
if questionType == "DYNAMIC" && effectiveDefinitionID == nil {
return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{
Message: "Invalid dynamic question",
Error: "question_type_definition_id is required for DYNAMIC question_type",
})
}
input := domain.CreateQuestionInput{
QuestionText: questionText,
QuestionType: questionType,
QuestionTypeDefinitionID: req.QuestionTypeDefinitionID,
QuestionTypeDefinitionID: effectiveDefinitionID,
DynamicPayload: effectiveDynamicPayload,
DifficultyLevel: req.DifficultyLevel,
Points: req.Points,
Explanation: req.Explanation,
@ -1158,6 +1217,7 @@ type questionSetItemRes struct {
DisplayOrder int32 `json:"display_order"`
QuestionText string `json:"question_text"`
QuestionType string `json:"question_type"`
DynamicPayload *domain.DynamicQuestionPayload `json:"dynamic_payload,omitempty"`
DifficultyLevel *string `json:"difficulty_level,omitempty"`
Points int32 `json:"points"`
Explanation *string `json:"explanation,omitempty"`
@ -1186,6 +1246,7 @@ func questionSetItemsToRes(items []domain.QuestionSetItemWithQuestion) []questio
DisplayOrder: item.DisplayOrder,
QuestionText: item.QuestionText,
QuestionType: item.QuestionType,
DynamicPayload: item.DynamicPayload,
DifficultyLevel: item.DifficultyLevel,
Points: item.Points,
Explanation: item.Explanation,
@ -1334,6 +1395,8 @@ func (h *Handler) GetQuestionsInSet(c *fiber.Ctx) error {
ID: question.ID,
QuestionText: question.QuestionText,
QuestionType: question.QuestionType,
QuestionTypeDefinitionID: question.QuestionTypeDefinitionID,
DynamicPayload: question.DynamicPayload,
DifficultyLevel: question.DifficultyLevel,
Points: question.Points,
Explanation: question.Explanation,

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

View File

@ -1,148 +1,240 @@
{
"info": {
"name": "Yimaru Dynamic Question Type Builder API",
"_postman_id": "f0f9c795-09aa-4f5a-9cc0-1f2fcb0f1b01",
"description": "Complete Postman collection for the dynamic question type builder feature, including catalog, validation, reusable type-definition CRUD, and question create/update using question_type_definition_id.",
"_postman_id": "c9e5e08f-e878-4d8a-b24c-5f866eb4c735",
"name": "Dynamic Question Type Builder - Runtime Complete",
"description": "Complete collection for the new dynamic question type builder flow: component catalog, definition schema CRUD, dynamic payload validation, and DYNAMIC question lifecycle.",
"schema": "https://schema.getpostman.com/json/collection/v2.1.0/collection.json"
},
"variable": [
{
"key": "baseUrl",
"value": "http://localhost:8080"
},
{
"key": "apiPrefix",
"value": "/api/v1"
},
{
"key": "token",
"value": ""
},
{
"key": "questionTypeDefinitionId",
"value": ""
},
{
"key": "questionId",
"value": ""
}
],
"auth": {
"type": "bearer",
"bearer": [
{
"key": "token",
"value": "{{token}}",
"value": "{{access_token}}",
"type": "string"
}
]
},
"variable": [
{
"key": "base_url",
"value": "http://localhost:8080"
},
{
"key": "access_token",
"value": ""
},
{
"key": "definition_key",
"value": "dynamic_builder_{{$timestamp}}"
},
{
"key": "dynamic_definition_id",
"value": ""
},
{
"key": "dynamic_question_id",
"value": ""
}
],
"item": [
{
"name": "01 - Builder Component Catalog",
"name": "01 - Builder Catalog and Validation",
"item": [
{
"name": "Get Component Catalog",
"request": {
"method": "GET",
"header": [],
"url": {
"raw": "{{baseUrl}}{{apiPrefix}}/questions/component-catalog",
"raw": "{{base_url}}/api/v1/questions/component-catalog",
"host": [
"{{baseUrl}}"
"{{base_url}}"
],
"path": [
"{{apiPrefix}}",
"api",
"v1",
"questions",
"component-catalog"
]
},
"description": "Returns supported stimulus and response component kinds for dynamic type definitions."
}
},
"response": []
},
{
"name": "02 - Validate Dynamic Type Definition",
"request": {
"method": "POST",
"header": [
{
"key": "Content-Type",
"value": "application/json"
}
],
"body": {
"mode": "raw",
"raw": "{\n \"stimulus_component_kinds\": [\"INSTRUCTION\", \"TEXT_PASSAGE\"],\n \"response_component_kinds\": [\"MULTIPLE_CHOICE\"]\n}"
},
"url": {
"raw": "{{baseUrl}}{{apiPrefix}}/questions/validate-question-type-definition",
"host": [
"{{baseUrl}}"
],
"path": [
"{{apiPrefix}}",
"questions",
"validate-question-type-definition"
]
},
"description": "Validates a candidate dynamic question-type definition before saving."
},
"response": []
},
{
"name": "03 - Create Question Type Definition (MCQ Dynamic)",
"request": {
"method": "POST",
"header": [
{
"key": "Content-Type",
"value": "application/json"
}
],
"body": {
"mode": "raw",
"raw": "{\n \"key\": \"mcq_dynamic_vocab\",\n \"display_name\": \"MCQ Dynamic Vocabulary\",\n \"description\": \"Dynamic multiple-choice template for vocabulary checks.\",\n \"stimulus_component_kinds\": [\"INSTRUCTION\", \"TEXT_PASSAGE\"],\n \"response_component_kinds\": [\"MULTIPLE_CHOICE\"],\n \"status\": \"ACTIVE\"\n}"
},
"url": {
"raw": "{{baseUrl}}{{apiPrefix}}/questions/type-definitions",
"host": [
"{{baseUrl}}"
],
"path": [
"{{apiPrefix}}",
"questions",
"type-definitions"
]
},
"description": "Creates a reusable dynamic question-type definition."
},
"name": "Validate Definition (Valid)",
"event": [
{
"listen": "test",
"script": {
"exec": [
"pm.test('Status is 201', function () { pm.response.to.have.status(201); });",
"var json = pm.response.json();",
"if (json && json.data && json.data.id) {",
" pm.collectionVariables.set('questionTypeDefinitionId', json.data.id);",
"}"
"pm.test(\"Status code is 200\", function () {",
" pm.response.to.have.status(200);",
"});",
"const body = pm.response.json();",
"pm.test(\"valid = true\", function () {",
" pm.expect(body.data.valid).to.eql(true);",
"});"
],
"type": "text/javascript"
}
}
],
"request": {
"method": "POST",
"header": [
{
"key": "Content-Type",
"value": "application/json",
"type": "text"
}
],
"body": {
"mode": "raw",
"raw": "{\n \"stimulus_component_kinds\": [\"QUESTION_TEXT\", \"IMAGE\", \"TABLE\"],\n \"response_component_kinds\": [\"OPTION\", \"ANSWER_TIMER\"]\n}"
},
"url": {
"raw": "{{base_url}}/api/v1/questions/validate-question-type-definition",
"host": [
"{{base_url}}"
],
"path": [
"api",
"v1",
"questions",
"validate-question-type-definition"
]
}
},
"response": []
},
{
"name": "04 - List Question Type Definitions (Include System)",
"name": "Validate Definition (Invalid - Timer Only)",
"event": [
{
"listen": "test",
"script": {
"exec": [
"pm.test(\"Status code is 400\", function () {",
" pm.response.to.have.status(400);",
"});"
],
"type": "text/javascript"
}
}
],
"request": {
"method": "POST",
"header": [
{
"key": "Content-Type",
"value": "application/json",
"type": "text"
}
],
"body": {
"mode": "raw",
"raw": "{\n \"stimulus_component_kinds\": [\"QUESTION_TEXT\"],\n \"response_component_kinds\": [\"ANSWER_TIMER\"]\n}"
},
"url": {
"raw": "{{base_url}}/api/v1/questions/validate-question-type-definition",
"host": [
"{{base_url}}"
],
"path": [
"api",
"v1",
"questions",
"validate-question-type-definition"
]
}
},
"response": []
}
]
},
{
"name": "02 - Definition CRUD (Schema-Driven)",
"item": [
{
"name": "Create Dynamic Definition (with schema)",
"event": [
{
"listen": "test",
"script": {
"exec": [
"pm.test(\"Status code is 201\", function () {",
" pm.response.to.have.status(201);",
"});",
"const body = pm.response.json();",
"pm.collectionVariables.set(\"dynamic_definition_id\", body.data.id);"
],
"type": "text/javascript"
}
}
],
"request": {
"method": "POST",
"header": [
{
"key": "Content-Type",
"value": "application/json",
"type": "text"
}
],
"body": {
"mode": "raw",
"raw": "{\n \"key\": \"{{definition_key}}\",\n \"display_name\": \"Dynamic Grammar + Visual MCQ\",\n \"description\": \"Reusable dynamic question type with question text, image, table, and option responses.\",\n \"stimulus_component_kinds\": [\"QUESTION_TEXT\", \"IMAGE\", \"TABLE\"],\n \"response_component_kinds\": [\"OPTION\", \"ANSWER_TIMER\"],\n \"stimulus_schema\": [\n {\n \"id\": \"prompt\",\n \"kind\": \"QUESTION_TEXT\",\n \"label\": \"Prompt\",\n \"required\": true,\n \"config\": {\n \"max_length\": 1000\n }\n },\n {\n \"id\": \"illustration\",\n \"kind\": \"IMAGE\",\n \"label\": \"Supporting Image\",\n \"required\": false,\n \"config\": {\n \"allowed_formats\": [\"png\", \"jpg\", \"webp\"]\n }\n },\n {\n \"id\": \"data_table\",\n \"kind\": \"TABLE\",\n \"label\": \"Reference Table\",\n \"required\": false,\n \"config\": {\n \"max_rows\": 20,\n \"max_columns\": 8\n }\n }\n ],\n \"response_schema\": [\n {\n \"id\": \"choices\",\n \"kind\": \"OPTION\",\n \"label\": \"Answer Options\",\n \"required\": true,\n \"config\": {\n \"min_options\": 2,\n \"max_options\": 6,\n \"allow_multiple\": false\n }\n },\n {\n \"id\": \"timer\",\n \"kind\": \"ANSWER_TIMER\",\n \"label\": \"Answer Time Limit\",\n \"required\": false,\n \"config\": {\n \"min_seconds\": 5,\n \"max_seconds\": 180\n }\n }\n ],\n \"status\": \"ACTIVE\"\n}"
},
"url": {
"raw": "{{base_url}}/api/v1/questions/type-definitions",
"host": [
"{{base_url}}"
],
"path": [
"api",
"v1",
"questions",
"type-definitions"
]
}
},
"response": []
},
{
"name": "Get Definition By ID",
"request": {
"method": "GET",
"header": [],
"url": {
"raw": "{{baseUrl}}{{apiPrefix}}/questions/type-definitions?include_system=true&status=ACTIVE",
"raw": "{{base_url}}/api/v1/questions/type-definitions/{{dynamic_definition_id}}",
"host": [
"{{baseUrl}}"
"{{base_url}}"
],
"path": [
"{{apiPrefix}}",
"api",
"v1",
"questions",
"type-definitions",
"{{dynamic_definition_id}}"
]
}
},
"response": []
},
{
"name": "List Definitions (include system)",
"request": {
"method": "GET",
"header": [],
"url": {
"raw": "{{base_url}}/api/v1/questions/type-definitions?include_system=true&status=ACTIVE",
"host": [
"{{base_url}}"
],
"path": [
"api",
"v1",
"questions",
"type-definitions"
],
@ -156,263 +248,416 @@
"value": "ACTIVE"
}
]
},
"description": "Lists reusable dynamic definitions (system + custom)."
},
"response": []
},
{
"name": "05 - Get Question Type Definition By ID",
"request": {
"method": "GET",
"header": [],
"url": {
"raw": "{{baseUrl}}{{apiPrefix}}/questions/type-definitions/{{questionTypeDefinitionId}}",
"host": [
"{{baseUrl}}"
],
"path": [
"{{apiPrefix}}",
"questions",
"type-definitions",
"{{questionTypeDefinitionId}}"
]
},
"description": "Fetches one dynamic type-definition by ID."
},
"response": []
},
{
"name": "06 - Update Question Type Definition",
"request": {
"method": "PUT",
"header": [
{
"key": "Content-Type",
"value": "application/json"
}
],
"body": {
"mode": "raw",
"raw": "{\n \"display_name\": \"MCQ Dynamic Vocabulary (Updated)\",\n \"description\": \"Updated dynamic MCQ template.\",\n \"stimulus_component_kinds\": [\"INSTRUCTION\", \"TEXT_PASSAGE\", \"IMAGE\"],\n \"response_component_kinds\": [\"MULTIPLE_CHOICE\", \"ANSWER_TIMER\"],\n \"status\": \"ACTIVE\"\n}"
},
"url": {
"raw": "{{baseUrl}}{{apiPrefix}}/questions/type-definitions/{{questionTypeDefinitionId}}",
"host": [
"{{baseUrl}}"
],
"path": [
"{{apiPrefix}}",
"questions",
"type-definitions",
"{{questionTypeDefinitionId}}"
]
},
"description": "Updates dynamic definition (except key/system flag)."
},
"response": []
},
{
"name": "07 - Create Question Using question_type_definition_id",
"request": {
"method": "POST",
"header": [
{
"key": "Content-Type",
"value": "application/json"
}
],
"body": {
"mode": "raw",
"raw": "{\n \"question_text\": \"Choose the correct synonym for 'rapid'.\",\n \"question_type_definition_id\": {{questionTypeDefinitionId}},\n \"difficulty_level\": \"EASY\",\n \"points\": 1,\n \"status\": \"PUBLISHED\",\n \"options\": [\n { \"option_text\": \"Slow\", \"option_order\": 1, \"is_correct\": false },\n { \"option_text\": \"Quick\", \"option_order\": 2, \"is_correct\": true },\n { \"option_text\": \"Heavy\", \"option_order\": 3, \"is_correct\": false },\n { \"option_text\": \"Late\", \"option_order\": 4, \"is_correct\": false }\n ]\n}"
},
"url": {
"raw": "{{baseUrl}}{{apiPrefix}}/questions",
"host": [
"{{baseUrl}}"
],
"path": [
"{{apiPrefix}}",
"questions"
]
},
"description": "Creates a question by binding to a dynamic definition. Backend infers runtime question_type from the definition."
},
"name": "Update Definition (schema evolution)",
"event": [
{
"listen": "test",
"script": {
"exec": [
"pm.test('Status is 201', function () { pm.response.to.have.status(201); });",
"var json = pm.response.json();",
"if (json && json.data && json.data.id) {",
" pm.collectionVariables.set('questionId', json.data.id);",
"}"
"pm.test(\"Status code is 200\", function () {",
" pm.response.to.have.status(200);",
"});"
],
"type": "text/javascript"
}
}
],
"response": []
},
{
"name": "08 - Create Question (Explicit question_type + Definition)",
"request": {
"method": "POST",
"header": [
{
"key": "Content-Type",
"value": "application/json"
}
],
"body": {
"mode": "raw",
"raw": "{\n \"question_text\": \"Pick the antonym of 'expand'.\",\n \"question_type\": \"MCQ\",\n \"question_type_definition_id\": {{questionTypeDefinitionId}},\n \"difficulty_level\": \"MEDIUM\",\n \"points\": 2,\n \"options\": [\n { \"option_text\": \"Increase\", \"option_order\": 1, \"is_correct\": false },\n { \"option_text\": \"Contract\", \"option_order\": 2, \"is_correct\": true },\n { \"option_text\": \"Stretch\", \"option_order\": 3, \"is_correct\": false },\n { \"option_text\": \"Grow\", \"option_order\": 4, \"is_correct\": false }\n ]\n}"
},
"url": {
"raw": "{{baseUrl}}{{apiPrefix}}/questions",
"host": [
"{{baseUrl}}"
],
"path": [
"{{apiPrefix}}",
"questions"
]
},
"description": "Valid combination: explicit question_type must match the type inferred from the selected definition."
},
"response": []
},
{
"name": "09 - Update Question (Switch/Attach Definition)",
"request": {
"method": "PUT",
"header": [
{
"key": "Content-Type",
"value": "application/json"
"value": "application/json",
"type": "text"
}
],
"body": {
"mode": "raw",
"raw": "{\n \"question_type_definition_id\": {{questionTypeDefinitionId}},\n \"question_type\": \"MCQ\",\n \"question_text\": \"Choose the best definition of 'meticulous'.\",\n \"options\": [\n { \"option_text\": \"Careless\", \"option_order\": 1, \"is_correct\": false },\n { \"option_text\": \"Very careful and precise\", \"option_order\": 2, \"is_correct\": true },\n { \"option_text\": \"Quickly done\", \"option_order\": 3, \"is_correct\": false }\n ],\n \"status\": \"PUBLISHED\"\n}"
"raw": "{\n \"display_name\": \"Dynamic Grammar + Visual MCQ (Updated)\",\n \"response_component_kinds\": [\"OPTION\", \"ANSWER_TIMER\", \"SEQUENCE_ORDER\"],\n \"response_schema\": [\n {\n \"id\": \"choices\",\n \"kind\": \"OPTION\",\n \"label\": \"Answer Options\",\n \"required\": true,\n \"config\": {\n \"min_options\": 2,\n \"max_options\": 6,\n \"allow_multiple\": false\n }\n },\n {\n \"id\": \"timer\",\n \"kind\": \"ANSWER_TIMER\",\n \"label\": \"Answer Time Limit\",\n \"required\": false,\n \"config\": {\n \"min_seconds\": 5,\n \"max_seconds\": 180\n }\n },\n {\n \"id\": \"ordering\",\n \"kind\": \"SEQUENCE_ORDER\",\n \"label\": \"Optional Sequence Order\",\n \"required\": false,\n \"config\": {\n \"max_items\": 8\n }\n }\n ]\n}"
},
"url": {
"raw": "{{baseUrl}}{{apiPrefix}}/questions/{{questionId}}",
"raw": "{{base_url}}/api/v1/questions/type-definitions/{{dynamic_definition_id}}",
"host": [
"{{baseUrl}}"
"{{base_url}}"
],
"path": [
"{{apiPrefix}}",
"api",
"v1",
"questions",
"{{questionId}}"
"type-definitions",
"{{dynamic_definition_id}}"
]
},
"description": "Updates a question and links (or re-links) it to a dynamic definition."
}
},
"response": []
},
{
"name": "10 - Get Question By ID",
"name": "Update Definition (Invalid Schema Kind) - Expect 400",
"event": [
{
"listen": "test",
"script": {
"exec": [
"pm.test(\"Status code is 400\", function () {",
" pm.response.to.have.status(400);",
"});"
],
"type": "text/javascript"
}
}
],
"request": {
"method": "GET",
"header": [],
"method": "PUT",
"header": [
{
"key": "Content-Type",
"value": "application/json",
"type": "text"
}
],
"body": {
"mode": "raw",
"raw": "{\n \"response_schema\": [\n {\n \"id\": \"bad\",\n \"kind\": \"NON_EXISTENT_KIND\",\n \"required\": true\n }\n ]\n}"
},
"url": {
"raw": "{{baseUrl}}{{apiPrefix}}/questions/{{questionId}}",
"raw": "{{base_url}}/api/v1/questions/type-definitions/{{dynamic_definition_id}}",
"host": [
"{{baseUrl}}"
"{{base_url}}"
],
"path": [
"{{apiPrefix}}",
"api",
"v1",
"questions",
"{{questionId}}"
"type-definitions",
"{{dynamic_definition_id}}"
]
}
},
"response": []
}
]
},
"description": "Returns question details (options/short_answers/audio fields as applicable)."
{
"name": "03 - Dynamic Question Runtime",
"item": [
{
"name": "Create Dynamic Question (valid payload)",
"event": [
{
"listen": "test",
"script": {
"exec": [
"pm.test(\"Status code is 201\", function () {",
" pm.response.to.have.status(201);",
"});",
"const body = pm.response.json();",
"pm.collectionVariables.set(\"dynamic_question_id\", body.data.id);",
"pm.test(\"Question type is DYNAMIC\", function () {",
" pm.expect(body.data.question_type).to.eql(\"DYNAMIC\");",
"});"
],
"type": "text/javascript"
}
}
],
"request": {
"method": "POST",
"header": [
{
"key": "Content-Type",
"value": "application/json",
"type": "text"
}
],
"body": {
"mode": "raw",
"raw": "{\n \"question_text\": \"Complete the sentence using the table and image.\",\n \"question_type\": \"DYNAMIC\",\n \"question_type_definition_id\": {{dynamic_definition_id}},\n \"difficulty_level\": \"MEDIUM\",\n \"points\": 2,\n \"status\": \"DRAFT\",\n \"dynamic_payload\": {\n \"stimulus\": [\n {\n \"id\": \"prompt\",\n \"kind\": \"QUESTION_TEXT\",\n \"value\": \"Select the best completion based on the visual and table.\"\n },\n {\n \"id\": \"illustration\",\n \"kind\": \"IMAGE\",\n \"value\": \"https://cdn.example.com/images/sample-grammar-scene.jpg\"\n },\n {\n \"id\": \"data_table\",\n \"kind\": \"TABLE\",\n \"value\": {\n \"columns\": [\"Verb\", \"Past Form\"],\n \"rows\": [\n [\"go\", \"went\"],\n [\"write\", \"wrote\"]\n ]\n }\n }\n ],\n \"response\": [\n {\n \"id\": \"choices\",\n \"kind\": \"OPTION\",\n \"value\": {\n \"options\": [\n {\"id\": \"a\", \"text\": \"I goed to school\", \"is_correct\": false},\n {\"id\": \"b\", \"text\": \"I went to school\", \"is_correct\": true}\n ]\n }\n },\n {\n \"id\": \"timer\",\n \"kind\": \"ANSWER_TIMER\",\n \"value\": {\n \"seconds\": 30\n }\n }\n ]\n }\n}"
},
"url": {
"raw": "{{base_url}}/api/v1/questions",
"host": [
"{{base_url}}"
],
"path": [
"api",
"v1",
"questions"
]
}
},
"response": []
},
{
"name": "11 - List Questions",
"name": "Get Dynamic Question By ID",
"request": {
"method": "GET",
"header": [],
"url": {
"raw": "{{baseUrl}}{{apiPrefix}}/questions?question_type=MCQ&limit=10&offset=0",
"raw": "{{base_url}}/api/v1/questions/{{dynamic_question_id}}",
"host": [
"{{baseUrl}}"
"{{base_url}}"
],
"path": [
"{{apiPrefix}}",
"api",
"v1",
"questions",
"{{dynamic_question_id}}"
]
}
},
"response": []
},
{
"name": "Update Dynamic Question (valid payload)",
"event": [
{
"listen": "test",
"script": {
"exec": [
"pm.test(\"Status code is 200\", function () {",
" pm.response.to.have.status(200);",
"});"
],
"type": "text/javascript"
}
}
],
"request": {
"method": "PUT",
"header": [
{
"key": "Content-Type",
"value": "application/json",
"type": "text"
}
],
"body": {
"mode": "raw",
"raw": "{\n \"question_text\": \"Updated: choose the best sentence.\",\n \"question_type_definition_id\": {{dynamic_definition_id}},\n \"question_type\": \"DYNAMIC\",\n \"status\": \"PUBLISHED\",\n \"dynamic_payload\": {\n \"stimulus\": [\n {\n \"id\": \"prompt\",\n \"kind\": \"QUESTION_TEXT\",\n \"value\": \"Pick the grammatically correct option.\"\n }\n ],\n \"response\": [\n {\n \"id\": \"choices\",\n \"kind\": \"OPTION\",\n \"value\": {\n \"options\": [\n {\"id\": \"a\", \"text\": \"He go home yesterday\", \"is_correct\": false},\n {\"id\": \"b\", \"text\": \"He went home yesterday\", \"is_correct\": true}\n ]\n }\n }\n ]\n }\n}"
},
"url": {
"raw": "{{base_url}}/api/v1/questions/{{dynamic_question_id}}",
"host": [
"{{base_url}}"
],
"path": [
"api",
"v1",
"questions",
"{{dynamic_question_id}}"
]
}
},
"response": []
},
{
"name": "List Questions (question_type=DYNAMIC)",
"request": {
"method": "GET",
"header": [],
"url": {
"raw": "{{base_url}}/api/v1/questions?question_type=DYNAMIC&limit=20&offset=0",
"host": [
"{{base_url}}"
],
"path": [
"api",
"v1",
"questions"
],
"query": [
{
"key": "question_type",
"value": "MCQ"
"value": "DYNAMIC"
},
{
"key": "limit",
"value": "10"
"value": "20"
},
{
"key": "offset",
"value": "0"
}
]
},
"description": "Lists questions filtered by runtime question_type."
}
},
"response": []
},
{
"name": "12 - Negative Test: Mismatched Type and Definition",
"name": "Search Questions",
"request": {
"method": "GET",
"header": [],
"url": {
"raw": "{{base_url}}/api/v1/questions/search?q=choose&limit=20&offset=0",
"host": [
"{{base_url}}"
],
"path": [
"api",
"v1",
"questions",
"search"
],
"query": [
{
"key": "q",
"value": "choose"
},
{
"key": "limit",
"value": "20"
},
{
"key": "offset",
"value": "0"
}
]
}
},
"response": []
}
]
},
{
"name": "04 - Negative Runtime Validation Cases",
"item": [
{
"name": "Create Dynamic Question without dynamic_payload - Expect 400",
"event": [
{
"listen": "test",
"script": {
"exec": [
"pm.test(\"Status code is 400\", function () {",
" pm.response.to.have.status(400);",
"});"
],
"type": "text/javascript"
}
}
],
"request": {
"method": "POST",
"header": [
{
"key": "Content-Type",
"value": "application/json"
"value": "application/json",
"type": "text"
}
],
"body": {
"mode": "raw",
"raw": "{\n \"question_text\": \"This should fail.\",\n \"question_type\": \"AUDIO\",\n \"question_type_definition_id\": {{questionTypeDefinitionId}},\n \"options\": [\n { \"option_text\": \"A\", \"option_order\": 1, \"is_correct\": true }\n ]\n}"
"raw": "{\n \"question_text\": \"Should fail\",\n \"question_type\": \"DYNAMIC\",\n \"question_type_definition_id\": {{dynamic_definition_id}},\n \"difficulty_level\": \"EASY\"\n}"
},
"url": {
"raw": "{{baseUrl}}{{apiPrefix}}/questions",
"raw": "{{base_url}}/api/v1/questions",
"host": [
"{{baseUrl}}"
"{{base_url}}"
],
"path": [
"{{apiPrefix}}",
"api",
"v1",
"questions"
]
},
"description": "Expected 400 because explicit question_type does not match inferred type from definition."
}
},
"response": []
},
{
"name": "13 - Delete Custom Question Type Definition",
"name": "Create Dynamic Question with disallowed element kind - Expect 400",
"event": [
{
"listen": "test",
"script": {
"exec": [
"pm.test(\"Status code is 400\", function () {",
" pm.response.to.have.status(400);",
"});"
],
"type": "text/javascript"
}
}
],
"request": {
"method": "DELETE",
"header": [],
"method": "POST",
"header": [
{
"key": "Content-Type",
"value": "application/json",
"type": "text"
}
],
"body": {
"mode": "raw",
"raw": "{\n \"question_text\": \"Should fail because AUDIO_CLIP is not allowed by selected definition\",\n \"question_type\": \"DYNAMIC\",\n \"question_type_definition_id\": {{dynamic_definition_id}},\n \"dynamic_payload\": {\n \"stimulus\": [\n {\n \"id\": \"audio1\",\n \"kind\": \"AUDIO_CLIP\",\n \"value\": \"https://cdn.example.com/audio/not-allowed.mp3\"\n }\n ],\n \"response\": [\n {\n \"id\": \"choices\",\n \"kind\": \"OPTION\",\n \"value\": {\n \"options\": [\n {\"id\": \"a\", \"text\": \"A\", \"is_correct\": true},\n {\"id\": \"b\", \"text\": \"B\", \"is_correct\": false}\n ]\n }\n }\n ]\n }\n}"
},
"url": {
"raw": "{{baseUrl}}{{apiPrefix}}/questions/type-definitions/{{questionTypeDefinitionId}}",
"raw": "{{base_url}}/api/v1/questions",
"host": [
"{{baseUrl}}"
"{{base_url}}"
],
"path": [
"{{apiPrefix}}",
"questions",
"type-definitions",
"{{questionTypeDefinitionId}}"
"api",
"v1",
"questions"
]
},
"description": "Deletes a custom definition. System definitions cannot be deleted."
}
},
"response": []
}
]
},
{
"name": "05 - Cleanup",
"item": [
{
"name": "Delete Dynamic Question",
"request": {
"method": "DELETE",
"header": [],
"url": {
"raw": "{{base_url}}/api/v1/questions/{{dynamic_question_id}}",
"host": [
"{{base_url}}"
],
"path": [
"api",
"v1",
"questions",
"{{dynamic_question_id}}"
]
}
},
"response": []
},
{
"name": "Delete Dynamic Definition",
"request": {
"method": "DELETE",
"header": [],
"url": {
"raw": "{{base_url}}/api/v1/questions/type-definitions/{{dynamic_definition_id}}",
"host": [
"{{base_url}}"
],
"path": [
"api",
"v1",
"questions",
"type-definitions",
"{{dynamic_definition_id}}"
]
}
},
"response": []
}
]
}
]
}

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