package practicecontent import ( "Yimaru-Backend/internal/domain" "context" "fmt" "strings" ) func normalizeRuntimeQuestionType(v string) string { return strings.ToUpper(strings.TrimSpace(v)) } func optionalTrimmedString(s *string) string { if s == nil { return "" } 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 == "" { if strings.TrimSpace(existing) != "" { return strings.TrimSpace(existing), nil } return "", fmt.Errorf("question_text is required") } return exp, nil } func buildQuestionInput( item domain.FullUpdatePracticeQuestionItem, existing *domain.Question, getDefinition func(id int64) (domain.QuestionTypeDefinition, error), ) (domain.CreateQuestionInput, error) { var questionType string if existing != nil { questionType = existing.QuestionType } if item.QuestionType != nil { questionType = normalizeRuntimeQuestionType(*item.QuestionType) } if questionType == "" { return domain.CreateQuestionInput{}, fmt.Errorf("%w: question_type is required for new questions", ErrInvalidQuestionItem) } effectiveDefinitionID := (*int64)(nil) if existing != nil { effectiveDefinitionID = existing.QuestionTypeDefinitionID } if item.QuestionTypeDefinitionID != nil { effectiveDefinitionID = item.QuestionTypeDefinitionID } effectiveDynamicPayload := (*domain.DynamicQuestionPayload)(nil) if existing != nil { effectiveDynamicPayload = existing.DynamicPayload } if item.DynamicPayload != nil { effectiveDynamicPayload = item.DynamicPayload } if effectiveDefinitionID != nil { def, err := getDefinition(*effectiveDefinitionID) if err != nil { return domain.CreateQuestionInput{}, fmt.Errorf("invalid question_type_definition_id: %w", err) } if questionType == "" { questionType = "DYNAMIC" } if questionType != "DYNAMIC" { return domain.CreateQuestionInput{}, fmt.Errorf("%w: question_type must be DYNAMIC when question_type_definition_id is provided", ErrInvalidQuestionItem) } if effectiveDynamicPayload == nil { return domain.CreateQuestionInput{}, fmt.Errorf("%w: dynamic_payload is required for dynamic questions", ErrInvalidQuestionItem) } if err := domain.ValidateDynamicPayloadAgainstDefinition(*effectiveDynamicPayload, def); err != nil { return domain.CreateQuestionInput{}, err } } if effectiveDefinitionID == nil && item.DynamicPayload != nil { return domain.CreateQuestionInput{}, fmt.Errorf("%w: dynamic_payload requires question_type_definition_id", ErrInvalidQuestionItem) } switch questionType { case "MCQ", "TRUE_FALSE", "SHORT_ANSWER", "AUDIO", "DYNAMIC": default: return domain.CreateQuestionInput{}, fmt.Errorf("%w: question_type must be one of MCQ, TRUE_FALSE, SHORT_ANSWER, AUDIO, DYNAMIC", ErrInvalidQuestionItem) } if questionType == "DYNAMIC" && effectiveDefinitionID == nil { return domain.CreateQuestionInput{}, fmt.Errorf("%w: question_type_definition_id is required for DYNAMIC question_type", ErrInvalidQuestionItem) } existingText := "" if existing != nil { existingText = existing.QuestionText } questionText, err := resolveStoredQuestionText(questionType, item.QuestionText, effectiveDynamicPayload, existingText) if err != nil { return domain.CreateQuestionInput{}, err } return domain.CreateQuestionInput{ QuestionText: questionText, QuestionType: questionType, QuestionTypeDefinitionID: effectiveDefinitionID, DynamicPayload: effectiveDynamicPayload, DifficultyLevel: coalesceStringPtr(item.DifficultyLevel, existingDifficulty(existing)), Points: coalesceInt32Ptr(item.Points, existingPoints(existing)), Explanation: coalesceStringPtr(item.Explanation, existingExplanation(existing)), Tips: coalesceStringPtr(item.Tips, existingTips(existing)), VoicePrompt: coalesceStringPtr(item.VoicePrompt, existingVoicePrompt(existing)), SampleAnswerVoicePrompt: coalesceStringPtr(item.SampleAnswerVoicePrompt, existingSampleVoice(existing)), ImageURL: coalesceStringPtr(item.ImageURL, existingImageURL(existing)), Status: coalesceStringPtr(item.Status, existingStatus(existing)), Options: item.Options, ShortAnswers: item.ShortAnswers, AudioCorrectAnswerText: item.AudioCorrectAnswerText, }, nil } func existingDifficulty(q *domain.Question) *string { if q == nil { return nil } return q.DifficultyLevel } func existingPoints(q *domain.Question) *int32 { if q == nil { return nil } p := q.Points return &p } func existingExplanation(q *domain.Question) *string { if q == nil { return nil } return q.Explanation } func existingTips(q *domain.Question) *string { if q == nil { return nil } return q.Tips } func existingVoicePrompt(q *domain.Question) *string { if q == nil { return nil } return q.VoicePrompt } func existingSampleVoice(q *domain.Question) *string { if q == nil { return nil } return q.SampleAnswerVoicePrompt } func existingImageURL(q *domain.Question) *string { if q == nil { return nil } return q.ImageURL } func existingStatus(q *domain.Question) *string { if q == nil { return nil } s := q.Status return &s } func coalesceStringPtr(in *string, existing *string) *string { if in != nil { return in } return existing } func coalesceInt32Ptr(in *int32, existing *int32) *int32 { if in != nil { return in } return existing } func displayOrderForItem(item domain.FullUpdatePracticeQuestionItem, index int) int32 { if item.DisplayOrder != nil && *item.DisplayOrder > 0 { return *item.DisplayOrder } return int32(index + 1) } func (s *Service) replaceQuestionChildren(ctx context.Context, questionID int64, input domain.CreateQuestionInput) error { if err := s.questions.DeleteOptionsByQuestionID(ctx, questionID); err != nil { return err } for _, opt := range input.Options { if _, err := s.questions.CreateQuestionOption(ctx, questionID, opt.OptionText, opt.OptionOrder, opt.IsCorrect); err != nil { return err } } if err := s.questions.DeleteShortAnswersByQuestionID(ctx, questionID); err != nil { return err } for _, sa := range input.ShortAnswers { if _, err := s.questions.CreateQuestionShortAnswer(ctx, questionID, sa.AcceptableAnswer, sa.MatchType); err != nil { return err } } return nil } func (s *Service) syncQuestionsInSet(ctx context.Context, setID int64, items []domain.FullUpdatePracticeQuestionItem) ([]domain.QuestionWithDetails, error) { currentItems, err := s.questions.GetQuestionSetItems(ctx, setID) if err != nil { return nil, err } if len(items) == 0 { for _, it := range currentItems { if err := s.questions.RemoveQuestionFromSet(ctx, setID, it.QuestionID); err != nil { return nil, err } } return []domain.QuestionWithDetails{}, nil } currentInSet := make(map[int64]struct{}, len(currentItems)) for _, it := range currentItems { currentInSet[it.QuestionID] = struct{}{} } getDefinition := func(id int64) (domain.QuestionTypeDefinition, error) { return s.questions.GetQuestionTypeDefinitionByID(ctx, id) } desiredIDs := make(map[int64]struct{}, len(items)) orderedIDs := make([]int64, 0, len(items)) for i, item := range items { order := displayOrderForItem(item, i) if item.ID != nil && *item.ID > 0 { questionID := *item.ID existing, err := s.questions.GetQuestionByID(ctx, questionID) if err != nil { return nil, fmt.Errorf("%w: %d", ErrQuestionNotFound, questionID) } input, err := buildQuestionInput(item, &existing, getDefinition) if err != nil { return nil, err } if err := s.questions.UpdateQuestion(ctx, questionID, input); err != nil { return nil, err } if err := s.replaceQuestionChildren(ctx, questionID, input); err != nil { return nil, err } if _, ok := currentInSet[questionID]; !ok { if _, err := s.questions.AddQuestionToSet(ctx, setID, questionID, &order); err != nil { return nil, err } } else if err := s.questions.UpdateQuestionOrder(ctx, setID, questionID, order); err != nil { return nil, err } desiredIDs[questionID] = struct{}{} orderedIDs = append(orderedIDs, questionID) continue } input, err := buildQuestionInput(item, nil, getDefinition) if err != nil { return nil, err } created, err := s.questions.CreateQuestion(ctx, input) if err != nil { return nil, err } if _, err := s.questions.AddQuestionToSet(ctx, setID, created.ID, &order); err != nil { return nil, err } desiredIDs[created.ID] = struct{}{} orderedIDs = append(orderedIDs, created.ID) } for _, it := range currentItems { if _, keep := desiredIDs[it.QuestionID]; !keep { if err := s.questions.RemoveQuestionFromSet(ctx, setID, it.QuestionID); err != nil { return nil, err } } } out := make([]domain.QuestionWithDetails, 0, len(orderedIDs)) for _, id := range orderedIDs { q, err := s.questions.GetQuestionWithDetails(ctx, id) if err != nil { return nil, err } out = append(out, q) } return out, nil }