package ent import ( "context" "errors" "fmt" "strings" "time" "github.com/google/uuid" "github.com/jackc/pgx/v5" domain "knowfoolery/backend/services/question-bank-service/internal/domain/question" sharederrors "knowfoolery/backend/shared/domain/errors" sharedpostgres "knowfoolery/backend/shared/infra/database/postgres" ) // QuestionRepository implements question storage on PostgreSQL. type QuestionRepository struct { client *sharedpostgres.Client } // NewQuestionRepository creates a new question repository. func NewQuestionRepository(client *sharedpostgres.Client) *QuestionRepository { return &QuestionRepository{client: client} } // EnsureSchema creates the service table if missing. func (r *QuestionRepository) EnsureSchema(ctx context.Context) error { const ddl = ` CREATE TABLE IF NOT EXISTS questions ( id UUID PRIMARY KEY, theme VARCHAR(100) NOT NULL, text TEXT NOT NULL, answer VARCHAR(500) NOT NULL, hint TEXT, difficulty VARCHAR(10) NOT NULL DEFAULT 'medium', is_active BOOLEAN NOT NULL DEFAULT TRUE, created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() ); CREATE INDEX IF NOT EXISTS idx_questions_theme_active ON questions (theme, is_active); CREATE INDEX IF NOT EXISTS idx_questions_difficulty_active ON questions (difficulty, is_active); ` _, err := r.client.Pool.Exec(ctx, ddl) return err } func (r *QuestionRepository) GetByID(ctx context.Context, id string) (*domain.Question, error) { const q = ` SELECT id, theme, text, answer, hint, difficulty, is_active, created_at, updated_at FROM questions WHERE id=$1` row := r.client.Pool.QueryRow(ctx, q, id) question, err := scanQuestion(row) if err != nil { if errors.Is(err, pgx.ErrNoRows) { return nil, sharederrors.Wrap( sharederrors.CodeQuestionNotFound, "question not found", err, ) } return nil, err } return question, nil } func (r *QuestionRepository) Create(ctx context.Context, qn *domain.Question) (*domain.Question, error) { id := uuid.NewString() now := time.Now().UTC() if qn.Difficulty == "" { qn.Difficulty = domain.DifficultyMedium } const q = ` INSERT INTO questions (id, theme, text, answer, hint, difficulty, is_active, created_at, updated_at) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9) RETURNING id, theme, text, answer, hint, difficulty, is_active, created_at, updated_at` row := r.client.Pool.QueryRow(ctx, q, id, qn.Theme, qn.Text, qn.Answer, qn.Hint, string(qn.Difficulty), true, now, now, ) created, err := scanQuestion(row) if err != nil { return nil, err } return created, nil } func (r *QuestionRepository) Update( ctx context.Context, id string, qn *domain.Question, ) (*domain.Question, error) { const q = ` UPDATE questions SET theme=$2, text=$3, answer=$4, hint=$5, difficulty=$6, is_active=$7, updated_at=NOW() WHERE id=$1 RETURNING id, theme, text, answer, hint, difficulty, is_active, created_at, updated_at` row := r.client.Pool.QueryRow(ctx, q, id, qn.Theme, qn.Text, qn.Answer, qn.Hint, string(qn.Difficulty), qn.IsActive, ) updated, err := scanQuestion(row) if err != nil { if errors.Is(err, pgx.ErrNoRows) { return nil, sharederrors.Wrap( sharederrors.CodeQuestionNotFound, "question not found", err, ) } return nil, err } return updated, nil } func (r *QuestionRepository) SoftDelete(ctx context.Context, id string) error { const q = `UPDATE questions SET is_active=false, updated_at=NOW() WHERE id=$1` res, err := r.client.Pool.Exec(ctx, q, id) if err != nil { return err } if res.RowsAffected() == 0 { return sharederrors.Wrap( sharederrors.CodeQuestionNotFound, "question not found", nil, ) } return nil } func (r *QuestionRepository) ListThemes(ctx context.Context) ([]string, error) { const q = `SELECT DISTINCT theme FROM questions WHERE is_active=true ORDER BY theme ASC` rows, err := r.client.Pool.Query(ctx, q) if err != nil { return nil, err } defer rows.Close() out := make([]string, 0) for rows.Next() { var theme string if err := rows.Scan(&theme); err != nil { return nil, err } out = append(out, theme) } return out, rows.Err() } func (r *QuestionRepository) CountRandomCandidates( ctx context.Context, filter domain.RandomFilter, ) (int, error) { where, args := buildRandomWhere(filter) query := `SELECT COUNT(*) FROM questions WHERE is_active=true` + where row := r.client.Pool.QueryRow(ctx, query, args...) var count int if err := row.Scan(&count); err != nil { return 0, err } return count, nil } func (r *QuestionRepository) RandomByOffset( ctx context.Context, filter domain.RandomFilter, offset int, ) (*domain.Question, error) { where, args := buildRandomWhere(filter) args = append(args, offset) query := ` SELECT id, theme, text, answer, hint, difficulty, is_active, created_at, updated_at FROM questions WHERE is_active=true` + where + ` ORDER BY id LIMIT 1 OFFSET $` + fmt.Sprintf("%d", len(args)) row := r.client.Pool.QueryRow(ctx, query, args...) question, err := scanQuestion(row) if err != nil { if errors.Is(err, pgx.ErrNoRows) { return nil, sharederrors.Wrap( sharederrors.CodeNoQuestionsAvailable, "no questions available", err, ) } return nil, err } return question, nil } func (r *QuestionRepository) BulkCreate( ctx context.Context, questions []*domain.Question, ) (int, []domain.BulkError, error) { tx, err := r.client.Pool.Begin(ctx) if err != nil { return 0, nil, err } defer func() { _ = tx.Rollback(ctx) }() created := 0 errs := make([]domain.BulkError, 0) const q = ` INSERT INTO questions (id, theme, text, answer, hint, difficulty, is_active, created_at, updated_at) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9)` for i, item := range questions { id := uuid.NewString() now := time.Now().UTC() _, execErr := tx.Exec(ctx, q, id, item.Theme, item.Text, item.Answer, item.Hint, string(item.Difficulty), true, now, now, ) if execErr != nil { errs = append( errs, domain.BulkError{Index: i, Reason: execErr.Error()}, ) continue } created++ } if err := tx.Commit(ctx); err != nil { return 0, nil, err } return created, errs, nil } func buildRandomWhere(filter domain.RandomFilter) (string, []interface{}) { clauses := make([]string, 0) args := make([]interface{}, 0) if filter.Theme != "" { args = append(args, filter.Theme) clauses = append(clauses, fmt.Sprintf("theme=$%d", len(args))) } if filter.Difficulty != "" { args = append(args, string(filter.Difficulty)) clauses = append(clauses, fmt.Sprintf("difficulty=$%d", len(args))) } if len(filter.ExcludeQuestionIDs) > 0 { excludeClause := make([]string, 0, len(filter.ExcludeQuestionIDs)) for _, id := range filter.ExcludeQuestionIDs { args = append(args, id) excludeClause = append(excludeClause, fmt.Sprintf("$%d", len(args))) } clauses = append(clauses, "id NOT IN ("+strings.Join(excludeClause, ",")+")") } if len(clauses) == 0 { return "", args } return " AND " + strings.Join(clauses, " AND "), args } type scanner interface { Scan(dest ...interface{}) error } func scanQuestion(row scanner) (*domain.Question, error) { var ( id string theme string text string answer string hint *string difficulty string isActive bool createdAt time.Time updatedAt time.Time ) if err := row.Scan( &id, &theme, &text, &answer, &hint, &difficulty, &isActive, &createdAt, &updatedAt, ); err != nil { return nil, err } q := &domain.Question{ ID: id, Theme: theme, Text: text, Answer: answer, Difficulty: domain.Difficulty(difficulty), IsActive: isActive, CreatedAt: createdAt, UpdatedAt: updatedAt, } if hint != nil { q.Hint = *hint } return q, nil }