feat: PDF_ATTACHMENT stimulus, dynamic question_text rules, admin builder docs

Add PDF_ATTACHMENT stimulus kind and MinIO pdf upload (media_type=pdf) for question-side PDFs.

Reject top-level question_text on DYNAMIC create/update; omit it from API responses and derive stored text from stimulus only.

Expand DYNAMIC_QUESTION_TYPE_BUILDER_ADMIN_INTEGRATION.md with full API request/response reference and workflows.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Yared Yemane 2026-06-04 11:07:02 -07:00
parent 08a2886654
commit 33355a4b23
8 changed files with 1204 additions and 326 deletions

File diff suppressed because it is too large Load Diff

View File

@ -65,7 +65,7 @@ If you create/update dynamic definitions:
## Step 0 (Optional): Upload Media ## Step 0 (Optional): Upload Media
Use this when question content references audio/image URLs. Use this when question content references audio/image/PDF URLs (e.g. dynamic `IMAGE`, `AUDIO_CLIP`, or `PDF_ATTACHMENT` stimulus).
### Endpoint ### Endpoint
@ -74,7 +74,7 @@ Use this when question content references audio/image URLs.
### Form fields ### Form fields
- `file`: binary - `file`: binary
- `media_type`: `image` or `audio` or `video` - `media_type`: `image`, `audio`, `video`, or `pdf` (PDF is stored in MinIO; response includes presigned `url` and `object_key`)
### Example success response (shape) ### Example success response (shape)

View File

@ -22,6 +22,8 @@ const (
StimulusSelectMissingWords StimulusComponentKind = "SELECT_MISSING_WORDS" StimulusSelectMissingWords StimulusComponentKind = "SELECT_MISSING_WORDS"
StimulusTable StimulusComponentKind = "TABLE" StimulusTable StimulusComponentKind = "TABLE"
StimulusFlowChart StimulusComponentKind = "FLOW_CHART" StimulusFlowChart StimulusComponentKind = "FLOW_CHART"
// StimulusPDFAttachment is question-side PDF content (URL from MinIO upload or HTTPS).
StimulusPDFAttachment StimulusComponentKind = "PDF_ATTACHMENT"
) )
// Response-side components for the question-type builder (Section B — answer types). // Response-side components for the question-type builder (Section B — answer types).
@ -75,6 +77,7 @@ var (
StimulusSelectMissingWords, StimulusSelectMissingWords,
StimulusTable, StimulusTable,
StimulusFlowChart, StimulusFlowChart,
StimulusPDFAttachment,
} }
stimulusSet map[string]struct{} stimulusSet map[string]struct{}
@ -564,27 +567,48 @@ var stimulusTextKindsForQuestionText = []string{
string(StimulusTextPassage), string(StimulusTextPassage),
} }
// ResolveQuestionTextForWrite returns the questions.question_text column value for create/update. // UsesDynamicQuestionPayload reports whether the runtime type stores prompt content in dynamic_payload only.
// explicit is the optional request field; existing is the current DB value on update (empty on create). func UsesDynamicQuestionPayload(questionType string) bool {
// For DYNAMIC questions, text may come from stimulus components (QUESTION_TEXT, INSTRUCTION, TEXT_PASSAGE). return strings.ToUpper(strings.TrimSpace(questionType)) == "DYNAMIC"
func ResolveQuestionTextForWrite(explicit string, questionType string, payload *DynamicQuestionPayload, existing string) (string, error) {
explicit = strings.TrimSpace(explicit)
if explicit != "" {
return explicit, nil
} }
if derived := stimulusTextFromPayload(payload); derived != "" {
// ValidateQuestionTextNotAllowedForDynamic rejects top-level question_text on DYNAMIC create/update requests.
func ValidateQuestionTextNotAllowedForDynamic(questionType string, explicit string) error {
if !UsesDynamicQuestionPayload(questionType) {
return nil
}
if strings.TrimSpace(explicit) != "" {
return fmt.Errorf("question_text is not used for DYNAMIC questions; set prompt text in dynamic_payload stimulus (QUESTION_TEXT, INSTRUCTION, or TEXT_PASSAGE)")
}
return nil
}
// ResolveDynamicStoredQuestionText returns the questions.question_text column value for DYNAMIC rows
// (search/activity logs only; clients read prompt text from dynamic_payload).
func ResolveDynamicStoredQuestionText(payload *DynamicQuestionPayload, existing string) (string, error) {
if derived := StimulusTextFromPayload(payload); derived != "" {
return derived, nil return derived, nil
} }
if strings.TrimSpace(existing) != "" { if strings.TrimSpace(existing) != "" {
return strings.TrimSpace(existing), nil return strings.TrimSpace(existing), nil
} }
if strings.ToUpper(strings.TrimSpace(questionType)) == "DYNAMIC" { return "", fmt.Errorf("dynamic_payload must include prompt text (QUESTION_TEXT, INSTRUCTION, or TEXT_PASSAGE)")
return "", fmt.Errorf("provide question_text or stimulus text (QUESTION_TEXT, INSTRUCTION, or TEXT_PASSAGE) in dynamic_payload")
}
return "", fmt.Errorf("question_text is required")
} }
func stimulusTextFromPayload(payload *DynamicQuestionPayload) string { // QuestionTextJSONField returns question_text for API responses. Omitted (nil) for DYNAMIC questions.
func QuestionTextJSONField(questionType string, stored string) *string {
if UsesDynamicQuestionPayload(questionType) {
return nil
}
stored = strings.TrimSpace(stored)
if stored == "" {
return nil
}
return &stored
}
// StimulusTextFromPayload extracts the first non-empty prompt string from allowed stimulus kinds.
func StimulusTextFromPayload(payload *DynamicQuestionPayload) string {
if payload == nil { if payload == nil {
return "" return ""
} }

View File

@ -5,6 +5,20 @@ import (
"testing" "testing"
) )
func TestStimulusComponentCatalog_includesPDFAttachment(t *testing.T) {
catalog := StimulusComponentCatalog()
found := false
for _, k := range catalog {
if k == string(StimulusPDFAttachment) {
found = true
break
}
}
if !found {
t.Fatalf("expected PDF_ATTACHMENT in stimulus catalog, got %v", catalog)
}
}
func TestValidateDynamicQuestionTypeDefinition_valid(t *testing.T) { func TestValidateDynamicQuestionTypeDefinition_valid(t *testing.T) {
err := ValidateDynamicQuestionTypeDefinition( err := ValidateDynamicQuestionTypeDefinition(
[]string{"INSTRUCTION", "IMAGE"}, []string{"INSTRUCTION", "IMAGE"},
@ -15,6 +29,16 @@ func TestValidateDynamicQuestionTypeDefinition_valid(t *testing.T) {
} }
} }
func TestValidateDynamicQuestionTypeDefinition_pdfAttachmentStimulus(t *testing.T) {
err := ValidateDynamicQuestionTypeDefinition(
[]string{"QUESTION_TEXT", "PDF_ATTACHMENT"},
[]string{"SHORT_ANSWER"},
)
if err != nil {
t.Fatalf("expected nil, got %v", err)
}
}
func TestValidateDynamicQuestionTypeDefinition_unknownStimulus(t *testing.T) { func TestValidateDynamicQuestionTypeDefinition_unknownStimulus(t *testing.T) {
err := ValidateDynamicQuestionTypeDefinition( err := ValidateDynamicQuestionTypeDefinition(
[]string{"NOT_A_KIND"}, []string{"NOT_A_KIND"},
@ -177,58 +201,65 @@ func TestResolveQuestionTypeDefinitionForQuestion_linkedDefinition(t *testing.T)
} }
} }
func TestResolveQuestionTextForWrite_explicit(t *testing.T) { func TestValidateQuestionTextNotAllowedForDynamic(t *testing.T) {
got, err := ResolveQuestionTextForWrite("Hello", "DYNAMIC", nil, "") if err := ValidateQuestionTextNotAllowedForDynamic("DYNAMIC", "nope"); err == nil {
if err != nil || got != "Hello" { t.Fatal("expected error when question_text sent for DYNAMIC")
t.Fatalf("expected Hello, got %q err=%v", got, err) }
if err := ValidateQuestionTextNotAllowedForDynamic("DYNAMIC", ""); err != nil {
t.Fatalf("expected nil for empty explicit, got %v", err)
}
if err := ValidateQuestionTextNotAllowedForDynamic("MCQ", "ok"); err != nil {
t.Fatalf("expected nil for legacy, got %v", err)
} }
} }
func TestResolveQuestionTextForWrite_fromQUESTION_TEXTStimulus(t *testing.T) { func TestResolveDynamicStoredQuestionText_fromQUESTION_TEXTStimulus(t *testing.T) {
payload := &DynamicQuestionPayload{ payload := &DynamicQuestionPayload{
Stimulus: []DynamicElementInstance{ Stimulus: []DynamicElementInstance{
{ID: "prompt", Kind: "QUESTION_TEXT", Value: "Pick the correct answer."}, {ID: "prompt", Kind: "QUESTION_TEXT", Value: "Pick the correct answer."},
}, },
} }
got, err := ResolveQuestionTextForWrite("", "DYNAMIC", payload, "") got, err := ResolveDynamicStoredQuestionText(payload, "")
if err != nil || got != "Pick the correct answer." { if err != nil || got != "Pick the correct answer." {
t.Fatalf("expected derived text, got %q err=%v", got, err) t.Fatalf("expected derived text, got %q err=%v", got, err)
} }
} }
func TestResolveQuestionTextForWrite_fromINSTRUCTIONStimulus(t *testing.T) { func TestResolveDynamicStoredQuestionText_fromINSTRUCTIONStimulus(t *testing.T) {
payload := &DynamicQuestionPayload{ payload := &DynamicQuestionPayload{
Stimulus: []DynamicElementInstance{ Stimulus: []DynamicElementInstance{
{ID: "prompt", Kind: "INSTRUCTION", Value: "Choose true or false."}, {ID: "prompt", Kind: "INSTRUCTION", Value: "Choose true or false."},
}, },
} }
got, err := ResolveQuestionTextForWrite("", "DYNAMIC", payload, "") got, err := ResolveDynamicStoredQuestionText(payload, "")
if err != nil || got != "Choose true or false." { if err != nil || got != "Choose true or false." {
t.Fatalf("expected derived text, got %q err=%v", got, err) t.Fatalf("expected derived text, got %q err=%v", got, err)
} }
} }
func TestResolveQuestionTextForWrite_dynamicMissingText(t *testing.T) { func TestResolveDynamicStoredQuestionText_missingText(t *testing.T) {
_, err := ResolveQuestionTextForWrite("", "DYNAMIC", &DynamicQuestionPayload{}, "") _, err := ResolveDynamicStoredQuestionText(&DynamicQuestionPayload{}, "")
if err == nil { if err == nil {
t.Fatal("expected error when no question text source") t.Fatal("expected error when no prompt in payload")
} }
} }
func TestResolveQuestionTextForWrite_legacyRequiresText(t *testing.T) { func TestResolveDynamicStoredQuestionText_updateKeepsExisting(t *testing.T) {
_, err := ResolveQuestionTextForWrite("", "MCQ", nil, "") got, err := ResolveDynamicStoredQuestionText(nil, "Previous title")
if err == nil || !strings.Contains(err.Error(), "question_text is required") {
t.Fatalf("expected legacy required error, got %v", err)
}
}
func TestResolveQuestionTextForWrite_updateKeepsExisting(t *testing.T) {
got, err := ResolveQuestionTextForWrite("", "DYNAMIC", nil, "Previous title")
if err != nil || got != "Previous title" { if err != nil || got != "Previous title" {
t.Fatalf("expected existing text, got %q err=%v", got, err) t.Fatalf("expected existing text, got %q err=%v", got, err)
} }
} }
func TestQuestionTextJSONField_omitsDynamic(t *testing.T) {
if got := QuestionTextJSONField("DYNAMIC", "stored"); got != nil {
t.Fatalf("expected nil for DYNAMIC, got %v", got)
}
if got := QuestionTextJSONField("MCQ", "Pick one"); got == nil || *got != "Pick one" {
t.Fatalf("expected MCQ text, got %v", got)
}
}
func TestSummarizeQuestionSetQuestionTypes_mergesLegacyAndLinked(t *testing.T) { func TestSummarizeQuestionSetQuestionTypes_mergesLegacyAndLinked(t *testing.T) {
id := int64(10) id := int64(10)
summary := SummarizeQuestionSetQuestionTypes([]QuestionSetQuestionTypeGroup{ summary := SummarizeQuestionSetQuestionTypes([]QuestionSetQuestionTypeGroup{

View File

@ -8,6 +8,7 @@ import (
"io" "io"
"net/http" "net/http"
"net/url" "net/url"
"path"
"strconv" "strconv"
"strings" "strings"
"time" "time"
@ -174,11 +175,11 @@ func (h *Handler) RefreshFileURL(c *fiber.Ctx) error {
}) })
} }
// UploadMedia uploads an image/audio/video file and returns its URL and key. // UploadMedia uploads an image/audio/video/pdf file and returns its URL and key.
// @Summary Upload media file // @Summary Upload media file
// @Tags files // @Tags files
// @Accept multipart/form-data // @Accept multipart/form-data
// @Param media_type formData string true "Media type: image|audio|video" // @Param media_type formData string true "Media type: image|audio|video|pdf"
// @Param file formData file true "Media file" // @Param file formData file true "Media file"
// @Success 200 {object} domain.Response // @Success 200 {object} domain.Response
// @Router /api/v1/files/upload [post] // @Router /api/v1/files/upload [post]
@ -205,10 +206,10 @@ func (h *Handler) UploadMedia(c *fiber.Ctx) error {
if mediaType == "" { if mediaType == "" {
mediaType = "file" mediaType = "file"
} }
if mediaType != "image" && mediaType != "audio" && mediaType != "video" { if mediaType != "image" && mediaType != "audio" && mediaType != "video" && mediaType != "pdf" {
return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{ return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{
Message: "Invalid media_type", Message: "Invalid media_type",
Error: "media_type must be one of: image, audio, video", Error: "media_type must be one of: image, audio, video, pdf",
}) })
} }
@ -218,6 +219,8 @@ func (h *Handler) UploadMedia(c *fiber.Ctx) error {
maxSize = 10 * 1024 * 1024 maxSize = 10 * 1024 * 1024
case "audio": case "audio":
maxSize = 50 * 1024 * 1024 maxSize = 50 * 1024 * 1024
case "pdf":
maxSize = 25 * 1024 * 1024
case "video": case "video":
maxSize = 500 * 1024 * 1024 maxSize = 500 * 1024 * 1024
} }
@ -226,9 +229,9 @@ func (h *Handler) UploadMedia(c *fiber.Ctx) error {
Message: "Vimeo service is not available for video uploads", Message: "Vimeo service is not available for video uploads",
}) })
} }
if (mediaType == "image" || mediaType == "audio") && h.minioSvc == nil { if (mediaType == "image" || mediaType == "audio" || mediaType == "pdf") && h.minioSvc == nil {
return c.Status(fiber.StatusServiceUnavailable).JSON(domain.ErrorResponse{ return c.Status(fiber.StatusServiceUnavailable).JSON(domain.ErrorResponse{
Message: "MinIO service is not available for image/audio uploads", Message: "MinIO service is not available for image/audio/pdf uploads",
}) })
} }
@ -396,6 +399,15 @@ func normalizeAndValidateMediaContentType(mediaType, contentType, fileName strin
if !strings.HasPrefix(contentType, "video/") { if !strings.HasPrefix(contentType, "video/") {
return "", fmt.Errorf("only video files are allowed") return "", fmt.Errorf("only video files are allowed")
} }
case "pdf":
if contentType == "application/octet-stream" {
if ext := strings.ToLower(path.Ext(fileName)); ext == ".pdf" {
contentType = "application/pdf"
}
}
if contentType != "application/pdf" {
return "", fmt.Errorf("only PDF files are allowed")
}
} }
return contentType, nil return contentType, nil

View File

@ -0,0 +1,20 @@
package handlers
import "testing"
func TestNormalizeAndValidateMediaContentType_pdf(t *testing.T) {
got, err := normalizeAndValidateMediaContentType("pdf", "application/pdf", "reading-passage.pdf")
if err != nil || got != "application/pdf" {
t.Fatalf("expected application/pdf, got %q err=%v", got, err)
}
got, err = normalizeAndValidateMediaContentType("pdf", "application/octet-stream", "notes.pdf")
if err != nil || got != "application/pdf" {
t.Fatalf("expected pdf from extension, got %q err=%v", got, err)
}
_, err = normalizeAndValidateMediaContentType("pdf", "image/png", "file.png")
if err == nil {
t.Fatal("expected error for non-pdf content")
}
}

View File

@ -28,12 +28,7 @@ func (h *Handler) CreateAssessmentQuestion(c *fiber.Ctx) error {
} }
questionType := normalizeRuntimeQuestionType(req.QuestionType) questionType := normalizeRuntimeQuestionType(req.QuestionType)
questionText, err := domain.ResolveQuestionTextForWrite( questionText, err := resolveStoredQuestionText(questionType, req.QuestionText, req.DynamicPayload, "")
optionalTrimmedString(req.QuestionText),
questionType,
req.DynamicPayload,
"",
)
if err != nil { if err != nil {
return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{ return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{
Message: "Invalid question_text", Message: "Invalid question_text",
@ -88,7 +83,7 @@ func (h *Handler) CreateAssessmentQuestion(c *fiber.Ctx) error {
Success: true, Success: true,
Data: questionRes{ Data: questionRes{
ID: question.ID, ID: question.ID,
QuestionText: question.QuestionText, QuestionText: questionTextField(question.QuestionType, question.QuestionText),
QuestionType: question.QuestionType, QuestionType: question.QuestionType,
Status: question.Status, Status: question.Status,
CreatedAt: question.CreatedAt.String(), CreatedAt: question.CreatedAt.String(),
@ -136,7 +131,7 @@ func (h *Handler) ListAssessmentQuestions(c *fiber.Ctx) error {
questionResponses = append(questionResponses, questionRes{ questionResponses = append(questionResponses, questionRes{
ID: q.ID, ID: q.ID,
QuestionText: q.QuestionText, QuestionText: questionTextField(q.QuestionType, q.QuestionText),
QuestionType: q.QuestionType, QuestionType: q.QuestionType,
DifficultyLevel: q.DifficultyLevel, DifficultyLevel: q.DifficultyLevel,
Points: q.Points, Points: q.Points,
@ -207,7 +202,7 @@ func (h *Handler) GetAssessmentQuestionByID(c *fiber.Ctx) error {
Message: "Question fetched successfully", Message: "Question fetched successfully",
Data: questionRes{ Data: questionRes{
ID: question.ID, ID: question.ID,
QuestionText: question.QuestionText, QuestionText: questionTextField(question.QuestionType, question.QuestionText),
QuestionType: question.QuestionType, QuestionType: question.QuestionType,
DifficultyLevel: question.DifficultyLevel, DifficultyLevel: question.DifficultyLevel,
Points: question.Points, Points: question.Points,

View File

@ -58,7 +58,7 @@ type shortAnswerRes struct {
type questionRes struct { type questionRes struct {
ID int64 `json:"id"` ID int64 `json:"id"`
QuestionText string `json:"question_text"` QuestionText *string `json:"question_text,omitempty"`
QuestionType string `json:"question_type"` QuestionType string `json:"question_type"`
QuestionTypeDefinitionID *int64 `json:"question_type_definition_id,omitempty"` QuestionTypeDefinitionID *int64 `json:"question_type_definition_id,omitempty"`
DynamicPayload *domain.DynamicQuestionPayload `json:"dynamic_payload,omitempty"` DynamicPayload *domain.DynamicQuestionPayload `json:"dynamic_payload,omitempty"`
@ -96,9 +96,36 @@ func optionalTrimmedString(s *string) string {
return strings.TrimSpace(*s) return strings.TrimSpace(*s)
} }
func resolveStoredQuestionText(questionType string, explicit *string, payload *domain.DynamicQuestionPayload, existing string) (string, error) {
exp := optionalTrimmedString(explicit)
if err := domain.ValidateQuestionTextNotAllowedForDynamic(questionType, exp); err != nil {
return "", err
}
if domain.UsesDynamicQuestionPayload(questionType) {
return domain.ResolveDynamicStoredQuestionText(payload, existing)
}
if exp == "" {
return "", fmt.Errorf("question_text is required")
}
return exp, nil
}
func questionTextField(questionType string, stored string) *string {
return domain.QuestionTextJSONField(questionType, stored)
}
func activityLogQuestionSummary(questionType string, stored string, payload *domain.DynamicQuestionPayload) string {
if domain.UsesDynamicQuestionPayload(questionType) {
if s := domain.StimulusTextFromPayload(payload); s != "" {
return s
}
}
return stored
}
// 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). question_text is optional for DYNAMIC questions when stimulus includes QUESTION_TEXT, INSTRUCTION, or TEXT_PASSAGE; it is required for legacy types (MCQ, TRUE_FALSE, SHORT_ANSWER, AUDIO). // @Description Creates a new question with options (for MCQ/TRUE_FALSE) or short answers (for SHORT_ANSWER). DYNAMIC questions must not send question_text; use dynamic_payload stimulus instead. Legacy types require question_text.
// @Tags questions // @Tags questions
// @Accept json // @Accept json
// @Produce json // @Produce json
@ -193,12 +220,7 @@ func (h *Handler) CreateQuestion(c *fiber.Ctx) error {
}) })
} }
questionText, err := domain.ResolveQuestionTextForWrite( questionText, err := resolveStoredQuestionText(questionType, req.QuestionText, req.DynamicPayload, "")
optionalTrimmedString(req.QuestionText),
questionType,
req.DynamicPayload,
"",
)
if err != nil { if err != nil {
return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{ return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{
Message: "Invalid question_text", Message: "Invalid question_text",
@ -240,13 +262,13 @@ func (h *Handler) CreateQuestion(c *fiber.Ctx) error {
"question_type": question.QuestionType, "question_type": question.QuestionType,
"question_type_definition_id": req.QuestionTypeDefinitionID, "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: "+activityLogQuestionSummary(question.QuestionType, question.QuestionText, question.DynamicPayload), meta, &ip, &ua)
return c.Status(fiber.StatusCreated).JSON(domain.Response{ return c.Status(fiber.StatusCreated).JSON(domain.Response{
Message: "Question created successfully", Message: "Question created successfully",
Data: questionRes{ Data: questionRes{
ID: question.ID, ID: question.ID,
QuestionText: question.QuestionText, QuestionText: questionTextField(question.QuestionType, question.QuestionText),
QuestionType: question.QuestionType, QuestionType: question.QuestionType,
QuestionTypeDefinitionID: question.QuestionTypeDefinitionID, QuestionTypeDefinitionID: question.QuestionTypeDefinitionID,
DynamicPayload: question.DynamicPayload, DynamicPayload: question.DynamicPayload,
@ -319,7 +341,7 @@ func (h *Handler) GetQuestionByID(c *fiber.Ctx) error {
Message: "Question retrieved successfully", Message: "Question retrieved successfully",
Data: questionRes{ Data: questionRes{
ID: question.ID, ID: question.ID,
QuestionText: question.QuestionText, QuestionText: questionTextField(question.QuestionType, question.QuestionText),
QuestionType: question.QuestionType, QuestionType: question.QuestionType,
QuestionTypeDefinitionID: question.QuestionTypeDefinitionID, QuestionTypeDefinitionID: question.QuestionTypeDefinitionID,
DynamicPayload: question.DynamicPayload, DynamicPayload: question.DynamicPayload,
@ -386,7 +408,7 @@ func (h *Handler) ListQuestions(c *fiber.Ctx) error {
for _, q := range questions { for _, q := range questions {
questionResponses = append(questionResponses, questionRes{ questionResponses = append(questionResponses, questionRes{
ID: q.ID, ID: q.ID,
QuestionText: q.QuestionText, QuestionText: questionTextField(q.QuestionType, q.QuestionText),
QuestionType: q.QuestionType, QuestionType: q.QuestionType,
QuestionTypeDefinitionID: q.QuestionTypeDefinitionID, QuestionTypeDefinitionID: q.QuestionTypeDefinitionID,
DynamicPayload: q.DynamicPayload, DynamicPayload: q.DynamicPayload,
@ -446,7 +468,7 @@ func (h *Handler) SearchQuestions(c *fiber.Ctx) error {
for _, q := range questions { for _, q := range questions {
questionResponses = append(questionResponses, questionRes{ questionResponses = append(questionResponses, questionRes{
ID: q.ID, ID: q.ID,
QuestionText: q.QuestionText, QuestionText: questionTextField(q.QuestionType, q.QuestionText),
QuestionType: q.QuestionType, QuestionType: q.QuestionType,
QuestionTypeDefinitionID: q.QuestionTypeDefinitionID, QuestionTypeDefinitionID: q.QuestionTypeDefinitionID,
DynamicPayload: q.DynamicPayload, DynamicPayload: q.DynamicPayload,
@ -604,12 +626,7 @@ func (h *Handler) UpdateQuestion(c *fiber.Ctx) error {
}) })
} }
questionText, err := domain.ResolveQuestionTextForWrite( questionText, err := resolveStoredQuestionText(questionType, req.QuestionText, effectiveDynamicPayload, existingQuestion.QuestionText)
optionalTrimmedString(req.QuestionText),
questionType,
effectiveDynamicPayload,
existingQuestion.QuestionText,
)
if err != nil { if err != nil {
return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{ return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{
Message: "Invalid question_text", Message: "Invalid question_text",
@ -1245,7 +1262,7 @@ type questionSetItemRes struct {
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,omitempty"`
QuestionType string `json:"question_type"` QuestionType string `json:"question_type"`
DynamicPayload *domain.DynamicQuestionPayload `json:"dynamic_payload,omitempty"` DynamicPayload *domain.DynamicQuestionPayload `json:"dynamic_payload,omitempty"`
DifficultyLevel *string `json:"difficulty_level,omitempty"` DifficultyLevel *string `json:"difficulty_level,omitempty"`
@ -1274,7 +1291,7 @@ func questionSetItemsToRes(items []domain.QuestionSetItemWithQuestion) []questio
SetID: item.SetID, SetID: item.SetID,
QuestionID: item.QuestionID, QuestionID: item.QuestionID,
DisplayOrder: item.DisplayOrder, DisplayOrder: item.DisplayOrder,
QuestionText: item.QuestionText, QuestionText: questionTextField(item.QuestionType, item.QuestionText),
QuestionType: item.QuestionType, QuestionType: item.QuestionType,
DynamicPayload: item.DynamicPayload, DynamicPayload: item.DynamicPayload,
DifficultyLevel: item.DifficultyLevel, DifficultyLevel: item.DifficultyLevel,
@ -1477,7 +1494,7 @@ func (h *Handler) GetQuestionsInSet(c *fiber.Ctx) error {
questionResponses = append(questionResponses, questionRes{ questionResponses = append(questionResponses, questionRes{
ID: question.ID, ID: question.ID,
QuestionText: question.QuestionText, QuestionText: questionTextField(question.QuestionType, question.QuestionText),
QuestionType: question.QuestionType, QuestionType: question.QuestionType,
QuestionTypeDefinitionID: question.QuestionTypeDefinitionID, QuestionTypeDefinitionID: question.QuestionTypeDefinitionID,
DynamicPayload: question.DynamicPayload, DynamicPayload: question.DynamicPayload,