package postgres import ( "context" "encoding/json" "fmt" "github.com/jackc/pgx/v5" ) type SessionResult struct { ID string SessionID string ResultStatus string SummaryJSON string FinalDurationSec *int FinalScore *int CompletedControls *int TotalControls *int DistanceMeters *float64 AverageSpeedKmh *float64 MaxHeartRateBpm *int } type UpsertSessionResultParams struct { SessionID string ResultStatus string Summary map[string]any FinalDurationSec *int FinalScore *int CompletedControls *int TotalControls *int DistanceMeters *float64 AverageSpeedKmh *float64 MaxHeartRateBpm *int } type SessionResultRecord struct { Session Result *SessionResult } func (s *Store) UpsertSessionResult(ctx context.Context, tx Tx, params UpsertSessionResultParams) (*SessionResult, error) { summaryJSON, err := json.Marshal(params.Summary) if err != nil { return nil, fmt.Errorf("marshal session summary: %w", err) } row := tx.QueryRow(ctx, ` INSERT INTO session_results ( session_id, result_status, summary_jsonb, final_duration_sec, final_score, completed_controls, total_controls, distance_meters, average_speed_kmh, max_heart_rate_bpm ) VALUES ($1, $2, $3::jsonb, $4, $5, $6, $7, $8, $9, $10) ON CONFLICT (session_id) DO UPDATE SET result_status = EXCLUDED.result_status, summary_jsonb = EXCLUDED.summary_jsonb, final_duration_sec = EXCLUDED.final_duration_sec, final_score = EXCLUDED.final_score, completed_controls = EXCLUDED.completed_controls, total_controls = EXCLUDED.total_controls, distance_meters = EXCLUDED.distance_meters, average_speed_kmh = EXCLUDED.average_speed_kmh, max_heart_rate_bpm = EXCLUDED.max_heart_rate_bpm RETURNING id, session_id, result_status, summary_jsonb::text, final_duration_sec, final_score, completed_controls, total_controls, distance_meters::float8, average_speed_kmh::float8, max_heart_rate_bpm `, params.SessionID, params.ResultStatus, string(summaryJSON), params.FinalDurationSec, params.FinalScore, params.CompletedControls, params.TotalControls, params.DistanceMeters, params.AverageSpeedKmh, params.MaxHeartRateBpm) return scanSessionResult(row) } func (s *Store) GetSessionResultByPublicID(ctx context.Context, sessionPublicID string) (*SessionResultRecord, error) { row := s.pool.QueryRow(ctx, ` SELECT gs.id, gs.session_public_id, gs.user_id, gs.event_id, gs.event_release_id, er.release_public_id, er.config_label, er.manifest_url, er.manifest_checksum_sha256, gs.device_key, gs.client_type, gs.assignment_mode, gs.variant_id, gs.variant_name, gs.route_code, gs.status, gs.session_token_hash, gs.session_token_expires_at, gs.launched_at, gs.started_at, gs.ended_at, e.event_public_id, e.display_name, sr.id, sr.session_id, sr.result_status, sr.summary_jsonb::text, sr.final_duration_sec, sr.final_score, sr.completed_controls, sr.total_controls, sr.distance_meters::float8, sr.average_speed_kmh::float8, sr.max_heart_rate_bpm FROM game_sessions gs JOIN events e ON e.id = gs.event_id JOIN event_releases er ON er.id = gs.event_release_id LEFT JOIN session_results sr ON sr.session_id = gs.id WHERE gs.session_public_id = $1 LIMIT 1 `, sessionPublicID) return scanSessionResultRecord(row) } func (s *Store) ListSessionResultsByUserID(ctx context.Context, userID string, limit int) ([]SessionResultRecord, error) { if limit <= 0 || limit > 100 { limit = 20 } rows, err := s.pool.Query(ctx, ` SELECT gs.id, gs.session_public_id, gs.user_id, gs.event_id, gs.event_release_id, er.release_public_id, er.config_label, er.manifest_url, er.manifest_checksum_sha256, gs.device_key, gs.client_type, gs.assignment_mode, gs.variant_id, gs.variant_name, gs.route_code, gs.status, gs.session_token_hash, gs.session_token_expires_at, gs.launched_at, gs.started_at, gs.ended_at, e.event_public_id, e.display_name, sr.id, sr.session_id, sr.result_status, sr.summary_jsonb::text, sr.final_duration_sec, sr.final_score, sr.completed_controls, sr.total_controls, sr.distance_meters::float8, sr.average_speed_kmh::float8, sr.max_heart_rate_bpm FROM game_sessions gs JOIN events e ON e.id = gs.event_id JOIN event_releases er ON er.id = gs.event_release_id LEFT JOIN session_results sr ON sr.session_id = gs.id WHERE gs.user_id = $1 AND gs.status IN ('finished', 'failed', 'cancelled') ORDER BY COALESCE(gs.ended_at, gs.updated_at, gs.created_at) DESC LIMIT $2 `, userID, limit) if err != nil { return nil, fmt.Errorf("list session results by user id: %w", err) } defer rows.Close() var items []SessionResultRecord for rows.Next() { item, err := scanSessionResultRecordFromRows(rows) if err != nil { return nil, err } items = append(items, *item) } if err := rows.Err(); err != nil { return nil, fmt.Errorf("iterate session results by user id: %w", err) } return items, nil } func scanSessionResult(row pgx.Row) (*SessionResult, error) { var result SessionResult err := row.Scan( &result.ID, &result.SessionID, &result.ResultStatus, &result.SummaryJSON, &result.FinalDurationSec, &result.FinalScore, &result.CompletedControls, &result.TotalControls, &result.DistanceMeters, &result.AverageSpeedKmh, &result.MaxHeartRateBpm, ) if err != nil { return nil, fmt.Errorf("scan session result: %w", err) } return &result, nil } func scanSessionResultRecord(row pgx.Row) (*SessionResultRecord, error) { var record SessionResultRecord var resultID *string var resultSessionID *string var resultStatus *string var resultSummaryJSON *string var finalDurationSec *int var finalScore *int var completedControls *int var totalControls *int var distanceMeters *float64 var averageSpeedKmh *float64 var maxHeartRateBpm *int err := row.Scan( &record.ID, &record.SessionPublicID, &record.UserID, &record.EventID, &record.EventReleaseID, &record.ReleasePublicID, &record.ConfigLabel, &record.ManifestURL, &record.ManifestChecksum, &record.DeviceKey, &record.ClientType, &record.AssignmentMode, &record.VariantID, &record.VariantName, &record.RouteCode, &record.Status, &record.SessionTokenHash, &record.SessionTokenExpiresAt, &record.LaunchedAt, &record.StartedAt, &record.EndedAt, &record.EventPublicID, &record.EventDisplayName, &resultID, &resultSessionID, &resultStatus, &resultSummaryJSON, &finalDurationSec, &finalScore, &completedControls, &totalControls, &distanceMeters, &averageSpeedKmh, &maxHeartRateBpm, ) if err != nil { if err == pgx.ErrNoRows { return nil, nil } return nil, fmt.Errorf("scan session result record: %w", err) } if resultID != nil { record.Result = &SessionResult{ ID: *resultID, SessionID: derefString(resultSessionID), ResultStatus: derefString(resultStatus), SummaryJSON: derefString(resultSummaryJSON), FinalDurationSec: finalDurationSec, FinalScore: finalScore, CompletedControls: completedControls, TotalControls: totalControls, DistanceMeters: distanceMeters, AverageSpeedKmh: averageSpeedKmh, MaxHeartRateBpm: maxHeartRateBpm, } } return &record, nil } func scanSessionResultRecordFromRows(rows pgx.Rows) (*SessionResultRecord, error) { var record SessionResultRecord var resultID *string var resultSessionID *string var resultStatus *string var resultSummaryJSON *string var finalDurationSec *int var finalScore *int var completedControls *int var totalControls *int var distanceMeters *float64 var averageSpeedKmh *float64 var maxHeartRateBpm *int err := rows.Scan( &record.ID, &record.SessionPublicID, &record.UserID, &record.EventID, &record.EventReleaseID, &record.ReleasePublicID, &record.ConfigLabel, &record.ManifestURL, &record.ManifestChecksum, &record.DeviceKey, &record.ClientType, &record.AssignmentMode, &record.VariantID, &record.VariantName, &record.RouteCode, &record.Status, &record.SessionTokenHash, &record.SessionTokenExpiresAt, &record.LaunchedAt, &record.StartedAt, &record.EndedAt, &record.EventPublicID, &record.EventDisplayName, &resultID, &resultSessionID, &resultStatus, &resultSummaryJSON, &finalDurationSec, &finalScore, &completedControls, &totalControls, &distanceMeters, &averageSpeedKmh, &maxHeartRateBpm, ) if err != nil { return nil, fmt.Errorf("scan session result row: %w", err) } if resultID != nil { record.Result = &SessionResult{ ID: *resultID, SessionID: derefString(resultSessionID), ResultStatus: derefString(resultStatus), SummaryJSON: derefString(resultSummaryJSON), FinalDurationSec: finalDurationSec, FinalScore: finalScore, CompletedControls: completedControls, TotalControls: totalControls, DistanceMeters: distanceMeters, AverageSpeedKmh: averageSpeedKmh, MaxHeartRateBpm: maxHeartRateBpm, } } return &record, nil } func derefString(value *string) string { if value == nil { return "" } return *value }