partly implemented dynamic question builder + payment routes fix
This commit is contained in:
parent
73370633ce
commit
f906862676
|
|
@ -0,0 +1,3 @@
|
|||
DROP INDEX IF EXISTS idx_question_type_definitions_status;
|
||||
|
||||
DROP TABLE IF EXISTS question_type_definitions;
|
||||
|
|
@ -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;
|
||||
|
|
@ -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;
|
||||
14
db/migrations/000057_questions_dynamic_type_link.up.sql
Normal file
14
db/migrations/000057_questions_dynamic_type_link.up.sql
Normal 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);
|
||||
34
internal/domain/question_type_definitions.go
Normal file
34
internal/domain/question_type_definitions.go
Normal 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
|
||||
}
|
||||
|
|
@ -48,6 +48,7 @@ type Question struct {
|
|||
ID int64
|
||||
QuestionText string
|
||||
QuestionType string
|
||||
QuestionTypeDefinitionID *int64
|
||||
DifficultyLevel *string
|
||||
Points int32
|
||||
Explanation *string
|
||||
|
|
@ -136,6 +137,7 @@ type QuestionSetItemWithQuestion struct {
|
|||
type CreateQuestionInput struct {
|
||||
QuestionText string
|
||||
QuestionType string
|
||||
QuestionTypeDefinitionID *int64
|
||||
DifficultyLevel *string
|
||||
Points *int32
|
||||
Explanation *string
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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},
|
||||
})
|
||||
}
|
||||
|
|
|
|||
|
|
@ -26,7 +26,8 @@ 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"`
|
||||
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"`
|
||||
|
|
@ -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 {
|
||||
|
|
@ -117,7 +182,8 @@ func (h *Handler) CreateQuestion(c *fiber.Ctx) error {
|
|||
|
||||
input := domain.CreateQuestionInput{
|
||||
QuestionText: req.QuestionText,
|
||||
QuestionType: req.QuestionType,
|
||||
QuestionType: questionType,
|
||||
QuestionTypeDefinitionID: req.QuestionTypeDefinitionID,
|
||||
DifficultyLevel: req.DifficultyLevel,
|
||||
Points: req.Points,
|
||||
Explanation: req.Explanation,
|
||||
|
|
@ -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{
|
||||
|
|
@ -365,6 +434,7 @@ 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"`
|
||||
|
|
@ -431,12 +501,38 @@ 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,
|
||||
QuestionTypeDefinitionID: req.QuestionTypeDefinitionID,
|
||||
DifficultyLevel: req.DifficultyLevel,
|
||||
Points: req.Points,
|
||||
Explanation: req.Explanation,
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
418
postman/Dynamic-Question-Type-Builder.postman_collection.json
Normal file
418
postman/Dynamic-Question-Type-Builder.postman_collection.json
Normal 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": []
|
||||
}
|
||||
]
|
||||
}
|
||||
|
|
@ -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"
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user