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, ¤tQuestionID, &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)