Add backend foundation and config-driven workbench
This commit is contained in:
367
backend/internal/store/postgres/result_store.go
Normal file
367
backend/internal/store/postgres/result_store.go
Normal file
@@ -0,0 +1,367 @@
|
||||
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.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.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.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.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
|
||||
}
|
||||
Reference in New Issue
Block a user