package repository import ( "context" "encoding/json" "errors" "strings" "time" dbgen "Yimaru-Backend/gen/db" "Yimaru-Backend/internal/domain" "github.com/jackc/pgx/v5" "github.com/jackc/pgx/v5/pgtype" ) func toPgText(s *string) pgtype.Text { if s == nil { return pgtype.Text{Valid: false} } return pgtype.Text{String: *s, Valid: true} } func fromPgText(t pgtype.Text) *string { if !t.Valid { return nil } return &t.String } func fromPgInt4(i pgtype.Int4) *int32 { if !i.Valid { return nil } return &i.Int32 } func fromPgInt8(i pgtype.Int8) *int64 { if !i.Valid { return nil } return &i.Int64 } func toPgInt4(i *int32) pgtype.Int4 { if i == nil { return pgtype.Int4{Valid: false} } return pgtype.Int4{Int32: *i, Valid: true} } func toPgInt8(i *int64) pgtype.Int8 { if i == nil { return pgtype.Int8{Valid: false} } return pgtype.Int8{Int64: *i, Valid: true} } func timePtr(t pgtype.Timestamptz) *time.Time { if !t.Valid { return nil } return &t.Time } func questionToDomain(q dbgen.Question) domain.Question { return domain.Question{ ID: q.ID, QuestionText: q.QuestionText, QuestionType: q.QuestionType, QuestionTypeDefinitionID: fromPgInt8(q.QuestionTypeDefinitionID), DynamicPayload: parseDynamicPayload(q.DynamicPayload), DifficultyLevel: fromPgText(q.DifficultyLevel), Points: q.Points, Explanation: fromPgText(q.Explanation), Tips: fromPgText(q.Tips), VoicePrompt: fromPgText(q.VoicePrompt), SampleAnswerVoicePrompt: fromPgText(q.SampleAnswerVoicePrompt), ImageURL: fromPgText(q.ImageUrl), Status: q.Status, CreatedAt: q.CreatedAt.Time, UpdatedAt: timePtr(q.UpdatedAt), } } func parseDynamicPayload(raw []byte) *domain.DynamicQuestionPayload { if len(raw) == 0 { return nil } var payload domain.DynamicQuestionPayload if err := json.Unmarshal(raw, &payload); err != nil { return nil } return &payload } func encodeDynamicPayload(payload *domain.DynamicQuestionPayload) []byte { if payload == nil { return nil } b, err := json.Marshal(payload) if err != nil { return nil } return b } func (s *Store) setQuestionTypeDefinitionID(ctx context.Context, questionID int64, definitionID *int64) error { _, err := s.conn.Exec(ctx, ` UPDATE questions SET question_type_definition_id = $2, updated_at = CURRENT_TIMESTAMP WHERE id = $1 `, questionID, toPgInt8(definitionID)) return err } func questionOptionToDomain(o dbgen.QuestionOption) domain.QuestionOption { return domain.QuestionOption{ ID: o.ID, QuestionID: o.QuestionID, OptionText: o.OptionText, OptionOrder: o.OptionOrder, IsCorrect: o.IsCorrect, CreatedAt: o.CreatedAt.Time, } } func questionShortAnswerToDomain(a dbgen.QuestionShortAnswer) domain.QuestionShortAnswer { return domain.QuestionShortAnswer{ ID: a.ID, QuestionID: a.QuestionID, AcceptableAnswer: a.AcceptableAnswer, MatchType: a.MatchType, CreatedAt: a.CreatedAt.Time, } } func questionAudioAnswerToDomain(a dbgen.QuestionAudioAnswer) domain.QuestionAudioAnswer { return domain.QuestionAudioAnswer{ ID: a.ID, QuestionID: a.QuestionID, CorrectAnswerText: a.CorrectAnswerText, CreatedAt: a.CreatedAt.Time, } } func questionSetToDomain(qs dbgen.QuestionSet) domain.QuestionSet { return domain.QuestionSet{ ID: qs.ID, Title: qs.Title, Description: fromPgText(qs.Description), SetType: qs.SetType, OwnerType: fromPgText(qs.OwnerType), OwnerID: fromPgInt8(qs.OwnerID), BannerImage: fromPgText(qs.BannerImage), Persona: fromPgText(qs.Persona), TimeLimitMinutes: fromPgInt4(qs.TimeLimitMinutes), PassingScore: fromPgInt4(qs.PassingScore), ShuffleQuestions: qs.ShuffleQuestions, Status: qs.Status, IntroVideoURL: fromPgText(qs.IntroVideoUrl), CreatedAt: qs.CreatedAt.Time, UpdatedAt: timePtr(qs.UpdatedAt), } } func questionSetItemToDomain(i dbgen.QuestionSetItem) domain.QuestionSetItem { return domain.QuestionSetItem{ ID: i.ID, SetID: i.SetID, QuestionID: i.QuestionID, DisplayOrder: i.DisplayOrder, CreatedAt: i.CreatedAt.Time, } } func questionTypeDefinitionToDomain( id int64, key string, displayName string, description pgtype.Text, stimulusKinds []string, responseKinds []string, stimulusSchema []byte, responseSchema []byte, isSystem bool, status string, createdAt time.Time, updatedAt pgtype.Timestamptz, ) domain.QuestionTypeDefinition { var stimulusSchemaDef []domain.DynamicElementDefinition var responseSchemaDef []domain.DynamicElementDefinition if len(stimulusSchema) > 0 { _ = json.Unmarshal(stimulusSchema, &stimulusSchemaDef) } if len(responseSchema) > 0 { _ = json.Unmarshal(responseSchema, &responseSchemaDef) } return domain.QuestionTypeDefinition{ ID: id, Key: key, DisplayName: displayName, Description: fromPgText(description), StimulusComponentKinds: stimulusKinds, ResponseComponentKinds: responseKinds, StimulusSchema: stimulusSchemaDef, ResponseSchema: responseSchemaDef, IsSystem: isSystem, Status: status, CreatedAt: createdAt, UpdatedAt: timePtr(updatedAt), } } func normalizeDefinitionStatus(status *string) string { if status == nil || strings.TrimSpace(*status) == "" { return "ACTIVE" } return strings.ToUpper(strings.TrimSpace(*status)) } func normalizeDefinitionKinds(values []string) []string { out := make([]string, 0, len(values)) for _, v := range values { trimmed := strings.TrimSpace(v) if trimmed == "" { continue } out = append(out, trimmed) } return out } func normalizeDefinitionKey(key string) string { key = strings.TrimSpace(strings.ToLower(key)) key = strings.ReplaceAll(key, " ", "_") return key } func kindsFromStimulusSchema(schema []domain.DynamicElementDefinition) []string { seen := make(map[string]struct{}) out := make([]string, 0, len(schema)) for _, el := range schema { kind := strings.TrimSpace(el.Kind) if kind == "" { continue } if _, exists := seen[kind]; exists { continue } seen[kind] = struct{}{} out = append(out, kind) } return out } func kindsFromResponseSchema(schema []domain.DynamicElementDefinition) []string { seen := make(map[string]struct{}) out := make([]string, 0, len(schema)) for _, el := range schema { kind := strings.TrimSpace(el.Kind) if kind == "" { continue } if _, exists := seen[kind]; exists { continue } seen[kind] = struct{}{} out = append(out, kind) } return out } func mustJSON(v interface{}) []byte { b, err := json.Marshal(v) if err != nil { return []byte("[]") } return b } func (s *Store) CreateQuestionTypeDefinition(ctx context.Context, input domain.CreateQuestionTypeDefinitionInput) (domain.QuestionTypeDefinition, error) { normalizedKey := normalizeDefinitionKey(input.Key) stimulusKinds := normalizeDefinitionKinds(input.StimulusComponentKinds) responseKinds := normalizeDefinitionKinds(input.ResponseComponentKinds) stimulusSchema := input.StimulusSchema responseSchema := input.ResponseSchema if len(stimulusKinds) == 0 && len(stimulusSchema) > 0 { stimulusKinds = kindsFromStimulusSchema(stimulusSchema) } if len(responseKinds) == 0 && len(responseSchema) > 0 { responseKinds = kindsFromResponseSchema(responseSchema) } if err := domain.ValidateDynamicQuestionTypeDefinition(stimulusKinds, responseKinds); err != nil { return domain.QuestionTypeDefinition{}, err } if err := domain.ValidateDefinitionSchemas(stimulusSchema, responseSchema); err != nil { return domain.QuestionTypeDefinition{}, err } if err := domain.ValidatePersistableQuestionTypeDefinition(normalizedKey, responseKinds); err != nil { return domain.QuestionTypeDefinition{}, err } row := s.conn.QueryRow(ctx, ` INSERT INTO question_type_definitions (key, display_name, description, stimulus_component_kinds, response_component_kinds, stimulus_schema, response_schema, is_system, status) VALUES ($1, $2, $3, $4, $5, $6::jsonb, $7::jsonb, $8, $9) RETURNING id, key, display_name, description, stimulus_component_kinds, response_component_kinds, stimulus_schema, response_schema, is_system, status, created_at, updated_at `, normalizedKey, strings.TrimSpace(input.DisplayName), toPgText(input.Description), stimulusKinds, responseKinds, mustJSON(stimulusSchema), mustJSON(responseSchema), input.IsSystem, normalizeDefinitionStatus(input.Status), ) var ( id int64 key string displayName string description pgtype.Text stimulus []string response []string stimulusSch []byte responseSch []byte isSystem bool status string createdAt time.Time updatedAt pgtype.Timestamptz ) if err := row.Scan(&id, &key, &displayName, &description, &stimulus, &response, &stimulusSch, &responseSch, &isSystem, &status, &createdAt, &updatedAt); err != nil { return domain.QuestionTypeDefinition{}, err } return questionTypeDefinitionToDomain(id, key, displayName, description, stimulus, response, stimulusSch, responseSch, isSystem, status, createdAt, updatedAt), nil } func (s *Store) GetQuestionTypeDefinitionByID(ctx context.Context, id int64) (domain.QuestionTypeDefinition, error) { row := s.conn.QueryRow(ctx, ` SELECT id, key, display_name, description, stimulus_component_kinds, response_component_kinds, stimulus_schema, response_schema, is_system, status, created_at, updated_at FROM question_type_definitions WHERE id = $1 `, id) var ( key string displayName string description pgtype.Text stimulus []string response []string stimulusSch []byte responseSch []byte isSystem bool status string createdAt time.Time updatedAt pgtype.Timestamptz ) if err := row.Scan(&id, &key, &displayName, &description, &stimulus, &response, &stimulusSch, &responseSch, &isSystem, &status, &createdAt, &updatedAt); err != nil { return domain.QuestionTypeDefinition{}, err } return questionTypeDefinitionToDomain(id, key, displayName, description, stimulus, response, stimulusSch, responseSch, isSystem, status, createdAt, updatedAt), nil } func (s *Store) ListQuestionTypeDefinitions(ctx context.Context, status *string, includeSystem bool) ([]domain.QuestionTypeDefinition, error) { rows, err := s.conn.Query(ctx, ` SELECT id, key, display_name, description, stimulus_component_kinds, response_component_kinds, stimulus_schema, response_schema, is_system, status, created_at, updated_at FROM question_type_definitions WHERE ($1::VARCHAR IS NULL OR status = $1) AND ($2::BOOLEAN = TRUE OR is_system = FALSE) ORDER BY is_system DESC, display_name ASC `, status, includeSystem) if err != nil { return nil, err } defer rows.Close() var out []domain.QuestionTypeDefinition for rows.Next() { var ( id int64 key string displayName string description pgtype.Text stimulus []string response []string stimulusSch []byte responseSch []byte isSystem bool defStatus string createdAt time.Time updatedAt pgtype.Timestamptz ) if err := rows.Scan(&id, &key, &displayName, &description, &stimulus, &response, &stimulusSch, &responseSch, &isSystem, &defStatus, &createdAt, &updatedAt); err != nil { return nil, err } out = append(out, questionTypeDefinitionToDomain(id, key, displayName, description, stimulus, response, stimulusSch, responseSch, isSystem, defStatus, createdAt, updatedAt)) } return out, rows.Err() } func (s *Store) UpdateQuestionTypeDefinition(ctx context.Context, id int64, input domain.UpdateQuestionTypeDefinitionInput) error { existing, err := s.GetQuestionTypeDefinitionByID(ctx, id) if err != nil { return err } displayName := existing.DisplayName if input.DisplayName != nil && strings.TrimSpace(*input.DisplayName) != "" { displayName = strings.TrimSpace(*input.DisplayName) } description := existing.Description if input.Description != nil { description = input.Description } stimulusKinds := existing.StimulusComponentKinds if input.StimulusComponentKinds != nil { stimulusKinds = normalizeDefinitionKinds(input.StimulusComponentKinds) } responseKinds := existing.ResponseComponentKinds if input.ResponseComponentKinds != nil { responseKinds = normalizeDefinitionKinds(input.ResponseComponentKinds) } stimulusSchema := existing.StimulusSchema if input.StimulusSchema != nil { stimulusSchema = input.StimulusSchema } responseSchema := existing.ResponseSchema if input.ResponseSchema != nil { responseSchema = input.ResponseSchema } if len(stimulusKinds) == 0 && len(stimulusSchema) > 0 { stimulusKinds = kindsFromStimulusSchema(stimulusSchema) } if len(responseKinds) == 0 && len(responseSchema) > 0 { responseKinds = kindsFromResponseSchema(responseSchema) } if err := domain.ValidateDynamicQuestionTypeDefinition(stimulusKinds, responseKinds); err != nil { return err } if err := domain.ValidateDefinitionSchemas(stimulusSchema, responseSchema); err != nil { return err } if err := domain.ValidatePersistableQuestionTypeDefinition(existing.Key, responseKinds); err != nil { return err } status := existing.Status if input.Status != nil { status = normalizeDefinitionStatus(input.Status) } _, err = s.conn.Exec(ctx, ` UPDATE question_type_definitions SET display_name = $2, description = $3, stimulus_component_kinds = $4, response_component_kinds = $5, stimulus_schema = $6::jsonb, response_schema = $7::jsonb, status = $8, updated_at = NOW() WHERE id = $1 `, id, displayName, toPgText(description), stimulusKinds, responseKinds, mustJSON(stimulusSchema), mustJSON(responseSchema), status) return err } func (s *Store) DeleteQuestionTypeDefinition(ctx context.Context, id int64) error { var isSystem bool err := s.conn.QueryRow(ctx, `SELECT is_system FROM question_type_definitions WHERE id = $1`, id).Scan(&isSystem) if err != nil { return err } if isSystem { return errors.New("system question type definitions cannot be deleted") } cmd, err := s.conn.Exec(ctx, `DELETE FROM question_type_definitions WHERE id = $1`, id) if err != nil { return err } if cmd.RowsAffected() == 0 { return pgx.ErrNoRows } return nil } func (s *Store) CreateQuestion(ctx context.Context, input domain.CreateQuestionInput) (domain.Question, error) { q, tx, err := s.BeginTx(ctx) if err != nil { return domain.Question{}, err } defer tx.Rollback(ctx) var points interface{} if input.Points != nil { points = *input.Points } var status interface{} if input.Status != nil { status = *input.Status } question, err := q.CreateQuestion(ctx, dbgen.CreateQuestionParams{ QuestionText: input.QuestionText, QuestionType: input.QuestionType, DifficultyLevel: toPgText(input.DifficultyLevel), Column4: points, Explanation: toPgText(input.Explanation), Tips: toPgText(input.Tips), VoicePrompt: toPgText(input.VoicePrompt), SampleAnswerVoicePrompt: toPgText(input.SampleAnswerVoicePrompt), ImageUrl: toPgText(input.ImageURL), Column10: status, Column11: encodeDynamicPayload(input.DynamicPayload), }) if err != nil { return domain.Question{}, err } for _, opt := range input.Options { var order interface{} if opt.OptionOrder != nil { order = *opt.OptionOrder } _, err = q.CreateQuestionOption(ctx, dbgen.CreateQuestionOptionParams{ QuestionID: question.ID, OptionText: opt.OptionText, Column3: order, Column4: opt.IsCorrect, }) if err != nil { return domain.Question{}, err } } for _, sa := range input.ShortAnswers { var matchType interface{} if sa.MatchType != nil { matchType = *sa.MatchType } _, err = q.CreateQuestionShortAnswer(ctx, dbgen.CreateQuestionShortAnswerParams{ QuestionID: question.ID, AcceptableAnswer: sa.AcceptableAnswer, Column3: matchType, }) if err != nil { return domain.Question{}, err } } if input.AudioCorrectAnswerText != nil && *input.AudioCorrectAnswerText != "" { _, err = q.CreateQuestionAudioAnswer(ctx, dbgen.CreateQuestionAudioAnswerParams{ QuestionID: question.ID, CorrectAnswerText: *input.AudioCorrectAnswerText, }) if err != nil { return domain.Question{}, err } } if err = s.setQuestionTypeDefinitionID(ctx, question.ID, input.QuestionTypeDefinitionID); err != nil { return domain.Question{}, err } if err = tx.Commit(ctx); err != nil { return domain.Question{}, err } created := questionToDomain(question) created.QuestionTypeDefinitionID = input.QuestionTypeDefinitionID return created, nil } func (s *Store) GetQuestionByID(ctx context.Context, id int64) (domain.Question, error) { q, err := s.queries.GetQuestionByID(ctx, id) if err != nil { return domain.Question{}, err } return questionToDomain(q), nil } func (s *Store) GetQuestionWithDetails(ctx context.Context, id int64) (domain.QuestionWithDetails, error) { q, err := s.queries.GetQuestionByID(ctx, id) if err != nil { return domain.QuestionWithDetails{}, err } opts, err := s.queries.GetOptionsByQuestionID(ctx, id) if err != nil { return domain.QuestionWithDetails{}, err } shortAnswers, err := s.queries.GetShortAnswersByQuestionID(ctx, id) if err != nil { return domain.QuestionWithDetails{}, err } options := make([]domain.QuestionOption, len(opts)) for i, o := range opts { options[i] = questionOptionToDomain(o) } answers := make([]domain.QuestionShortAnswer, len(shortAnswers)) for i, a := range shortAnswers { answers[i] = questionShortAnswerToDomain(a) } var audioAnswer *domain.QuestionAudioAnswer aa, err := s.queries.GetAudioAnswerByQuestionID(ctx, id) if err == nil { mapped := questionAudioAnswerToDomain(aa) audioAnswer = &mapped } return domain.QuestionWithDetails{ Question: questionToDomain(q), Options: options, ShortAnswers: answers, AudioAnswer: audioAnswer, }, nil } func (s *Store) ListQuestions(ctx context.Context, questionType, difficulty, status *string, limit, offset int32) ([]domain.Question, int64, error) { var qType, diff, stat string if questionType != nil { qType = *questionType } if difficulty != nil { diff = *difficulty } if status != nil { stat = *status } rows, err := s.queries.ListQuestions(ctx, dbgen.ListQuestionsParams{ Column1: qType, Column2: diff, Column3: stat, Limit: pgtype.Int4{Int32: limit, Valid: true}, Offset: pgtype.Int4{Int32: offset, Valid: true}, }) if err != nil { return nil, 0, err } var totalCount int64 questions := make([]domain.Question, len(rows)) for i, r := range rows { if i == 0 { totalCount = r.TotalCount } questions[i] = domain.Question{ ID: r.ID, QuestionText: r.QuestionText, QuestionType: r.QuestionType, QuestionTypeDefinitionID: fromPgInt8(r.QuestionTypeDefinitionID), DynamicPayload: parseDynamicPayload(r.DynamicPayload), DifficultyLevel: fromPgText(r.DifficultyLevel), Points: r.Points, Explanation: fromPgText(r.Explanation), Tips: fromPgText(r.Tips), VoicePrompt: fromPgText(r.VoicePrompt), SampleAnswerVoicePrompt: fromPgText(r.SampleAnswerVoicePrompt), ImageURL: fromPgText(r.ImageUrl), Status: r.Status, CreatedAt: r.CreatedAt.Time, UpdatedAt: timePtr(r.UpdatedAt), } } return questions, totalCount, nil } func (s *Store) SearchQuestions(ctx context.Context, query string, limit, offset int32) ([]domain.Question, int64, error) { rows, err := s.queries.SearchQuestions(ctx, dbgen.SearchQuestionsParams{ Column1: pgtype.Text{String: query, Valid: true}, Limit: pgtype.Int4{Int32: limit, Valid: true}, Offset: pgtype.Int4{Int32: offset, Valid: true}, }) if err != nil { return nil, 0, err } var totalCount int64 questions := make([]domain.Question, len(rows)) for i, r := range rows { if i == 0 { totalCount = r.TotalCount } questions[i] = domain.Question{ ID: r.ID, QuestionText: r.QuestionText, QuestionType: r.QuestionType, QuestionTypeDefinitionID: fromPgInt8(r.QuestionTypeDefinitionID), DynamicPayload: parseDynamicPayload(r.DynamicPayload), DifficultyLevel: fromPgText(r.DifficultyLevel), Points: r.Points, Explanation: fromPgText(r.Explanation), Tips: fromPgText(r.Tips), VoicePrompt: fromPgText(r.VoicePrompt), SampleAnswerVoicePrompt: fromPgText(r.SampleAnswerVoicePrompt), ImageURL: fromPgText(r.ImageUrl), Status: r.Status, CreatedAt: r.CreatedAt.Time, UpdatedAt: timePtr(r.UpdatedAt), } } return questions, totalCount, nil } func (s *Store) UpdateQuestion(ctx context.Context, id int64, input domain.CreateQuestionInput) error { var points int32 if input.Points != nil { points = *input.Points } var status string if input.Status != nil { status = *input.Status } err := s.queries.UpdateQuestion(ctx, dbgen.UpdateQuestionParams{ ID: id, QuestionText: input.QuestionText, QuestionType: input.QuestionType, DifficultyLevel: toPgText(input.DifficultyLevel), Points: points, Explanation: toPgText(input.Explanation), Tips: toPgText(input.Tips), VoicePrompt: toPgText(input.VoicePrompt), SampleAnswerVoicePrompt: toPgText(input.SampleAnswerVoicePrompt), ImageUrl: toPgText(input.ImageURL), Status: status, Column11: encodeDynamicPayload(input.DynamicPayload), }) if err != nil { return err } if input.AudioCorrectAnswerText != nil { _ = s.queries.DeleteAudioAnswerByQuestionID(ctx, id) if *input.AudioCorrectAnswerText != "" { _, err = s.queries.CreateQuestionAudioAnswer(ctx, dbgen.CreateQuestionAudioAnswerParams{ QuestionID: id, CorrectAnswerText: *input.AudioCorrectAnswerText, }) if err != nil { return err } } } if input.QuestionTypeDefinitionID != nil { if err := s.setQuestionTypeDefinitionID(ctx, id, input.QuestionTypeDefinitionID); err != nil { return err } } return nil } func (s *Store) ArchiveQuestion(ctx context.Context, id int64) error { return s.queries.ArchiveQuestion(ctx, id) } func (s *Store) DeleteQuestion(ctx context.Context, id int64) error { return s.queries.DeleteQuestion(ctx, id) } func (s *Store) CreateQuestionOption(ctx context.Context, questionID int64, optionText string, optionOrder *int32, isCorrect bool) (domain.QuestionOption, error) { var order interface{} if optionOrder != nil { order = *optionOrder } opt, err := s.queries.CreateQuestionOption(ctx, dbgen.CreateQuestionOptionParams{ QuestionID: questionID, OptionText: optionText, Column3: order, Column4: isCorrect, }) if err != nil { return domain.QuestionOption{}, err } return questionOptionToDomain(opt), nil } func (s *Store) GetOptionsByQuestionID(ctx context.Context, questionID int64) ([]domain.QuestionOption, error) { opts, err := s.queries.GetOptionsByQuestionID(ctx, questionID) if err != nil { return nil, err } result := make([]domain.QuestionOption, len(opts)) for i, o := range opts { result[i] = questionOptionToDomain(o) } return result, nil } func (s *Store) UpdateQuestionOption(ctx context.Context, id int64, optionText *string, optionOrder *int32, isCorrect *bool) error { var text string if optionText != nil { text = *optionText } var order int32 if optionOrder != nil { order = *optionOrder } var correct bool if isCorrect != nil { correct = *isCorrect } return s.queries.UpdateQuestionOption(ctx, dbgen.UpdateQuestionOptionParams{ ID: id, OptionText: text, OptionOrder: order, IsCorrect: correct, }) } func (s *Store) DeleteQuestionOption(ctx context.Context, id int64) error { return s.queries.DeleteQuestionOption(ctx, id) } func (s *Store) DeleteOptionsByQuestionID(ctx context.Context, questionID int64) error { return s.queries.DeleteOptionsByQuestionID(ctx, questionID) } func (s *Store) CreateQuestionShortAnswer(ctx context.Context, questionID int64, acceptableAnswer string, matchType *string) (domain.QuestionShortAnswer, error) { var mt interface{} if matchType != nil { mt = *matchType } sa, err := s.queries.CreateQuestionShortAnswer(ctx, dbgen.CreateQuestionShortAnswerParams{ QuestionID: questionID, AcceptableAnswer: acceptableAnswer, Column3: mt, }) if err != nil { return domain.QuestionShortAnswer{}, err } return questionShortAnswerToDomain(sa), nil } func (s *Store) GetShortAnswersByQuestionID(ctx context.Context, questionID int64) ([]domain.QuestionShortAnswer, error) { answers, err := s.queries.GetShortAnswersByQuestionID(ctx, questionID) if err != nil { return nil, err } result := make([]domain.QuestionShortAnswer, len(answers)) for i, a := range answers { result[i] = questionShortAnswerToDomain(a) } return result, nil } func (s *Store) UpdateQuestionShortAnswer(ctx context.Context, id int64, acceptableAnswer, matchType *string) error { var answer, mt string if acceptableAnswer != nil { answer = *acceptableAnswer } if matchType != nil { mt = *matchType } return s.queries.UpdateQuestionShortAnswer(ctx, dbgen.UpdateQuestionShortAnswerParams{ ID: id, AcceptableAnswer: answer, MatchType: mt, }) } func (s *Store) DeleteQuestionShortAnswer(ctx context.Context, id int64) error { return s.queries.DeleteQuestionShortAnswer(ctx, id) } func (s *Store) DeleteShortAnswersByQuestionID(ctx context.Context, questionID int64) error { return s.queries.DeleteShortAnswersByQuestionID(ctx, questionID) } func (s *Store) CreateQuestionSet(ctx context.Context, input domain.CreateQuestionSetInput) (domain.QuestionSet, error) { var shuffleQuestions interface{} if input.ShuffleQuestions != nil { shuffleQuestions = *input.ShuffleQuestions } var status interface{} if input.Status != nil { status = *input.Status } qs, err := s.queries.CreateQuestionSet(ctx, dbgen.CreateQuestionSetParams{ Title: input.Title, Description: toPgText(input.Description), SetType: input.SetType, OwnerType: toPgText(input.OwnerType), OwnerID: toPgInt8(input.OwnerID), BannerImage: toPgText(input.BannerImage), Persona: toPgText(input.Persona), TimeLimitMinutes: toPgInt4(input.TimeLimitMinutes), PassingScore: toPgInt4(input.PassingScore), Column10: shuffleQuestions, Column11: status, IntroVideoUrl: toPgText(input.IntroVideoURL), }) if err != nil { return domain.QuestionSet{}, err } return questionSetToDomain(qs), nil } func (s *Store) GetQuestionSetByID(ctx context.Context, id int64) (domain.QuestionSet, error) { qs, err := s.queries.GetQuestionSetByID(ctx, id) if err != nil { return domain.QuestionSet{}, err } return questionSetToDomain(qs), nil } func (s *Store) GetQuestionSetsByOwner(ctx context.Context, ownerType string, ownerID int64) ([]domain.QuestionSet, error) { sets, err := s.queries.GetQuestionSetsByOwner(ctx, dbgen.GetQuestionSetsByOwnerParams{ OwnerType: pgtype.Text{String: ownerType, Valid: true}, OwnerID: pgtype.Int8{Int64: ownerID, Valid: true}, }) if err != nil { return nil, err } result := make([]domain.QuestionSet, len(sets)) for i, qs := range sets { result[i] = questionSetToDomain(qs) } return result, nil } func (s *Store) GetQuestionSetsByType(ctx context.Context, setType string, limit, offset int32) ([]domain.QuestionSet, int64, error) { rows, err := s.queries.GetQuestionSetsByType(ctx, dbgen.GetQuestionSetsByTypeParams{ SetType: setType, Limit: pgtype.Int4{Int32: limit, Valid: true}, Offset: pgtype.Int4{Int32: offset, Valid: true}, }) if err != nil { return nil, 0, err } var totalCount int64 result := make([]domain.QuestionSet, len(rows)) for i, r := range rows { if i == 0 { totalCount = r.TotalCount } result[i] = domain.QuestionSet{ ID: r.ID, Title: r.Title, Description: fromPgText(r.Description), SetType: r.SetType, OwnerType: fromPgText(r.OwnerType), OwnerID: fromPgInt8(r.OwnerID), BannerImage: fromPgText(r.BannerImage), Persona: fromPgText(r.Persona), TimeLimitMinutes: fromPgInt4(r.TimeLimitMinutes), PassingScore: fromPgInt4(r.PassingScore), ShuffleQuestions: r.ShuffleQuestions, Status: r.Status, IntroVideoURL: fromPgText(r.IntroVideoUrl), CreatedAt: r.CreatedAt.Time, UpdatedAt: timePtr(r.UpdatedAt), } } // COUNT(*) OVER() only appears when at least one row is returned. // For out-of-range offsets, fetch total count explicitly so pagination metadata stays correct. if len(rows) == 0 { err = s.conn.QueryRow( ctx, `SELECT COUNT(*) FROM question_sets WHERE set_type = $1 AND status != 'ARCHIVED'`, setType, ).Scan(&totalCount) if err != nil { return nil, 0, err } } return result, totalCount, nil } func (s *Store) GetPublishedQuestionSetsByOwner(ctx context.Context, ownerType string, ownerID int64) ([]domain.QuestionSet, error) { sets, err := s.queries.GetPublishedQuestionSetsByOwner(ctx, dbgen.GetPublishedQuestionSetsByOwnerParams{ OwnerType: pgtype.Text{String: ownerType, Valid: true}, OwnerID: pgtype.Int8{Int64: ownerID, Valid: true}, }) if err != nil { return nil, err } result := make([]domain.QuestionSet, len(sets)) for i, qs := range sets { result[i] = questionSetToDomain(qs) } return result, nil } func (s *Store) GetInitialAssessmentSet(ctx context.Context) (domain.QuestionSet, error) { qs, err := s.queries.GetInitialAssessmentSet(ctx) if err != nil { return domain.QuestionSet{}, err } return questionSetToDomain(qs), nil } func (s *Store) GetFirstIncompletePreviousPractice(ctx context.Context, userID int64, questionSetID int64) (*domain.PracticeAccessBlock, error) { row, err := s.queries.GetFirstIncompletePreviousPractice(ctx, dbgen.GetFirstIncompletePreviousPracticeParams{ UserID: userID, QuestionSetID: questionSetID, }) if err != nil { if errors.Is(err, pgx.ErrNoRows) { return nil, nil } return nil, err } return &domain.PracticeAccessBlock{ QuestionSetID: row.ID, Title: row.Title, DisplayOrder: row.DisplayOrder, }, nil } func (s *Store) MarkPracticeCompleted(ctx context.Context, userID int64, questionSetID int64) error { _, err := s.queries.MarkPracticeCompleted(ctx, dbgen.MarkPracticeCompletedParams{ UserID: userID, QuestionSetID: questionSetID, }) return err } func (s *Store) UpdateQuestionSet(ctx context.Context, id int64, input domain.CreateQuestionSetInput) error { var shuffleQuestions bool if input.ShuffleQuestions != nil { shuffleQuestions = *input.ShuffleQuestions } var status string if input.Status != nil { status = *input.Status } return s.queries.UpdateQuestionSet(ctx, dbgen.UpdateQuestionSetParams{ ID: id, Title: input.Title, Description: toPgText(input.Description), BannerImage: toPgText(input.BannerImage), Persona: toPgText(input.Persona), TimeLimitMinutes: toPgInt4(input.TimeLimitMinutes), PassingScore: toPgInt4(input.PassingScore), ShuffleQuestions: shuffleQuestions, Status: status, IntroVideoUrl: toPgText(input.IntroVideoURL), }) } func (s *Store) ArchiveQuestionSet(ctx context.Context, id int64) error { return s.queries.ArchiveQuestionSet(ctx, id) } func (s *Store) DeleteQuestionSet(ctx context.Context, id int64) error { return s.queries.DeleteQuestionSet(ctx, id) } func (s *Store) AddQuestionToSet(ctx context.Context, setID, questionID int64, displayOrder *int32) (domain.QuestionSetItem, error) { var order interface{} if displayOrder != nil { order = *displayOrder } item, err := s.queries.AddQuestionToSet(ctx, dbgen.AddQuestionToSetParams{ SetID: setID, QuestionID: questionID, Column3: order, }) if err != nil { return domain.QuestionSetItem{}, err } return questionSetItemToDomain(item), nil } func (s *Store) GetQuestionSetItems(ctx context.Context, setID int64) ([]domain.QuestionSetItemWithQuestion, error) { rows, err := s.queries.GetQuestionSetItems(ctx, setID) if err != nil { return nil, err } result := make([]domain.QuestionSetItemWithQuestion, len(rows)) for i, r := range rows { result[i] = domain.QuestionSetItemWithQuestion{ QuestionSetItem: domain.QuestionSetItem{ ID: r.ID, SetID: r.SetID, QuestionID: r.QuestionID, DisplayOrder: r.DisplayOrder, }, QuestionText: r.QuestionText, QuestionType: r.QuestionType, DynamicPayload: parseDynamicPayload(r.DynamicPayload), DifficultyLevel: fromPgText(r.DifficultyLevel), Points: r.Points, Explanation: fromPgText(r.Explanation), Tips: fromPgText(r.Tips), VoicePrompt: fromPgText(r.VoicePrompt), SampleAnswerVoicePrompt: fromPgText(r.SampleAnswerVoicePrompt), ImageURL: fromPgText(r.ImageUrl), AudioCorrectAnswerText: fromPgText(r.AudioCorrectAnswerText), QuestionStatus: r.QuestionStatus, } } return result, nil } func (s *Store) GetQuestionSetItemsPaginated(ctx context.Context, setID int64, questionType *string, limit, offset int32) ([]domain.QuestionSetItemWithQuestion, int64, error) { qType := "" if questionType != nil { qType = *questionType } rows, err := s.queries.GetQuestionSetItemsPaginated(ctx, dbgen.GetQuestionSetItemsPaginatedParams{ SetID: setID, Column2: qType, Offset: pgtype.Int4{Int32: offset, Valid: true}, Limit: pgtype.Int4{Int32: limit, Valid: true}, }) if err != nil { return nil, 0, err } var totalCount int64 result := make([]domain.QuestionSetItemWithQuestion, len(rows)) for i, r := range rows { if i == 0 { totalCount = r.TotalCount } result[i] = domain.QuestionSetItemWithQuestion{ QuestionSetItem: domain.QuestionSetItem{ ID: r.ID, SetID: r.SetID, QuestionID: r.QuestionID, DisplayOrder: r.DisplayOrder, }, QuestionText: r.QuestionText, QuestionType: r.QuestionType, DynamicPayload: parseDynamicPayload(r.DynamicPayload), DifficultyLevel: fromPgText(r.DifficultyLevel), Points: r.Points, Explanation: fromPgText(r.Explanation), Tips: fromPgText(r.Tips), VoicePrompt: fromPgText(r.VoicePrompt), SampleAnswerVoicePrompt: fromPgText(r.SampleAnswerVoicePrompt), ImageURL: fromPgText(r.ImageUrl), AudioCorrectAnswerText: fromPgText(r.AudioCorrectAnswerText), QuestionStatus: r.QuestionStatus, } } return result, totalCount, nil } func (s *Store) GetPublishedQuestionsInSet(ctx context.Context, setID int64) ([]domain.QuestionSetItemWithQuestion, error) { rows, err := s.queries.GetPublishedQuestionsInSet(ctx, setID) if err != nil { return nil, err } result := make([]domain.QuestionSetItemWithQuestion, len(rows)) for i, r := range rows { result[i] = domain.QuestionSetItemWithQuestion{ QuestionSetItem: domain.QuestionSetItem{ ID: r.ID, SetID: r.SetID, QuestionID: r.QuestionID, DisplayOrder: r.DisplayOrder, }, QuestionText: r.QuestionText, QuestionType: r.QuestionType, DynamicPayload: parseDynamicPayload(r.DynamicPayload), DifficultyLevel: fromPgText(r.DifficultyLevel), Points: r.Points, Explanation: fromPgText(r.Explanation), Tips: fromPgText(r.Tips), VoicePrompt: fromPgText(r.VoicePrompt), SampleAnswerVoicePrompt: fromPgText(r.SampleAnswerVoicePrompt), ImageURL: fromPgText(r.ImageUrl), AudioCorrectAnswerText: fromPgText(r.AudioCorrectAnswerText), QuestionStatus: "PUBLISHED", } } return result, nil } func (s *Store) RemoveQuestionFromSet(ctx context.Context, setID, questionID int64) error { return s.queries.RemoveQuestionFromSet(ctx, dbgen.RemoveQuestionFromSetParams{ SetID: setID, QuestionID: questionID, }) } func (s *Store) UpdateQuestionOrder(ctx context.Context, setID, questionID int64, displayOrder int32) error { return s.queries.UpdateQuestionOrder(ctx, dbgen.UpdateQuestionOrderParams{ SetID: setID, QuestionID: questionID, DisplayOrder: displayOrder, }) } func (s *Store) CountQuestionsInSet(ctx context.Context, setID int64) (int64, error) { return s.queries.CountQuestionsInSet(ctx, setID) } func (s *Store) GetQuestionTypeCountsInSet(ctx context.Context, setID int64) ([]domain.QuestionSetQuestionTypeGroup, error) { rows, err := s.queries.GetQuestionTypeCountsInSet(ctx, setID) if err != nil { return nil, err } result := make([]domain.QuestionSetQuestionTypeGroup, len(rows)) for i, r := range rows { result[i] = domain.QuestionSetQuestionTypeGroup{ QuestionTypeDefinitionID: fromPgInt8(r.QuestionTypeDefinitionID), QuestionType: r.QuestionType, Count: r.QuestionCount, } } return result, nil } func (s *Store) GetQuestionSetsContainingQuestion(ctx context.Context, questionID int64) ([]domain.QuestionSet, error) { sets, err := s.queries.GetQuestionSetsContainingQuestion(ctx, questionID) if err != nil { return nil, err } result := make([]domain.QuestionSet, len(sets)) for i, qs := range sets { result[i] = questionSetToDomain(qs) } return result, nil } // User Persona methods for question sets func (s *Store) AddUserPersonaToQuestionSet(ctx context.Context, questionSetID, userID int64, displayOrder int32) error { _, err := s.queries.AddUserPersonaToQuestionSet(ctx, dbgen.AddUserPersonaToQuestionSetParams{ QuestionSetID: questionSetID, UserID: userID, Column3: displayOrder, }) return err } func (s *Store) RemoveUserPersonaFromQuestionSet(ctx context.Context, questionSetID, userID int64) error { return s.queries.RemoveUserPersonaFromQuestionSet(ctx, dbgen.RemoveUserPersonaFromQuestionSetParams{ QuestionSetID: questionSetID, UserID: userID, }) } func (s *Store) GetUserPersonasByQuestionSetID(ctx context.Context, questionSetID int64) ([]domain.UserPersona, error) { rows, err := s.queries.GetUserPersonasByQuestionSetID(ctx, questionSetID) if err != nil { return nil, err } result := make([]domain.UserPersona, len(rows)) for i, r := range rows { result[i] = domain.UserPersona{ ID: r.ID, FirstName: fromPgText(r.FirstName), LastName: fromPgText(r.LastName), NickName: fromPgText(r.NickName), ProfilePictureURL: fromPgText(r.ProfilePictureUrl), Role: r.Role, DisplayOrder: r.DisplayOrder.Int32, } } return result, nil } func (s *Store) ReorderQuestionSets(ctx context.Context, ids []int64, positions []int32) error { return s.queries.ReorderQuestionSets(ctx, dbgen.ReorderQuestionSetsParams{ Ids: ids, Positions: positions, }) }