Compare commits

...

3 Commits

Author SHA1 Message Date
3d1b3ad9b8 dynamic question type builder completion 2026-05-08 10:12:02 -07:00
9a17f0b3c4 use descriptive top-level message for direct payments
Keep provider-specific details in data.message and return a stable, human-readable top-level success message for /payments/direct responses.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-07 09:26:07 -07:00
0983589e36 extend full-payload direct proxy flow to MPESA
Align MPESA direct payment with TELEBIRR_USSD by routing it through the provider's full checkout payload proxy endpoint for consistent gateway behavior.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-07 09:21:43 -07:00
22 changed files with 2275 additions and 1215 deletions

View File

@ -0,0 +1,13 @@
ALTER TABLE question_type_definitions
DROP COLUMN IF EXISTS stimulus_schema,
DROP COLUMN IF EXISTS response_schema;
ALTER TABLE questions
DROP COLUMN IF EXISTS dynamic_payload;
ALTER TABLE questions
DROP CONSTRAINT IF EXISTS questions_question_type_check;
ALTER TABLE questions
ADD CONSTRAINT questions_question_type_check
CHECK (question_type IN ('MCQ', 'TRUE_FALSE', 'SHORT_ANSWER', 'AUDIO'));

View File

@ -0,0 +1,13 @@
ALTER TABLE questions
DROP CONSTRAINT IF EXISTS questions_question_type_check;
ALTER TABLE questions
ADD CONSTRAINT questions_question_type_check
CHECK (question_type IN ('MCQ', 'TRUE_FALSE', 'SHORT_ANSWER', 'AUDIO', 'DYNAMIC'));
ALTER TABLE questions
ADD COLUMN IF NOT EXISTS dynamic_payload JSONB NULL;
ALTER TABLE question_type_definitions
ADD COLUMN IF NOT EXISTS stimulus_schema JSONB NOT NULL DEFAULT '[]'::jsonb,
ADD COLUMN IF NOT EXISTS response_schema JSONB NOT NULL DEFAULT '[]'::jsonb;

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -248,19 +248,21 @@ type Program struct {
}
type Question struct {
ID int64 `json:"id"`
QuestionText string `json:"question_text"`
QuestionType string `json:"question_type"`
DifficultyLevel pgtype.Text `json:"difficulty_level"`
Points int32 `json:"points"`
Explanation pgtype.Text `json:"explanation"`
Tips pgtype.Text `json:"tips"`
VoicePrompt pgtype.Text `json:"voice_prompt"`
SampleAnswerVoicePrompt pgtype.Text `json:"sample_answer_voice_prompt"`
Status string `json:"status"`
CreatedAt pgtype.Timestamptz `json:"created_at"`
UpdatedAt pgtype.Timestamptz `json:"updated_at"`
ImageUrl pgtype.Text `json:"image_url"`
ID int64 `json:"id"`
QuestionText string `json:"question_text"`
QuestionType string `json:"question_type"`
DifficultyLevel pgtype.Text `json:"difficulty_level"`
Points int32 `json:"points"`
Explanation pgtype.Text `json:"explanation"`
Tips pgtype.Text `json:"tips"`
VoicePrompt pgtype.Text `json:"voice_prompt"`
SampleAnswerVoicePrompt pgtype.Text `json:"sample_answer_voice_prompt"`
Status string `json:"status"`
CreatedAt pgtype.Timestamptz `json:"created_at"`
UpdatedAt pgtype.Timestamptz `json:"updated_at"`
ImageUrl pgtype.Text `json:"image_url"`
QuestionTypeDefinitionID pgtype.Int8 `json:"question_type_definition_id"`
DynamicPayload []byte `json:"dynamic_payload"`
}
type QuestionAudioAnswer struct {
@ -322,6 +324,21 @@ type QuestionShortAnswer struct {
CreatedAt pgtype.Timestamptz `json:"created_at"`
}
type QuestionTypeDefinition struct {
ID int64 `json:"id"`
Key string `json:"key"`
DisplayName string `json:"display_name"`
Description pgtype.Text `json:"description"`
StimulusComponentKinds []string `json:"stimulus_component_kinds"`
ResponseComponentKinds []string `json:"response_component_kinds"`
IsSystem bool `json:"is_system"`
Status string `json:"status"`
CreatedAt pgtype.Timestamptz `json:"created_at"`
UpdatedAt pgtype.Timestamptz `json:"updated_at"`
StimulusSchema []byte `json:"stimulus_schema"`
ResponseSchema []byte `json:"response_schema"`
}
type Rating struct {
ID int64 `json:"id"`
UserID int64 `json:"user_id"`

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -604,9 +604,9 @@ func (s *ArifpayService) InitiateDirectPayment(ctx context.Context, userID int64
directResp string
)
if req.PaymentMethod == domain.DirectPaymentTelebirrUSSD {
// TELEBIRR_USSD uses a direct proxy endpoint with full checkout payload.
sessionID, directResp, err = s.initiateTelebirrUSSDDirect(ctx, checkoutReq)
if req.PaymentMethod == domain.DirectPaymentTelebirrUSSD || req.PaymentMethod == domain.DirectPaymentMPesa {
// TELEBIRR_USSD and MPESA use direct proxy endpoints with full checkout payload.
sessionID, directResp, err = s.initiateFullPayloadDirectProxy(ctx, checkoutReq, req.PaymentMethod)
} else {
sessionID, err = s.createCheckoutSessionForDirect(ctx, checkoutReq)
if err == nil {
@ -728,13 +728,26 @@ func (s *ArifpayService) createCheckoutSessionForDirect(ctx context.Context, che
return fmt.Sprintf("%v", data["sessionId"]), nil
}
func (s *ArifpayService) initiateTelebirrUSSDDirect(ctx context.Context, checkoutReq domain.CheckoutSessionRequest) (string, string, error) {
func (s *ArifpayService) initiateFullPayloadDirectProxy(
ctx context.Context,
checkoutReq domain.CheckoutSessionRequest,
method domain.DirectPaymentMethod,
) (string, string, error) {
payload, err := json.Marshal(checkoutReq)
if err != nil {
return "", "", fmt.Errorf("failed to marshal telebirr ussd request: %w", err)
return "", "", fmt.Errorf("failed to marshal direct proxy request: %w", err)
}
var endpoint string
switch method {
case domain.DirectPaymentTelebirrUSSD:
endpoint = fmt.Sprintf("%s/api/checkout/telebirr-ussd/transfer/direct", s.cfg.ARIFPAY.BaseURL)
case domain.DirectPaymentMPesa:
endpoint = fmt.Sprintf("%s/api/checkout/mpesa/transfer/direct", s.cfg.ARIFPAY.BaseURL)
default:
return "", "", fmt.Errorf("unsupported full-payload direct proxy method: %s", method)
}
endpoint := fmt.Sprintf("%s/api/checkout/telebirr-ussd/transfer/direct", s.cfg.ARIFPAY.BaseURL)
httpReq, err := http.NewRequestWithContext(ctx, http.MethodPost, endpoint, bytes.NewBuffer(payload))
if err != nil {
return "", "", err
@ -744,7 +757,7 @@ func (s *ArifpayService) initiateTelebirrUSSDDirect(ctx context.Context, checkou
resp, err := s.httpClient.Do(httpReq)
if err != nil {
return "", "", fmt.Errorf("failed to call telebirr ussd direct API: %w", err)
return "", "", fmt.Errorf("failed to call direct proxy API: %w", err)
}
defer resp.Body.Close()
@ -753,7 +766,7 @@ func (s *ArifpayService) initiateTelebirrUSSDDirect(ctx context.Context, checkou
return "", "", err
}
if resp.StatusCode != http.StatusOK {
return "", "", fmt.Errorf("telebirr ussd direct failed: %s", string(body))
return "", "", fmt.Errorf("direct proxy request failed: %s", string(body))
}
var result struct {
@ -764,7 +777,7 @@ func (s *ArifpayService) initiateTelebirrUSSDDirect(ctx context.Context, checkou
} `json:"data"`
}
if err := json.Unmarshal(body, &result); err != nil {
return "", "", fmt.Errorf("invalid telebirr ussd response: %w", err)
return "", "", fmt.Errorf("invalid direct proxy response: %w", err)
}
sessionID := fmt.Sprintf("%v", result.Data.SessionID)

View File

@ -350,7 +350,7 @@ func (h *Handler) InitiateDirectPayment(c *fiber.Ctx) error {
}
return c.JSON(domain.Response{
Message: result.Message,
Message: "Direct payment initiated successfully",
Data: result,
})
}

View File

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

View File

@ -25,20 +25,21 @@ type shortAnswerInput struct {
}
type createQuestionReq struct {
QuestionText string `json:"question_text" validate:"required"`
QuestionType string `json:"question_type"`
QuestionTypeDefinitionID *int64 `json:"question_type_definition_id"`
DifficultyLevel *string `json:"difficulty_level"`
Points *int32 `json:"points"`
Explanation *string `json:"explanation"`
Tips *string `json:"tips"`
VoicePrompt *string `json:"voice_prompt"`
SampleAnswerVoicePrompt *string `json:"sample_answer_voice_prompt"`
ImageURL *string `json:"image_url"`
Status *string `json:"status"`
Options []optionInput `json:"options"`
ShortAnswers []shortAnswerInput `json:"short_answers"`
AudioCorrectAnswerText *string `json:"audio_correct_answer_text"`
QuestionText string `json:"question_text" validate:"required"`
QuestionType string `json:"question_type"`
QuestionTypeDefinitionID *int64 `json:"question_type_definition_id"`
DynamicPayload *domain.DynamicQuestionPayload `json:"dynamic_payload"`
DifficultyLevel *string `json:"difficulty_level"`
Points *int32 `json:"points"`
Explanation *string `json:"explanation"`
Tips *string `json:"tips"`
VoicePrompt *string `json:"voice_prompt"`
SampleAnswerVoicePrompt *string `json:"sample_answer_voice_prompt"`
ImageURL *string `json:"image_url"`
Status *string `json:"status"`
Options []optionInput `json:"options"`
ShortAnswers []shortAnswerInput `json:"short_answers"`
AudioCorrectAnswerText *string `json:"audio_correct_answer_text"`
}
type optionRes struct {
@ -55,21 +56,23 @@ type shortAnswerRes struct {
}
type questionRes struct {
ID int64 `json:"id"`
QuestionText string `json:"question_text"`
QuestionType string `json:"question_type"`
DifficultyLevel *string `json:"difficulty_level,omitempty"`
Points int32 `json:"points"`
Explanation *string `json:"explanation,omitempty"`
Tips *string `json:"tips,omitempty"`
VoicePrompt *string `json:"voice_prompt,omitempty"`
SampleAnswerVoicePrompt *string `json:"sample_answer_voice_prompt,omitempty"`
ImageURL *string `json:"image_url,omitempty"`
Status string `json:"status"`
CreatedAt string `json:"created_at"`
Options []optionRes `json:"options,omitempty"`
ShortAnswers []shortAnswerRes `json:"short_answers,omitempty"`
AudioCorrectAnswerText *string `json:"audio_correct_answer_text,omitempty"`
ID int64 `json:"id"`
QuestionText string `json:"question_text"`
QuestionType string `json:"question_type"`
QuestionTypeDefinitionID *int64 `json:"question_type_definition_id,omitempty"`
DynamicPayload *domain.DynamicQuestionPayload `json:"dynamic_payload,omitempty"`
DifficultyLevel *string `json:"difficulty_level,omitempty"`
Points int32 `json:"points"`
Explanation *string `json:"explanation,omitempty"`
Tips *string `json:"tips,omitempty"`
VoicePrompt *string `json:"voice_prompt,omitempty"`
SampleAnswerVoicePrompt *string `json:"sample_answer_voice_prompt,omitempty"`
ImageURL *string `json:"image_url,omitempty"`
Status string `json:"status"`
CreatedAt string `json:"created_at"`
Options []optionRes `json:"options,omitempty"`
ShortAnswers []shortAnswerRes `json:"short_answers,omitempty"`
AudioCorrectAnswerText *string `json:"audio_correct_answer_text,omitempty"`
}
type listQuestionsRes struct {
@ -78,22 +81,7 @@ type listQuestionsRes struct {
}
func resolveQuestionTypeFromDefinition(def domain.QuestionTypeDefinition) string {
key := strings.ToLower(strings.TrimSpace(def.Key))
if key == "true_false" || key == "true-false" {
return "TRUE_FALSE"
}
for _, kind := range def.ResponseComponentKinds {
switch strings.TrimSpace(kind) {
case "AUDIO_RESPONSE":
return "AUDIO"
case "MULTIPLE_CHOICE":
return "MCQ"
case "SHORT_ANSWER", "TEXT_INPUT", "SELECT_MISSING_WORDS", "MATCHING_ANSWER", "LABEL_SELECTION":
return "SHORT_ANSWER"
}
}
return ""
return domain.ResolveRuntimeQuestionTypeFromDefinition(def.Key, def.ResponseComponentKinds)
}
func normalizeRuntimeQuestionType(v string) string {
@ -102,7 +90,7 @@ func normalizeRuntimeQuestionType(v string) string {
// CreateQuestion godoc
// @Summary Create a new question
// @Description Creates a new question with options (for MCQ/TRUE_FALSE) or short answers (for SHORT_ANSWER)
// @Description Creates a new question with options (for MCQ/TRUE_FALSE) or short answers (for SHORT_ANSWER). Supports question_type_definition_id for dynamic builder-linked questions.
// @Tags questions
// @Accept json
// @Produce json
@ -129,22 +117,33 @@ func (h *Handler) CreateQuestion(c *fiber.Ctx) error {
Error: err.Error(),
})
}
inferred := resolveQuestionTypeFromDefinition(def)
if inferred == "" {
return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{
Message: "Unsupported dynamic question type definition",
Error: "unable to map definition to runtime question_type",
})
}
if questionType == "" {
questionType = inferred
questionType = "DYNAMIC"
}
if questionType != inferred {
if questionType != "DYNAMIC" {
return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{
Message: "Mismatched question_type and definition",
Error: "question_type must match the selected question_type_definition_id",
Message: "Invalid question_type for dynamic definition",
Error: "question_type must be DYNAMIC when question_type_definition_id is provided",
})
}
if req.DynamicPayload == nil {
return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{
Message: "Missing dynamic_payload",
Error: "dynamic_payload is required when question_type_definition_id is provided",
})
}
if err := domain.ValidateDynamicPayloadAgainstDefinition(*req.DynamicPayload, def); err != nil {
return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{
Message: "Invalid dynamic_payload",
Error: err.Error(),
})
}
}
if req.QuestionTypeDefinitionID == nil && req.DynamicPayload != nil {
return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{
Message: "Invalid dynamic_payload usage",
Error: "dynamic_payload requires question_type_definition_id",
})
}
if questionType == "" {
return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{
@ -153,11 +152,17 @@ func (h *Handler) CreateQuestion(c *fiber.Ctx) error {
})
}
switch questionType {
case "MCQ", "TRUE_FALSE", "SHORT_ANSWER", "AUDIO":
case "MCQ", "TRUE_FALSE", "SHORT_ANSWER", "AUDIO", "DYNAMIC":
default:
return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{
Message: "Invalid question_type",
Error: "question_type must be one of MCQ, TRUE_FALSE, SHORT_ANSWER, AUDIO",
Error: "question_type must be one of MCQ, TRUE_FALSE, SHORT_ANSWER, AUDIO, DYNAMIC",
})
}
if questionType == "DYNAMIC" && req.QuestionTypeDefinitionID == nil {
return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{
Message: "Invalid dynamic question",
Error: "question_type_definition_id is required for DYNAMIC question_type",
})
}
@ -184,6 +189,7 @@ func (h *Handler) CreateQuestion(c *fiber.Ctx) error {
QuestionText: req.QuestionText,
QuestionType: questionType,
QuestionTypeDefinitionID: req.QuestionTypeDefinitionID,
DynamicPayload: req.DynamicPayload,
DifficultyLevel: req.DifficultyLevel,
Points: req.Points,
Explanation: req.Explanation,
@ -218,18 +224,20 @@ func (h *Handler) CreateQuestion(c *fiber.Ctx) error {
return c.Status(fiber.StatusCreated).JSON(domain.Response{
Message: "Question created successfully",
Data: questionRes{
ID: question.ID,
QuestionText: question.QuestionText,
QuestionType: question.QuestionType,
DifficultyLevel: question.DifficultyLevel,
Points: question.Points,
Explanation: question.Explanation,
Tips: question.Tips,
VoicePrompt: question.VoicePrompt,
SampleAnswerVoicePrompt: question.SampleAnswerVoicePrompt,
ImageURL: question.ImageURL,
Status: question.Status,
CreatedAt: question.CreatedAt.String(),
ID: question.ID,
QuestionText: question.QuestionText,
QuestionType: question.QuestionType,
QuestionTypeDefinitionID: question.QuestionTypeDefinitionID,
DynamicPayload: question.DynamicPayload,
DifficultyLevel: question.DifficultyLevel,
Points: question.Points,
Explanation: question.Explanation,
Tips: question.Tips,
VoicePrompt: question.VoicePrompt,
SampleAnswerVoicePrompt: question.SampleAnswerVoicePrompt,
ImageURL: question.ImageURL,
Status: question.Status,
CreatedAt: question.CreatedAt.String(),
},
})
}
@ -289,21 +297,23 @@ func (h *Handler) GetQuestionByID(c *fiber.Ctx) error {
return c.JSON(domain.Response{
Message: "Question retrieved successfully",
Data: questionRes{
ID: question.ID,
QuestionText: question.QuestionText,
QuestionType: question.QuestionType,
DifficultyLevel: question.DifficultyLevel,
Points: question.Points,
Explanation: question.Explanation,
Tips: question.Tips,
VoicePrompt: question.VoicePrompt,
SampleAnswerVoicePrompt: question.SampleAnswerVoicePrompt,
ImageURL: question.ImageURL,
Status: question.Status,
CreatedAt: question.CreatedAt.String(),
Options: options,
ShortAnswers: shortAnswers,
AudioCorrectAnswerText: audioCorrectAnswerText,
ID: question.ID,
QuestionText: question.QuestionText,
QuestionType: question.QuestionType,
QuestionTypeDefinitionID: question.QuestionTypeDefinitionID,
DynamicPayload: question.DynamicPayload,
DifficultyLevel: question.DifficultyLevel,
Points: question.Points,
Explanation: question.Explanation,
Tips: question.Tips,
VoicePrompt: question.VoicePrompt,
SampleAnswerVoicePrompt: question.SampleAnswerVoicePrompt,
ImageURL: question.ImageURL,
Status: question.Status,
CreatedAt: question.CreatedAt.String(),
Options: options,
ShortAnswers: shortAnswers,
AudioCorrectAnswerText: audioCorrectAnswerText,
},
})
}
@ -354,16 +364,18 @@ func (h *Handler) ListQuestions(c *fiber.Ctx) error {
var questionResponses []questionRes
for _, q := range questions {
questionResponses = append(questionResponses, questionRes{
ID: q.ID,
QuestionText: q.QuestionText,
QuestionType: q.QuestionType,
DifficultyLevel: q.DifficultyLevel,
Points: q.Points,
Explanation: q.Explanation,
Tips: q.Tips,
VoicePrompt: q.VoicePrompt,
Status: q.Status,
CreatedAt: q.CreatedAt.String(),
ID: q.ID,
QuestionText: q.QuestionText,
QuestionType: q.QuestionType,
QuestionTypeDefinitionID: q.QuestionTypeDefinitionID,
DynamicPayload: q.DynamicPayload,
DifficultyLevel: q.DifficultyLevel,
Points: q.Points,
Explanation: q.Explanation,
Tips: q.Tips,
VoicePrompt: q.VoicePrompt,
Status: q.Status,
CreatedAt: q.CreatedAt.String(),
})
}
@ -412,13 +424,15 @@ func (h *Handler) SearchQuestions(c *fiber.Ctx) error {
var questionResponses []questionRes
for _, q := range questions {
questionResponses = append(questionResponses, questionRes{
ID: q.ID,
QuestionText: q.QuestionText,
QuestionType: q.QuestionType,
DifficultyLevel: q.DifficultyLevel,
Points: q.Points,
Status: q.Status,
CreatedAt: q.CreatedAt.String(),
ID: q.ID,
QuestionText: q.QuestionText,
QuestionType: q.QuestionType,
QuestionTypeDefinitionID: q.QuestionTypeDefinitionID,
DynamicPayload: q.DynamicPayload,
DifficultyLevel: q.DifficultyLevel,
Points: q.Points,
Status: q.Status,
CreatedAt: q.CreatedAt.String(),
})
}
@ -432,25 +446,26 @@ func (h *Handler) SearchQuestions(c *fiber.Ctx) error {
}
type updateQuestionReq struct {
QuestionText *string `json:"question_text"`
QuestionType *string `json:"question_type"`
QuestionTypeDefinitionID *int64 `json:"question_type_definition_id"`
DifficultyLevel *string `json:"difficulty_level"`
Points *int32 `json:"points"`
Explanation *string `json:"explanation"`
Tips *string `json:"tips"`
VoicePrompt *string `json:"voice_prompt"`
SampleAnswerVoicePrompt *string `json:"sample_answer_voice_prompt"`
ImageURL *string `json:"image_url"`
Status *string `json:"status"`
Options []optionInput `json:"options"`
ShortAnswers []shortAnswerInput `json:"short_answers"`
AudioCorrectAnswerText *string `json:"audio_correct_answer_text"`
QuestionText *string `json:"question_text"`
QuestionType *string `json:"question_type"`
QuestionTypeDefinitionID *int64 `json:"question_type_definition_id"`
DynamicPayload *domain.DynamicQuestionPayload `json:"dynamic_payload"`
DifficultyLevel *string `json:"difficulty_level"`
Points *int32 `json:"points"`
Explanation *string `json:"explanation"`
Tips *string `json:"tips"`
VoicePrompt *string `json:"voice_prompt"`
SampleAnswerVoicePrompt *string `json:"sample_answer_voice_prompt"`
ImageURL *string `json:"image_url"`
Status *string `json:"status"`
Options []optionInput `json:"options"`
ShortAnswers []shortAnswerInput `json:"short_answers"`
AudioCorrectAnswerText *string `json:"audio_correct_answer_text"`
}
// UpdateQuestion godoc
// @Summary Update a question
// @Description Updates a question and optionally replaces its options/short answers
// @Description Updates a question and optionally replaces its options/short answers. Supports question_type_definition_id for dynamic builder-linked questions.
// @Tags questions
// @Accept json
// @Produce json
@ -478,6 +493,14 @@ func (h *Handler) UpdateQuestion(c *fiber.Ctx) error {
})
}
existingQuestion, err := h.questionsSvc.GetQuestionByID(c.Context(), id)
if err != nil {
return c.Status(fiber.StatusNotFound).JSON(domain.ErrorResponse{
Message: "Question not found",
Error: err.Error(),
})
}
var options []domain.CreateQuestionOptionInput
for _, opt := range req.Options {
options = append(options, domain.CreateQuestionOptionInput{
@ -495,44 +518,80 @@ func (h *Handler) UpdateQuestion(c *fiber.Ctx) error {
})
}
questionText := ""
questionText := existingQuestion.QuestionText
if req.QuestionText != nil {
questionText = *req.QuestionText
}
questionType := ""
questionType := normalizeRuntimeQuestionType(existingQuestion.QuestionType)
if req.QuestionType != nil {
questionType = normalizeRuntimeQuestionType(*req.QuestionType)
}
effectiveDefinitionID := existingQuestion.QuestionTypeDefinitionID
if req.QuestionTypeDefinitionID != nil {
def, err := h.questionsSvc.GetQuestionTypeDefinitionByID(c.Context(), *req.QuestionTypeDefinitionID)
effectiveDefinitionID = req.QuestionTypeDefinitionID
}
effectiveDynamicPayload := existingQuestion.DynamicPayload
if req.DynamicPayload != nil {
effectiveDynamicPayload = req.DynamicPayload
}
if effectiveDefinitionID != nil {
def, err := h.questionsSvc.GetQuestionTypeDefinitionByID(c.Context(), *effectiveDefinitionID)
if err != nil {
return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{
Message: "Invalid question_type_definition_id",
Error: err.Error(),
})
}
inferred := resolveQuestionTypeFromDefinition(def)
if inferred == "" {
return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{
Message: "Unsupported dynamic question type definition",
Error: "unable to map definition to runtime question_type",
})
}
if questionType == "" {
questionType = inferred
questionType = "DYNAMIC"
}
if questionType != inferred {
if questionType != "DYNAMIC" {
return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{
Message: "Mismatched question_type and definition",
Error: "question_type must match the selected question_type_definition_id",
Message: "Invalid question_type for dynamic definition",
Error: "question_type must be DYNAMIC when question_type_definition_id is provided",
})
}
if effectiveDynamicPayload == nil {
return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{
Message: "Missing dynamic_payload",
Error: "dynamic_payload is required for dynamic questions",
})
}
if err := domain.ValidateDynamicPayloadAgainstDefinition(*effectiveDynamicPayload, def); err != nil {
return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{
Message: "Invalid dynamic_payload",
Error: err.Error(),
})
}
}
if effectiveDefinitionID == nil && req.DynamicPayload != nil {
return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{
Message: "Invalid dynamic_payload usage",
Error: "dynamic_payload requires question_type_definition_id",
})
}
switch questionType {
case "MCQ", "TRUE_FALSE", "SHORT_ANSWER", "AUDIO", "DYNAMIC":
default:
return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{
Message: "Invalid question_type",
Error: "question_type must be one of MCQ, TRUE_FALSE, SHORT_ANSWER, AUDIO, DYNAMIC",
})
}
if questionType == "DYNAMIC" && effectiveDefinitionID == nil {
return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{
Message: "Invalid dynamic question",
Error: "question_type_definition_id is required for DYNAMIC question_type",
})
}
input := domain.CreateQuestionInput{
QuestionText: questionText,
QuestionType: questionType,
QuestionTypeDefinitionID: req.QuestionTypeDefinitionID,
QuestionTypeDefinitionID: effectiveDefinitionID,
DynamicPayload: effectiveDynamicPayload,
DifficultyLevel: req.DifficultyLevel,
Points: req.Points,
Explanation: req.Explanation,
@ -1152,21 +1211,22 @@ type addQuestionToSetReq struct {
}
type questionSetItemRes struct {
ID int64 `json:"id"`
SetID int64 `json:"set_id"`
QuestionID int64 `json:"question_id"`
DisplayOrder int32 `json:"display_order"`
QuestionText string `json:"question_text"`
QuestionType string `json:"question_type"`
DifficultyLevel *string `json:"difficulty_level,omitempty"`
Points int32 `json:"points"`
Explanation *string `json:"explanation,omitempty"`
Tips *string `json:"tips,omitempty"`
VoicePrompt *string `json:"voice_prompt,omitempty"`
SampleAnswerVoicePrompt *string `json:"sample_answer_voice_prompt,omitempty"`
ImageURL *string `json:"image_url,omitempty"`
AudioCorrectAnswerText *string `json:"audio_correct_answer_text,omitempty"`
QuestionStatus string `json:"question_status"`
ID int64 `json:"id"`
SetID int64 `json:"set_id"`
QuestionID int64 `json:"question_id"`
DisplayOrder int32 `json:"display_order"`
QuestionText string `json:"question_text"`
QuestionType string `json:"question_type"`
DynamicPayload *domain.DynamicQuestionPayload `json:"dynamic_payload,omitempty"`
DifficultyLevel *string `json:"difficulty_level,omitempty"`
Points int32 `json:"points"`
Explanation *string `json:"explanation,omitempty"`
Tips *string `json:"tips,omitempty"`
VoicePrompt *string `json:"voice_prompt,omitempty"`
SampleAnswerVoicePrompt *string `json:"sample_answer_voice_prompt,omitempty"`
ImageURL *string `json:"image_url,omitempty"`
AudioCorrectAnswerText *string `json:"audio_correct_answer_text,omitempty"`
QuestionStatus string `json:"question_status"`
}
type paginatedQuestionSetItemsRes struct {
@ -1186,6 +1246,7 @@ func questionSetItemsToRes(items []domain.QuestionSetItemWithQuestion) []questio
DisplayOrder: item.DisplayOrder,
QuestionText: item.QuestionText,
QuestionType: item.QuestionType,
DynamicPayload: item.DynamicPayload,
DifficultyLevel: item.DifficultyLevel,
Points: item.Points,
Explanation: item.Explanation,
@ -1331,21 +1392,23 @@ func (h *Handler) GetQuestionsInSet(c *fiber.Ctx) error {
}
questionResponses = append(questionResponses, questionRes{
ID: question.ID,
QuestionText: question.QuestionText,
QuestionType: question.QuestionType,
DifficultyLevel: question.DifficultyLevel,
Points: question.Points,
Explanation: question.Explanation,
Tips: question.Tips,
VoicePrompt: question.VoicePrompt,
SampleAnswerVoicePrompt: question.SampleAnswerVoicePrompt,
ImageURL: question.ImageURL,
Status: question.Status,
CreatedAt: question.CreatedAt.String(),
Options: options,
ShortAnswers: shortAnswers,
AudioCorrectAnswerText: audioCorrectAnswerText,
ID: question.ID,
QuestionText: question.QuestionText,
QuestionType: question.QuestionType,
QuestionTypeDefinitionID: question.QuestionTypeDefinitionID,
DynamicPayload: question.DynamicPayload,
DifficultyLevel: question.DifficultyLevel,
Points: question.Points,
Explanation: question.Explanation,
Tips: question.Tips,
VoicePrompt: question.VoicePrompt,
SampleAnswerVoicePrompt: question.SampleAnswerVoicePrompt,
ImageURL: question.ImageURL,
Status: question.Status,
CreatedAt: question.CreatedAt.String(),
Options: options,
ShortAnswers: shortAnswers,
AudioCorrectAnswerText: audioCorrectAnswerText,
})
}

View File

@ -1,457 +0,0 @@
{
"info": {
"_postman_id": "f7c2e4a1-8b3d-4e9f-a2c6-11dd99ee5501",
"name": "Yimaru Exam Prep (Duolingo)",
"description": "Exam-prep tree API (`/api/v1/exam-prep/...`): catalog courses → units → modules → lessons → practices. Requires Bearer token.\n\n**Courses** = `catalog-courses` in the backend. Set collection variables before chaining requests.",
"schema": "https://schema.getpostman.com/json/collection/v2.1.0/collection.json"
},
"auth": {
"type": "bearer",
"bearer": [
{
"key": "token",
"value": "{{accessToken}}",
"type": "string"
}
]
},
"variable": [
{
"key": "baseUrl",
"value": "http://localhost:8080"
},
{
"key": "accessToken",
"value": ""
},
{
"key": "catalogCourseId",
"value": "1"
},
{
"key": "unitId",
"value": "1"
},
{
"key": "moduleId",
"value": "1"
},
{
"key": "lessonId",
"value": "1"
},
{
"key": "practiceId",
"value": "1"
}
],
"item": [
{
"name": "Duolingo",
"item": [
{
"name": "Courses",
"description": "Backend route group: **`catalog-courses`** (`exam_prep.catalog_courses.*`)",
"item": [
{
"name": "Create catalog course",
"request": {
"method": "POST",
"header": [
{
"key": "Content-Type",
"value": "application/json"
}
],
"body": {
"mode": "raw",
"raw": "{\n \"name\": \"IELTS Prep\",\n \"description\": \"Optional description\",\n \"thumbnail\": null\n}"
},
"url": "{{baseUrl}}/api/v1/exam-prep/catalog-courses",
"description": "Permission: `exam_prep.catalog_courses.create`"
}
},
{
"name": "List catalog courses",
"request": {
"method": "GET",
"url": "{{baseUrl}}/api/v1/exam-prep/catalog-courses?limit=20&offset=0",
"description": "Permission: `exam_prep.catalog_courses.list`"
}
},
{
"name": "Reorder catalog courses",
"request": {
"method": "PUT",
"header": [
{
"key": "Content-Type",
"value": "application/json"
}
],
"body": {
"mode": "raw",
"raw": "{\n \"ordered_ids\": [1, 2, 3]\n}"
},
"url": "{{baseUrl}}/api/v1/exam-prep/catalog-courses/reorder",
"description": "Permission: `exam_prep.catalog_courses.reorder`. Must include every id in scope exactly once."
}
},
{
"name": "Get catalog course by ID",
"request": {
"method": "GET",
"url": "{{baseUrl}}/api/v1/exam-prep/catalog-courses/{{catalogCourseId}}",
"description": "Permission: `exam_prep.catalog_courses.get`"
}
},
{
"name": "Update catalog course",
"request": {
"method": "PUT",
"header": [
{
"key": "Content-Type",
"value": "application/json"
}
],
"body": {
"mode": "raw",
"raw": "{\n \"name\": \"Updated name\",\n \"description\": null,\n \"thumbnail\": null,\n \"sort_order\": 1\n}"
},
"url": "{{baseUrl}}/api/v1/exam-prep/catalog-courses/{{catalogCourseId}}",
"description": "Permission: `exam_prep.catalog_courses.update`"
}
},
{
"name": "Delete catalog course",
"request": {
"method": "DELETE",
"url": "{{baseUrl}}/api/v1/exam-prep/catalog-courses/{{catalogCourseId}}",
"description": "Permission: `exam_prep.catalog_courses.delete`"
}
}
]
},
{
"name": "Units",
"description": "Nested under catalog course (`exam_prep.units.*`)",
"item": [
{
"name": "Create unit",
"request": {
"method": "POST",
"header": [
{
"key": "Content-Type",
"value": "application/json"
}
],
"body": {
"mode": "raw",
"raw": "{\n \"name\": \"Grammar foundations\",\n \"description\": null,\n \"thumbnail\": null\n}"
},
"url": "{{baseUrl}}/api/v1/exam-prep/catalog-courses/{{catalogCourseId}}/units",
"description": "Permission: `exam_prep.units.create`"
}
},
{
"name": "List units by catalog course",
"request": {
"method": "GET",
"url": "{{baseUrl}}/api/v1/exam-prep/catalog-courses/{{catalogCourseId}}/units?limit=20&offset=0",
"description": "Permission: `exam_prep.units.list`"
}
},
{
"name": "Reorder units in catalog course",
"request": {
"method": "PUT",
"header": [
{
"key": "Content-Type",
"value": "application/json"
}
],
"body": {
"mode": "raw",
"raw": "{\n \"ordered_ids\": [1, 2, 3]\n}"
},
"url": "{{baseUrl}}/api/v1/exam-prep/catalog-courses/{{catalogCourseId}}/units/reorder",
"description": "Permission: `exam_prep.units.reorder`"
}
},
{
"name": "Get unit by ID",
"request": {
"method": "GET",
"url": "{{baseUrl}}/api/v1/exam-prep/units/{{unitId}}",
"description": "Permission: `exam_prep.units.get`"
}
},
{
"name": "Update unit",
"request": {
"method": "PUT",
"header": [
{
"key": "Content-Type",
"value": "application/json"
}
],
"body": {
"mode": "raw",
"raw": "{\n \"name\": \"Updated unit\",\n \"description\": null,\n \"thumbnail\": null,\n \"sort_order\": 1\n}"
},
"url": "{{baseUrl}}/api/v1/exam-prep/units/{{unitId}}",
"description": "Permission: `exam_prep.units.update`"
}
},
{
"name": "Delete unit",
"request": {
"method": "DELETE",
"url": "{{baseUrl}}/api/v1/exam-prep/units/{{unitId}}",
"description": "Permission: `exam_prep.units.delete`"
}
}
]
},
{
"name": "Modules",
"description": "Exam-prep **`unit_modules`** (`exam_prep.modules.*`)",
"item": [
{
"name": "Create module",
"request": {
"method": "POST",
"header": [
{
"key": "Content-Type",
"value": "application/json"
}
],
"body": {
"mode": "raw",
"raw": "{\n \"name\": \"Present tense\",\n \"description\": null,\n \"thumbnail\": null,\n \"icon\": null\n}"
},
"url": "{{baseUrl}}/api/v1/exam-prep/units/{{unitId}}/modules",
"description": "Permission: `exam_prep.modules.create`"
}
},
{
"name": "List modules by unit",
"request": {
"method": "GET",
"url": "{{baseUrl}}/api/v1/exam-prep/units/{{unitId}}/modules?limit=20&offset=0",
"description": "Permission: `exam_prep.modules.list`"
}
},
{
"name": "Reorder modules in unit",
"request": {
"method": "PUT",
"header": [
{
"key": "Content-Type",
"value": "application/json"
}
],
"body": {
"mode": "raw",
"raw": "{\n \"ordered_ids\": [1, 2, 3]\n}"
},
"url": "{{baseUrl}}/api/v1/exam-prep/units/{{unitId}}/modules/reorder",
"description": "Permission: `exam_prep.modules.reorder`"
}
},
{
"name": "Get module by ID",
"request": {
"method": "GET",
"url": "{{baseUrl}}/api/v1/exam-prep/modules/{{moduleId}}",
"description": "Permission: `exam_prep.modules.get`"
}
},
{
"name": "Update module",
"request": {
"method": "PUT",
"header": [
{
"key": "Content-Type",
"value": "application/json"
}
],
"body": {
"mode": "raw",
"raw": "{\n \"name\": \"Updated module\",\n \"description\": null,\n \"thumbnail\": null,\n \"icon\": null,\n \"sort_order\": 1\n}"
},
"url": "{{baseUrl}}/api/v1/exam-prep/modules/{{moduleId}}",
"description": "Permission: `exam_prep.modules.update`"
}
},
{
"name": "Delete module",
"request": {
"method": "DELETE",
"url": "{{baseUrl}}/api/v1/exam-prep/modules/{{moduleId}}",
"description": "Permission: `exam_prep.modules.delete`"
}
}
]
},
{
"name": "Lessons",
"description": "`exam_prep.lessons.*`",
"item": [
{
"name": "Create lesson",
"request": {
"method": "POST",
"header": [
{
"key": "Content-Type",
"value": "application/json"
}
],
"body": {
"mode": "raw",
"raw": "{\n \"title\": \"Intro video\",\n \"video_url\": \"https://example.com/video\",\n \"thumbnail\": null,\n \"description\": null\n}"
},
"url": "{{baseUrl}}/api/v1/exam-prep/modules/{{moduleId}}/lessons",
"description": "Permission: `exam_prep.lessons.create`"
}
},
{
"name": "List lessons by module",
"request": {
"method": "GET",
"url": "{{baseUrl}}/api/v1/exam-prep/modules/{{moduleId}}/lessons?limit=20&offset=0",
"description": "Permission: `exam_prep.lessons.list_by_module`"
}
},
{
"name": "Reorder lessons in module",
"request": {
"method": "PUT",
"header": [
{
"key": "Content-Type",
"value": "application/json"
}
],
"body": {
"mode": "raw",
"raw": "{\n \"ordered_ids\": [1, 2, 3]\n}"
},
"url": "{{baseUrl}}/api/v1/exam-prep/modules/{{moduleId}}/lessons/reorder",
"description": "Permission: `exam_prep.lessons.reorder`"
}
},
{
"name": "Get lesson by ID",
"request": {
"method": "GET",
"url": "{{baseUrl}}/api/v1/exam-prep/lessons/{{lessonId}}",
"description": "Permission: `exam_prep.lessons.get`"
}
},
{
"name": "Update lesson",
"request": {
"method": "PUT",
"header": [
{
"key": "Content-Type",
"value": "application/json"
}
],
"body": {
"mode": "raw",
"raw": "{\n \"title\": \"Updated lesson\",\n \"video_url\": null,\n \"thumbnail\": null,\n \"description\": null,\n \"sort_order\": 1\n}"
},
"url": "{{baseUrl}}/api/v1/exam-prep/lessons/{{lessonId}}",
"description": "Permission: `exam_prep.lessons.update`"
}
},
{
"name": "Delete lesson",
"request": {
"method": "DELETE",
"url": "{{baseUrl}}/api/v1/exam-prep/lessons/{{lessonId}}",
"description": "Permission: `exam_prep.lessons.delete`"
}
}
]
},
{
"name": "Practices",
"description": "Tied to lesson; **`question_set_id`** references shared `question_sets`. `exam_prep.practices.*`",
"item": [
{
"name": "Create practice",
"request": {
"method": "POST",
"header": [
{
"key": "Content-Type",
"value": "application/json"
}
],
"body": {
"mode": "raw",
"raw": "{\n \"title\": \"Drill: articles\",\n \"story_description\": null,\n \"story_image\": null,\n \"persona_id\": null,\n \"question_set_id\": 1,\n \"quick_tips\": null\n}"
},
"url": "{{baseUrl}}/api/v1/exam-prep/lessons/{{lessonId}}/practices",
"description": "Permission: `exam_prep.practices.create`"
}
},
{
"name": "List practices by lesson",
"request": {
"method": "GET",
"url": "{{baseUrl}}/api/v1/exam-prep/lessons/{{lessonId}}/practices?limit=20&offset=0",
"description": "Permission: `exam_prep.practices.list_by_lesson`"
}
},
{
"name": "Get practice by ID",
"request": {
"method": "GET",
"url": "{{baseUrl}}/api/v1/exam-prep/practices/{{practiceId}}",
"description": "Permission: `exam_prep.practices.get`"
}
},
{
"name": "Update practice",
"request": {
"method": "PUT",
"header": [
{
"key": "Content-Type",
"value": "application/json"
}
],
"body": {
"mode": "raw",
"raw": "{\n \"title\": \"Updated practice\",\n \"story_description\": null,\n \"story_image\": null,\n \"persona_id\": null,\n \"question_set_id\": 1,\n \"quick_tips\": null\n}"
},
"url": "{{baseUrl}}/api/v1/exam-prep/practices/{{practiceId}}",
"description": "Permission: `exam_prep.practices.update`. Omit fields you do not change."
}
},
{
"name": "Delete practice",
"request": {
"method": "DELETE",
"url": "{{baseUrl}}/api/v1/exam-prep/practices/{{practiceId}}",
"description": "Permission: `exam_prep.practices.delete`"
}
}
]
}
]
}
]
}

File diff suppressed because it is too large Load Diff

View File

@ -1,39 +0,0 @@
{
"id": "2e932640-c314-4f18-b273-3a55fcb9384e",
"name": "Yimaru Local Dynamic Builder",
"values": [
{
"key": "baseUrl",
"value": "http://localhost:8080",
"type": "default",
"enabled": true
},
{
"key": "apiPrefix",
"value": "/api/v1",
"type": "default",
"enabled": true
},
{
"key": "token",
"value": "",
"type": "secret",
"enabled": true
},
{
"key": "questionTypeDefinitionId",
"value": "",
"type": "default",
"enabled": true
},
{
"key": "questionId",
"value": "",
"type": "default",
"enabled": true
}
],
"_postman_variable_scope": "environment",
"_postman_exported_at": "2026-05-07T08:41:00.000Z",
"_postman_exported_using": "Cursor Agent"
}