Yimaru-BackEnd/internal/repository/questions.go

1287 lines
39 KiB
Go

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) 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,
})
}