diff --git a/cmd/main.go b/cmd/main.go index 0f31aae..86a27cf 100644 --- a/cmd/main.go +++ b/cmd/main.go @@ -1,9 +1,6 @@ package main import ( - // "context" - - // "context" _ "Yimaru-Backend/docs" dbgen "Yimaru-Backend/gen/db" "Yimaru-Backend/internal/config" @@ -31,6 +28,7 @@ import ( moduleservice "Yimaru-Backend/internal/services/modules" notificationservice "Yimaru-Backend/internal/services/notification" personasservice "Yimaru-Backend/internal/services/personas" + practicecontentservice "Yimaru-Backend/internal/services/practicecontent" practicesservice "Yimaru-Backend/internal/services/practices" profilefieldoptions "Yimaru-Backend/internal/services/profilefieldoptions" programsservice "Yimaru-Backend/internal/services/programs" @@ -305,6 +303,7 @@ func main() { // LMS practices (under course, module, or lesson) practiceSvc := practicesservice.NewService(store, store, store, store, store, store) + practiceContentSvc := practicecontentservice.NewService(store, store, store, store) // Subscriptions service subscriptionsSvc := subscriptions.NewService(store) @@ -370,6 +369,7 @@ func main() { lessonSvc, lmsProgressSvc, practiceSvc, + practiceContentSvc, subscriptionsSvc, arifpaySvc, chapaSvc, diff --git a/internal/domain/practice_full_update.go b/internal/domain/practice_full_update.go new file mode 100644 index 0000000..1aaadc9 --- /dev/null +++ b/internal/domain/practice_full_update.go @@ -0,0 +1,61 @@ +package domain + +// FullUpdatePracticeInput updates a practice shell, its linked question set, and set questions in one request. +type FullUpdatePracticeInput struct { + Practice *FullUpdatePracticeShellInput `json:"practice,omitempty"` + QuestionSet *FullUpdateQuestionSetInput `json:"question_set,omitempty"` + Questions []FullUpdatePracticeQuestionItem `json:"questions,omitempty"` +} + +// FullUpdatePracticeShellInput updates LMS or exam-prep practice metadata (parent is immutable). +type FullUpdatePracticeShellInput struct { + Title *string `json:"title,omitempty"` + StoryDescription *string `json:"story_description,omitempty"` + StoryImage *string `json:"story_image,omitempty"` + PersonaID *int64 `json:"persona_id,omitempty"` + QuestionSetID *int64 `json:"question_set_id,omitempty"` + QuickTips *string `json:"quick_tips,omitempty"` + PublishStatus *string `json:"publish_status,omitempty"` +} + +// FullUpdateQuestionSetInput updates settings on the practice's linked question set. +type FullUpdateQuestionSetInput struct { + Title *string `json:"title,omitempty"` + Description *string `json:"description,omitempty"` + BannerImage *string `json:"banner_image,omitempty"` + Persona *string `json:"persona,omitempty"` + TimeLimitMinutes *int32 `json:"time_limit_minutes,omitempty"` + PassingScore *int32 `json:"passing_score,omitempty"` + ShuffleQuestions *bool `json:"shuffle_questions,omitempty"` + Status *string `json:"status,omitempty"` + IntroVideoURL *string `json:"intro_video_url,omitempty"` +} + +// FullUpdatePracticeQuestionItem creates or updates a question and its membership in the practice question set. +// Omit id to create a new question. display_order defaults to the item's position in the questions array (1-based). +type FullUpdatePracticeQuestionItem struct { + ID *int64 `json:"id,omitempty"` + DisplayOrder *int32 `json:"display_order,omitempty"` + QuestionText *string `json:"question_text,omitempty"` + QuestionType *string `json:"question_type,omitempty"` + QuestionTypeDefinitionID *int64 `json:"question_type_definition_id,omitempty"` + DynamicPayload *DynamicQuestionPayload `json:"dynamic_payload,omitempty"` + DifficultyLevel *string `json:"difficulty_level,omitempty"` + Points *int32 `json:"points,omitempty"` + Explanation *string `json:"explanation,omitempty"` + Tips *string `json:"tips,omitempty"` + VoicePrompt *string `json:"voice_prompt,omitempty"` + SampleAnswerVoicePrompt *string `json:"sample_answer_voice_prompt,omitempty"` + ImageURL *string `json:"image_url,omitempty"` + Status *string `json:"status,omitempty"` + Options []CreateQuestionOptionInput `json:"options,omitempty"` + ShortAnswers []CreateShortAnswerInput `json:"short_answers,omitempty"` + AudioCorrectAnswerText *string `json:"audio_correct_answer_text,omitempty"` +} + +// FullUpdatePracticeResult is returned after a successful full practice update. +type FullUpdatePracticeResult struct { + Practice interface{} `json:"practice"` + QuestionSet QuestionSet `json:"question_set"` + Questions []QuestionWithDetails `json:"questions"` +} diff --git a/internal/services/practicecontent/errors.go b/internal/services/practicecontent/errors.go new file mode 100644 index 0000000..5f3603d --- /dev/null +++ b/internal/services/practicecontent/errors.go @@ -0,0 +1,10 @@ +package practicecontent + +import "errors" + +var ( + ErrPracticeNotFound = errors.New("practice not found") + ErrQuestionSetNotFound = errors.New("question set not found") + ErrQuestionNotFound = errors.New("question not found") + ErrInvalidQuestionItem = errors.New("invalid question item") +) diff --git a/internal/services/practicecontent/question_sync.go b/internal/services/practicecontent/question_sync.go new file mode 100644 index 0000000..9e22234 --- /dev/null +++ b/internal/services/practicecontent/question_sync.go @@ -0,0 +1,322 @@ +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 +} diff --git a/internal/services/practicecontent/service.go b/internal/services/practicecontent/service.go new file mode 100644 index 0000000..bd554fa --- /dev/null +++ b/internal/services/practicecontent/service.go @@ -0,0 +1,257 @@ +package practicecontent + +import ( + "Yimaru-Backend/internal/domain" + "Yimaru-Backend/internal/ports" + "context" + "errors" + + "github.com/jackc/pgx/v5" +) + +type Service struct { + questions ports.QuestionStore + lms ports.LmsPracticeStore + examPrep ports.ExamPrepPracticeStore + personas ports.LmsPersonaReader +} + +func NewService( + questions ports.QuestionStore, + lms ports.LmsPracticeStore, + examPrep ports.ExamPrepPracticeStore, + personas ports.LmsPersonaReader, +) *Service { + return &Service{ + questions: questions, + lms: lms, + examPrep: examPrep, + personas: personas, + } +} + +func (s *Service) validatePersona(ctx context.Context, id int64) error { + if id <= 0 { + return domain.ErrPersonaNotFound + } + _, err := s.personas.GetLmsPersonaByID(ctx, id) + if errors.Is(err, pgx.ErrNoRows) { + return domain.ErrPersonaNotFound + } + return err +} + +func (s *Service) validateQuestionSet(ctx context.Context, id int64) error { + _, err := s.questions.GetQuestionSetByID(ctx, id) + if errors.Is(err, pgx.ErrNoRows) { + return ErrQuestionSetNotFound + } + return err +} + +func mergeQuestionSetInput(existing domain.QuestionSet, in *domain.FullUpdateQuestionSetInput) domain.CreateQuestionSetInput { + out := domain.CreateQuestionSetInput{ + Title: existing.Title, + Description: existing.Description, + SetType: existing.SetType, + OwnerType: existing.OwnerType, + OwnerID: existing.OwnerID, + BannerImage: existing.BannerImage, + Persona: existing.Persona, + TimeLimitMinutes: existing.TimeLimitMinutes, + PassingScore: existing.PassingScore, + ShuffleQuestions: &existing.ShuffleQuestions, + Status: &existing.Status, + IntroVideoURL: existing.IntroVideoURL, + } + if in == nil { + return out + } + if in.Title != nil { + out.Title = *in.Title + } + if in.Description != nil { + out.Description = in.Description + } + if in.BannerImage != nil { + out.BannerImage = in.BannerImage + } + if in.Persona != nil { + out.Persona = in.Persona + } + if in.TimeLimitMinutes != nil { + out.TimeLimitMinutes = in.TimeLimitMinutes + } + if in.PassingScore != nil { + out.PassingScore = in.PassingScore + } + if in.ShuffleQuestions != nil { + out.ShuffleQuestions = in.ShuffleQuestions + } + if in.Status != nil { + out.Status = in.Status + } + if in.IntroVideoURL != nil { + out.IntroVideoURL = in.IntroVideoURL + } + return out +} + +func (s *Service) applySharedUpdates( + ctx context.Context, + questionSetID int64, + in domain.FullUpdatePracticeInput, +) (domain.QuestionSet, []domain.QuestionWithDetails, error) { + if in.QuestionSet != nil { + existingSet, err := s.questions.GetQuestionSetByID(ctx, questionSetID) + if err != nil { + if errors.Is(err, pgx.ErrNoRows) { + return domain.QuestionSet{}, nil, ErrQuestionSetNotFound + } + return domain.QuestionSet{}, nil, err + } + merged := mergeQuestionSetInput(existingSet, in.QuestionSet) + if err := s.questions.UpdateQuestionSet(ctx, questionSetID, merged); err != nil { + return domain.QuestionSet{}, nil, err + } + } + + var synced []domain.QuestionWithDetails + if in.Questions != nil { + var err error + synced, err = s.syncQuestionsInSet(ctx, questionSetID, in.Questions) + if err != nil { + return domain.QuestionSet{}, nil, err + } + } + + set, err := s.questions.GetQuestionSetByID(ctx, questionSetID) + if err != nil { + return domain.QuestionSet{}, nil, err + } + + if synced == nil && in.Questions == nil { + items, err := s.questions.GetQuestionSetItems(ctx, questionSetID) + if err != nil { + return domain.QuestionSet{}, nil, err + } + synced = make([]domain.QuestionWithDetails, 0, len(items)) + for _, item := range items { + q, err := s.questions.GetQuestionWithDetails(ctx, item.QuestionID) + if err != nil { + return domain.QuestionSet{}, nil, err + } + synced = append(synced, q) + } + } + + return set, synced, nil +} + +// UpdateLmsPracticeFull updates an LMS practice shell, linked question set, and questions. +func (s *Service) UpdateLmsPracticeFull(ctx context.Context, practiceID int64, in domain.FullUpdatePracticeInput) (domain.FullUpdatePracticeResult, error) { + practice, err := s.lms.GetLmsPracticeByID(ctx, practiceID) + if err != nil { + if errors.Is(err, pgx.ErrNoRows) { + return domain.FullUpdatePracticeResult{}, ErrPracticeNotFound + } + return domain.FullUpdatePracticeResult{}, err + } + + questionSetID := practice.QuestionSetID + if in.Practice != nil { + if in.Practice.PersonaID != nil { + if err := s.validatePersona(ctx, *in.Practice.PersonaID); err != nil { + return domain.FullUpdatePracticeResult{}, err + } + } + if in.Practice.QuestionSetID != nil { + if err := s.validateQuestionSet(ctx, *in.Practice.QuestionSetID); err != nil { + return domain.FullUpdatePracticeResult{}, err + } + } + + update := domain.UpdatePracticeInput{ + Title: in.Practice.Title, + StoryDescription: in.Practice.StoryDescription, + StoryImage: in.Practice.StoryImage, + PersonaID: in.Practice.PersonaID, + QuestionSetID: in.Practice.QuestionSetID, + QuickTips: in.Practice.QuickTips, + PublishStatus: in.Practice.PublishStatus, + } + practice, err = s.lms.UpdateLmsPractice(ctx, practiceID, update) + if err != nil { + if errors.Is(err, pgx.ErrNoRows) { + return domain.FullUpdatePracticeResult{}, ErrPracticeNotFound + } + return domain.FullUpdatePracticeResult{}, err + } + questionSetID = practice.QuestionSetID + } + + set, questions, err := s.applySharedUpdates(ctx, questionSetID, in) + if err != nil { + return domain.FullUpdatePracticeResult{}, err + } + + return domain.FullUpdatePracticeResult{ + Practice: practice, + QuestionSet: set, + Questions: questions, + }, nil +} + +// UpdateExamPrepPracticeFull updates an exam-prep practice shell, linked question set, and questions. +func (s *Service) UpdateExamPrepPracticeFull(ctx context.Context, practiceID int64, in domain.FullUpdatePracticeInput) (domain.FullUpdatePracticeResult, error) { + practice, err := s.examPrep.GetExamPrepLessonPracticeByID(ctx, practiceID) + if err != nil { + if errors.Is(err, pgx.ErrNoRows) { + return domain.FullUpdatePracticeResult{}, ErrPracticeNotFound + } + return domain.FullUpdatePracticeResult{}, err + } + + questionSetID := practice.QuestionSetID + if in.Practice != nil { + if in.Practice.PersonaID != nil { + if err := s.validatePersona(ctx, *in.Practice.PersonaID); err != nil { + return domain.FullUpdatePracticeResult{}, err + } + } + if in.Practice.QuestionSetID != nil { + if err := s.validateQuestionSet(ctx, *in.Practice.QuestionSetID); err != nil { + return domain.FullUpdatePracticeResult{}, err + } + } + + update := domain.UpdateExamPrepPracticeInput{ + Title: in.Practice.Title, + StoryDescription: in.Practice.StoryDescription, + StoryImage: in.Practice.StoryImage, + PersonaID: in.Practice.PersonaID, + QuestionSetID: in.Practice.QuestionSetID, + QuickTips: in.Practice.QuickTips, + PublishStatus: in.Practice.PublishStatus, + } + practice, err = s.examPrep.UpdateExamPrepLessonPractice(ctx, practiceID, update) + if err != nil { + if errors.Is(err, pgx.ErrNoRows) { + return domain.FullUpdatePracticeResult{}, ErrPracticeNotFound + } + return domain.FullUpdatePracticeResult{}, err + } + questionSetID = practice.QuestionSetID + } + + set, questions, err := s.applySharedUpdates(ctx, questionSetID, in) + if err != nil { + return domain.FullUpdatePracticeResult{}, err + } + + return domain.FullUpdatePracticeResult{ + Practice: practice, + QuestionSet: set, + Questions: questions, + }, nil +} diff --git a/internal/services/practicecontent/service_test.go b/internal/services/practicecontent/service_test.go new file mode 100644 index 0000000..ae75a73 --- /dev/null +++ b/internal/services/practicecontent/service_test.go @@ -0,0 +1,44 @@ +package practicecontent + +import ( + "Yimaru-Backend/internal/domain" + "testing" +) + +func TestBuildQuestionInput_dynamicAudioOnly(t *testing.T) { + item := domain.FullUpdatePracticeQuestionItem{ + QuestionType: strPtr("DYNAMIC"), + QuestionTypeDefinitionID: int64Ptr(20), + DynamicPayload: &domain.DynamicQuestionPayload{ + Stimulus: []domain.DynamicElementInstance{ + {ID: "audio_prompt_1", Kind: "AUDIO_PROMPT", Value: "https://cdn.example.com/prompt.mp3"}, + }, + Response: []domain.DynamicElementInstance{ + {ID: "answer_timer_1", Kind: "ANSWER_TIMER", Value: map[string]interface{}{"seconds": 30}}, + {ID: "audio_response_1", Kind: "AUDIO_RESPONSE"}, + }, + }, + } + + def := domain.QuestionTypeDefinition{ + StimulusComponentKinds: []string{"AUDIO_PROMPT"}, + ResponseComponentKinds: []string{"ANSWER_TIMER", "AUDIO_RESPONSE"}, + } + + got, err := buildQuestionInput(item, nil, func(id int64) (domain.QuestionTypeDefinition, error) { + return def, nil + }) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if got.QuestionType != "DYNAMIC" { + t.Fatalf("expected DYNAMIC, got %q", got.QuestionType) + } + if got.QuestionText != "" { + t.Fatalf("expected empty stored text, got %q", got.QuestionText) + } +} + +func strPtr(s string) *string { return &s } + +func int64Ptr(v int64) *int64 { return &v } diff --git a/internal/web_server/app.go b/internal/web_server/app.go index c1722c9..3786f6d 100644 --- a/internal/web_server/app.go +++ b/internal/web_server/app.go @@ -21,6 +21,7 @@ import ( "Yimaru-Backend/internal/services/modules" notificationservice "Yimaru-Backend/internal/services/notification" "Yimaru-Backend/internal/services/personas" + practicecontentservice "Yimaru-Backend/internal/services/practicecontent" "Yimaru-Backend/internal/services/practices" profilefieldoptions "Yimaru-Backend/internal/services/profilefieldoptions" "Yimaru-Backend/internal/services/programs" @@ -64,7 +65,8 @@ type App struct { moduleSvc *modules.Service lessonSvc *lessons.Service lmsProgressSvc *lmsprogress.Service - practiceSvc *practices.Service + practiceSvc *practices.Service + practiceContentSvc *practicecontentservice.Service subscriptionsSvc *subscriptions.Service arifpaySvc *arifpay.ArifpayService chapaSvc *chapa.Service @@ -110,6 +112,7 @@ func NewApp( lessonSvc *lessons.Service, lmsProgressSvc *lmsprogress.Service, practiceSvc *practices.Service, + practiceContentSvc *practicecontentservice.Service, subscriptionsSvc *subscriptions.Service, arifpaySvc *arifpay.ArifpayService, chapaSvc *chapa.Service, @@ -166,7 +169,8 @@ func NewApp( moduleSvc: moduleSvc, lessonSvc: lessonSvc, lmsProgressSvc: lmsProgressSvc, - practiceSvc: practiceSvc, + practiceSvc: practiceSvc, + practiceContentSvc: practiceContentSvc, subscriptionsSvc: subscriptionsSvc, arifpaySvc: arifpaySvc, chapaSvc: chapaSvc, diff --git a/internal/web_server/handlers/handlers.go b/internal/web_server/handlers/handlers.go index 9b1a00a..873875a 100644 --- a/internal/web_server/handlers/handlers.go +++ b/internal/web_server/handlers/handlers.go @@ -24,6 +24,7 @@ import ( "Yimaru-Backend/internal/services/modules" notificationservice "Yimaru-Backend/internal/services/notification" "Yimaru-Backend/internal/services/personas" + practicecontentservice "Yimaru-Backend/internal/services/practicecontent" "Yimaru-Backend/internal/services/practices" profilefieldoptions "Yimaru-Backend/internal/services/profilefieldoptions" "Yimaru-Backend/internal/services/programs" @@ -63,7 +64,8 @@ type Handler struct { moduleSvc *modules.Service lessonSvc *lessons.Service lmsProgressSvc *lmsprogress.Service - practiceSvc *practices.Service + practiceSvc *practices.Service + practiceContentSvc *practicecontentservice.Service subscriptionsSvc *subscriptions.Service arifpaySvc *arifpay.ArifpayService chapaSvc *chapa.Service @@ -105,6 +107,7 @@ func New( lessonSvc *lessons.Service, lmsProgressSvc *lmsprogress.Service, practiceSvc *practices.Service, + practiceContentSvc *practicecontentservice.Service, subscriptionsSvc *subscriptions.Service, arifpaySvc *arifpay.ArifpayService, chapaSvc *chapa.Service, @@ -144,7 +147,8 @@ func New( moduleSvc: moduleSvc, lessonSvc: lessonSvc, lmsProgressSvc: lmsProgressSvc, - practiceSvc: practiceSvc, + practiceSvc: practiceSvc, + practiceContentSvc: practiceContentSvc, subscriptionsSvc: subscriptionsSvc, arifpaySvc: arifpaySvc, chapaSvc: chapaSvc, diff --git a/internal/web_server/handlers/practice_full_update_handler.go b/internal/web_server/handlers/practice_full_update_handler.go new file mode 100644 index 0000000..0416a7f --- /dev/null +++ b/internal/web_server/handlers/practice_full_update_handler.go @@ -0,0 +1,196 @@ +package handlers + +import ( + "Yimaru-Backend/internal/domain" + "Yimaru-Backend/internal/services/practicecontent" + "errors" + "strconv" + + "github.com/gofiber/fiber/v2" +) + +func fullUpdatePracticeQuestionResponses(questions []domain.QuestionWithDetails) []questionRes { + out := make([]questionRes, 0, len(questions)) + for _, question := range questions { + options := make([]optionRes, 0, len(question.Options)) + for _, opt := range question.Options { + options = append(options, optionRes{ + ID: opt.ID, + OptionText: opt.OptionText, + OptionOrder: opt.OptionOrder, + IsCorrect: opt.IsCorrect, + }) + } + + shortAnswers := make([]shortAnswerRes, 0, len(question.ShortAnswers)) + for _, sa := range question.ShortAnswers { + shortAnswers = append(shortAnswers, shortAnswerRes{ + ID: sa.ID, + AcceptableAnswer: sa.AcceptableAnswer, + MatchType: sa.MatchType, + }) + } + + var audioCorrectAnswerText *string + if question.AudioAnswer != nil { + audioCorrectAnswerText = &question.AudioAnswer.CorrectAnswerText + } + + out = append(out, questionRes{ + ID: question.ID, + QuestionText: questionTextField(question.QuestionType, question.QuestionText), + QuestionType: question.QuestionType, + QuestionTypeDefinitionID: question.QuestionTypeDefinitionID, + DynamicPayload: question.DynamicPayload, + DifficultyLevel: question.DifficultyLevel, + Points: question.Points, + Explanation: question.Explanation, + Tips: question.Tips, + VoicePrompt: question.VoicePrompt, + SampleAnswerVoicePrompt: question.SampleAnswerVoicePrompt, + ImageURL: question.ImageURL, + Status: question.Status, + CreatedAt: question.CreatedAt.String(), + Options: options, + ShortAnswers: shortAnswers, + AudioCorrectAnswerText: audioCorrectAnswerText, + }) + } + return out +} + +func fullUpdatePracticeResponse(result domain.FullUpdatePracticeResult) fiber.Map { + return fiber.Map{ + "practice": result.Practice, + "question_set": questionSetResFromDomain(result.QuestionSet), + "questions": fullUpdatePracticeQuestionResponses(result.Questions), + } +} + +func questionSetResFromDomain(set domain.QuestionSet) questionSetRes { + return questionSetRes{ + ID: set.ID, + Title: set.Title, + Description: set.Description, + SetType: set.SetType, + OwnerType: set.OwnerType, + OwnerID: set.OwnerID, + BannerImage: set.BannerImage, + Persona: set.Persona, + TimeLimitMinutes: set.TimeLimitMinutes, + PassingScore: set.PassingScore, + ShuffleQuestions: set.ShuffleQuestions, + Status: set.Status, + IntroVideoURL: set.IntroVideoURL, + CreatedAt: set.CreatedAt.String(), + } +} + +func mapFullUpdatePracticeError(err error) (int, string) { + switch { + case errors.Is(err, practicecontent.ErrPracticeNotFound): + return fiber.StatusNotFound, "Practice not found" + case errors.Is(err, practicecontent.ErrQuestionSetNotFound): + return fiber.StatusNotFound, "Question set not found" + case errors.Is(err, practicecontent.ErrQuestionNotFound): + return fiber.StatusNotFound, "Question not found" + case errors.Is(err, domain.ErrPersonaNotFound): + return fiber.StatusNotFound, "Persona not found" + case errors.Is(err, practicecontent.ErrInvalidQuestionItem): + return fiber.StatusBadRequest, "Invalid question item" + default: + return fiber.StatusBadRequest, "Failed to update practice" + } +} + +// UpdateLmsPracticeFull godoc +// @Summary Full update LMS practice with question set and questions +// @Description Updates practice metadata, linked question set settings, and syncs questions in one request. Questions omitted from the questions array are removed from the set (not deleted from the bank). +// @Tags practices +// @Accept json +// @Produce json +// @Param id path int true "Practice ID" +// @Param body body domain.FullUpdatePracticeInput true "Full practice update payload" +// @Success 200 {object} domain.Response +// @Failure 400 {object} domain.ErrorResponse +// @Failure 404 {object} domain.ErrorResponse +// @Router /api/v1/practices/{id}/full [put] +func (h *Handler) UpdateLmsPracticeFull(c *fiber.Ctx) error { + id, err := strconv.ParseInt(c.Params("id"), 10, 64) + if err != nil { + return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{ + Message: "Invalid practice id", + Error: err.Error(), + }) + } + + var req domain.FullUpdatePracticeInput + if err := c.BodyParser(&req); err != nil { + return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{ + Message: "Invalid request body", + Error: err.Error(), + }) + } + + result, err := h.practiceContentSvc.UpdateLmsPracticeFull(c.Context(), id, req) + if err != nil { + status, msg := mapFullUpdatePracticeError(err) + return c.Status(status).JSON(domain.ErrorResponse{ + Message: msg, + Error: err.Error(), + }) + } + + return c.JSON(domain.Response{ + Message: "Practice updated successfully", + Data: fullUpdatePracticeResponse(result), + Success: true, + StatusCode: fiber.StatusOK, + }) +} + +// UpdateExamPrepPracticeFull godoc +// @Summary Full update exam-prep practice with question set and questions +// @Description Updates exam-prep practice metadata, linked question set settings, and syncs questions in one request. +// @Tags exam-prep +// @Accept json +// @Produce json +// @Param id path int true "Exam prep practice ID" +// @Param body body domain.FullUpdatePracticeInput true "Full practice update payload" +// @Success 200 {object} domain.Response +// @Failure 400 {object} domain.ErrorResponse +// @Failure 404 {object} domain.ErrorResponse +// @Router /api/v1/exam-prep/practices/{id}/full [put] +func (h *Handler) UpdateExamPrepPracticeFull(c *fiber.Ctx) error { + id, err := strconv.ParseInt(c.Params("id"), 10, 64) + if err != nil { + return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{ + Message: "Invalid practice id", + Error: err.Error(), + }) + } + + var req domain.FullUpdatePracticeInput + if err := c.BodyParser(&req); err != nil { + return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{ + Message: "Invalid request body", + Error: err.Error(), + }) + } + + result, err := h.practiceContentSvc.UpdateExamPrepPracticeFull(c.Context(), id, req) + if err != nil { + status, msg := mapFullUpdatePracticeError(err) + return c.Status(status).JSON(domain.ErrorResponse{ + Message: msg, + Error: err.Error(), + }) + } + + return c.JSON(domain.Response{ + Message: "Practice updated successfully", + Data: fullUpdatePracticeResponse(result), + Success: true, + StatusCode: fiber.StatusOK, + }) +} diff --git a/internal/web_server/routes.go b/internal/web_server/routes.go index cb0a52d..78d7d5b 100644 --- a/internal/web_server/routes.go +++ b/internal/web_server/routes.go @@ -27,6 +27,7 @@ func (a *App) initAppRoutes() { a.lessonSvc, a.lmsProgressSvc, a.practiceSvc, + a.practiceContentSvc, a.subscriptionsSvc, a.arifpaySvc, a.chapaSvc, @@ -114,6 +115,7 @@ func (a *App) initAppRoutes() { examPrep.Get("/lessons/:lessonId/practices", a.RequirePermission("exam_prep.practices.list_by_lesson"), h.ListExamPrepPracticesByLesson) examPrep.Get("/practices/:id", a.RequirePermission("exam_prep.practices.get"), h.GetExamPrepPracticeByID) examPrep.Put("/practices/:id", a.RequirePermission("exam_prep.practices.update"), h.UpdateExamPrepPractice) + examPrep.Put("/practices/:id/full", a.RequirePermission("exam_prep.practices.update"), h.UpdateExamPrepPracticeFull) examPrep.Delete("/practices/:id", a.RequirePermission("exam_prep.practices.delete"), h.DeleteExamPrepPractice) examPrep.Get("/lessons/:id", a.RequirePermission("exam_prep.lessons.get"), h.GetExamPrepLessonByID) examPrep.Put("/lessons/:id", a.RequirePermission("exam_prep.lessons.update"), h.UpdateExamPrepLesson) @@ -157,6 +159,7 @@ func (a *App) initAppRoutes() { groupV1.Post("/practices", a.authMiddleware, a.RequirePermission("practices.create"), h.CreatePractice) groupV1.Get("/practices/:id", a.authMiddleware, a.RequireSubscriptionCategory(domain.SubscriptionCategoryLearnEnglish), a.RequirePermission("practices.get"), h.GetPractice) groupV1.Put("/practices/:id", a.authMiddleware, a.RequirePermission("practices.update"), h.UpdatePractice) + groupV1.Put("/practices/:id/full", a.authMiddleware, a.RequirePermission("practices.update"), h.UpdateLmsPracticeFull) groupV1.Delete("/practices/:id", a.authMiddleware, a.RequirePermission("practices.delete"), h.DeletePractice) // LMS personas (catalog referenced by persona_id on practices)