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:
parent
08a2886654
commit
33355a4b23
File diff suppressed because it is too large
Load Diff
|
|
@ -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)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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 ""
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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{
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
20
internal/web_server/handlers/file_handler_media_test.go
Normal file
20
internal/web_server/handlers/file_handler_media_test.go
Normal 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")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue
Block a user