1148 lines
34 KiB
Go
1148 lines
34 KiB
Go
package repository
|
|
|
|
import (
|
|
"context"
|
|
"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,
|
|
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 (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,
|
|
isSystem bool,
|
|
status string,
|
|
createdAt time.Time,
|
|
updatedAt pgtype.Timestamptz,
|
|
) domain.QuestionTypeDefinition {
|
|
return domain.QuestionTypeDefinition{
|
|
ID: id,
|
|
Key: key,
|
|
DisplayName: displayName,
|
|
Description: fromPgText(description),
|
|
StimulusComponentKinds: stimulusKinds,
|
|
ResponseComponentKinds: responseKinds,
|
|
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 (s *Store) CreateQuestionTypeDefinition(ctx context.Context, input domain.CreateQuestionTypeDefinitionInput) (domain.QuestionTypeDefinition, error) {
|
|
stimulusKinds := normalizeDefinitionKinds(input.StimulusComponentKinds)
|
|
responseKinds := normalizeDefinitionKinds(input.ResponseComponentKinds)
|
|
if err := domain.ValidateDynamicQuestionTypeDefinition(stimulusKinds, 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, is_system, status)
|
|
VALUES ($1, $2, $3, $4, $5, $6, $7)
|
|
RETURNING id, key, display_name, description, stimulus_component_kinds, response_component_kinds, is_system, status, created_at, updated_at
|
|
`,
|
|
normalizeDefinitionKey(input.Key),
|
|
strings.TrimSpace(input.DisplayName),
|
|
toPgText(input.Description),
|
|
stimulusKinds,
|
|
responseKinds,
|
|
input.IsSystem,
|
|
normalizeDefinitionStatus(input.Status),
|
|
)
|
|
|
|
var (
|
|
id int64
|
|
key string
|
|
displayName string
|
|
description pgtype.Text
|
|
stimulus []string
|
|
response []string
|
|
isSystem bool
|
|
status string
|
|
createdAt time.Time
|
|
updatedAt pgtype.Timestamptz
|
|
)
|
|
if err := row.Scan(&id, &key, &displayName, &description, &stimulus, &response, &isSystem, &status, &createdAt, &updatedAt); err != nil {
|
|
return domain.QuestionTypeDefinition{}, err
|
|
}
|
|
|
|
return questionTypeDefinitionToDomain(id, key, displayName, description, stimulus, response, 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, 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
|
|
isSystem bool
|
|
status string
|
|
createdAt time.Time
|
|
updatedAt pgtype.Timestamptz
|
|
)
|
|
if err := row.Scan(&id, &key, &displayName, &description, &stimulus, &response, &isSystem, &status, &createdAt, &updatedAt); err != nil {
|
|
return domain.QuestionTypeDefinition{}, err
|
|
}
|
|
return questionTypeDefinitionToDomain(id, key, displayName, description, stimulus, response, 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, 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
|
|
isSystem bool
|
|
defStatus string
|
|
createdAt time.Time
|
|
updatedAt pgtype.Timestamptz
|
|
)
|
|
if err := rows.Scan(&id, &key, &displayName, &description, &stimulus, &response, &isSystem, &defStatus, &createdAt, &updatedAt); err != nil {
|
|
return nil, err
|
|
}
|
|
out = append(out, questionTypeDefinitionToDomain(id, key, displayName, description, stimulus, response, 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)
|
|
}
|
|
|
|
if err := domain.ValidateDynamicQuestionTypeDefinition(stimulusKinds, 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,
|
|
status = $6,
|
|
updated_at = NOW()
|
|
WHERE id = $1
|
|
`, id, displayName, toPgText(description), stimulusKinds, responseKinds, 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,
|
|
})
|
|
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
|
|
}
|
|
|
|
return questionToDomain(question), 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,
|
|
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,
|
|
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,
|
|
})
|
|
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,
|
|
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,
|
|
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,
|
|
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,
|
|
})
|
|
}
|