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>
323 lines
9.3 KiB
Go
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
|
|
}
|