Yimaru-BackEnd/internal/services/practicecontent/question_sync.go
Yared Yemane 22464479ae feat: add full practice update endpoints for LMS and exam prep
PUT /practices/:id/full and PUT /exam-prep/practices/:id/full sync practice shell, question set settings, and questions in one request.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-08 02:43:34 -07:00

323 lines
9.3 KiB
Go

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
}