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.

296 lines
8.7 KiB
Go

package ent
import (
"context"
"errors"
"strings"
"time"
"github.com/google/uuid"
"github.com/jackc/pgx/v5"
domain "knowfoolery/backend/services/game-session-service/internal/domain/session"
)
// SessionRepository implements game session persistence in PostgreSQL.
type SessionRepository struct {
client *Client
}
// NewSessionRepository creates a new session repository.
func NewSessionRepository(client *Client) *SessionRepository {
return &SessionRepository{client: client}
}
// EnsureSchema creates required tables and indexes.
func (r *SessionRepository) EnsureSchema(ctx context.Context) error {
const ddl = `
CREATE TABLE IF NOT EXISTS game_sessions (
id UUID PRIMARY KEY,
player_id VARCHAR(128) NOT NULL,
player_name VARCHAR(50) NOT NULL,
status VARCHAR(16) NOT NULL,
total_score INT NOT NULL DEFAULT 0,
questions_asked INT NOT NULL DEFAULT 0,
questions_correct INT NOT NULL DEFAULT 0,
hints_used INT NOT NULL DEFAULT 0,
current_question_id VARCHAR(64),
current_attempts INT NOT NULL DEFAULT 0,
current_hint_used BOOLEAN NOT NULL DEFAULT FALSE,
question_started_at TIMESTAMPTZ,
start_time TIMESTAMPTZ NOT NULL,
end_time TIMESTAMPTZ,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE INDEX IF NOT EXISTS idx_game_sessions_player_status ON game_sessions (player_id, status);
CREATE INDEX IF NOT EXISTS idx_game_sessions_status_start ON game_sessions (status, start_time DESC);
CREATE INDEX IF NOT EXISTS idx_game_sessions_created_at ON game_sessions (created_at DESC);
CREATE TABLE IF NOT EXISTS session_attempts (
id UUID PRIMARY KEY,
session_id UUID NOT NULL,
question_id VARCHAR(64) NOT NULL,
attempt_number INT NOT NULL,
provided_answer VARCHAR(500) NOT NULL,
is_correct BOOLEAN NOT NULL,
used_hint BOOLEAN NOT NULL,
awarded_score INT NOT NULL,
latency_ms INT NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE INDEX IF NOT EXISTS idx_session_attempts_session_question
ON session_attempts (session_id, question_id, attempt_number);
CREATE INDEX IF NOT EXISTS idx_session_attempts_session_created ON session_attempts (session_id, created_at DESC);
CREATE TABLE IF NOT EXISTS session_events (
id UUID PRIMARY KEY,
session_id UUID NOT NULL,
event_type VARCHAR(64) NOT NULL,
metadata_json JSONB NOT NULL DEFAULT '{}'::jsonb,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE INDEX IF NOT EXISTS idx_session_events_session ON session_events (session_id, created_at DESC);
`
_, err := r.client.Pool.Exec(ctx, ddl)
return err
}
// CreateSession inserts a new game session.
func (r *SessionRepository) CreateSession(
ctx context.Context,
session *domain.GameSession,
) (*domain.GameSession, error) {
id := uuid.NewString()
now := time.Now().UTC()
const q = `
INSERT INTO game_sessions (
id, player_id, player_name, status, total_score, questions_asked, questions_correct, hints_used,
current_question_id, current_attempts, current_hint_used, question_started_at,
start_time, end_time, created_at, updated_at
)
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15,$16)
RETURNING id, player_id, player_name, status, total_score, questions_asked, questions_correct, hints_used,
current_question_id, current_attempts, current_hint_used, question_started_at,
start_time, end_time, created_at, updated_at`
row := r.client.Pool.QueryRow(ctx, q,
id,
session.PlayerID,
session.PlayerName,
string(session.Status),
session.TotalScore,
session.QuestionsAsked,
session.QuestionsCorrect,
session.HintsUsed,
nullIfEmpty(session.CurrentQuestionID),
session.CurrentAttempts,
session.CurrentHintUsed,
session.QuestionStartedAt,
session.StartTime,
session.EndTime,
now,
now,
)
return scanSession(row)
}
// GetSessionByID returns a session by ID.
func (r *SessionRepository) GetSessionByID(ctx context.Context, id string) (*domain.GameSession, error) {
const q = `
SELECT id, player_id, player_name, status, total_score, questions_asked, questions_correct, hints_used,
current_question_id, current_attempts, current_hint_used, question_started_at,
start_time, end_time, created_at, updated_at
FROM game_sessions WHERE id=$1`
row := r.client.Pool.QueryRow(ctx, q, id)
session, err := scanSession(row)
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return nil, domain.ErrSessionNotFound
}
return nil, err
}
return session, nil
}
// GetActiveSessionByPlayerID returns an active session for player.
func (r *SessionRepository) GetActiveSessionByPlayerID(
ctx context.Context,
playerID string,
) (*domain.GameSession, error) {
const q = `
SELECT id, player_id, player_name, status, total_score, questions_asked, questions_correct, hints_used,
current_question_id, current_attempts, current_hint_used, question_started_at,
start_time, end_time, created_at, updated_at
FROM game_sessions WHERE player_id=$1 AND status='active' ORDER BY start_time DESC LIMIT 1`
row := r.client.Pool.QueryRow(ctx, q, playerID)
session, err := scanSession(row)
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return nil, domain.ErrSessionNotFound
}
return nil, err
}
return session, nil
}
// UpdateSession updates mutable session fields.
func (r *SessionRepository) UpdateSession(
ctx context.Context,
session *domain.GameSession,
) (*domain.GameSession, error) {
const q = `
UPDATE game_sessions
SET status=$2, total_score=$3, questions_asked=$4, questions_correct=$5, hints_used=$6,
current_question_id=$7, current_attempts=$8, current_hint_used=$9, question_started_at=$10,
start_time=$11, end_time=$12, updated_at=NOW()
WHERE id=$1
RETURNING id, player_id, player_name, status, total_score, questions_asked, questions_correct, hints_used,
current_question_id, current_attempts, current_hint_used, question_started_at,
start_time, end_time, created_at, updated_at`
row := r.client.Pool.QueryRow(ctx, q,
session.ID,
string(session.Status),
session.TotalScore,
session.QuestionsAsked,
session.QuestionsCorrect,
session.HintsUsed,
nullIfEmpty(session.CurrentQuestionID),
session.CurrentAttempts,
session.CurrentHintUsed,
session.QuestionStartedAt,
session.StartTime,
session.EndTime,
)
updated, err := scanSession(row)
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return nil, domain.ErrSessionNotFound
}
return nil, err
}
return updated, nil
}
// CreateAttempt stores an answer attempt.
func (r *SessionRepository) CreateAttempt(ctx context.Context, attempt *domain.SessionAttempt) error {
const q = `
INSERT INTO session_attempts
(id, session_id, question_id, attempt_number, provided_answer, is_correct, used_hint,
awarded_score, latency_ms, created_at)
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,NOW())`
_, err := r.client.Pool.Exec(ctx, q,
uuid.NewString(),
attempt.SessionID,
attempt.QuestionID,
attempt.AttemptNumber,
attempt.ProvidedAnswer,
attempt.IsCorrect,
attempt.UsedHint,
attempt.AwardedScore,
attempt.LatencyMs,
)
return err
}
// CreateEvent stores a session lifecycle or anti-cheat event.
func (r *SessionRepository) CreateEvent(ctx context.Context, event *domain.SessionEvent) error {
metadata := strings.TrimSpace(event.Metadata)
if metadata == "" {
metadata = "{}"
}
const q = `
INSERT INTO session_events
(id, session_id, event_type, metadata_json, created_at)
VALUES ($1,$2,$3,$4::jsonb,NOW())`
_, err := r.client.Pool.Exec(ctx, q,
uuid.NewString(),
event.SessionID,
event.EventType,
metadata,
)
return err
}
// ListQuestionIDsForSession lists unique question IDs already attempted in a session.
func (r *SessionRepository) ListQuestionIDsForSession(ctx context.Context, sessionID string) ([]string, error) {
const q = `SELECT DISTINCT question_id FROM session_attempts WHERE session_id=$1 ORDER BY question_id ASC`
rows, err := r.client.Pool.Query(ctx, q, sessionID)
if err != nil {
return nil, err
}
defer rows.Close()
ids := make([]string, 0)
for rows.Next() {
var id string
if err := rows.Scan(&id); err != nil {
return nil, err
}
ids = append(ids, id)
}
return ids, rows.Err()
}
func scanSession(scanner interface {
Scan(dest ...interface{}) error
}) (*domain.GameSession, error) {
var s domain.GameSession
var status string
var currentQuestionID *string
if err := scanner.Scan(
&s.ID,
&s.PlayerID,
&s.PlayerName,
&status,
&s.TotalScore,
&s.QuestionsAsked,
&s.QuestionsCorrect,
&s.HintsUsed,
&currentQuestionID,
&s.CurrentAttempts,
&s.CurrentHintUsed,
&s.QuestionStartedAt,
&s.StartTime,
&s.EndTime,
&s.CreatedAt,
&s.UpdatedAt,
); err != nil {
return nil, err
}
s.Status = domain.Status(status)
if currentQuestionID != nil {
s.CurrentQuestionID = *currentQuestionID
}
return &s, nil
}
func nullIfEmpty(v string) interface{} {
if strings.TrimSpace(v) == "" {
return nil
}
return v
}
var _ domain.Repository = (*SessionRepository)(nil)