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
|
||||||
|
}
|
||||||
|
|
@ -45,19 +45,20 @@ const (
|
||||||
)
|
)
|
||||||
|
|
||||||
type Question struct {
|
type Question struct {
|
||||||
ID int64
|
ID int64
|
||||||
QuestionText string
|
QuestionText string
|
||||||
QuestionType string
|
QuestionType string
|
||||||
DifficultyLevel *string
|
QuestionTypeDefinitionID *int64
|
||||||
Points int32
|
DifficultyLevel *string
|
||||||
Explanation *string
|
Points int32
|
||||||
Tips *string
|
Explanation *string
|
||||||
VoicePrompt *string
|
Tips *string
|
||||||
SampleAnswerVoicePrompt *string
|
VoicePrompt *string
|
||||||
ImageURL *string
|
SampleAnswerVoicePrompt *string
|
||||||
Status string
|
ImageURL *string
|
||||||
CreatedAt time.Time
|
Status string
|
||||||
UpdatedAt *time.Time
|
CreatedAt time.Time
|
||||||
|
UpdatedAt *time.Time
|
||||||
}
|
}
|
||||||
|
|
||||||
type QuestionAudioAnswer struct {
|
type QuestionAudioAnswer struct {
|
||||||
|
|
@ -120,33 +121,34 @@ type QuestionSetItem struct {
|
||||||
|
|
||||||
type QuestionSetItemWithQuestion struct {
|
type QuestionSetItemWithQuestion struct {
|
||||||
QuestionSetItem
|
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
|
QuestionText string
|
||||||
QuestionType string
|
QuestionType string
|
||||||
DifficultyLevel *string
|
DifficultyLevel *string
|
||||||
Points *int32
|
Points int32
|
||||||
Explanation *string
|
Explanation *string
|
||||||
Tips *string
|
Tips *string
|
||||||
VoicePrompt *string
|
VoicePrompt *string
|
||||||
SampleAnswerVoicePrompt *string
|
SampleAnswerVoicePrompt *string
|
||||||
ImageURL *string
|
ImageURL *string
|
||||||
Status *string
|
|
||||||
Options []CreateQuestionOptionInput
|
|
||||||
ShortAnswers []CreateShortAnswerInput
|
|
||||||
AudioCorrectAnswerText *string
|
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 {
|
type CreateQuestionOptionInput struct {
|
||||||
|
|
|
||||||
|
|
@ -6,6 +6,13 @@ import (
|
||||||
)
|
)
|
||||||
|
|
||||||
type QuestionStore interface {
|
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
|
// Questions
|
||||||
CreateQuestion(ctx context.Context, input domain.CreateQuestionInput) (domain.Question, error)
|
CreateQuestion(ctx context.Context, input domain.CreateQuestionInput) (domain.Question, error)
|
||||||
GetQuestionByID(ctx context.Context, id int64) (domain.Question, error)
|
GetQuestionByID(ctx context.Context, id int64) (domain.Question, error)
|
||||||
|
|
|
||||||
|
|
@ -3,6 +3,7 @@ package repository
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"errors"
|
"errors"
|
||||||
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
dbgen "Yimaru-Backend/gen/db"
|
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 {
|
func questionOptionToDomain(o dbgen.QuestionOption) domain.QuestionOption {
|
||||||
return domain.QuestionOption{
|
return domain.QuestionOption{
|
||||||
ID: o.ID,
|
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) {
|
func (s *Store) CreateQuestion(ctx context.Context, input domain.CreateQuestionInput) (domain.Question, error) {
|
||||||
q, tx, err := s.BeginTx(ctx)
|
q, tx, err := s.BeginTx(ctx)
|
||||||
if err != nil {
|
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 {
|
if err = tx.Commit(ctx); err != nil {
|
||||||
return domain.Question{}, err
|
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
|
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
|
// Questions
|
||||||
|
|
||||||
func (s *Service) CreateQuestion(ctx context.Context, input domain.CreateQuestionInput) (domain.Question, error) {
|
func (s *Service) CreateQuestion(ctx context.Context, input domain.CreateQuestionInput) (domain.Question, error) {
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,8 @@ package handlers
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"Yimaru-Backend/internal/domain"
|
"Yimaru-Backend/internal/domain"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
|
||||||
"github.com/gofiber/fiber/v2"
|
"github.com/gofiber/fiber/v2"
|
||||||
)
|
)
|
||||||
|
|
@ -16,6 +18,23 @@ type validateQuestionTypeDefinitionReq struct {
|
||||||
ResponseComponentKinds []string `json:"response_component_kinds"`
|
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
|
// GetQuestionTypeComponentCatalog godoc
|
||||||
// @Summary Question-type builder component catalog
|
// @Summary Question-type builder component catalog
|
||||||
// @Description Valid stimulus and response component kind codes for dynamic question-type definitions
|
// @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},
|
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},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -25,19 +25,20 @@ type shortAnswerInput struct {
|
||||||
}
|
}
|
||||||
|
|
||||||
type createQuestionReq struct {
|
type createQuestionReq struct {
|
||||||
QuestionText string `json:"question_text" validate:"required"`
|
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"`
|
||||||
DifficultyLevel *string `json:"difficulty_level"`
|
QuestionTypeDefinitionID *int64 `json:"question_type_definition_id"`
|
||||||
Points *int32 `json:"points"`
|
DifficultyLevel *string `json:"difficulty_level"`
|
||||||
Explanation *string `json:"explanation"`
|
Points *int32 `json:"points"`
|
||||||
Tips *string `json:"tips"`
|
Explanation *string `json:"explanation"`
|
||||||
VoicePrompt *string `json:"voice_prompt"`
|
Tips *string `json:"tips"`
|
||||||
SampleAnswerVoicePrompt *string `json:"sample_answer_voice_prompt"`
|
VoicePrompt *string `json:"voice_prompt"`
|
||||||
ImageURL *string `json:"image_url"`
|
SampleAnswerVoicePrompt *string `json:"sample_answer_voice_prompt"`
|
||||||
Status *string `json:"status"`
|
ImageURL *string `json:"image_url"`
|
||||||
Options []optionInput `json:"options"`
|
Status *string `json:"status"`
|
||||||
ShortAnswers []shortAnswerInput `json:"short_answers"`
|
Options []optionInput `json:"options"`
|
||||||
AudioCorrectAnswerText *string `json:"audio_correct_answer_text"`
|
ShortAnswers []shortAnswerInput `json:"short_answers"`
|
||||||
|
AudioCorrectAnswerText *string `json:"audio_correct_answer_text"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type optionRes struct {
|
type optionRes struct {
|
||||||
|
|
@ -76,6 +77,29 @@ type listQuestionsRes struct {
|
||||||
TotalCount int64 `json:"total_count"`
|
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
|
// CreateQuestion godoc
|
||||||
// @Summary Create a new question
|
// @Summary Create a new question
|
||||||
// @Description Creates a new question with options (for MCQ/TRUE_FALSE) or short answers (for SHORT_ANSWER)
|
// @Description Creates a new question with options (for MCQ/TRUE_FALSE) or short answers (for SHORT_ANSWER)
|
||||||
|
|
@ -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
|
// Build options input
|
||||||
var options []domain.CreateQuestionOptionInput
|
var options []domain.CreateQuestionOptionInput
|
||||||
for _, opt := range req.Options {
|
for _, opt := range req.Options {
|
||||||
|
|
@ -116,19 +181,20 @@ func (h *Handler) CreateQuestion(c *fiber.Ctx) error {
|
||||||
}
|
}
|
||||||
|
|
||||||
input := domain.CreateQuestionInput{
|
input := domain.CreateQuestionInput{
|
||||||
QuestionText: req.QuestionText,
|
QuestionText: req.QuestionText,
|
||||||
QuestionType: req.QuestionType,
|
QuestionType: questionType,
|
||||||
DifficultyLevel: req.DifficultyLevel,
|
QuestionTypeDefinitionID: req.QuestionTypeDefinitionID,
|
||||||
Points: req.Points,
|
DifficultyLevel: req.DifficultyLevel,
|
||||||
Explanation: req.Explanation,
|
Points: req.Points,
|
||||||
Tips: req.Tips,
|
Explanation: req.Explanation,
|
||||||
VoicePrompt: req.VoicePrompt,
|
Tips: req.Tips,
|
||||||
SampleAnswerVoicePrompt: req.SampleAnswerVoicePrompt,
|
VoicePrompt: req.VoicePrompt,
|
||||||
ImageURL: req.ImageURL,
|
SampleAnswerVoicePrompt: req.SampleAnswerVoicePrompt,
|
||||||
Status: req.Status,
|
ImageURL: req.ImageURL,
|
||||||
Options: options,
|
Status: req.Status,
|
||||||
ShortAnswers: shortAnswers,
|
Options: options,
|
||||||
AudioCorrectAnswerText: req.AudioCorrectAnswerText,
|
ShortAnswers: shortAnswers,
|
||||||
|
AudioCorrectAnswerText: req.AudioCorrectAnswerText,
|
||||||
}
|
}
|
||||||
|
|
||||||
question, err := h.questionsSvc.CreateQuestion(c.Context(), input)
|
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))
|
actorRole := string(c.Locals("role").(domain.Role))
|
||||||
ip := c.IP()
|
ip := c.IP()
|
||||||
ua := c.Get("User-Agent")
|
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)
|
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{
|
return c.Status(fiber.StatusCreated).JSON(domain.Response{
|
||||||
|
|
@ -363,19 +432,20 @@ func (h *Handler) SearchQuestions(c *fiber.Ctx) error {
|
||||||
}
|
}
|
||||||
|
|
||||||
type updateQuestionReq struct {
|
type updateQuestionReq struct {
|
||||||
QuestionText *string `json:"question_text"`
|
QuestionText *string `json:"question_text"`
|
||||||
QuestionType *string `json:"question_type"`
|
QuestionType *string `json:"question_type"`
|
||||||
DifficultyLevel *string `json:"difficulty_level"`
|
QuestionTypeDefinitionID *int64 `json:"question_type_definition_id"`
|
||||||
Points *int32 `json:"points"`
|
DifficultyLevel *string `json:"difficulty_level"`
|
||||||
Explanation *string `json:"explanation"`
|
Points *int32 `json:"points"`
|
||||||
Tips *string `json:"tips"`
|
Explanation *string `json:"explanation"`
|
||||||
VoicePrompt *string `json:"voice_prompt"`
|
Tips *string `json:"tips"`
|
||||||
SampleAnswerVoicePrompt *string `json:"sample_answer_voice_prompt"`
|
VoicePrompt *string `json:"voice_prompt"`
|
||||||
ImageURL *string `json:"image_url"`
|
SampleAnswerVoicePrompt *string `json:"sample_answer_voice_prompt"`
|
||||||
Status *string `json:"status"`
|
ImageURL *string `json:"image_url"`
|
||||||
Options []optionInput `json:"options"`
|
Status *string `json:"status"`
|
||||||
ShortAnswers []shortAnswerInput `json:"short_answers"`
|
Options []optionInput `json:"options"`
|
||||||
AudioCorrectAnswerText *string `json:"audio_correct_answer_text"`
|
ShortAnswers []shortAnswerInput `json:"short_answers"`
|
||||||
|
AudioCorrectAnswerText *string `json:"audio_correct_answer_text"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// UpdateQuestion godoc
|
// UpdateQuestion godoc
|
||||||
|
|
@ -431,23 +501,49 @@ func (h *Handler) UpdateQuestion(c *fiber.Ctx) error {
|
||||||
}
|
}
|
||||||
questionType := ""
|
questionType := ""
|
||||||
if req.QuestionType != nil {
|
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{
|
input := domain.CreateQuestionInput{
|
||||||
QuestionText: questionText,
|
QuestionText: questionText,
|
||||||
QuestionType: questionType,
|
QuestionType: questionType,
|
||||||
DifficultyLevel: req.DifficultyLevel,
|
QuestionTypeDefinitionID: req.QuestionTypeDefinitionID,
|
||||||
Points: req.Points,
|
DifficultyLevel: req.DifficultyLevel,
|
||||||
Explanation: req.Explanation,
|
Points: req.Points,
|
||||||
Tips: req.Tips,
|
Explanation: req.Explanation,
|
||||||
VoicePrompt: req.VoicePrompt,
|
Tips: req.Tips,
|
||||||
SampleAnswerVoicePrompt: req.SampleAnswerVoicePrompt,
|
VoicePrompt: req.VoicePrompt,
|
||||||
ImageURL: req.ImageURL,
|
SampleAnswerVoicePrompt: req.SampleAnswerVoicePrompt,
|
||||||
Status: req.Status,
|
ImageURL: req.ImageURL,
|
||||||
Options: options,
|
Status: req.Status,
|
||||||
ShortAnswers: shortAnswers,
|
Options: options,
|
||||||
AudioCorrectAnswerText: req.AudioCorrectAnswerText,
|
ShortAnswers: shortAnswers,
|
||||||
|
AudioCorrectAnswerText: req.AudioCorrectAnswerText,
|
||||||
}
|
}
|
||||||
|
|
||||||
err = h.questionsSvc.UpdateQuestion(c.Context(), id, input)
|
err = h.questionsSvc.UpdateQuestion(c.Context(), id, input)
|
||||||
|
|
@ -539,8 +635,8 @@ type questionSetRes struct {
|
||||||
TimeLimitMinutes *int32 `json:"time_limit_minutes,omitempty"`
|
TimeLimitMinutes *int32 `json:"time_limit_minutes,omitempty"`
|
||||||
PassingScore *int32 `json:"passing_score,omitempty"`
|
PassingScore *int32 `json:"passing_score,omitempty"`
|
||||||
ShuffleQuestions bool `json:"shuffle_questions"`
|
ShuffleQuestions bool `json:"shuffle_questions"`
|
||||||
Status string `json:"status"`
|
Status string `json:"status"`
|
||||||
IntroVideoURL *string `json:"intro_video_url,omitempty"`
|
IntroVideoURL *string `json:"intro_video_url,omitempty"`
|
||||||
CreatedAt string `json:"created_at"`
|
CreatedAt string `json:"created_at"`
|
||||||
QuestionCount *int64 `json:"question_count,omitempty"`
|
QuestionCount *int64 `json:"question_count,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
@ -811,8 +907,8 @@ func (h *Handler) GetQuestionSetsByType(c *fiber.Ctx) error {
|
||||||
}
|
}
|
||||||
|
|
||||||
return c.JSON(domain.Response{
|
return c.JSON(domain.Response{
|
||||||
Message: "Question sets retrieved successfully",
|
Message: "Question sets retrieved successfully",
|
||||||
Success: true,
|
Success: true,
|
||||||
StatusCode: fiber.StatusOK,
|
StatusCode: fiber.StatusOK,
|
||||||
Data: listQuestionSetsRes{
|
Data: listQuestionSetsRes{
|
||||||
QuestionSets: setResponses,
|
QuestionSets: setResponses,
|
||||||
|
|
@ -894,8 +990,8 @@ type updateQuestionSetReq struct {
|
||||||
TimeLimitMinutes *int32 `json:"time_limit_minutes"`
|
TimeLimitMinutes *int32 `json:"time_limit_minutes"`
|
||||||
PassingScore *int32 `json:"passing_score"`
|
PassingScore *int32 `json:"passing_score"`
|
||||||
ShuffleQuestions *bool `json:"shuffle_questions"`
|
ShuffleQuestions *bool `json:"shuffle_questions"`
|
||||||
Status *string `json:"status"`
|
Status *string `json:"status"`
|
||||||
IntroVideoURL *string `json:"intro_video_url"`
|
IntroVideoURL *string `json:"intro_video_url"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// UpdateQuestionSet godoc
|
// UpdateQuestionSet godoc
|
||||||
|
|
@ -1056,21 +1152,21 @@ type addQuestionToSetReq struct {
|
||||||
}
|
}
|
||||||
|
|
||||||
type questionSetItemRes struct {
|
type questionSetItemRes struct {
|
||||||
ID int64 `json:"id"`
|
ID int64 `json:"id"`
|
||||||
SetID int64 `json:"set_id"`
|
SetID int64 `json:"set_id"`
|
||||||
QuestionID int64 `json:"question_id"`
|
QuestionID int64 `json:"question_id"`
|
||||||
DisplayOrder int32 `json:"display_order"`
|
DisplayOrder int32 `json:"display_order"`
|
||||||
QuestionText string `json:"question_text"`
|
QuestionText string `json:"question_text"`
|
||||||
QuestionType string `json:"question_type"`
|
QuestionType string `json:"question_type"`
|
||||||
DifficultyLevel *string `json:"difficulty_level,omitempty"`
|
DifficultyLevel *string `json:"difficulty_level,omitempty"`
|
||||||
Points int32 `json:"points"`
|
Points int32 `json:"points"`
|
||||||
Explanation *string `json:"explanation,omitempty"`
|
Explanation *string `json:"explanation,omitempty"`
|
||||||
Tips *string `json:"tips,omitempty"`
|
Tips *string `json:"tips,omitempty"`
|
||||||
VoicePrompt *string `json:"voice_prompt,omitempty"`
|
VoicePrompt *string `json:"voice_prompt,omitempty"`
|
||||||
SampleAnswerVoicePrompt *string `json:"sample_answer_voice_prompt,omitempty"`
|
SampleAnswerVoicePrompt *string `json:"sample_answer_voice_prompt,omitempty"`
|
||||||
ImageURL *string `json:"image_url,omitempty"`
|
ImageURL *string `json:"image_url,omitempty"`
|
||||||
AudioCorrectAnswerText *string `json:"audio_correct_answer_text,omitempty"`
|
AudioCorrectAnswerText *string `json:"audio_correct_answer_text,omitempty"`
|
||||||
QuestionStatus string `json:"question_status"`
|
QuestionStatus string `json:"question_status"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type paginatedQuestionSetItemsRes struct {
|
type paginatedQuestionSetItemsRes struct {
|
||||||
|
|
@ -1254,10 +1350,10 @@ func (h *Handler) GetQuestionsInSet(c *fiber.Ctx) error {
|
||||||
}
|
}
|
||||||
|
|
||||||
return c.JSON(domain.Response{
|
return c.JSON(domain.Response{
|
||||||
Message: "Questions retrieved successfully",
|
Message: "Questions retrieved successfully",
|
||||||
Success: true,
|
Success: true,
|
||||||
StatusCode: fiber.StatusOK,
|
StatusCode: fiber.StatusOK,
|
||||||
Data: questionResponses,
|
Data: questionResponses,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -167,6 +167,11 @@ func (a *App) initAppRoutes() {
|
||||||
groupV1.Get("/questions/search", a.authMiddleware, a.RequirePermission("questions.search"), h.SearchQuestions)
|
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.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/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.Get("/questions/:id", a.authMiddleware, a.RequirePermission("questions.get"), h.GetQuestionByID)
|
||||||
groupV1.Put("/questions/:id", a.authMiddleware, a.RequirePermission("questions.update"), h.UpdateQuestion)
|
groupV1.Put("/questions/:id", a.authMiddleware, a.RequirePermission("questions.update"), h.UpdateQuestion)
|
||||||
groupV1.Delete("/questions/:id", a.authMiddleware, a.RequirePermission("questions.delete"), h.DeleteQuestion)
|
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.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/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", 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.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.Post("/payments/:id/cancel", a.authMiddleware, a.RequirePermission("payments.cancel"), h.CancelPayment)
|
||||||
groupV1.Get("/payments/methods", h.GetArifpayPaymentMethods)
|
|
||||||
groupV1.Post("/payments/webhook", h.HandleArifpayWebhook)
|
groupV1.Post("/payments/webhook", h.HandleArifpayWebhook)
|
||||||
|
|
||||||
// Direct Payments
|
// 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