partly implemented dynamic question builder + payment routes fix

This commit is contained in:
Yared Yemane 2026-05-07 08:10:21 -07:00
parent 73370633ce
commit f906862676
14 changed files with 1264 additions and 107 deletions

View File

@ -0,0 +1,3 @@
DROP INDEX IF EXISTS idx_question_type_definitions_status;
DROP TABLE IF EXISTS question_type_definitions;

View File

@ -0,0 +1,56 @@
CREATE TABLE IF NOT EXISTS question_type_definitions (
id BIGSERIAL PRIMARY KEY,
key VARCHAR(64) NOT NULL UNIQUE,
display_name VARCHAR(120) NOT NULL,
description TEXT,
stimulus_component_kinds TEXT[] NOT NULL DEFAULT '{}',
response_component_kinds TEXT[] NOT NULL DEFAULT '{}',
is_system BOOLEAN NOT NULL DEFAULT FALSE,
status VARCHAR(20) NOT NULL DEFAULT 'ACTIVE' CHECK (status IN ('ACTIVE', 'INACTIVE')),
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ
);
CREATE INDEX IF NOT EXISTS idx_question_type_definitions_status
ON question_type_definitions(status);
INSERT INTO question_type_definitions
(key, display_name, description, stimulus_component_kinds, response_component_kinds, is_system, status)
VALUES
(
'multiple_choice',
'Multiple Choice',
'Select one correct answer from a list of options.',
ARRAY['INSTRUCTION']::TEXT[],
ARRAY['MULTIPLE_CHOICE']::TEXT[],
TRUE,
'ACTIVE'
),
(
'true_false',
'True / False',
'Binary response question with true/false options.',
ARRAY['INSTRUCTION']::TEXT[],
ARRAY['MULTIPLE_CHOICE']::TEXT[],
TRUE,
'ACTIVE'
),
(
'fill_in_the_blank',
'Fill In The Blank',
'Learner fills missing words in a prompt or passage.',
ARRAY['TEXT_PASSAGE', 'SELECT_MISSING_WORDS']::TEXT[],
ARRAY['TEXT_INPUT', 'SELECT_MISSING_WORDS']::TEXT[],
TRUE,
'ACTIVE'
),
(
'short_answer',
'Short Answer',
'Learner provides a concise text answer.',
ARRAY['INSTRUCTION']::TEXT[],
ARRAY['SHORT_ANSWER']::TEXT[],
TRUE,
'ACTIVE'
)
ON CONFLICT (key) DO NOTHING;

View File

@ -0,0 +1,7 @@
DROP INDEX IF EXISTS idx_questions_question_type_definition_id;
ALTER TABLE questions
DROP CONSTRAINT IF EXISTS questions_question_type_definition_id_fkey;
ALTER TABLE questions
DROP COLUMN IF EXISTS question_type_definition_id;

View File

@ -0,0 +1,14 @@
ALTER TABLE questions
ADD COLUMN IF NOT EXISTS question_type_definition_id BIGINT NULL;
ALTER TABLE questions
DROP CONSTRAINT IF EXISTS questions_question_type_definition_id_fkey;
ALTER TABLE questions
ADD CONSTRAINT questions_question_type_definition_id_fkey
FOREIGN KEY (question_type_definition_id)
REFERENCES question_type_definitions(id)
ON DELETE SET NULL;
CREATE INDEX IF NOT EXISTS idx_questions_question_type_definition_id
ON questions(question_type_definition_id);

View File

@ -0,0 +1,34 @@
package domain
import "time"
type QuestionTypeDefinition struct {
ID int64
Key string
DisplayName string
Description *string
StimulusComponentKinds []string
ResponseComponentKinds []string
IsSystem bool
Status string
CreatedAt time.Time
UpdatedAt *time.Time
}
type CreateQuestionTypeDefinitionInput struct {
Key string
DisplayName string
Description *string
StimulusComponentKinds []string
ResponseComponentKinds []string
IsSystem bool
Status *string
}
type UpdateQuestionTypeDefinitionInput struct {
DisplayName *string
Description *string
StimulusComponentKinds []string
ResponseComponentKinds []string
Status *string
}

View File

@ -45,19 +45,20 @@ const (
)
type Question struct {
ID int64
QuestionText string
QuestionType string
DifficultyLevel *string
Points int32
Explanation *string
Tips *string
VoicePrompt *string
SampleAnswerVoicePrompt *string
ImageURL *string
Status string
CreatedAt time.Time
UpdatedAt *time.Time
ID int64
QuestionText string
QuestionType string
QuestionTypeDefinitionID *int64
DifficultyLevel *string
Points int32
Explanation *string
Tips *string
VoicePrompt *string
SampleAnswerVoicePrompt *string
ImageURL *string
Status string
CreatedAt time.Time
UpdatedAt *time.Time
}
type QuestionAudioAnswer struct {
@ -120,33 +121,34 @@ type QuestionSetItem struct {
type QuestionSetItemWithQuestion struct {
QuestionSetItem
QuestionText string
QuestionType string
DifficultyLevel *string
Points int32
Explanation *string
Tips *string
VoicePrompt *string
SampleAnswerVoicePrompt *string
ImageURL *string
AudioCorrectAnswerText *string
QuestionStatus string
}
type CreateQuestionInput struct {
QuestionText string
QuestionType string
DifficultyLevel *string
Points *int32
Points int32
Explanation *string
Tips *string
VoicePrompt *string
SampleAnswerVoicePrompt *string
ImageURL *string
Status *string
Options []CreateQuestionOptionInput
ShortAnswers []CreateShortAnswerInput
AudioCorrectAnswerText *string
QuestionStatus string
}
type CreateQuestionInput struct {
QuestionText string
QuestionType string
QuestionTypeDefinitionID *int64
DifficultyLevel *string
Points *int32
Explanation *string
Tips *string
VoicePrompt *string
SampleAnswerVoicePrompt *string
ImageURL *string
Status *string
Options []CreateQuestionOptionInput
ShortAnswers []CreateShortAnswerInput
AudioCorrectAnswerText *string
}
type CreateQuestionOptionInput struct {

View File

@ -6,6 +6,13 @@ import (
)
type QuestionStore interface {
// Question Type Definitions (dynamic builder presets)
CreateQuestionTypeDefinition(ctx context.Context, input domain.CreateQuestionTypeDefinitionInput) (domain.QuestionTypeDefinition, error)
GetQuestionTypeDefinitionByID(ctx context.Context, id int64) (domain.QuestionTypeDefinition, error)
ListQuestionTypeDefinitions(ctx context.Context, status *string, includeSystem bool) ([]domain.QuestionTypeDefinition, error)
UpdateQuestionTypeDefinition(ctx context.Context, id int64, input domain.UpdateQuestionTypeDefinitionInput) error
DeleteQuestionTypeDefinition(ctx context.Context, id int64) error
// Questions
CreateQuestion(ctx context.Context, input domain.CreateQuestionInput) (domain.Question, error)
GetQuestionByID(ctx context.Context, id int64) (domain.Question, error)

View File

@ -3,6 +3,7 @@ package repository
import (
"context"
"errors"
"strings"
"time"
dbgen "Yimaru-Backend/gen/db"
@ -79,6 +80,15 @@ func questionToDomain(q dbgen.Question) domain.Question {
}
}
func (s *Store) setQuestionTypeDefinitionID(ctx context.Context, questionID int64, definitionID *int64) error {
_, err := s.conn.Exec(ctx, `
UPDATE questions
SET question_type_definition_id = $2, updated_at = CURRENT_TIMESTAMP
WHERE id = $1
`, questionID, toPgInt8(definitionID))
return err
}
func questionOptionToDomain(o dbgen.QuestionOption) domain.QuestionOption {
return domain.QuestionOption{
ID: o.ID,
@ -139,6 +149,226 @@ func questionSetItemToDomain(i dbgen.QuestionSetItem) domain.QuestionSetItem {
}
}
func questionTypeDefinitionToDomain(
id int64,
key string,
displayName string,
description pgtype.Text,
stimulusKinds []string,
responseKinds []string,
isSystem bool,
status string,
createdAt time.Time,
updatedAt pgtype.Timestamptz,
) domain.QuestionTypeDefinition {
return domain.QuestionTypeDefinition{
ID: id,
Key: key,
DisplayName: displayName,
Description: fromPgText(description),
StimulusComponentKinds: stimulusKinds,
ResponseComponentKinds: responseKinds,
IsSystem: isSystem,
Status: status,
CreatedAt: createdAt,
UpdatedAt: timePtr(updatedAt),
}
}
func normalizeDefinitionStatus(status *string) string {
if status == nil || strings.TrimSpace(*status) == "" {
return "ACTIVE"
}
return strings.ToUpper(strings.TrimSpace(*status))
}
func normalizeDefinitionKinds(values []string) []string {
out := make([]string, 0, len(values))
for _, v := range values {
trimmed := strings.TrimSpace(v)
if trimmed == "" {
continue
}
out = append(out, trimmed)
}
return out
}
func normalizeDefinitionKey(key string) string {
key = strings.TrimSpace(strings.ToLower(key))
key = strings.ReplaceAll(key, " ", "_")
return key
}
func (s *Store) CreateQuestionTypeDefinition(ctx context.Context, input domain.CreateQuestionTypeDefinitionInput) (domain.QuestionTypeDefinition, error) {
stimulusKinds := normalizeDefinitionKinds(input.StimulusComponentKinds)
responseKinds := normalizeDefinitionKinds(input.ResponseComponentKinds)
if err := domain.ValidateDynamicQuestionTypeDefinition(stimulusKinds, 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
`,
normalizeDefinitionKey(input.Key),
strings.TrimSpace(input.DisplayName),
toPgText(input.Description),
stimulusKinds,
responseKinds,
input.IsSystem,
normalizeDefinitionStatus(input.Status),
)
var (
id int64
key string
displayName string
description pgtype.Text
stimulus []string
response []string
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 {
return domain.QuestionTypeDefinition{}, err
}
return questionTypeDefinitionToDomain(id, key, displayName, description, stimulus, response, 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
FROM question_type_definitions
WHERE id = $1
`, id)
var (
key string
displayName string
description pgtype.Text
stimulus []string
response []string
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 {
return domain.QuestionTypeDefinition{}, err
}
return questionTypeDefinitionToDomain(id, key, displayName, description, stimulus, response, 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
FROM question_type_definitions
WHERE ($1::VARCHAR IS NULL OR status = $1)
AND ($2::BOOLEAN = TRUE OR is_system = FALSE)
ORDER BY is_system DESC, display_name ASC
`, status, includeSystem)
if err != nil {
return nil, err
}
defer rows.Close()
var out []domain.QuestionTypeDefinition
for rows.Next() {
var (
id int64
key string
displayName string
description pgtype.Text
stimulus []string
response []string
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 {
return nil, err
}
out = append(out, questionTypeDefinitionToDomain(id, key, displayName, description, stimulus, response, isSystem, defStatus, createdAt, updatedAt))
}
return out, rows.Err()
}
func (s *Store) UpdateQuestionTypeDefinition(ctx context.Context, id int64, input domain.UpdateQuestionTypeDefinitionInput) error {
existing, err := s.GetQuestionTypeDefinitionByID(ctx, id)
if err != nil {
return err
}
displayName := existing.DisplayName
if input.DisplayName != nil && strings.TrimSpace(*input.DisplayName) != "" {
displayName = strings.TrimSpace(*input.DisplayName)
}
description := existing.Description
if input.Description != nil {
description = input.Description
}
stimulusKinds := existing.StimulusComponentKinds
if input.StimulusComponentKinds != nil {
stimulusKinds = normalizeDefinitionKinds(input.StimulusComponentKinds)
}
responseKinds := existing.ResponseComponentKinds
if input.ResponseComponentKinds != nil {
responseKinds = normalizeDefinitionKinds(input.ResponseComponentKinds)
}
if err := domain.ValidateDynamicQuestionTypeDefinition(stimulusKinds, responseKinds); err != nil {
return err
}
status := existing.Status
if input.Status != nil {
status = normalizeDefinitionStatus(input.Status)
}
_, err = s.conn.Exec(ctx, `
UPDATE question_type_definitions
SET display_name = $2,
description = $3,
stimulus_component_kinds = $4,
response_component_kinds = $5,
status = $6,
updated_at = NOW()
WHERE id = $1
`, id, displayName, toPgText(description), stimulusKinds, responseKinds, status)
return err
}
func (s *Store) DeleteQuestionTypeDefinition(ctx context.Context, id int64) error {
var isSystem bool
err := s.conn.QueryRow(ctx, `SELECT is_system FROM question_type_definitions WHERE id = $1`, id).Scan(&isSystem)
if err != nil {
return err
}
if isSystem {
return errors.New("system question type definitions cannot be deleted")
}
cmd, err := s.conn.Exec(ctx, `DELETE FROM question_type_definitions WHERE id = $1`, id)
if err != nil {
return err
}
if cmd.RowsAffected() == 0 {
return pgx.ErrNoRows
}
return nil
}
func (s *Store) CreateQuestion(ctx context.Context, input domain.CreateQuestionInput) (domain.Question, error) {
q, tx, err := s.BeginTx(ctx)
if err != nil {
@ -212,6 +442,10 @@ func (s *Store) CreateQuestion(ctx context.Context, input domain.CreateQuestionI
}
}
if err = s.setQuestionTypeDefinitionID(ctx, question.ID, input.QuestionTypeDefinitionID); err != nil {
return domain.Question{}, err
}
if err = tx.Commit(ctx); err != nil {
return domain.Question{}, err
}
@ -393,6 +627,12 @@ func (s *Store) UpdateQuestion(ctx context.Context, id int64, input domain.Creat
}
}
if input.QuestionTypeDefinitionID != nil {
if err := s.setQuestionTypeDefinitionID(ctx, id, input.QuestionTypeDefinitionID); err != nil {
return err
}
}
return nil
}

View File

@ -16,6 +16,28 @@ func NewService(questionStore ports.QuestionStore) *Service {
}
}
// Question Type Definitions (dynamic builder presets)
func (s *Service) CreateQuestionTypeDefinition(ctx context.Context, input domain.CreateQuestionTypeDefinitionInput) (domain.QuestionTypeDefinition, error) {
return s.questionStore.CreateQuestionTypeDefinition(ctx, input)
}
func (s *Service) GetQuestionTypeDefinitionByID(ctx context.Context, id int64) (domain.QuestionTypeDefinition, error) {
return s.questionStore.GetQuestionTypeDefinitionByID(ctx, id)
}
func (s *Service) ListQuestionTypeDefinitions(ctx context.Context, status *string, includeSystem bool) ([]domain.QuestionTypeDefinition, error) {
return s.questionStore.ListQuestionTypeDefinitions(ctx, status, includeSystem)
}
func (s *Service) UpdateQuestionTypeDefinition(ctx context.Context, id int64, input domain.UpdateQuestionTypeDefinitionInput) error {
return s.questionStore.UpdateQuestionTypeDefinition(ctx, id, input)
}
func (s *Service) DeleteQuestionTypeDefinition(ctx context.Context, id int64) error {
return s.questionStore.DeleteQuestionTypeDefinition(ctx, id)
}
// Questions
func (s *Service) CreateQuestion(ctx context.Context, input domain.CreateQuestionInput) (domain.Question, error) {

View File

@ -2,6 +2,8 @@ package handlers
import (
"Yimaru-Backend/internal/domain"
"strconv"
"strings"
"github.com/gofiber/fiber/v2"
)
@ -16,6 +18,23 @@ type validateQuestionTypeDefinitionReq struct {
ResponseComponentKinds []string `json:"response_component_kinds"`
}
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"`
}
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"`
}
// GetQuestionTypeComponentCatalog godoc
// @Summary Question-type builder component catalog
// @Description Valid stimulus and response component kind codes for dynamic question-type definitions
@ -64,3 +83,198 @@ func (h *Handler) ValidateQuestionTypeDefinition(c *fiber.Ctx) error {
Data: fiber.Map{"valid": true},
})
}
// CreateQuestionTypeDefinition godoc
// @Summary Create reusable question-type definition
// @Description Stores a reusable dynamic question-type definition for future question construction
// @Tags questions
// @Accept json
// @Produce json
// @Param body body createQuestionTypeDefinitionReq true "Question type definition payload"
// @Success 201 {object} domain.Response
// @Failure 400 {object} domain.ErrorResponse
// @Failure 500 {object} domain.ErrorResponse
// @Router /api/v1/questions/type-definitions [post]
func (h *Handler) CreateQuestionTypeDefinition(c *fiber.Ctx) error {
var req createQuestionTypeDefinitionReq
if err := c.BodyParser(&req); err != nil {
return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{
Message: "Invalid request body",
Error: err.Error(),
})
}
if valErrs, ok := h.validator.Validate(c, req); !ok {
return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{
Message: "Validation failed",
Error: firstValidationError(valErrs),
})
}
def, err := h.questionsSvc.CreateQuestionTypeDefinition(c.Context(), domain.CreateQuestionTypeDefinitionInput{
Key: strings.TrimSpace(req.Key),
DisplayName: strings.TrimSpace(req.DisplayName),
Description: req.Description,
StimulusComponentKinds: req.StimulusComponentKinds,
ResponseComponentKinds: req.ResponseComponentKinds,
Status: req.Status,
IsSystem: false,
})
if err != nil {
return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{
Message: "Unable to create question type definition",
Error: err.Error(),
})
}
return c.Status(fiber.StatusCreated).JSON(domain.Response{
Message: "Question type definition created",
Data: def,
})
}
// ListQuestionTypeDefinitions godoc
// @Summary List reusable question-type definitions
// @Tags questions
// @Produce json
// @Param status query string false "Filter by status (ACTIVE, INACTIVE)"
// @Param include_system query bool false "Include system seeded definitions"
// @Success 200 {object} domain.Response
// @Failure 500 {object} domain.ErrorResponse
// @Router /api/v1/questions/type-definitions [get]
func (h *Handler) ListQuestionTypeDefinitions(c *fiber.Ctx) error {
status := strings.ToUpper(strings.TrimSpace(c.Query("status")))
var statusPtr *string
if status != "" {
statusPtr = &status
}
includeSystem := strings.EqualFold(c.Query("include_system", "true"), "true")
defs, err := h.questionsSvc.ListQuestionTypeDefinitions(c.Context(), statusPtr, includeSystem)
if err != nil {
return c.Status(fiber.StatusInternalServerError).JSON(domain.ErrorResponse{
Message: "Failed to list question type definitions",
Error: err.Error(),
})
}
return c.JSON(domain.Response{
Message: "Question type definitions",
Data: defs,
})
}
// GetQuestionTypeDefinitionByID godoc
// @Summary Get reusable question-type definition by id
// @Tags questions
// @Produce json
// @Param id path int true "Question type definition id"
// @Success 200 {object} domain.Response
// @Failure 400 {object} domain.ErrorResponse
// @Failure 404 {object} domain.ErrorResponse
// @Router /api/v1/questions/type-definitions/{id} [get]
func (h *Handler) GetQuestionTypeDefinitionByID(c *fiber.Ctx) error {
id, err := strconv.ParseInt(c.Params("id"), 10, 64)
if err != nil || id <= 0 {
return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{
Message: "Invalid id",
Error: "id must be a positive integer",
})
}
def, err := h.questionsSvc.GetQuestionTypeDefinitionByID(c.Context(), id)
if err != nil {
return c.Status(fiber.StatusNotFound).JSON(domain.ErrorResponse{
Message: "Question type definition not found",
Error: err.Error(),
})
}
return c.JSON(domain.Response{
Message: "Question type definition",
Data: def,
})
}
// UpdateQuestionTypeDefinition godoc
// @Summary Update reusable question-type definition
// @Tags questions
// @Accept json
// @Produce json
// @Param id path int true "Question type definition id"
// @Param body body updateQuestionTypeDefinitionReq true "Update question type definition payload"
// @Success 200 {object} domain.Response
// @Failure 400 {object} domain.ErrorResponse
// @Failure 500 {object} domain.ErrorResponse
// @Router /api/v1/questions/type-definitions/{id} [put]
func (h *Handler) UpdateQuestionTypeDefinition(c *fiber.Ctx) error {
id, err := strconv.ParseInt(c.Params("id"), 10, 64)
if err != nil || id <= 0 {
return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{
Message: "Invalid id",
Error: "id must be a positive integer",
})
}
var req updateQuestionTypeDefinitionReq
if err := c.BodyParser(&req); err != nil {
return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{
Message: "Invalid request body",
Error: err.Error(),
})
}
if req.DisplayName != nil {
displayName := strings.TrimSpace(*req.DisplayName)
req.DisplayName = &displayName
}
err = h.questionsSvc.UpdateQuestionTypeDefinition(c.Context(), id, domain.UpdateQuestionTypeDefinitionInput{
DisplayName: req.DisplayName,
Description: req.Description,
StimulusComponentKinds: req.StimulusComponentKinds,
ResponseComponentKinds: req.ResponseComponentKinds,
Status: req.Status,
})
if err != nil {
return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{
Message: "Unable to update question type definition",
Error: err.Error(),
})
}
return c.JSON(domain.Response{
Message: "Question type definition updated",
Data: fiber.Map{"id": id},
})
}
// DeleteQuestionTypeDefinition godoc
// @Summary Delete reusable question-type definition
// @Tags questions
// @Produce json
// @Param id path int true "Question type definition id"
// @Success 200 {object} domain.Response
// @Failure 400 {object} domain.ErrorResponse
// @Failure 500 {object} domain.ErrorResponse
// @Router /api/v1/questions/type-definitions/{id} [delete]
func (h *Handler) DeleteQuestionTypeDefinition(c *fiber.Ctx) error {
id, err := strconv.ParseInt(c.Params("id"), 10, 64)
if err != nil || id <= 0 {
return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{
Message: "Invalid id",
Error: "id must be a positive integer",
})
}
if err := h.questionsSvc.DeleteQuestionTypeDefinition(c.Context(), id); err != nil {
return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{
Message: "Unable to delete question type definition",
Error: err.Error(),
})
}
return c.JSON(domain.Response{
Message: "Question type definition deleted",
Data: fiber.Map{"id": id},
})
}

View File

@ -25,19 +25,20 @@ type shortAnswerInput struct {
}
type createQuestionReq struct {
QuestionText string `json:"question_text" validate:"required"`
QuestionType string `json:"question_type" validate:"required,oneof=MCQ TRUE_FALSE SHORT_ANSWER AUDIO"`
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"`
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 {
@ -76,6 +77,29 @@ type listQuestionsRes struct {
TotalCount int64 `json:"total_count"`
}
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 ""
}
func normalizeRuntimeQuestionType(v string) string {
return strings.ToUpper(strings.TrimSpace(v))
}
// CreateQuestion godoc
// @Summary Create a new question
// @Description Creates a new question with options (for MCQ/TRUE_FALSE) or short answers (for SHORT_ANSWER)
@ -96,6 +120,47 @@ func (h *Handler) CreateQuestion(c *fiber.Ctx) error {
})
}
questionType := normalizeRuntimeQuestionType(req.QuestionType)
if req.QuestionTypeDefinitionID != nil {
def, err := h.questionsSvc.GetQuestionTypeDefinitionByID(c.Context(), *req.QuestionTypeDefinitionID)
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
}
if questionType != inferred {
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",
})
}
}
if questionType == "" {
return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{
Message: "Invalid question_type",
Error: "question_type is required when no question_type_definition_id is provided",
})
}
switch questionType {
case "MCQ", "TRUE_FALSE", "SHORT_ANSWER", "AUDIO":
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",
})
}
// Build options input
var options []domain.CreateQuestionOptionInput
for _, opt := range req.Options {
@ -116,19 +181,20 @@ func (h *Handler) CreateQuestion(c *fiber.Ctx) error {
}
input := domain.CreateQuestionInput{
QuestionText: req.QuestionText,
QuestionType: req.QuestionType,
DifficultyLevel: req.DifficultyLevel,
Points: req.Points,
Explanation: req.Explanation,
Tips: req.Tips,
VoicePrompt: req.VoicePrompt,
SampleAnswerVoicePrompt: req.SampleAnswerVoicePrompt,
ImageURL: req.ImageURL,
Status: req.Status,
Options: options,
ShortAnswers: shortAnswers,
AudioCorrectAnswerText: req.AudioCorrectAnswerText,
QuestionText: req.QuestionText,
QuestionType: questionType,
QuestionTypeDefinitionID: req.QuestionTypeDefinitionID,
DifficultyLevel: req.DifficultyLevel,
Points: req.Points,
Explanation: req.Explanation,
Tips: req.Tips,
VoicePrompt: req.VoicePrompt,
SampleAnswerVoicePrompt: req.SampleAnswerVoicePrompt,
ImageURL: req.ImageURL,
Status: req.Status,
Options: options,
ShortAnswers: shortAnswers,
AudioCorrectAnswerText: req.AudioCorrectAnswerText,
}
question, err := h.questionsSvc.CreateQuestion(c.Context(), input)
@ -143,7 +209,10 @@ func (h *Handler) CreateQuestion(c *fiber.Ctx) error {
actorRole := string(c.Locals("role").(domain.Role))
ip := c.IP()
ua := c.Get("User-Agent")
meta, _ := json.Marshal(map[string]interface{}{"question_type": question.QuestionType})
meta, _ := json.Marshal(map[string]interface{}{
"question_type": question.QuestionType,
"question_type_definition_id": req.QuestionTypeDefinitionID,
})
go h.activityLogSvc.RecordAction(context.Background(), &actorID, &actorRole, domain.ActionQuestionCreated, domain.ResourceQuestion, &question.ID, "Created question: "+question.QuestionText, meta, &ip, &ua)
return c.Status(fiber.StatusCreated).JSON(domain.Response{
@ -363,19 +432,20 @@ func (h *Handler) SearchQuestions(c *fiber.Ctx) error {
}
type updateQuestionReq struct {
QuestionText *string `json:"question_text"`
QuestionType *string `json:"question_type"`
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"`
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
@ -431,23 +501,49 @@ func (h *Handler) UpdateQuestion(c *fiber.Ctx) error {
}
questionType := ""
if req.QuestionType != nil {
questionType = *req.QuestionType
questionType = normalizeRuntimeQuestionType(*req.QuestionType)
}
if req.QuestionTypeDefinitionID != nil {
def, err := h.questionsSvc.GetQuestionTypeDefinitionByID(c.Context(), *req.QuestionTypeDefinitionID)
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
}
if questionType != inferred {
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",
})
}
}
input := domain.CreateQuestionInput{
QuestionText: questionText,
QuestionType: questionType,
DifficultyLevel: req.DifficultyLevel,
Points: req.Points,
Explanation: req.Explanation,
Tips: req.Tips,
VoicePrompt: req.VoicePrompt,
SampleAnswerVoicePrompt: req.SampleAnswerVoicePrompt,
ImageURL: req.ImageURL,
Status: req.Status,
Options: options,
ShortAnswers: shortAnswers,
AudioCorrectAnswerText: req.AudioCorrectAnswerText,
QuestionText: questionText,
QuestionType: questionType,
QuestionTypeDefinitionID: req.QuestionTypeDefinitionID,
DifficultyLevel: req.DifficultyLevel,
Points: req.Points,
Explanation: req.Explanation,
Tips: req.Tips,
VoicePrompt: req.VoicePrompt,
SampleAnswerVoicePrompt: req.SampleAnswerVoicePrompt,
ImageURL: req.ImageURL,
Status: req.Status,
Options: options,
ShortAnswers: shortAnswers,
AudioCorrectAnswerText: req.AudioCorrectAnswerText,
}
err = h.questionsSvc.UpdateQuestion(c.Context(), id, input)
@ -539,8 +635,8 @@ type questionSetRes struct {
TimeLimitMinutes *int32 `json:"time_limit_minutes,omitempty"`
PassingScore *int32 `json:"passing_score,omitempty"`
ShuffleQuestions bool `json:"shuffle_questions"`
Status string `json:"status"`
IntroVideoURL *string `json:"intro_video_url,omitempty"`
Status string `json:"status"`
IntroVideoURL *string `json:"intro_video_url,omitempty"`
CreatedAt string `json:"created_at"`
QuestionCount *int64 `json:"question_count,omitempty"`
}
@ -811,8 +907,8 @@ func (h *Handler) GetQuestionSetsByType(c *fiber.Ctx) error {
}
return c.JSON(domain.Response{
Message: "Question sets retrieved successfully",
Success: true,
Message: "Question sets retrieved successfully",
Success: true,
StatusCode: fiber.StatusOK,
Data: listQuestionSetsRes{
QuestionSets: setResponses,
@ -894,8 +990,8 @@ type updateQuestionSetReq struct {
TimeLimitMinutes *int32 `json:"time_limit_minutes"`
PassingScore *int32 `json:"passing_score"`
ShuffleQuestions *bool `json:"shuffle_questions"`
Status *string `json:"status"`
IntroVideoURL *string `json:"intro_video_url"`
Status *string `json:"status"`
IntroVideoURL *string `json:"intro_video_url"`
}
// UpdateQuestionSet godoc
@ -1056,21 +1152,21 @@ 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"`
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"`
QuestionStatus string `json:"question_status"`
}
type paginatedQuestionSetItemsRes struct {
@ -1254,10 +1350,10 @@ func (h *Handler) GetQuestionsInSet(c *fiber.Ctx) error {
}
return c.JSON(domain.Response{
Message: "Questions retrieved successfully",
Success: true,
Message: "Questions retrieved successfully",
Success: true,
StatusCode: fiber.StatusOK,
Data: questionResponses,
Data: questionResponses,
})
}

View File

@ -167,6 +167,11 @@ func (a *App) initAppRoutes() {
groupV1.Get("/questions/search", a.authMiddleware, a.RequirePermission("questions.search"), h.SearchQuestions)
groupV1.Get("/questions/component-catalog", a.authMiddleware, a.RequirePermission("questions.list"), h.GetQuestionTypeComponentCatalog)
groupV1.Post("/questions/validate-question-type-definition", a.authMiddleware, a.RequirePermission("questions.create"), h.ValidateQuestionTypeDefinition)
groupV1.Post("/questions/type-definitions", a.authMiddleware, a.RequirePermission("questions.create"), h.CreateQuestionTypeDefinition)
groupV1.Get("/questions/type-definitions", a.authMiddleware, a.RequirePermission("questions.list"), h.ListQuestionTypeDefinitions)
groupV1.Get("/questions/type-definitions/:id", a.authMiddleware, a.RequirePermission("questions.get"), h.GetQuestionTypeDefinitionByID)
groupV1.Put("/questions/type-definitions/:id", a.authMiddleware, a.RequirePermission("questions.update"), h.UpdateQuestionTypeDefinition)
groupV1.Delete("/questions/type-definitions/:id", a.authMiddleware, a.RequirePermission("questions.delete"), h.DeleteQuestionTypeDefinition)
groupV1.Get("/questions/:id", a.authMiddleware, a.RequirePermission("questions.get"), h.GetQuestionByID)
groupV1.Put("/questions/:id", a.authMiddleware, a.RequirePermission("questions.update"), h.UpdateQuestion)
groupV1.Delete("/questions/:id", a.authMiddleware, a.RequirePermission("questions.delete"), h.DeleteQuestion)
@ -211,9 +216,9 @@ func (a *App) initAppRoutes() {
groupV1.Post("/payments/subscribe", a.authMiddleware, a.RequirePermission("payments.initiate"), h.InitiateSubscriptionPayment)
groupV1.Get("/payments/verify/:session_id", a.authMiddleware, a.RequirePermission("payments.verify"), h.VerifyPayment)
groupV1.Get("/payments", a.authMiddleware, a.RequirePermission("payments.list_mine"), h.GetMyPayments)
groupV1.Get("/payments/methods", h.GetArifpayPaymentMethods)
groupV1.Get("/payments/:id", a.authMiddleware, a.RequirePermission("payments.get"), h.GetPaymentByID)
groupV1.Post("/payments/:id/cancel", a.authMiddleware, a.RequirePermission("payments.cancel"), h.CancelPayment)
groupV1.Get("/payments/methods", h.GetArifpayPaymentMethods)
groupV1.Post("/payments/webhook", h.HandleArifpayWebhook)
// Direct Payments

View File

@ -0,0 +1,418 @@
{
"info": {
"name": "Yimaru Dynamic Question Type Builder API",
"_postman_id": "f0f9c795-09aa-4f5a-9cc0-1f2fcb0f1b01",
"description": "Complete Postman collection for the dynamic question type builder feature, including catalog, validation, reusable type-definition CRUD, and question create/update using question_type_definition_id.",
"schema": "https://schema.getpostman.com/json/collection/v2.1.0/collection.json"
},
"variable": [
{
"key": "baseUrl",
"value": "http://localhost:8080"
},
{
"key": "apiPrefix",
"value": "/api/v1"
},
{
"key": "token",
"value": ""
},
{
"key": "questionTypeDefinitionId",
"value": ""
},
{
"key": "questionId",
"value": ""
}
],
"auth": {
"type": "bearer",
"bearer": [
{
"key": "token",
"value": "{{token}}",
"type": "string"
}
]
},
"item": [
{
"name": "01 - Builder Component Catalog",
"request": {
"method": "GET",
"header": [],
"url": {
"raw": "{{baseUrl}}{{apiPrefix}}/questions/component-catalog",
"host": [
"{{baseUrl}}"
],
"path": [
"{{apiPrefix}}",
"questions",
"component-catalog"
]
},
"description": "Returns supported stimulus and response component kinds for dynamic type definitions."
},
"response": []
},
{
"name": "02 - Validate Dynamic Type Definition",
"request": {
"method": "POST",
"header": [
{
"key": "Content-Type",
"value": "application/json"
}
],
"body": {
"mode": "raw",
"raw": "{\n \"stimulus_component_kinds\": [\"INSTRUCTION\", \"TEXT_PASSAGE\"],\n \"response_component_kinds\": [\"MULTIPLE_CHOICE\"]\n}"
},
"url": {
"raw": "{{baseUrl}}{{apiPrefix}}/questions/validate-question-type-definition",
"host": [
"{{baseUrl}}"
],
"path": [
"{{apiPrefix}}",
"questions",
"validate-question-type-definition"
]
},
"description": "Validates a candidate dynamic question-type definition before saving."
},
"response": []
},
{
"name": "03 - Create Question Type Definition (MCQ Dynamic)",
"request": {
"method": "POST",
"header": [
{
"key": "Content-Type",
"value": "application/json"
}
],
"body": {
"mode": "raw",
"raw": "{\n \"key\": \"mcq_dynamic_vocab\",\n \"display_name\": \"MCQ Dynamic Vocabulary\",\n \"description\": \"Dynamic multiple-choice template for vocabulary checks.\",\n \"stimulus_component_kinds\": [\"INSTRUCTION\", \"TEXT_PASSAGE\"],\n \"response_component_kinds\": [\"MULTIPLE_CHOICE\"],\n \"status\": \"ACTIVE\"\n}"
},
"url": {
"raw": "{{baseUrl}}{{apiPrefix}}/questions/type-definitions",
"host": [
"{{baseUrl}}"
],
"path": [
"{{apiPrefix}}",
"questions",
"type-definitions"
]
},
"description": "Creates a reusable dynamic question-type definition."
},
"event": [
{
"listen": "test",
"script": {
"exec": [
"pm.test('Status is 201', function () { pm.response.to.have.status(201); });",
"var json = pm.response.json();",
"if (json && json.data && json.data.id) {",
" pm.collectionVariables.set('questionTypeDefinitionId', json.data.id);",
"}"
],
"type": "text/javascript"
}
}
],
"response": []
},
{
"name": "04 - List Question Type Definitions (Include System)",
"request": {
"method": "GET",
"header": [],
"url": {
"raw": "{{baseUrl}}{{apiPrefix}}/questions/type-definitions?include_system=true&status=ACTIVE",
"host": [
"{{baseUrl}}"
],
"path": [
"{{apiPrefix}}",
"questions",
"type-definitions"
],
"query": [
{
"key": "include_system",
"value": "true"
},
{
"key": "status",
"value": "ACTIVE"
}
]
},
"description": "Lists reusable dynamic definitions (system + custom)."
},
"response": []
},
{
"name": "05 - Get Question Type Definition By ID",
"request": {
"method": "GET",
"header": [],
"url": {
"raw": "{{baseUrl}}{{apiPrefix}}/questions/type-definitions/{{questionTypeDefinitionId}}",
"host": [
"{{baseUrl}}"
],
"path": [
"{{apiPrefix}}",
"questions",
"type-definitions",
"{{questionTypeDefinitionId}}"
]
},
"description": "Fetches one dynamic type-definition by ID."
},
"response": []
},
{
"name": "06 - Update Question Type Definition",
"request": {
"method": "PUT",
"header": [
{
"key": "Content-Type",
"value": "application/json"
}
],
"body": {
"mode": "raw",
"raw": "{\n \"display_name\": \"MCQ Dynamic Vocabulary (Updated)\",\n \"description\": \"Updated dynamic MCQ template.\",\n \"stimulus_component_kinds\": [\"INSTRUCTION\", \"TEXT_PASSAGE\", \"IMAGE\"],\n \"response_component_kinds\": [\"MULTIPLE_CHOICE\", \"ANSWER_TIMER\"],\n \"status\": \"ACTIVE\"\n}"
},
"url": {
"raw": "{{baseUrl}}{{apiPrefix}}/questions/type-definitions/{{questionTypeDefinitionId}}",
"host": [
"{{baseUrl}}"
],
"path": [
"{{apiPrefix}}",
"questions",
"type-definitions",
"{{questionTypeDefinitionId}}"
]
},
"description": "Updates dynamic definition (except key/system flag)."
},
"response": []
},
{
"name": "07 - Create Question Using question_type_definition_id",
"request": {
"method": "POST",
"header": [
{
"key": "Content-Type",
"value": "application/json"
}
],
"body": {
"mode": "raw",
"raw": "{\n \"question_text\": \"Choose the correct synonym for 'rapid'.\",\n \"question_type_definition_id\": {{questionTypeDefinitionId}},\n \"difficulty_level\": \"EASY\",\n \"points\": 1,\n \"status\": \"PUBLISHED\",\n \"options\": [\n { \"option_text\": \"Slow\", \"option_order\": 1, \"is_correct\": false },\n { \"option_text\": \"Quick\", \"option_order\": 2, \"is_correct\": true },\n { \"option_text\": \"Heavy\", \"option_order\": 3, \"is_correct\": false },\n { \"option_text\": \"Late\", \"option_order\": 4, \"is_correct\": false }\n ]\n}"
},
"url": {
"raw": "{{baseUrl}}{{apiPrefix}}/questions",
"host": [
"{{baseUrl}}"
],
"path": [
"{{apiPrefix}}",
"questions"
]
},
"description": "Creates a question by binding to a dynamic definition. Backend infers runtime question_type from the definition."
},
"event": [
{
"listen": "test",
"script": {
"exec": [
"pm.test('Status is 201', function () { pm.response.to.have.status(201); });",
"var json = pm.response.json();",
"if (json && json.data && json.data.id) {",
" pm.collectionVariables.set('questionId', json.data.id);",
"}"
],
"type": "text/javascript"
}
}
],
"response": []
},
{
"name": "08 - Create Question (Explicit question_type + Definition)",
"request": {
"method": "POST",
"header": [
{
"key": "Content-Type",
"value": "application/json"
}
],
"body": {
"mode": "raw",
"raw": "{\n \"question_text\": \"Pick the antonym of 'expand'.\",\n \"question_type\": \"MCQ\",\n \"question_type_definition_id\": {{questionTypeDefinitionId}},\n \"difficulty_level\": \"MEDIUM\",\n \"points\": 2,\n \"options\": [\n { \"option_text\": \"Increase\", \"option_order\": 1, \"is_correct\": false },\n { \"option_text\": \"Contract\", \"option_order\": 2, \"is_correct\": true },\n { \"option_text\": \"Stretch\", \"option_order\": 3, \"is_correct\": false },\n { \"option_text\": \"Grow\", \"option_order\": 4, \"is_correct\": false }\n ]\n}"
},
"url": {
"raw": "{{baseUrl}}{{apiPrefix}}/questions",
"host": [
"{{baseUrl}}"
],
"path": [
"{{apiPrefix}}",
"questions"
]
},
"description": "Valid combination: explicit question_type must match the type inferred from the selected definition."
},
"response": []
},
{
"name": "09 - Update Question (Switch/Attach Definition)",
"request": {
"method": "PUT",
"header": [
{
"key": "Content-Type",
"value": "application/json"
}
],
"body": {
"mode": "raw",
"raw": "{\n \"question_type_definition_id\": {{questionTypeDefinitionId}},\n \"question_type\": \"MCQ\",\n \"question_text\": \"Choose the best definition of 'meticulous'.\",\n \"options\": [\n { \"option_text\": \"Careless\", \"option_order\": 1, \"is_correct\": false },\n { \"option_text\": \"Very careful and precise\", \"option_order\": 2, \"is_correct\": true },\n { \"option_text\": \"Quickly done\", \"option_order\": 3, \"is_correct\": false }\n ],\n \"status\": \"PUBLISHED\"\n}"
},
"url": {
"raw": "{{baseUrl}}{{apiPrefix}}/questions/{{questionId}}",
"host": [
"{{baseUrl}}"
],
"path": [
"{{apiPrefix}}",
"questions",
"{{questionId}}"
]
},
"description": "Updates a question and links (or re-links) it to a dynamic definition."
},
"response": []
},
{
"name": "10 - Get Question By ID",
"request": {
"method": "GET",
"header": [],
"url": {
"raw": "{{baseUrl}}{{apiPrefix}}/questions/{{questionId}}",
"host": [
"{{baseUrl}}"
],
"path": [
"{{apiPrefix}}",
"questions",
"{{questionId}}"
]
},
"description": "Returns question details (options/short_answers/audio fields as applicable)."
},
"response": []
},
{
"name": "11 - List Questions",
"request": {
"method": "GET",
"header": [],
"url": {
"raw": "{{baseUrl}}{{apiPrefix}}/questions?question_type=MCQ&limit=10&offset=0",
"host": [
"{{baseUrl}}"
],
"path": [
"{{apiPrefix}}",
"questions"
],
"query": [
{
"key": "question_type",
"value": "MCQ"
},
{
"key": "limit",
"value": "10"
},
{
"key": "offset",
"value": "0"
}
]
},
"description": "Lists questions filtered by runtime question_type."
},
"response": []
},
{
"name": "12 - Negative Test: Mismatched Type and Definition",
"request": {
"method": "POST",
"header": [
{
"key": "Content-Type",
"value": "application/json"
}
],
"body": {
"mode": "raw",
"raw": "{\n \"question_text\": \"This should fail.\",\n \"question_type\": \"AUDIO\",\n \"question_type_definition_id\": {{questionTypeDefinitionId}},\n \"options\": [\n { \"option_text\": \"A\", \"option_order\": 1, \"is_correct\": true }\n ]\n}"
},
"url": {
"raw": "{{baseUrl}}{{apiPrefix}}/questions",
"host": [
"{{baseUrl}}"
],
"path": [
"{{apiPrefix}}",
"questions"
]
},
"description": "Expected 400 because explicit question_type does not match inferred type from definition."
},
"response": []
},
{
"name": "13 - Delete Custom Question Type Definition",
"request": {
"method": "DELETE",
"header": [],
"url": {
"raw": "{{baseUrl}}{{apiPrefix}}/questions/type-definitions/{{questionTypeDefinitionId}}",
"host": [
"{{baseUrl}}"
],
"path": [
"{{apiPrefix}}",
"questions",
"type-definitions",
"{{questionTypeDefinitionId}}"
]
},
"description": "Deletes a custom definition. System definitions cannot be deleted."
},
"response": []
}
]
}

View File

@ -0,0 +1,39 @@
{
"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"
}