dynamic question type builder completion
This commit is contained in:
parent
9a17f0b3c4
commit
3d1b3ad9b8
|
|
@ -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'));
|
||||
13
db/migrations/000058_dynamic_question_builder_runtime.up.sql
Normal file
13
db/migrations/000058_dynamic_question_builder_runtime.up.sql
Normal 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;
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
298
docs/docs.go
298
docs/docs.go
|
|
@ -4560,7 +4560,7 @@ const docTemplate = `{
|
|||
}
|
||||
},
|
||||
"post": {
|
||||
"description": "Creates a new question with options (for MCQ/TRUE_FALSE) or short answers (for SHORT_ANSWER)",
|
||||
"description": "Creates a new question with options (for MCQ/TRUE_FALSE) or short answers (for SHORT_ANSWER). Supports question_type_definition_id for dynamic builder-linked questions.",
|
||||
"consumes": [
|
||||
"application/json"
|
||||
],
|
||||
|
|
@ -4721,9 +4721,221 @@ const docTemplate = `{
|
|||
}
|
||||
}
|
||||
},
|
||||
"/api/v1/questions/type-definitions": {
|
||||
"get": {
|
||||
"produces": [
|
||||
"application/json"
|
||||
],
|
||||
"tags": [
|
||||
"questions"
|
||||
],
|
||||
"summary": "List reusable question-type definitions",
|
||||
"parameters": [
|
||||
{
|
||||
"type": "string",
|
||||
"description": "Filter by status (ACTIVE, INACTIVE)",
|
||||
"name": "status",
|
||||
"in": "query"
|
||||
},
|
||||
{
|
||||
"type": "boolean",
|
||||
"description": "Include system seeded definitions",
|
||||
"name": "include_system",
|
||||
"in": "query"
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "OK",
|
||||
"schema": {
|
||||
"$ref": "#/definitions/domain.Response"
|
||||
}
|
||||
},
|
||||
"500": {
|
||||
"description": "Internal Server Error",
|
||||
"schema": {
|
||||
"$ref": "#/definitions/domain.ErrorResponse"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"post": {
|
||||
"description": "Stores a reusable dynamic question-type definition for future question construction. Only runtime-mappable definitions are persisted.",
|
||||
"consumes": [
|
||||
"application/json"
|
||||
],
|
||||
"produces": [
|
||||
"application/json"
|
||||
],
|
||||
"tags": [
|
||||
"questions"
|
||||
],
|
||||
"summary": "Create reusable question-type definition",
|
||||
"parameters": [
|
||||
{
|
||||
"description": "Question type definition payload",
|
||||
"name": "body",
|
||||
"in": "body",
|
||||
"required": true,
|
||||
"schema": {
|
||||
"$ref": "#/definitions/handlers.createQuestionTypeDefinitionReq"
|
||||
}
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"201": {
|
||||
"description": "Created",
|
||||
"schema": {
|
||||
"$ref": "#/definitions/domain.Response"
|
||||
}
|
||||
},
|
||||
"400": {
|
||||
"description": "Bad Request",
|
||||
"schema": {
|
||||
"$ref": "#/definitions/domain.ErrorResponse"
|
||||
}
|
||||
},
|
||||
"500": {
|
||||
"description": "Internal Server Error",
|
||||
"schema": {
|
||||
"$ref": "#/definitions/domain.ErrorResponse"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/api/v1/questions/type-definitions/{id}": {
|
||||
"get": {
|
||||
"produces": [
|
||||
"application/json"
|
||||
],
|
||||
"tags": [
|
||||
"questions"
|
||||
],
|
||||
"summary": "Get reusable question-type definition by id",
|
||||
"parameters": [
|
||||
{
|
||||
"type": "integer",
|
||||
"description": "Question type definition id",
|
||||
"name": "id",
|
||||
"in": "path",
|
||||
"required": true
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "OK",
|
||||
"schema": {
|
||||
"$ref": "#/definitions/domain.Response"
|
||||
}
|
||||
},
|
||||
"400": {
|
||||
"description": "Bad Request",
|
||||
"schema": {
|
||||
"$ref": "#/definitions/domain.ErrorResponse"
|
||||
}
|
||||
},
|
||||
"404": {
|
||||
"description": "Not Found",
|
||||
"schema": {
|
||||
"$ref": "#/definitions/domain.ErrorResponse"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"put": {
|
||||
"description": "Updates a reusable dynamic question-type definition. Updated definitions must remain runtime-mappable.",
|
||||
"consumes": [
|
||||
"application/json"
|
||||
],
|
||||
"produces": [
|
||||
"application/json"
|
||||
],
|
||||
"tags": [
|
||||
"questions"
|
||||
],
|
||||
"summary": "Update reusable question-type definition",
|
||||
"parameters": [
|
||||
{
|
||||
"type": "integer",
|
||||
"description": "Question type definition id",
|
||||
"name": "id",
|
||||
"in": "path",
|
||||
"required": true
|
||||
},
|
||||
{
|
||||
"description": "Update question type definition payload",
|
||||
"name": "body",
|
||||
"in": "body",
|
||||
"required": true,
|
||||
"schema": {
|
||||
"$ref": "#/definitions/handlers.updateQuestionTypeDefinitionReq"
|
||||
}
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "OK",
|
||||
"schema": {
|
||||
"$ref": "#/definitions/domain.Response"
|
||||
}
|
||||
},
|
||||
"400": {
|
||||
"description": "Bad Request",
|
||||
"schema": {
|
||||
"$ref": "#/definitions/domain.ErrorResponse"
|
||||
}
|
||||
},
|
||||
"500": {
|
||||
"description": "Internal Server Error",
|
||||
"schema": {
|
||||
"$ref": "#/definitions/domain.ErrorResponse"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"delete": {
|
||||
"produces": [
|
||||
"application/json"
|
||||
],
|
||||
"tags": [
|
||||
"questions"
|
||||
],
|
||||
"summary": "Delete reusable question-type definition",
|
||||
"parameters": [
|
||||
{
|
||||
"type": "integer",
|
||||
"description": "Question type definition id",
|
||||
"name": "id",
|
||||
"in": "path",
|
||||
"required": true
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "OK",
|
||||
"schema": {
|
||||
"$ref": "#/definitions/domain.Response"
|
||||
}
|
||||
},
|
||||
"400": {
|
||||
"description": "Bad Request",
|
||||
"schema": {
|
||||
"$ref": "#/definitions/domain.ErrorResponse"
|
||||
}
|
||||
},
|
||||
"500": {
|
||||
"description": "Internal Server Error",
|
||||
"schema": {
|
||||
"$ref": "#/definitions/domain.ErrorResponse"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/api/v1/questions/validate-question-type-definition": {
|
||||
"post": {
|
||||
"description": "Validates selected stimulus and response component kinds for temporary question-type definitions",
|
||||
"description": "Validates selected stimulus and response component kinds for temporary question-type definitions (component-level validation only)",
|
||||
"consumes": [
|
||||
"application/json"
|
||||
],
|
||||
|
|
@ -4802,7 +5014,7 @@ const docTemplate = `{
|
|||
}
|
||||
},
|
||||
"put": {
|
||||
"description": "Updates a question and optionally replaces its options/short answers",
|
||||
"description": "Updates a question and optionally replaces its options/short answers. Supports question_type_definition_id for dynamic builder-linked questions.",
|
||||
"consumes": [
|
||||
"application/json"
|
||||
],
|
||||
|
|
@ -9452,6 +9664,10 @@ const docTemplate = `{
|
|||
"questionType": {
|
||||
"type": "string"
|
||||
},
|
||||
"questionTypeDefinitionID": {
|
||||
"type": "integer",
|
||||
"format": "int64"
|
||||
},
|
||||
"sampleAnswerVoicePrompt": {
|
||||
"type": "string"
|
||||
},
|
||||
|
|
@ -10702,8 +10918,7 @@ const docTemplate = `{
|
|||
"handlers.createQuestionReq": {
|
||||
"type": "object",
|
||||
"required": [
|
||||
"question_text",
|
||||
"question_type"
|
||||
"question_text"
|
||||
],
|
||||
"properties": {
|
||||
"audio_correct_answer_text": {
|
||||
|
|
@ -10731,13 +10946,10 @@ const docTemplate = `{
|
|||
"type": "string"
|
||||
},
|
||||
"question_type": {
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"MCQ",
|
||||
"TRUE_FALSE",
|
||||
"SHORT_ANSWER",
|
||||
"AUDIO"
|
||||
]
|
||||
"type": "string"
|
||||
},
|
||||
"question_type_definition_id": {
|
||||
"type": "integer"
|
||||
},
|
||||
"sample_answer_voice_prompt": {
|
||||
"type": "string"
|
||||
|
|
@ -10812,6 +11024,39 @@ const docTemplate = `{
|
|||
}
|
||||
}
|
||||
},
|
||||
"handlers.createQuestionTypeDefinitionReq": {
|
||||
"type": "object",
|
||||
"required": [
|
||||
"display_name",
|
||||
"key"
|
||||
],
|
||||
"properties": {
|
||||
"description": {
|
||||
"type": "string"
|
||||
},
|
||||
"display_name": {
|
||||
"type": "string"
|
||||
},
|
||||
"key": {
|
||||
"type": "string"
|
||||
},
|
||||
"response_component_kinds": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"status": {
|
||||
"type": "string"
|
||||
},
|
||||
"stimulus_component_kinds": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"handlers.initiateDirectPaymentReq": {
|
||||
"type": "object",
|
||||
"required": [
|
||||
|
|
@ -11185,6 +11430,9 @@ const docTemplate = `{
|
|||
"question_type": {
|
||||
"type": "string"
|
||||
},
|
||||
"question_type_definition_id": {
|
||||
"type": "integer"
|
||||
},
|
||||
"sample_answer_voice_prompt": {
|
||||
"type": "string"
|
||||
},
|
||||
|
|
@ -11237,6 +11485,32 @@ const docTemplate = `{
|
|||
}
|
||||
}
|
||||
},
|
||||
"handlers.updateQuestionTypeDefinitionReq": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"description": {
|
||||
"type": "string"
|
||||
},
|
||||
"display_name": {
|
||||
"type": "string"
|
||||
},
|
||||
"response_component_kinds": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"status": {
|
||||
"type": "string"
|
||||
},
|
||||
"stimulus_component_kinds": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"handlers.validateQuestionTypeDefinitionReq": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
|
|
|
|||
|
|
@ -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": {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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"`
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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`"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
|
|
@ -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": "POST",
|
||||
"header": [
|
||||
{
|
||||
"key": "Content-Type",
|
||||
"value": "application/json",
|
||||
"type": "text"
|
||||
}
|
||||
],
|
||||
"body": {
|
||||
"mode": "raw",
|
||||
"raw": "{\n \"question_text\": \"Should fail because AUDIO_CLIP is not allowed by selected definition\",\n \"question_type\": \"DYNAMIC\",\n \"question_type_definition_id\": {{dynamic_definition_id}},\n \"dynamic_payload\": {\n \"stimulus\": [\n {\n \"id\": \"audio1\",\n \"kind\": \"AUDIO_CLIP\",\n \"value\": \"https://cdn.example.com/audio/not-allowed.mp3\"\n }\n ],\n \"response\": [\n {\n \"id\": \"choices\",\n \"kind\": \"OPTION\",\n \"value\": {\n \"options\": [\n {\"id\": \"a\", \"text\": \"A\", \"is_correct\": true},\n {\"id\": \"b\", \"text\": \"B\", \"is_correct\": false}\n ]\n }\n }\n ]\n }\n}"
|
||||
},
|
||||
"url": {
|
||||
"raw": "{{base_url}}/api/v1/questions",
|
||||
"host": [
|
||||
"{{base_url}}"
|
||||
],
|
||||
"path": [
|
||||
"api",
|
||||
"v1",
|
||||
"questions"
|
||||
]
|
||||
}
|
||||
},
|
||||
"response": []
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "05 - Cleanup",
|
||||
"item": [
|
||||
{
|
||||
"name": "Delete Dynamic Question",
|
||||
"request": {
|
||||
"method": "DELETE",
|
||||
"header": [],
|
||||
"url": {
|
||||
"raw": "{{baseUrl}}{{apiPrefix}}/questions/type-definitions/{{questionTypeDefinitionId}}",
|
||||
"raw": "{{base_url}}/api/v1/questions/{{dynamic_question_id}}",
|
||||
"host": [
|
||||
"{{baseUrl}}"
|
||||
"{{base_url}}"
|
||||
],
|
||||
"path": [
|
||||
"{{apiPrefix}}",
|
||||
"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",
|
||||
"{{questionTypeDefinitionId}}"
|
||||
"{{dynamic_definition_id}}"
|
||||
]
|
||||
},
|
||||
"description": "Deletes a custom definition. System definitions cannot be deleted."
|
||||
}
|
||||
},
|
||||
"response": []
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user