You cannot select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
325 lines
7.7 KiB
Go
325 lines
7.7 KiB
Go
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
|
|
}
|