diff --git a/docs/PRACTICE_CREATION_API_GUIDE.md b/docs/PRACTICE_CREATION_API_GUIDE.md index 5c95520..0c65428 100644 --- a/docs/PRACTICE_CREATION_API_GUIDE.md +++ b/docs/PRACTICE_CREATION_API_GUIDE.md @@ -267,7 +267,6 @@ Capture: ```json { - "question_text": "Listen and respond as Speaker B.", "question_type": "DYNAMIC", "question_type_definition_id": 123, "difficulty_level": "MEDIUM", diff --git a/docs/docs.go b/docs/docs.go index e6c8f32..674caf9 100644 --- a/docs/docs.go +++ b/docs/docs.go @@ -14182,9 +14182,6 @@ const docTemplate = `{ }, "handlers.createQuestionReq": { "type": "object", - "required": [ - "question_text" - ], "properties": { "audio_correct_answer_text": { "type": "string" diff --git a/docs/swagger.json b/docs/swagger.json index 4c24a0e..001d189 100644 --- a/docs/swagger.json +++ b/docs/swagger.json @@ -14174,9 +14174,6 @@ }, "handlers.createQuestionReq": { "type": "object", - "required": [ - "question_text" - ], "properties": { "audio_correct_answer_text": { "type": "string" diff --git a/docs/swagger.yaml b/docs/swagger.yaml index a1dd30e..ff97772 100644 --- a/docs/swagger.yaml +++ b/docs/swagger.yaml @@ -1988,8 +1988,6 @@ definitions: type: string voice_prompt: type: string - required: - - question_text type: object handlers.createQuestionSetReq: properties: diff --git a/internal/domain/lms_access.go b/internal/domain/lms_access.go index cc74a79..ef85de4 100644 --- a/internal/domain/lms_access.go +++ b/internal/domain/lms_access.go @@ -2,7 +2,7 @@ package domain // 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. -// 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 // entity's scope. progress_percent keeps the legacy whole-number value; use // progress_percent_precise for decimal precision in learner UIs. diff --git a/internal/domain/question_type_builder.go b/internal/domain/question_type_builder.go index 857b5d7..862557e 100644 --- a/internal/domain/question_type_builder.go +++ b/internal/domain/question_type_builder.go @@ -556,3 +556,59 @@ func onlyAuxiliaryResponseKinds(response []string) bool { } 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)) + } +} diff --git a/internal/domain/question_type_builder_test.go b/internal/domain/question_type_builder_test.go index 2259708..dab763c 100644 --- a/internal/domain/question_type_builder_test.go +++ b/internal/domain/question_type_builder_test.go @@ -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) { id := int64(10) summary := SummarizeQuestionSetQuestionTypes([]QuestionSetQuestionTypeGroup{ diff --git a/internal/domain/role.go b/internal/domain/role.go index cc3dd6a..62c358b 100644 --- a/internal/domain/role.go +++ b/internal/domain/role.go @@ -6,7 +6,7 @@ const ( RoleSuperAdmin Role = "SUPER_ADMIN" RoleAdmin Role = "ADMIN" 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" RoleInstructor Role = "INSTRUCTOR" RoleSupport Role = "SUPPORT" diff --git a/internal/services/lmsprogress/service.go b/internal/services/lmsprogress/service.go index 1652696..7317d6c 100644 --- a/internal/services/lmsprogress/service.go +++ b/internal/services/lmsprogress/service.go @@ -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. -// 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 { if !role.IsCustomerLearnerRole() { p.Access = nil @@ -166,7 +166,7 @@ func (s *Service) ApplyAccessProgram(ctx context.Context, role domain.Role, user return err } } - p.Access = buildLMSEntityAccessFromFraction(ok, reason, done, comp, tot, fraction) + p.Access = finalizeOpenLearnerAccess(role, buildLMSEntityAccessFromFraction(ok, reason, done, comp, tot, fraction)) return nil } @@ -187,7 +187,7 @@ func (s *Service) ApplyAccessCourse(ctx context.Context, role domain.Role, userI return err } } - c.Access = buildLMSEntityAccessFromFraction(ok, reason, done, comp, tot, fraction) + c.Access = finalizeOpenLearnerAccess(role, buildLMSEntityAccessFromFraction(ok, reason, done, comp, tot, fraction)) return nil } @@ -208,7 +208,7 @@ func (s *Service) ApplyAccessModule(ctx context.Context, role domain.Role, userI return err } } - m.Access = buildLMSEntityAccessFromFraction(ok, reason, done, comp, tot, fraction) + m.Access = finalizeOpenLearnerAccess(role, buildLMSEntityAccessFromFraction(ok, reason, done, comp, tot, fraction)) return nil } @@ -229,7 +229,7 @@ func (s *Service) ApplyAccessLesson(ctx context.Context, role domain.Role, userI return err } } - les.Access = buildLMSEntityAccessFromFraction(ok, reason, done, comp, tot, fraction) + les.Access = finalizeOpenLearnerAccess(role, buildLMSEntityAccessFromFraction(ok, reason, done, comp, tot, fraction)) return nil } @@ -243,7 +243,7 @@ func (s *Service) ApplyExamPrepAccessCatalogCourse(ctx context.Context, role dom if err != nil { return err } - cc.Access = buildLMSEntityAccessFromFraction(true, "", done, comp, tot, fraction) + cc.Access = finalizeOpenLearnerAccess(role, buildLMSEntityAccessFromFraction(true, "", done, comp, tot, fraction)) return nil } @@ -257,7 +257,7 @@ func (s *Service) ApplyExamPrepAccessUnit(ctx context.Context, role domain.Role, if err != nil { return err } - u.Access = buildLMSEntityAccessFromFraction(true, "", done, comp, tot, fraction) + u.Access = finalizeOpenLearnerAccess(role, buildLMSEntityAccessFromFraction(true, "", done, comp, tot, fraction)) return nil } @@ -271,7 +271,7 @@ func (s *Service) ApplyExamPrepAccessModule(ctx context.Context, role domain.Rol if err != nil { return err } - m.Access = buildLMSEntityAccessFromFraction(true, "", done, comp, tot, fraction) + m.Access = finalizeOpenLearnerAccess(role, buildLMSEntityAccessFromFraction(true, "", done, comp, tot, fraction)) return nil } @@ -285,7 +285,7 @@ func (s *Service) ApplyExamPrepAccessLesson(ctx context.Context, role domain.Rol if err != nil { return err } - les.Access = buildLMSEntityAccessFromFraction(true, "", done, comp, tot, fraction) + les.Access = finalizeOpenLearnerAccess(role, buildLMSEntityAccessFromFraction(true, "", done, comp, tot, fraction)) return nil } @@ -524,6 +524,22 @@ func lmsProgressComplete(completed, total int32) bool { 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 { c, t, pct, pctPrecise := lmsProgressCounts(completed, total, done) return &domain.LMSEntityAccess{ diff --git a/internal/services/lmsprogress/service_test.go b/internal/services/lmsprogress/service_test.go index 73045c8..b5380ee 100644 --- a/internal/services/lmsprogress/service_test.go +++ b/internal/services/lmsprogress/service_test.go @@ -1,6 +1,10 @@ package lmsprogress -import "testing" +import ( + "testing" + + "Yimaru-Backend/internal/domain" +) func TestLMSProgressCounts(t *testing.T) { 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) { tests := []struct { name string diff --git a/internal/web_server/handlers/initial_assessment.go b/internal/web_server/handlers/initial_assessment.go index e8e22e5..d61d674 100644 --- a/internal/web_server/handlers/initial_assessment.go +++ b/internal/web_server/handlers/initial_assessment.go @@ -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{ - Message: "Validation error", - Error: "question_text is required", + Message: "Invalid question_text", + Error: err.Error(), }) } @@ -54,8 +61,8 @@ func (h *Handler) CreateAssessmentQuestion(c *fiber.Ctx) error { } input := domain.CreateQuestionInput{ - QuestionText: req.QuestionText, - QuestionType: req.QuestionType, + QuestionText: questionText, + QuestionType: questionType, DifficultyLevel: req.DifficultyLevel, Points: req.Points, Explanation: req.Explanation, diff --git a/internal/web_server/handlers/questions.go b/internal/web_server/handlers/questions.go index cbf7f08..dea101d 100644 --- a/internal/web_server/handlers/questions.go +++ b/internal/web_server/handlers/questions.go @@ -26,7 +26,7 @@ type shortAnswerInput struct { } type createQuestionReq struct { - QuestionText string `json:"question_text" validate:"required"` + QuestionText *string `json:"question_text,omitempty"` QuestionType string `json:"question_type"` QuestionTypeDefinitionID *int64 `json:"question_type_definition_id"` DynamicPayload *domain.DynamicQuestionPayload `json:"dynamic_payload"` @@ -89,9 +89,16 @@ func normalizeRuntimeQuestionType(v string) string { return strings.ToUpper(strings.TrimSpace(v)) } +func optionalTrimmedString(s *string) string { + if s == nil { + return "" + } + return strings.TrimSpace(*s) +} + // CreateQuestion godoc // @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 // @Accept 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{ - QuestionText: req.QuestionText, + QuestionText: questionText, QuestionType: questionType, QuestionTypeDefinitionID: req.QuestionTypeDefinitionID, 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) if req.QuestionType != nil { 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{ QuestionText: questionText, QuestionType: questionType,