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