diff --git a/db/migrations/000056_dynamic_question_type_definitions.down.sql b/db/migrations/000056_dynamic_question_type_definitions.down.sql new file mode 100644 index 0000000..b058168 --- /dev/null +++ b/db/migrations/000056_dynamic_question_type_definitions.down.sql @@ -0,0 +1,3 @@ +DROP INDEX IF EXISTS idx_question_type_definitions_status; + +DROP TABLE IF EXISTS question_type_definitions; diff --git a/db/migrations/000056_dynamic_question_type_definitions.up.sql b/db/migrations/000056_dynamic_question_type_definitions.up.sql new file mode 100644 index 0000000..c362a74 --- /dev/null +++ b/db/migrations/000056_dynamic_question_type_definitions.up.sql @@ -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; diff --git a/db/migrations/000057_questions_dynamic_type_link.down.sql b/db/migrations/000057_questions_dynamic_type_link.down.sql new file mode 100644 index 0000000..4117076 --- /dev/null +++ b/db/migrations/000057_questions_dynamic_type_link.down.sql @@ -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; diff --git a/db/migrations/000057_questions_dynamic_type_link.up.sql b/db/migrations/000057_questions_dynamic_type_link.up.sql new file mode 100644 index 0000000..ba66268 --- /dev/null +++ b/db/migrations/000057_questions_dynamic_type_link.up.sql @@ -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); diff --git a/internal/domain/question_type_definitions.go b/internal/domain/question_type_definitions.go new file mode 100644 index 0000000..0c1f526 --- /dev/null +++ b/internal/domain/question_type_definitions.go @@ -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 +} diff --git a/internal/domain/questions.go b/internal/domain/questions.go index 25d481a..2ede2c4 100644 --- a/internal/domain/questions.go +++ b/internal/domain/questions.go @@ -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 { diff --git a/internal/ports/questions.go b/internal/ports/questions.go index a885374..db56086 100644 --- a/internal/ports/questions.go +++ b/internal/ports/questions.go @@ -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) diff --git a/internal/repository/questions.go b/internal/repository/questions.go index 6a7c582..fda8a74 100644 --- a/internal/repository/questions.go +++ b/internal/repository/questions.go @@ -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 } diff --git a/internal/services/questions/service.go b/internal/services/questions/service.go index 2ffc996..71a9b23 100644 --- a/internal/services/questions/service.go +++ b/internal/services/questions/service.go @@ -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) { diff --git a/internal/web_server/handlers/question_type_builder.go b/internal/web_server/handlers/question_type_builder.go index 2856970..6da9bbc 100644 --- a/internal/web_server/handlers/question_type_builder.go +++ b/internal/web_server/handlers/question_type_builder.go @@ -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}, + }) +} diff --git a/internal/web_server/handlers/questions.go b/internal/web_server/handlers/questions.go index 6574a54..83235aa 100644 --- a/internal/web_server/handlers/questions.go +++ b/internal/web_server/handlers/questions.go @@ -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, }) } diff --git a/internal/web_server/routes.go b/internal/web_server/routes.go index 8b608f0..bc0d601 100644 --- a/internal/web_server/routes.go +++ b/internal/web_server/routes.go @@ -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 diff --git a/postman/Dynamic-Question-Type-Builder.postman_collection.json b/postman/Dynamic-Question-Type-Builder.postman_collection.json new file mode 100644 index 0000000..e2e6ebb --- /dev/null +++ b/postman/Dynamic-Question-Type-Builder.postman_collection.json @@ -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": [] + } + ] +} diff --git a/postman/Yimaru-Local-Dynamic-Builder.postman_environment.json b/postman/Yimaru-Local-Dynamic-Builder.postman_environment.json new file mode 100644 index 0000000..0d082e3 --- /dev/null +++ b/postman/Yimaru-Local-Dynamic-Builder.postman_environment.json @@ -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" +}