feat: optional dynamic question_text and OPEN_LEARNER completed access
Derive question_text from QUESTION_TEXT, INSTRUCTION, or TEXT_PASSAGE stimulus for DYNAMIC questions so the top-level field is no longer required on create. OPEN_LEARNER access responses now set is_accessible and is_completed to true on all LMS and exam-prep content, with full progress when totals exist. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
parent
2605877f12
commit
08a2886654
|
|
@ -267,7 +267,6 @@ Capture:
|
||||||
|
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
"question_text": "Listen and respond as Speaker B.",
|
|
||||||
"question_type": "DYNAMIC",
|
"question_type": "DYNAMIC",
|
||||||
"question_type_definition_id": 123,
|
"question_type_definition_id": 123,
|
||||||
"difficulty_level": "MEDIUM",
|
"difficulty_level": "MEDIUM",
|
||||||
|
|
|
||||||
|
|
@ -14182,9 +14182,6 @@ const docTemplate = `{
|
||||||
},
|
},
|
||||||
"handlers.createQuestionReq": {
|
"handlers.createQuestionReq": {
|
||||||
"type": "object",
|
"type": "object",
|
||||||
"required": [
|
|
||||||
"question_text"
|
|
||||||
],
|
|
||||||
"properties": {
|
"properties": {
|
||||||
"audio_correct_answer_text": {
|
"audio_correct_answer_text": {
|
||||||
"type": "string"
|
"type": "string"
|
||||||
|
|
|
||||||
|
|
@ -14174,9 +14174,6 @@
|
||||||
},
|
},
|
||||||
"handlers.createQuestionReq": {
|
"handlers.createQuestionReq": {
|
||||||
"type": "object",
|
"type": "object",
|
||||||
"required": [
|
|
||||||
"question_text"
|
|
||||||
],
|
|
||||||
"properties": {
|
"properties": {
|
||||||
"audio_correct_answer_text": {
|
"audio_correct_answer_text": {
|
||||||
"type": "string"
|
"type": "string"
|
||||||
|
|
|
||||||
|
|
@ -1988,8 +1988,6 @@ definitions:
|
||||||
type: string
|
type: string
|
||||||
voice_prompt:
|
voice_prompt:
|
||||||
type: string
|
type: string
|
||||||
required:
|
|
||||||
- question_text
|
|
||||||
type: object
|
type: object
|
||||||
handlers.createQuestionSetReq:
|
handlers.createQuestionSetReq:
|
||||||
properties:
|
properties:
|
||||||
|
|
|
||||||
|
|
@ -2,7 +2,7 @@ package domain
|
||||||
|
|
||||||
// LMSEntityAccess describes learner gating for a program, course, module, or lesson.
|
// LMSEntityAccess describes learner gating for a program, course, module, or lesson.
|
||||||
// Included for STUDENT and OPEN_LEARNER; omitted (nil) for staff roles in API responses.
|
// Included for STUDENT and OPEN_LEARNER; omitted (nil) for staff roles in API responses.
|
||||||
// OPEN_LEARNER always has is_accessible true; STUDENT may be false when prerequisites are unmet.
|
// OPEN_LEARNER always has is_accessible and is_completed true; STUDENT reflects real progress and gating.
|
||||||
// Progress fields count completed published practices vs total published practices in the
|
// Progress fields count completed published practices vs total published practices in the
|
||||||
// entity's scope. progress_percent keeps the legacy whole-number value; use
|
// entity's scope. progress_percent keeps the legacy whole-number value; use
|
||||||
// progress_percent_precise for decimal precision in learner UIs.
|
// progress_percent_precise for decimal precision in learner UIs.
|
||||||
|
|
|
||||||
|
|
@ -556,3 +556,59 @@ func onlyAuxiliaryResponseKinds(response []string) bool {
|
||||||
}
|
}
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// stimulusTextKindsForQuestionText are checked in order when deriving question_text from dynamic_payload.
|
||||||
|
var stimulusTextKindsForQuestionText = []string{
|
||||||
|
string(StimulusQuestionText),
|
||||||
|
string(StimulusInstruction),
|
||||||
|
string(StimulusTextPassage),
|
||||||
|
}
|
||||||
|
|
||||||
|
// ResolveQuestionTextForWrite returns the questions.question_text column value for create/update.
|
||||||
|
// explicit is the optional request field; existing is the current DB value on update (empty on create).
|
||||||
|
// For DYNAMIC questions, text may come from stimulus components (QUESTION_TEXT, INSTRUCTION, TEXT_PASSAGE).
|
||||||
|
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 != "" {
|
||||||
|
return derived, nil
|
||||||
|
}
|
||||||
|
if strings.TrimSpace(existing) != "" {
|
||||||
|
return strings.TrimSpace(existing), nil
|
||||||
|
}
|
||||||
|
if strings.ToUpper(strings.TrimSpace(questionType)) == "DYNAMIC" {
|
||||||
|
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 {
|
||||||
|
if payload == nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
for _, want := range stimulusTextKindsForQuestionText {
|
||||||
|
for _, el := range payload.Stimulus {
|
||||||
|
if strings.TrimSpace(el.Kind) != want {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if s := stringFromDynamicElementValue(el.Value); s != "" {
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
func stringFromDynamicElementValue(v interface{}) string {
|
||||||
|
if v == nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
switch t := v.(type) {
|
||||||
|
case string:
|
||||||
|
return strings.TrimSpace(t)
|
||||||
|
default:
|
||||||
|
return strings.TrimSpace(fmt.Sprint(v))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -177,6 +177,58 @@ func TestResolveQuestionTypeDefinitionForQuestion_linkedDefinition(t *testing.T)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestResolveQuestionTextForWrite_explicit(t *testing.T) {
|
||||||
|
got, err := ResolveQuestionTextForWrite("Hello", "DYNAMIC", nil, "")
|
||||||
|
if err != nil || got != "Hello" {
|
||||||
|
t.Fatalf("expected Hello, got %q err=%v", got, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestResolveQuestionTextForWrite_fromQUESTION_TEXTStimulus(t *testing.T) {
|
||||||
|
payload := &DynamicQuestionPayload{
|
||||||
|
Stimulus: []DynamicElementInstance{
|
||||||
|
{ID: "prompt", Kind: "QUESTION_TEXT", Value: "Pick the correct answer."},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
got, err := ResolveQuestionTextForWrite("", "DYNAMIC", payload, "")
|
||||||
|
if err != nil || got != "Pick the correct answer." {
|
||||||
|
t.Fatalf("expected derived text, got %q err=%v", got, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestResolveQuestionTextForWrite_fromINSTRUCTIONStimulus(t *testing.T) {
|
||||||
|
payload := &DynamicQuestionPayload{
|
||||||
|
Stimulus: []DynamicElementInstance{
|
||||||
|
{ID: "prompt", Kind: "INSTRUCTION", Value: "Choose true or false."},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
got, err := ResolveQuestionTextForWrite("", "DYNAMIC", payload, "")
|
||||||
|
if err != nil || got != "Choose true or false." {
|
||||||
|
t.Fatalf("expected derived text, got %q err=%v", got, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestResolveQuestionTextForWrite_dynamicMissingText(t *testing.T) {
|
||||||
|
_, err := ResolveQuestionTextForWrite("", "DYNAMIC", &DynamicQuestionPayload{}, "")
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("expected error when no question text source")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestResolveQuestionTextForWrite_legacyRequiresText(t *testing.T) {
|
||||||
|
_, err := ResolveQuestionTextForWrite("", "MCQ", nil, "")
|
||||||
|
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" {
|
||||||
|
t.Fatalf("expected existing text, got %q err=%v", got, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestSummarizeQuestionSetQuestionTypes_mergesLegacyAndLinked(t *testing.T) {
|
func TestSummarizeQuestionSetQuestionTypes_mergesLegacyAndLinked(t *testing.T) {
|
||||||
id := int64(10)
|
id := int64(10)
|
||||||
summary := SummarizeQuestionSetQuestionTypes([]QuestionSetQuestionTypeGroup{
|
summary := SummarizeQuestionSetQuestionTypes([]QuestionSetQuestionTypeGroup{
|
||||||
|
|
|
||||||
|
|
@ -6,7 +6,7 @@ const (
|
||||||
RoleSuperAdmin Role = "SUPER_ADMIN"
|
RoleSuperAdmin Role = "SUPER_ADMIN"
|
||||||
RoleAdmin Role = "ADMIN"
|
RoleAdmin Role = "ADMIN"
|
||||||
RoleStudent Role = "STUDENT"
|
RoleStudent Role = "STUDENT"
|
||||||
// RoleOpenLearner can consume LMS content like a learner but without sequential prerequisite locking (step-by-step gates).
|
// RoleOpenLearner can consume LMS content like a learner without sequential locks; access APIs show all content accessible and completed.
|
||||||
RoleOpenLearner Role = "OPEN_LEARNER"
|
RoleOpenLearner Role = "OPEN_LEARNER"
|
||||||
RoleInstructor Role = "INSTRUCTOR"
|
RoleInstructor Role = "INSTRUCTOR"
|
||||||
RoleSupport Role = "SUPPORT"
|
RoleSupport Role = "SUPPORT"
|
||||||
|
|
|
||||||
|
|
@ -149,7 +149,7 @@ func (s *Service) CanAccessLesson(ctx context.Context, userID, lessonID int64) (
|
||||||
}
|
}
|
||||||
|
|
||||||
// ApplyAccessProgram sets p.Access for learner roles. Staff roles omit Access from JSON.
|
// ApplyAccessProgram sets p.Access for learner roles. Staff roles omit Access from JSON.
|
||||||
// STUDENT: is_accessible reflects sequential prerequisites; OPEN_LEARNER: always true.
|
// STUDENT: is_accessible reflects sequential prerequisites; OPEN_LEARNER: is_accessible and is_completed always true.
|
||||||
func (s *Service) ApplyAccessProgram(ctx context.Context, role domain.Role, userID int64, p *domain.Program) error {
|
func (s *Service) ApplyAccessProgram(ctx context.Context, role domain.Role, userID int64, p *domain.Program) error {
|
||||||
if !role.IsCustomerLearnerRole() {
|
if !role.IsCustomerLearnerRole() {
|
||||||
p.Access = nil
|
p.Access = nil
|
||||||
|
|
@ -166,7 +166,7 @@ func (s *Service) ApplyAccessProgram(ctx context.Context, role domain.Role, user
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
p.Access = buildLMSEntityAccessFromFraction(ok, reason, done, comp, tot, fraction)
|
p.Access = finalizeOpenLearnerAccess(role, buildLMSEntityAccessFromFraction(ok, reason, done, comp, tot, fraction))
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -187,7 +187,7 @@ func (s *Service) ApplyAccessCourse(ctx context.Context, role domain.Role, userI
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
c.Access = buildLMSEntityAccessFromFraction(ok, reason, done, comp, tot, fraction)
|
c.Access = finalizeOpenLearnerAccess(role, buildLMSEntityAccessFromFraction(ok, reason, done, comp, tot, fraction))
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -208,7 +208,7 @@ func (s *Service) ApplyAccessModule(ctx context.Context, role domain.Role, userI
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
m.Access = buildLMSEntityAccessFromFraction(ok, reason, done, comp, tot, fraction)
|
m.Access = finalizeOpenLearnerAccess(role, buildLMSEntityAccessFromFraction(ok, reason, done, comp, tot, fraction))
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -229,7 +229,7 @@ func (s *Service) ApplyAccessLesson(ctx context.Context, role domain.Role, userI
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
les.Access = buildLMSEntityAccessFromFraction(ok, reason, done, comp, tot, fraction)
|
les.Access = finalizeOpenLearnerAccess(role, buildLMSEntityAccessFromFraction(ok, reason, done, comp, tot, fraction))
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -243,7 +243,7 @@ func (s *Service) ApplyExamPrepAccessCatalogCourse(ctx context.Context, role dom
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
cc.Access = buildLMSEntityAccessFromFraction(true, "", done, comp, tot, fraction)
|
cc.Access = finalizeOpenLearnerAccess(role, buildLMSEntityAccessFromFraction(true, "", done, comp, tot, fraction))
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -257,7 +257,7 @@ func (s *Service) ApplyExamPrepAccessUnit(ctx context.Context, role domain.Role,
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
u.Access = buildLMSEntityAccessFromFraction(true, "", done, comp, tot, fraction)
|
u.Access = finalizeOpenLearnerAccess(role, buildLMSEntityAccessFromFraction(true, "", done, comp, tot, fraction))
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -271,7 +271,7 @@ func (s *Service) ApplyExamPrepAccessModule(ctx context.Context, role domain.Rol
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
m.Access = buildLMSEntityAccessFromFraction(true, "", done, comp, tot, fraction)
|
m.Access = finalizeOpenLearnerAccess(role, buildLMSEntityAccessFromFraction(true, "", done, comp, tot, fraction))
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -285,7 +285,7 @@ func (s *Service) ApplyExamPrepAccessLesson(ctx context.Context, role domain.Rol
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
les.Access = buildLMSEntityAccessFromFraction(true, "", done, comp, tot, fraction)
|
les.Access = finalizeOpenLearnerAccess(role, buildLMSEntityAccessFromFraction(true, "", done, comp, tot, fraction))
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -524,6 +524,22 @@ func lmsProgressComplete(completed, total int32) bool {
|
||||||
return total > 0 && completed >= total
|
return total > 0 && completed >= total
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// finalizeOpenLearnerAccess forces OPEN_LEARNER access JSON to show every item as accessible and completed.
|
||||||
|
func finalizeOpenLearnerAccess(role domain.Role, access *domain.LMSEntityAccess) *domain.LMSEntityAccess {
|
||||||
|
if access == nil || role != domain.RoleOpenLearner {
|
||||||
|
return access
|
||||||
|
}
|
||||||
|
access.IsAccessible = true
|
||||||
|
access.IsCompleted = true
|
||||||
|
access.Reason = ""
|
||||||
|
if access.TotalCount > 0 {
|
||||||
|
access.CompletedCount = access.TotalCount
|
||||||
|
access.ProgressPercent = 100
|
||||||
|
access.ProgressPercentPrecise = 100
|
||||||
|
}
|
||||||
|
return access
|
||||||
|
}
|
||||||
|
|
||||||
func buildLMSEntityAccess(ok bool, reason string, done bool, completed, total int32) *domain.LMSEntityAccess {
|
func buildLMSEntityAccess(ok bool, reason string, done bool, completed, total int32) *domain.LMSEntityAccess {
|
||||||
c, t, pct, pctPrecise := lmsProgressCounts(completed, total, done)
|
c, t, pct, pctPrecise := lmsProgressCounts(completed, total, done)
|
||||||
return &domain.LMSEntityAccess{
|
return &domain.LMSEntityAccess{
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,10 @@
|
||||||
package lmsprogress
|
package lmsprogress
|
||||||
|
|
||||||
import "testing"
|
import (
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"Yimaru-Backend/internal/domain"
|
||||||
|
)
|
||||||
|
|
||||||
func TestLMSProgressCounts(t *testing.T) {
|
func TestLMSProgressCounts(t *testing.T) {
|
||||||
tests := []struct {
|
tests := []struct {
|
||||||
|
|
@ -90,6 +94,28 @@ func TestPracticeScopeFraction(t *testing.T) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestFinalizeOpenLearnerAccess(t *testing.T) {
|
||||||
|
incomplete := buildLMSEntityAccessFromFraction(false, "locked", false, 1, 5, 0.2)
|
||||||
|
got := finalizeOpenLearnerAccess(domain.RoleOpenLearner, incomplete)
|
||||||
|
if got == nil || !got.IsAccessible || !got.IsCompleted {
|
||||||
|
t.Fatalf("expected accessible and completed, got %+v", got)
|
||||||
|
}
|
||||||
|
if got.CompletedCount != 5 || got.ProgressPercent != 100 {
|
||||||
|
t.Fatalf("expected full progress, got %+v", got)
|
||||||
|
}
|
||||||
|
|
||||||
|
studentAccess := buildLMSEntityAccessFromFraction(false, "locked", false, 1, 5, 0.2)
|
||||||
|
unchanged := finalizeOpenLearnerAccess(domain.RoleStudent, studentAccess)
|
||||||
|
if unchanged != studentAccess || unchanged.IsCompleted || unchanged.IsAccessible {
|
||||||
|
t.Fatalf("STUDENT access should be unchanged, got %+v", unchanged)
|
||||||
|
}
|
||||||
|
|
||||||
|
emptyScope := finalizeOpenLearnerAccess(domain.RoleOpenLearner, buildLMSEntityAccessFromFraction(true, "", false, 0, 0, 0))
|
||||||
|
if emptyScope == nil || !emptyScope.IsCompleted {
|
||||||
|
t.Fatalf("expected completed even with zero total, got %+v", emptyScope)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestLMSProgressComplete(t *testing.T) {
|
func TestLMSProgressComplete(t *testing.T) {
|
||||||
tests := []struct {
|
tests := []struct {
|
||||||
name string
|
name string
|
||||||
|
|
|
||||||
|
|
@ -27,10 +27,17 @@ func (h *Handler) CreateAssessmentQuestion(c *fiber.Ctx) error {
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
if req.QuestionText == "" {
|
questionType := normalizeRuntimeQuestionType(req.QuestionType)
|
||||||
|
questionText, err := domain.ResolveQuestionTextForWrite(
|
||||||
|
optionalTrimmedString(req.QuestionText),
|
||||||
|
questionType,
|
||||||
|
req.DynamicPayload,
|
||||||
|
"",
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{
|
return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{
|
||||||
Message: "Validation error",
|
Message: "Invalid question_text",
|
||||||
Error: "question_text is required",
|
Error: err.Error(),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -54,8 +61,8 @@ func (h *Handler) CreateAssessmentQuestion(c *fiber.Ctx) error {
|
||||||
}
|
}
|
||||||
|
|
||||||
input := domain.CreateQuestionInput{
|
input := domain.CreateQuestionInput{
|
||||||
QuestionText: req.QuestionText,
|
QuestionText: questionText,
|
||||||
QuestionType: req.QuestionType,
|
QuestionType: questionType,
|
||||||
DifficultyLevel: req.DifficultyLevel,
|
DifficultyLevel: req.DifficultyLevel,
|
||||||
Points: req.Points,
|
Points: req.Points,
|
||||||
Explanation: req.Explanation,
|
Explanation: req.Explanation,
|
||||||
|
|
|
||||||
|
|
@ -26,7 +26,7 @@ type shortAnswerInput struct {
|
||||||
}
|
}
|
||||||
|
|
||||||
type createQuestionReq struct {
|
type createQuestionReq struct {
|
||||||
QuestionText string `json:"question_text" validate:"required"`
|
QuestionText *string `json:"question_text,omitempty"`
|
||||||
QuestionType string `json:"question_type"`
|
QuestionType string `json:"question_type"`
|
||||||
QuestionTypeDefinitionID *int64 `json:"question_type_definition_id"`
|
QuestionTypeDefinitionID *int64 `json:"question_type_definition_id"`
|
||||||
DynamicPayload *domain.DynamicQuestionPayload `json:"dynamic_payload"`
|
DynamicPayload *domain.DynamicQuestionPayload `json:"dynamic_payload"`
|
||||||
|
|
@ -89,9 +89,16 @@ func normalizeRuntimeQuestionType(v string) string {
|
||||||
return strings.ToUpper(strings.TrimSpace(v))
|
return strings.ToUpper(strings.TrimSpace(v))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func optionalTrimmedString(s *string) string {
|
||||||
|
if s == nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return strings.TrimSpace(*s)
|
||||||
|
}
|
||||||
|
|
||||||
// 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). Supports question_type_definition_id for dynamic builder-linked questions.
|
// @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).
|
||||||
// @Tags questions
|
// @Tags questions
|
||||||
// @Accept json
|
// @Accept json
|
||||||
// @Produce json
|
// @Produce json
|
||||||
|
|
@ -186,8 +193,21 @@ func (h *Handler) CreateQuestion(c *fiber.Ctx) error {
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
questionText, err := domain.ResolveQuestionTextForWrite(
|
||||||
|
optionalTrimmedString(req.QuestionText),
|
||||||
|
questionType,
|
||||||
|
req.DynamicPayload,
|
||||||
|
"",
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{
|
||||||
|
Message: "Invalid question_text",
|
||||||
|
Error: err.Error(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
input := domain.CreateQuestionInput{
|
input := domain.CreateQuestionInput{
|
||||||
QuestionText: req.QuestionText,
|
QuestionText: questionText,
|
||||||
QuestionType: questionType,
|
QuestionType: questionType,
|
||||||
QuestionTypeDefinitionID: req.QuestionTypeDefinitionID,
|
QuestionTypeDefinitionID: req.QuestionTypeDefinitionID,
|
||||||
DynamicPayload: req.DynamicPayload,
|
DynamicPayload: req.DynamicPayload,
|
||||||
|
|
@ -519,10 +539,6 @@ func (h *Handler) UpdateQuestion(c *fiber.Ctx) error {
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
questionText := existingQuestion.QuestionText
|
|
||||||
if req.QuestionText != nil {
|
|
||||||
questionText = *req.QuestionText
|
|
||||||
}
|
|
||||||
questionType := normalizeRuntimeQuestionType(existingQuestion.QuestionType)
|
questionType := normalizeRuntimeQuestionType(existingQuestion.QuestionType)
|
||||||
if req.QuestionType != nil {
|
if req.QuestionType != nil {
|
||||||
questionType = normalizeRuntimeQuestionType(*req.QuestionType)
|
questionType = normalizeRuntimeQuestionType(*req.QuestionType)
|
||||||
|
|
@ -588,6 +604,19 @@ func (h *Handler) UpdateQuestion(c *fiber.Ctx) error {
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
questionText, err := domain.ResolveQuestionTextForWrite(
|
||||||
|
optionalTrimmedString(req.QuestionText),
|
||||||
|
questionType,
|
||||||
|
effectiveDynamicPayload,
|
||||||
|
existingQuestion.QuestionText,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{
|
||||||
|
Message: "Invalid question_text",
|
||||||
|
Error: err.Error(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
input := domain.CreateQuestionInput{
|
input := domain.CreateQuestionInput{
|
||||||
QuestionText: questionText,
|
QuestionText: questionText,
|
||||||
QuestionType: questionType,
|
QuestionType: questionType,
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue
Block a user