Add backend foundation and config-driven workbench
This commit is contained in:
310
backend/internal/store/postgres/auth_store.go
Normal file
310
backend/internal/store/postgres/auth_store.go
Normal file
@@ -0,0 +1,310 @@
|
||||
package postgres
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"cmr-backend/internal/apperr"
|
||||
|
||||
"github.com/jackc/pgx/v5"
|
||||
)
|
||||
|
||||
type SMSCodeMeta struct {
|
||||
ID string
|
||||
CodeHash string
|
||||
ExpiresAt time.Time
|
||||
CooldownUntil time.Time
|
||||
}
|
||||
|
||||
type CreateSMSCodeParams struct {
|
||||
Scene string
|
||||
CountryCode string
|
||||
Mobile string
|
||||
ClientType string
|
||||
DeviceKey string
|
||||
CodeHash string
|
||||
ProviderName string
|
||||
ProviderDebug map[string]any
|
||||
ExpiresAt time.Time
|
||||
CooldownUntil time.Time
|
||||
}
|
||||
|
||||
type CreateMobileIdentityParams struct {
|
||||
UserID string
|
||||
IdentityType string
|
||||
Provider string
|
||||
ProviderSubj string
|
||||
CountryCode string
|
||||
Mobile string
|
||||
}
|
||||
|
||||
type CreateIdentityParams struct {
|
||||
UserID string
|
||||
IdentityType string
|
||||
Provider string
|
||||
ProviderSubj string
|
||||
CountryCode *string
|
||||
Mobile *string
|
||||
ProfileJSON string
|
||||
}
|
||||
|
||||
type CreateRefreshTokenParams struct {
|
||||
UserID string
|
||||
ClientType string
|
||||
DeviceKey string
|
||||
TokenHash string
|
||||
ExpiresAt time.Time
|
||||
}
|
||||
|
||||
type RefreshTokenRecord struct {
|
||||
ID string
|
||||
UserID string
|
||||
ClientType string
|
||||
DeviceKey *string
|
||||
ExpiresAt time.Time
|
||||
IsRevoked bool
|
||||
}
|
||||
|
||||
func (s *Store) GetLatestSMSCodeMeta(ctx context.Context, countryCode, mobile, clientType, scene string) (*SMSCodeMeta, error) {
|
||||
row := s.pool.QueryRow(ctx, `
|
||||
SELECT id, code_hash, expires_at, cooldown_until
|
||||
FROM auth_sms_codes
|
||||
WHERE country_code = $1 AND mobile = $2 AND client_type = $3 AND scene = $4
|
||||
ORDER BY created_at DESC
|
||||
LIMIT 1
|
||||
`, countryCode, mobile, clientType, scene)
|
||||
|
||||
var record SMSCodeMeta
|
||||
err := row.Scan(&record.ID, &record.CodeHash, &record.ExpiresAt, &record.CooldownUntil)
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return nil, nil
|
||||
}
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("query latest sms code meta: %w", err)
|
||||
}
|
||||
return &record, nil
|
||||
}
|
||||
|
||||
func (s *Store) CreateSMSCode(ctx context.Context, params CreateSMSCodeParams) error {
|
||||
payload, err := json.Marshal(map[string]any{
|
||||
"provider": params.ProviderName,
|
||||
"debug": params.ProviderDebug,
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
_, err = s.pool.Exec(ctx, `
|
||||
INSERT INTO auth_sms_codes (
|
||||
scene, country_code, mobile, client_type, device_key, code_hash,
|
||||
provider_payload_jsonb, expires_at, cooldown_until
|
||||
)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7::jsonb, $8, $9)
|
||||
`, params.Scene, params.CountryCode, params.Mobile, params.ClientType, params.DeviceKey, params.CodeHash, string(payload), params.ExpiresAt, params.CooldownUntil)
|
||||
if err != nil {
|
||||
return fmt.Errorf("insert sms code: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Store) GetLatestValidSMSCode(ctx context.Context, countryCode, mobile, clientType, scene string) (*SMSCodeMeta, error) {
|
||||
row := s.pool.QueryRow(ctx, `
|
||||
SELECT id, code_hash, expires_at, cooldown_until
|
||||
FROM auth_sms_codes
|
||||
WHERE country_code = $1
|
||||
AND mobile = $2
|
||||
AND client_type = $3
|
||||
AND scene = $4
|
||||
AND consumed_at IS NULL
|
||||
AND expires_at > NOW()
|
||||
ORDER BY created_at DESC
|
||||
LIMIT 1
|
||||
`, countryCode, mobile, clientType, scene)
|
||||
|
||||
var record SMSCodeMeta
|
||||
err := row.Scan(&record.ID, &record.CodeHash, &record.ExpiresAt, &record.CooldownUntil)
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return nil, nil
|
||||
}
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("query latest valid sms code: %w", err)
|
||||
}
|
||||
return &record, nil
|
||||
}
|
||||
|
||||
func (s *Store) ConsumeSMSCode(ctx context.Context, tx Tx, id string) (bool, error) {
|
||||
commandTag, err := tx.Exec(ctx, `
|
||||
UPDATE auth_sms_codes
|
||||
SET consumed_at = NOW()
|
||||
WHERE id = $1 AND consumed_at IS NULL
|
||||
`, id)
|
||||
if err != nil {
|
||||
return false, fmt.Errorf("consume sms code: %w", err)
|
||||
}
|
||||
return commandTag.RowsAffected() == 1, nil
|
||||
}
|
||||
|
||||
func (s *Store) CreateMobileIdentity(ctx context.Context, tx Tx, params CreateMobileIdentityParams) error {
|
||||
countryCode := params.CountryCode
|
||||
mobile := params.Mobile
|
||||
return s.CreateIdentity(ctx, tx, CreateIdentityParams{
|
||||
UserID: params.UserID,
|
||||
IdentityType: params.IdentityType,
|
||||
Provider: params.Provider,
|
||||
ProviderSubj: params.ProviderSubj,
|
||||
CountryCode: &countryCode,
|
||||
Mobile: &mobile,
|
||||
ProfileJSON: "{}",
|
||||
})
|
||||
}
|
||||
|
||||
func (s *Store) CreateIdentity(ctx context.Context, tx Tx, params CreateIdentityParams) error {
|
||||
_, err := tx.Exec(ctx, `
|
||||
INSERT INTO login_identities (
|
||||
user_id, identity_type, provider, provider_subject, country_code, mobile, status, profile_jsonb
|
||||
)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, 'active', $7::jsonb)
|
||||
ON CONFLICT (provider, provider_subject) DO NOTHING
|
||||
`, params.UserID, params.IdentityType, params.Provider, params.ProviderSubj, params.CountryCode, params.Mobile, zeroJSON(params.ProfileJSON))
|
||||
if err != nil {
|
||||
return fmt.Errorf("create identity: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Store) FindUserByProviderSubject(ctx context.Context, tx Tx, provider, providerSubject string) (*User, error) {
|
||||
row := tx.QueryRow(ctx, `
|
||||
SELECT u.id, u.user_public_id, u.status, u.nickname, u.avatar_url
|
||||
FROM users u
|
||||
JOIN login_identities li ON li.user_id = u.id
|
||||
WHERE li.provider = $1
|
||||
AND li.provider_subject = $2
|
||||
AND li.status = 'active'
|
||||
LIMIT 1
|
||||
`, provider, providerSubject)
|
||||
return scanUser(row)
|
||||
}
|
||||
|
||||
func (s *Store) CreateRefreshToken(ctx context.Context, tx Tx, params CreateRefreshTokenParams) (string, error) {
|
||||
row := tx.QueryRow(ctx, `
|
||||
INSERT INTO auth_refresh_tokens (user_id, client_type, device_key, token_hash, expires_at)
|
||||
VALUES ($1, $2, NULLIF($3, ''), $4, $5)
|
||||
RETURNING id
|
||||
`, params.UserID, params.ClientType, params.DeviceKey, params.TokenHash, params.ExpiresAt)
|
||||
|
||||
var id string
|
||||
if err := row.Scan(&id); err != nil {
|
||||
return "", fmt.Errorf("create refresh token: %w", err)
|
||||
}
|
||||
return id, nil
|
||||
}
|
||||
|
||||
func (s *Store) GetRefreshTokenForUpdate(ctx context.Context, tx Tx, tokenHash string) (*RefreshTokenRecord, error) {
|
||||
row := tx.QueryRow(ctx, `
|
||||
SELECT id, user_id, client_type, device_key, expires_at, revoked_at IS NOT NULL
|
||||
FROM auth_refresh_tokens
|
||||
WHERE token_hash = $1
|
||||
FOR UPDATE
|
||||
`, tokenHash)
|
||||
|
||||
var record RefreshTokenRecord
|
||||
err := row.Scan(&record.ID, &record.UserID, &record.ClientType, &record.DeviceKey, &record.ExpiresAt, &record.IsRevoked)
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return nil, nil
|
||||
}
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("query refresh token for update: %w", err)
|
||||
}
|
||||
return &record, nil
|
||||
}
|
||||
|
||||
func (s *Store) RotateRefreshToken(ctx context.Context, tx Tx, oldTokenID, newTokenID string) error {
|
||||
_, err := tx.Exec(ctx, `
|
||||
UPDATE auth_refresh_tokens
|
||||
SET revoked_at = NOW(), replaced_by_token_id = $2
|
||||
WHERE id = $1
|
||||
`, oldTokenID, newTokenID)
|
||||
if err != nil {
|
||||
return fmt.Errorf("rotate refresh token: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Store) RevokeRefreshToken(ctx context.Context, tokenHash string) error {
|
||||
commandTag, err := s.pool.Exec(ctx, `
|
||||
UPDATE auth_refresh_tokens
|
||||
SET revoked_at = COALESCE(revoked_at, NOW())
|
||||
WHERE token_hash = $1
|
||||
`, tokenHash)
|
||||
if err != nil {
|
||||
return fmt.Errorf("revoke refresh token: %w", err)
|
||||
}
|
||||
if commandTag.RowsAffected() == 0 {
|
||||
return apperr.New(http.StatusNotFound, "refresh_token_not_found", "refresh token not found")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Store) RevokeRefreshTokensByUserID(ctx context.Context, tx Tx, userID string) error {
|
||||
_, err := tx.Exec(ctx, `
|
||||
UPDATE auth_refresh_tokens
|
||||
SET revoked_at = COALESCE(revoked_at, NOW())
|
||||
WHERE user_id = $1
|
||||
`, userID)
|
||||
if err != nil {
|
||||
return fmt.Errorf("revoke refresh tokens by user id: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Store) TransferNonMobileIdentities(ctx context.Context, tx Tx, sourceUserID, targetUserID string) error {
|
||||
if sourceUserID == targetUserID {
|
||||
return nil
|
||||
}
|
||||
|
||||
_, err := tx.Exec(ctx, `
|
||||
INSERT INTO login_identities (
|
||||
user_id, identity_type, provider, provider_subject, country_code, mobile, status, profile_jsonb, created_at, updated_at
|
||||
)
|
||||
SELECT
|
||||
$2,
|
||||
li.identity_type,
|
||||
li.provider,
|
||||
li.provider_subject,
|
||||
li.country_code,
|
||||
li.mobile,
|
||||
li.status,
|
||||
li.profile_jsonb,
|
||||
li.created_at,
|
||||
li.updated_at
|
||||
FROM login_identities li
|
||||
WHERE li.user_id = $1
|
||||
AND li.provider <> 'mobile'
|
||||
ON CONFLICT (provider, provider_subject) DO NOTHING
|
||||
`, sourceUserID, targetUserID)
|
||||
if err != nil {
|
||||
return fmt.Errorf("copy non-mobile identities: %w", err)
|
||||
}
|
||||
|
||||
_, err = tx.Exec(ctx, `
|
||||
DELETE FROM login_identities
|
||||
WHERE user_id = $1
|
||||
AND provider <> 'mobile'
|
||||
`, sourceUserID)
|
||||
if err != nil {
|
||||
return fmt.Errorf("delete source non-mobile identities: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func zeroJSON(value string) string {
|
||||
if value == "" {
|
||||
return "{}"
|
||||
}
|
||||
return value
|
||||
}
|
||||
93
backend/internal/store/postgres/card_store.go
Normal file
93
backend/internal/store/postgres/card_store.go
Normal file
@@ -0,0 +1,93 @@
|
||||
package postgres
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"time"
|
||||
)
|
||||
|
||||
type Card struct {
|
||||
ID string
|
||||
PublicID string
|
||||
CardType string
|
||||
Title string
|
||||
Subtitle *string
|
||||
CoverURL *string
|
||||
DisplaySlot string
|
||||
DisplayPriority int
|
||||
EntryChannelID *string
|
||||
EventPublicID *string
|
||||
EventDisplayName *string
|
||||
EventSummary *string
|
||||
HTMLURL *string
|
||||
}
|
||||
|
||||
func (s *Store) ListCardsForEntry(ctx context.Context, tenantID string, entryChannelID *string, slot string, now time.Time, limit int) ([]Card, error) {
|
||||
if limit <= 0 || limit > 100 {
|
||||
limit = 20
|
||||
}
|
||||
if slot == "" {
|
||||
slot = "home_primary"
|
||||
}
|
||||
|
||||
rows, err := s.pool.Query(ctx, `
|
||||
SELECT
|
||||
c.id,
|
||||
c.card_public_id,
|
||||
c.card_type,
|
||||
c.title,
|
||||
c.subtitle,
|
||||
c.cover_url,
|
||||
c.display_slot,
|
||||
c.display_priority,
|
||||
c.entry_channel_id,
|
||||
e.event_public_id,
|
||||
e.display_name,
|
||||
e.summary,
|
||||
c.html_url
|
||||
FROM cards c
|
||||
LEFT JOIN events e ON e.id = c.event_id
|
||||
WHERE c.tenant_id = $1
|
||||
AND ($2::uuid IS NULL OR c.entry_channel_id = $2 OR c.entry_channel_id IS NULL)
|
||||
AND c.display_slot = $3
|
||||
AND c.status = 'active'
|
||||
AND (c.starts_at IS NULL OR c.starts_at <= $4)
|
||||
AND (c.ends_at IS NULL OR c.ends_at >= $4)
|
||||
ORDER BY
|
||||
CASE WHEN $2::uuid IS NOT NULL AND c.entry_channel_id = $2 THEN 0 ELSE 1 END,
|
||||
c.display_priority DESC,
|
||||
c.created_at ASC
|
||||
LIMIT $5
|
||||
`, tenantID, entryChannelID, slot, now, limit)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("list cards for entry: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var cards []Card
|
||||
for rows.Next() {
|
||||
var card Card
|
||||
if err := rows.Scan(
|
||||
&card.ID,
|
||||
&card.PublicID,
|
||||
&card.CardType,
|
||||
&card.Title,
|
||||
&card.Subtitle,
|
||||
&card.CoverURL,
|
||||
&card.DisplaySlot,
|
||||
&card.DisplayPriority,
|
||||
&card.EntryChannelID,
|
||||
&card.EventPublicID,
|
||||
&card.EventDisplayName,
|
||||
&card.EventSummary,
|
||||
&card.HTMLURL,
|
||||
); err != nil {
|
||||
return nil, fmt.Errorf("scan card: %w", err)
|
||||
}
|
||||
cards = append(cards, card)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, fmt.Errorf("iterate cards: %w", err)
|
||||
}
|
||||
return cards, nil
|
||||
}
|
||||
323
backend/internal/store/postgres/config_store.go
Normal file
323
backend/internal/store/postgres/config_store.go
Normal file
@@ -0,0 +1,323 @@
|
||||
package postgres
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
|
||||
"github.com/jackc/pgx/v5"
|
||||
)
|
||||
|
||||
type EventConfigSource struct {
|
||||
ID string
|
||||
EventID string
|
||||
SourceVersionNo int
|
||||
SourceKind string
|
||||
SchemaID string
|
||||
SchemaVersion string
|
||||
Status string
|
||||
SourceJSON string
|
||||
Notes *string
|
||||
}
|
||||
|
||||
type EventConfigBuild struct {
|
||||
ID string
|
||||
EventID string
|
||||
SourceID string
|
||||
BuildNo int
|
||||
BuildStatus string
|
||||
BuildLog *string
|
||||
ManifestJSON string
|
||||
AssetIndexJSON string
|
||||
}
|
||||
|
||||
type EventReleaseAsset struct {
|
||||
ID string
|
||||
EventReleaseID string
|
||||
AssetType string
|
||||
AssetKey string
|
||||
AssetPath *string
|
||||
AssetURL string
|
||||
Checksum *string
|
||||
SizeBytes *int64
|
||||
MetaJSON string
|
||||
}
|
||||
|
||||
type UpsertEventConfigSourceParams struct {
|
||||
EventID string
|
||||
SourceVersionNo int
|
||||
SourceKind string
|
||||
SchemaID string
|
||||
SchemaVersion string
|
||||
Status string
|
||||
Source map[string]any
|
||||
Notes *string
|
||||
}
|
||||
|
||||
type UpsertEventConfigBuildParams struct {
|
||||
EventID string
|
||||
SourceID string
|
||||
BuildNo int
|
||||
BuildStatus string
|
||||
BuildLog *string
|
||||
Manifest map[string]any
|
||||
AssetIndex []map[string]any
|
||||
}
|
||||
|
||||
type UpsertEventReleaseAssetParams struct {
|
||||
EventReleaseID string
|
||||
AssetType string
|
||||
AssetKey string
|
||||
AssetPath *string
|
||||
AssetURL string
|
||||
Checksum *string
|
||||
SizeBytes *int64
|
||||
Meta map[string]any
|
||||
}
|
||||
|
||||
func (s *Store) UpsertEventConfigSource(ctx context.Context, tx Tx, params UpsertEventConfigSourceParams) (*EventConfigSource, error) {
|
||||
sourceJSON, err := json.Marshal(params.Source)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("marshal event config source: %w", err)
|
||||
}
|
||||
|
||||
row := tx.QueryRow(ctx, `
|
||||
INSERT INTO event_config_sources (
|
||||
event_id, source_version_no, source_kind, schema_id, schema_version, status, source_jsonb, notes
|
||||
)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7::jsonb, $8)
|
||||
ON CONFLICT (event_id, source_version_no) DO UPDATE SET
|
||||
source_kind = EXCLUDED.source_kind,
|
||||
schema_id = EXCLUDED.schema_id,
|
||||
schema_version = EXCLUDED.schema_version,
|
||||
status = EXCLUDED.status,
|
||||
source_jsonb = EXCLUDED.source_jsonb,
|
||||
notes = EXCLUDED.notes
|
||||
RETURNING id, event_id, source_version_no, source_kind, schema_id, schema_version, status, source_jsonb::text, notes
|
||||
`, params.EventID, params.SourceVersionNo, params.SourceKind, params.SchemaID, params.SchemaVersion, params.Status, string(sourceJSON), params.Notes)
|
||||
|
||||
var item EventConfigSource
|
||||
if err := row.Scan(
|
||||
&item.ID,
|
||||
&item.EventID,
|
||||
&item.SourceVersionNo,
|
||||
&item.SourceKind,
|
||||
&item.SchemaID,
|
||||
&item.SchemaVersion,
|
||||
&item.Status,
|
||||
&item.SourceJSON,
|
||||
&item.Notes,
|
||||
); err != nil {
|
||||
return nil, fmt.Errorf("upsert event config source: %w", err)
|
||||
}
|
||||
return &item, nil
|
||||
}
|
||||
|
||||
func (s *Store) UpsertEventConfigBuild(ctx context.Context, tx Tx, params UpsertEventConfigBuildParams) (*EventConfigBuild, error) {
|
||||
manifestJSON, err := json.Marshal(params.Manifest)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("marshal event config manifest: %w", err)
|
||||
}
|
||||
assetIndexJSON, err := json.Marshal(params.AssetIndex)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("marshal event config asset index: %w", err)
|
||||
}
|
||||
|
||||
row := tx.QueryRow(ctx, `
|
||||
INSERT INTO event_config_builds (
|
||||
event_id, source_id, build_no, build_status, build_log, manifest_jsonb, asset_index_jsonb
|
||||
)
|
||||
VALUES ($1, $2, $3, $4, $5, $6::jsonb, $7::jsonb)
|
||||
ON CONFLICT (event_id, build_no) DO UPDATE SET
|
||||
source_id = EXCLUDED.source_id,
|
||||
build_status = EXCLUDED.build_status,
|
||||
build_log = EXCLUDED.build_log,
|
||||
manifest_jsonb = EXCLUDED.manifest_jsonb,
|
||||
asset_index_jsonb = EXCLUDED.asset_index_jsonb
|
||||
RETURNING id, event_id, source_id, build_no, build_status, build_log, manifest_jsonb::text, asset_index_jsonb::text
|
||||
`, params.EventID, params.SourceID, params.BuildNo, params.BuildStatus, params.BuildLog, string(manifestJSON), string(assetIndexJSON))
|
||||
|
||||
var item EventConfigBuild
|
||||
if err := row.Scan(
|
||||
&item.ID,
|
||||
&item.EventID,
|
||||
&item.SourceID,
|
||||
&item.BuildNo,
|
||||
&item.BuildStatus,
|
||||
&item.BuildLog,
|
||||
&item.ManifestJSON,
|
||||
&item.AssetIndexJSON,
|
||||
); err != nil {
|
||||
return nil, fmt.Errorf("upsert event config build: %w", err)
|
||||
}
|
||||
return &item, nil
|
||||
}
|
||||
|
||||
func (s *Store) AttachBuildToRelease(ctx context.Context, tx Tx, releaseID, buildID string) error {
|
||||
if _, err := tx.Exec(ctx, `
|
||||
UPDATE event_releases
|
||||
SET build_id = $2
|
||||
WHERE id = $1
|
||||
`, releaseID, buildID); err != nil {
|
||||
return fmt.Errorf("attach build to release: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Store) ReplaceEventReleaseAssets(ctx context.Context, tx Tx, eventReleaseID string, assets []UpsertEventReleaseAssetParams) error {
|
||||
if _, err := tx.Exec(ctx, `DELETE FROM event_release_assets WHERE event_release_id = $1`, eventReleaseID); err != nil {
|
||||
return fmt.Errorf("clear event release assets: %w", err)
|
||||
}
|
||||
|
||||
for _, asset := range assets {
|
||||
metaJSON, err := json.Marshal(asset.Meta)
|
||||
if err != nil {
|
||||
return fmt.Errorf("marshal event release asset meta: %w", err)
|
||||
}
|
||||
if _, err := tx.Exec(ctx, `
|
||||
INSERT INTO event_release_assets (
|
||||
event_release_id, asset_type, asset_key, asset_path, asset_url, checksum, size_bytes, meta_jsonb
|
||||
)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8::jsonb)
|
||||
`, eventReleaseID, asset.AssetType, asset.AssetKey, asset.AssetPath, asset.AssetURL, asset.Checksum, asset.SizeBytes, string(metaJSON)); err != nil {
|
||||
return fmt.Errorf("insert event release asset: %w", err)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Store) NextEventConfigSourceVersion(ctx context.Context, eventID string) (int, error) {
|
||||
var next int
|
||||
if err := s.pool.QueryRow(ctx, `
|
||||
SELECT COALESCE(MAX(source_version_no), 0) + 1
|
||||
FROM event_config_sources
|
||||
WHERE event_id = $1
|
||||
`, eventID).Scan(&next); err != nil {
|
||||
return 0, fmt.Errorf("next event config source version: %w", err)
|
||||
}
|
||||
return next, nil
|
||||
}
|
||||
|
||||
func (s *Store) NextEventConfigBuildNo(ctx context.Context, eventID string) (int, error) {
|
||||
var next int
|
||||
if err := s.pool.QueryRow(ctx, `
|
||||
SELECT COALESCE(MAX(build_no), 0) + 1
|
||||
FROM event_config_builds
|
||||
WHERE event_id = $1
|
||||
`, eventID).Scan(&next); err != nil {
|
||||
return 0, fmt.Errorf("next event config build no: %w", err)
|
||||
}
|
||||
return next, nil
|
||||
}
|
||||
|
||||
func (s *Store) ListEventConfigSourcesByEventID(ctx context.Context, eventID string, limit int) ([]EventConfigSource, error) {
|
||||
if limit <= 0 || limit > 100 {
|
||||
limit = 20
|
||||
}
|
||||
rows, err := s.pool.Query(ctx, `
|
||||
SELECT id, event_id, source_version_no, source_kind, schema_id, schema_version, status, source_jsonb::text, notes
|
||||
FROM event_config_sources
|
||||
WHERE event_id = $1
|
||||
ORDER BY source_version_no DESC
|
||||
LIMIT $2
|
||||
`, eventID, limit)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("list event config sources: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var items []EventConfigSource
|
||||
for rows.Next() {
|
||||
item, err := scanEventConfigSourceFromRows(rows)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
items = append(items, *item)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, fmt.Errorf("iterate event config sources: %w", err)
|
||||
}
|
||||
return items, nil
|
||||
}
|
||||
|
||||
func (s *Store) GetEventConfigSourceByID(ctx context.Context, sourceID string) (*EventConfigSource, error) {
|
||||
row := s.pool.QueryRow(ctx, `
|
||||
SELECT id, event_id, source_version_no, source_kind, schema_id, schema_version, status, source_jsonb::text, notes
|
||||
FROM event_config_sources
|
||||
WHERE id = $1
|
||||
LIMIT 1
|
||||
`, sourceID)
|
||||
return scanEventConfigSource(row)
|
||||
}
|
||||
|
||||
func (s *Store) GetEventConfigBuildByID(ctx context.Context, buildID string) (*EventConfigBuild, error) {
|
||||
row := s.pool.QueryRow(ctx, `
|
||||
SELECT id, event_id, source_id, build_no, build_status, build_log, manifest_jsonb::text, asset_index_jsonb::text
|
||||
FROM event_config_builds
|
||||
WHERE id = $1
|
||||
LIMIT 1
|
||||
`, buildID)
|
||||
return scanEventConfigBuild(row)
|
||||
}
|
||||
|
||||
func scanEventConfigSource(row pgx.Row) (*EventConfigSource, error) {
|
||||
var item EventConfigSource
|
||||
err := row.Scan(
|
||||
&item.ID,
|
||||
&item.EventID,
|
||||
&item.SourceVersionNo,
|
||||
&item.SourceKind,
|
||||
&item.SchemaID,
|
||||
&item.SchemaVersion,
|
||||
&item.Status,
|
||||
&item.SourceJSON,
|
||||
&item.Notes,
|
||||
)
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return nil, nil
|
||||
}
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("scan event config source: %w", err)
|
||||
}
|
||||
return &item, nil
|
||||
}
|
||||
|
||||
func scanEventConfigSourceFromRows(rows pgx.Rows) (*EventConfigSource, error) {
|
||||
var item EventConfigSource
|
||||
if err := rows.Scan(
|
||||
&item.ID,
|
||||
&item.EventID,
|
||||
&item.SourceVersionNo,
|
||||
&item.SourceKind,
|
||||
&item.SchemaID,
|
||||
&item.SchemaVersion,
|
||||
&item.Status,
|
||||
&item.SourceJSON,
|
||||
&item.Notes,
|
||||
); err != nil {
|
||||
return nil, fmt.Errorf("scan event config source row: %w", err)
|
||||
}
|
||||
return &item, nil
|
||||
}
|
||||
|
||||
func scanEventConfigBuild(row pgx.Row) (*EventConfigBuild, error) {
|
||||
var item EventConfigBuild
|
||||
err := row.Scan(
|
||||
&item.ID,
|
||||
&item.EventID,
|
||||
&item.SourceID,
|
||||
&item.BuildNo,
|
||||
&item.BuildStatus,
|
||||
&item.BuildLog,
|
||||
&item.ManifestJSON,
|
||||
&item.AssetIndexJSON,
|
||||
)
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return nil, nil
|
||||
}
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("scan event config build: %w", err)
|
||||
}
|
||||
return &item, nil
|
||||
}
|
||||
46
backend/internal/store/postgres/db.go
Normal file
46
backend/internal/store/postgres/db.go
Normal file
@@ -0,0 +1,46 @@
|
||||
package postgres
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
|
||||
"github.com/jackc/pgx/v5"
|
||||
"github.com/jackc/pgx/v5/pgxpool"
|
||||
)
|
||||
|
||||
type Store struct {
|
||||
pool *pgxpool.Pool
|
||||
}
|
||||
|
||||
type Tx = pgx.Tx
|
||||
|
||||
func Open(ctx context.Context, databaseURL string) (*pgxpool.Pool, error) {
|
||||
pool, err := pgxpool.New(ctx, databaseURL)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("open postgres pool: %w", err)
|
||||
}
|
||||
|
||||
if err := pool.Ping(ctx); err != nil {
|
||||
pool.Close()
|
||||
return nil, fmt.Errorf("ping postgres: %w", err)
|
||||
}
|
||||
return pool, nil
|
||||
}
|
||||
|
||||
func NewStore(pool *pgxpool.Pool) *Store {
|
||||
return &Store{pool: pool}
|
||||
}
|
||||
|
||||
func (s *Store) Pool() *pgxpool.Pool {
|
||||
return s.pool
|
||||
}
|
||||
|
||||
func (s *Store) Close() {
|
||||
if s.pool != nil {
|
||||
s.pool.Close()
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Store) Begin(ctx context.Context) (pgx.Tx, error) {
|
||||
return s.pool.Begin(ctx)
|
||||
}
|
||||
324
backend/internal/store/postgres/dev_store.go
Normal file
324
backend/internal/store/postgres/dev_store.go
Normal file
@@ -0,0 +1,324 @@
|
||||
package postgres
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
)
|
||||
|
||||
type DemoBootstrapSummary struct {
|
||||
TenantCode string `json:"tenantCode"`
|
||||
ChannelCode string `json:"channelCode"`
|
||||
EventID string `json:"eventId"`
|
||||
ReleaseID string `json:"releaseId"`
|
||||
SourceID string `json:"sourceId"`
|
||||
BuildID string `json:"buildId"`
|
||||
CardID string `json:"cardId"`
|
||||
}
|
||||
|
||||
func (s *Store) EnsureDemoData(ctx context.Context) (*DemoBootstrapSummary, error) {
|
||||
tx, err := s.Begin(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer tx.Rollback(ctx)
|
||||
|
||||
var tenantID string
|
||||
if err := tx.QueryRow(ctx, `
|
||||
INSERT INTO tenants (tenant_code, name, status)
|
||||
VALUES ('tenant_demo', 'Demo Tenant', 'active')
|
||||
ON CONFLICT (tenant_code) DO UPDATE SET
|
||||
name = EXCLUDED.name,
|
||||
status = EXCLUDED.status
|
||||
RETURNING id
|
||||
`).Scan(&tenantID); err != nil {
|
||||
return nil, fmt.Errorf("ensure demo tenant: %w", err)
|
||||
}
|
||||
|
||||
var channelID string
|
||||
if err := tx.QueryRow(ctx, `
|
||||
INSERT INTO entry_channels (
|
||||
tenant_id, channel_code, channel_type, platform_app_id, display_name, status, is_default
|
||||
)
|
||||
VALUES ($1, 'mini-demo', 'wechat_mini', 'wx-demo-appid', 'Demo Mini Channel', 'active', true)
|
||||
ON CONFLICT (tenant_id, channel_code) DO UPDATE SET
|
||||
channel_type = EXCLUDED.channel_type,
|
||||
platform_app_id = EXCLUDED.platform_app_id,
|
||||
display_name = EXCLUDED.display_name,
|
||||
status = EXCLUDED.status,
|
||||
is_default = EXCLUDED.is_default
|
||||
RETURNING id
|
||||
`, tenantID).Scan(&channelID); err != nil {
|
||||
return nil, fmt.Errorf("ensure demo entry channel: %w", err)
|
||||
}
|
||||
|
||||
var eventID string
|
||||
if err := tx.QueryRow(ctx, `
|
||||
INSERT INTO events (
|
||||
tenant_id, event_public_id, slug, display_name, summary, status
|
||||
)
|
||||
VALUES ($1, 'evt_demo_001', 'demo-city-run', 'Demo City Run', 'Launch flow demo event', 'active')
|
||||
ON CONFLICT (event_public_id) DO UPDATE SET
|
||||
tenant_id = EXCLUDED.tenant_id,
|
||||
slug = EXCLUDED.slug,
|
||||
display_name = EXCLUDED.display_name,
|
||||
summary = EXCLUDED.summary,
|
||||
status = EXCLUDED.status
|
||||
RETURNING id
|
||||
`, tenantID).Scan(&eventID); err != nil {
|
||||
return nil, fmt.Errorf("ensure demo event: %w", err)
|
||||
}
|
||||
|
||||
var releaseRow struct {
|
||||
ID string
|
||||
PublicID string
|
||||
}
|
||||
if err := tx.QueryRow(ctx, `
|
||||
INSERT INTO event_releases (
|
||||
release_public_id,
|
||||
event_id,
|
||||
release_no,
|
||||
config_label,
|
||||
manifest_url,
|
||||
manifest_checksum_sha256,
|
||||
route_code,
|
||||
status
|
||||
)
|
||||
VALUES (
|
||||
'rel_demo_001',
|
||||
$1,
|
||||
1,
|
||||
'Demo Config v1',
|
||||
'https://oss-mbh5.colormaprun.com/gotomars/event/classic-sequential.json',
|
||||
'demo-checksum-001',
|
||||
'route-demo-001',
|
||||
'published'
|
||||
)
|
||||
ON CONFLICT (release_public_id) DO UPDATE SET
|
||||
event_id = EXCLUDED.event_id,
|
||||
config_label = EXCLUDED.config_label,
|
||||
manifest_url = EXCLUDED.manifest_url,
|
||||
manifest_checksum_sha256 = EXCLUDED.manifest_checksum_sha256,
|
||||
route_code = EXCLUDED.route_code,
|
||||
status = EXCLUDED.status
|
||||
RETURNING id, release_public_id
|
||||
`, eventID).Scan(&releaseRow.ID, &releaseRow.PublicID); err != nil {
|
||||
return nil, fmt.Errorf("ensure demo release: %w", err)
|
||||
}
|
||||
|
||||
if _, err := tx.Exec(ctx, `
|
||||
UPDATE events
|
||||
SET current_release_id = $2
|
||||
WHERE id = $1
|
||||
`, eventID, releaseRow.ID); err != nil {
|
||||
return nil, fmt.Errorf("attach demo release: %w", err)
|
||||
}
|
||||
|
||||
sourceNotes := "demo source config imported from local event sample"
|
||||
source, err := s.UpsertEventConfigSource(ctx, tx, UpsertEventConfigSourceParams{
|
||||
EventID: eventID,
|
||||
SourceVersionNo: 1,
|
||||
SourceKind: "event_bundle",
|
||||
SchemaID: "event-source",
|
||||
SchemaVersion: "1",
|
||||
Status: "active",
|
||||
Notes: &sourceNotes,
|
||||
Source: map[string]any{
|
||||
"app": map[string]any{
|
||||
"id": "sample-classic-001",
|
||||
"title": "顺序赛示例",
|
||||
},
|
||||
"branding": map[string]any{
|
||||
"tenantCode": "tenant_demo",
|
||||
"entryChannel": "mini-demo",
|
||||
},
|
||||
"map": map[string]any{
|
||||
"tiles": "../map/lxcb-001/tiles/",
|
||||
"mapmeta": "../map/lxcb-001/tiles/meta.json",
|
||||
},
|
||||
"playfield": map[string]any{
|
||||
"kind": "course",
|
||||
"source": map[string]any{
|
||||
"type": "kml",
|
||||
"url": "../kml/lxcb-001/10/c01.kml",
|
||||
},
|
||||
},
|
||||
"game": map[string]any{
|
||||
"mode": "classic-sequential",
|
||||
},
|
||||
"content": map[string]any{
|
||||
"h5Template": "content-h5-test-template.html",
|
||||
},
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("ensure demo event config source: %w", err)
|
||||
}
|
||||
|
||||
buildLog := "demo build generated from sample classic-sequential.json"
|
||||
build, err := s.UpsertEventConfigBuild(ctx, tx, UpsertEventConfigBuildParams{
|
||||
EventID: eventID,
|
||||
SourceID: source.ID,
|
||||
BuildNo: 1,
|
||||
BuildStatus: "success",
|
||||
BuildLog: &buildLog,
|
||||
Manifest: map[string]any{
|
||||
"schemaVersion": "1",
|
||||
"releaseId": "rel_demo_001",
|
||||
"version": "2026.04.01",
|
||||
"app": map[string]any{
|
||||
"id": "sample-classic-001",
|
||||
"title": "顺序赛示例",
|
||||
},
|
||||
"map": map[string]any{
|
||||
"tiles": "https://oss-mbh5.colormaprun.com/gotomars/map/lxcb-001/tiles/",
|
||||
"mapmeta": "https://oss-mbh5.colormaprun.com/gotomars/map/lxcb-001/tiles/meta.json",
|
||||
},
|
||||
"playfield": map[string]any{
|
||||
"kind": "course",
|
||||
"source": map[string]any{
|
||||
"type": "kml",
|
||||
"url": "https://oss-mbh5.colormaprun.com/gotomars/kml/lxcb-001/10/c01.kml",
|
||||
},
|
||||
},
|
||||
"game": map[string]any{
|
||||
"mode": "classic-sequential",
|
||||
},
|
||||
"assets": map[string]any{
|
||||
"contentHtml": "https://oss-mbh5.colormaprun.com/gotomars/event/content-h5-test-template.html",
|
||||
},
|
||||
},
|
||||
AssetIndex: []map[string]any{
|
||||
{
|
||||
"assetType": "manifest",
|
||||
"assetKey": "manifest",
|
||||
},
|
||||
{
|
||||
"assetType": "mapmeta",
|
||||
"assetKey": "mapmeta",
|
||||
},
|
||||
{
|
||||
"assetType": "playfield",
|
||||
"assetKey": "playfield-kml",
|
||||
},
|
||||
{
|
||||
"assetType": "content_html",
|
||||
"assetKey": "content-html",
|
||||
},
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("ensure demo event config build: %w", err)
|
||||
}
|
||||
|
||||
if err := s.AttachBuildToRelease(ctx, tx, releaseRow.ID, build.ID); err != nil {
|
||||
return nil, fmt.Errorf("attach demo build to release: %w", err)
|
||||
}
|
||||
|
||||
tilesPath := "map/lxcb-001/tiles/"
|
||||
mapmetaPath := "map/lxcb-001/tiles/meta.json"
|
||||
playfieldPath := "kml/lxcb-001/10/c01.kml"
|
||||
contentPath := "event/content-h5-test-template.html"
|
||||
manifestChecksum := "demo-checksum-001"
|
||||
if err := s.ReplaceEventReleaseAssets(ctx, tx, releaseRow.ID, []UpsertEventReleaseAssetParams{
|
||||
{
|
||||
EventReleaseID: releaseRow.ID,
|
||||
AssetType: "manifest",
|
||||
AssetKey: "manifest",
|
||||
AssetURL: "https://oss-mbh5.colormaprun.com/gotomars/event/classic-sequential.json",
|
||||
Checksum: &manifestChecksum,
|
||||
Meta: map[string]any{"source": "release-manifest"},
|
||||
},
|
||||
{
|
||||
EventReleaseID: releaseRow.ID,
|
||||
AssetType: "tiles",
|
||||
AssetKey: "tiles-root",
|
||||
AssetPath: &tilesPath,
|
||||
AssetURL: "https://oss-mbh5.colormaprun.com/gotomars/map/lxcb-001/tiles/",
|
||||
Meta: map[string]any{"kind": "directory"},
|
||||
},
|
||||
{
|
||||
EventReleaseID: releaseRow.ID,
|
||||
AssetType: "mapmeta",
|
||||
AssetKey: "mapmeta",
|
||||
AssetPath: &mapmetaPath,
|
||||
AssetURL: "https://oss-mbh5.colormaprun.com/gotomars/map/lxcb-001/tiles/meta.json",
|
||||
Meta: map[string]any{"format": "json"},
|
||||
},
|
||||
{
|
||||
EventReleaseID: releaseRow.ID,
|
||||
AssetType: "playfield",
|
||||
AssetKey: "course-kml",
|
||||
AssetPath: &playfieldPath,
|
||||
AssetURL: "https://oss-mbh5.colormaprun.com/gotomars/kml/lxcb-001/10/c01.kml",
|
||||
Meta: map[string]any{"format": "kml"},
|
||||
},
|
||||
{
|
||||
EventReleaseID: releaseRow.ID,
|
||||
AssetType: "content_html",
|
||||
AssetKey: "content-html",
|
||||
AssetPath: &contentPath,
|
||||
AssetURL: "https://oss-mbh5.colormaprun.com/gotomars/event/content-h5-test-template.html",
|
||||
Meta: map[string]any{"kind": "content-page"},
|
||||
},
|
||||
}); err != nil {
|
||||
return nil, fmt.Errorf("ensure demo event release assets: %w", err)
|
||||
}
|
||||
|
||||
var cardPublicID string
|
||||
if err := tx.QueryRow(ctx, `
|
||||
INSERT INTO cards (
|
||||
card_public_id,
|
||||
tenant_id,
|
||||
entry_channel_id,
|
||||
card_type,
|
||||
title,
|
||||
subtitle,
|
||||
cover_url,
|
||||
event_id,
|
||||
display_slot,
|
||||
display_priority,
|
||||
status
|
||||
)
|
||||
VALUES (
|
||||
'card_demo_001',
|
||||
$1,
|
||||
$2,
|
||||
'event',
|
||||
'Demo City Run',
|
||||
'今日推荐路线',
|
||||
'https://oss-mbh5.colormaprun.com/gotomars/assets/demo-cover.jpg',
|
||||
$3,
|
||||
'home_primary',
|
||||
100,
|
||||
'active'
|
||||
)
|
||||
ON CONFLICT (card_public_id) DO UPDATE SET
|
||||
tenant_id = EXCLUDED.tenant_id,
|
||||
entry_channel_id = EXCLUDED.entry_channel_id,
|
||||
card_type = EXCLUDED.card_type,
|
||||
title = EXCLUDED.title,
|
||||
subtitle = EXCLUDED.subtitle,
|
||||
cover_url = EXCLUDED.cover_url,
|
||||
event_id = EXCLUDED.event_id,
|
||||
display_slot = EXCLUDED.display_slot,
|
||||
display_priority = EXCLUDED.display_priority,
|
||||
status = EXCLUDED.status
|
||||
RETURNING card_public_id
|
||||
`, tenantID, channelID, eventID).Scan(&cardPublicID); err != nil {
|
||||
return nil, fmt.Errorf("ensure demo card: %w", err)
|
||||
}
|
||||
|
||||
if err := tx.Commit(ctx); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return &DemoBootstrapSummary{
|
||||
TenantCode: "tenant_demo",
|
||||
ChannelCode: "mini-demo",
|
||||
EventID: "evt_demo_001",
|
||||
ReleaseID: releaseRow.PublicID,
|
||||
SourceID: source.ID,
|
||||
BuildID: build.ID,
|
||||
CardID: cardPublicID,
|
||||
}, nil
|
||||
}
|
||||
74
backend/internal/store/postgres/entry_store.go
Normal file
74
backend/internal/store/postgres/entry_store.go
Normal file
@@ -0,0 +1,74 @@
|
||||
package postgres
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
|
||||
"github.com/jackc/pgx/v5"
|
||||
)
|
||||
|
||||
type EntryChannel struct {
|
||||
ID string
|
||||
ChannelCode string
|
||||
ChannelType string
|
||||
PlatformAppID *string
|
||||
DisplayName string
|
||||
Status string
|
||||
IsDefault bool
|
||||
TenantID string
|
||||
TenantCode string
|
||||
TenantName string
|
||||
}
|
||||
|
||||
type FindEntryChannelParams struct {
|
||||
ChannelCode string
|
||||
ChannelType string
|
||||
PlatformAppID string
|
||||
TenantCode string
|
||||
}
|
||||
|
||||
func (s *Store) FindEntryChannel(ctx context.Context, params FindEntryChannelParams) (*EntryChannel, error) {
|
||||
row := s.pool.QueryRow(ctx, `
|
||||
SELECT
|
||||
ec.id,
|
||||
ec.channel_code,
|
||||
ec.channel_type,
|
||||
ec.platform_app_id,
|
||||
ec.display_name,
|
||||
ec.status,
|
||||
ec.is_default,
|
||||
t.id,
|
||||
t.tenant_code,
|
||||
t.name
|
||||
FROM entry_channels ec
|
||||
JOIN tenants t ON t.id = ec.tenant_id
|
||||
WHERE ($1 = '' OR ec.channel_code = $1)
|
||||
AND ($2 = '' OR ec.channel_type = $2)
|
||||
AND ($3 = '' OR COALESCE(ec.platform_app_id, '') = $3)
|
||||
AND ($4 = '' OR t.tenant_code = $4)
|
||||
ORDER BY ec.is_default DESC, ec.created_at ASC
|
||||
LIMIT 1
|
||||
`, params.ChannelCode, params.ChannelType, params.PlatformAppID, params.TenantCode)
|
||||
|
||||
var entry EntryChannel
|
||||
err := row.Scan(
|
||||
&entry.ID,
|
||||
&entry.ChannelCode,
|
||||
&entry.ChannelType,
|
||||
&entry.PlatformAppID,
|
||||
&entry.DisplayName,
|
||||
&entry.Status,
|
||||
&entry.IsDefault,
|
||||
&entry.TenantID,
|
||||
&entry.TenantCode,
|
||||
&entry.TenantName,
|
||||
)
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return nil, nil
|
||||
}
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("find entry channel: %w", err)
|
||||
}
|
||||
return &entry, nil
|
||||
}
|
||||
263
backend/internal/store/postgres/event_store.go
Normal file
263
backend/internal/store/postgres/event_store.go
Normal file
@@ -0,0 +1,263 @@
|
||||
package postgres
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/jackc/pgx/v5"
|
||||
)
|
||||
|
||||
type Event struct {
|
||||
ID string
|
||||
PublicID string
|
||||
Slug string
|
||||
DisplayName string
|
||||
Summary *string
|
||||
Status string
|
||||
CurrentReleaseID *string
|
||||
CurrentReleasePubID *string
|
||||
ConfigLabel *string
|
||||
ManifestURL *string
|
||||
ManifestChecksum *string
|
||||
RouteCode *string
|
||||
}
|
||||
|
||||
type EventRelease struct {
|
||||
ID string
|
||||
PublicID string
|
||||
EventID string
|
||||
ReleaseNo int
|
||||
ConfigLabel string
|
||||
ManifestURL string
|
||||
ManifestChecksum *string
|
||||
RouteCode *string
|
||||
BuildID *string
|
||||
Status string
|
||||
PublishedAt time.Time
|
||||
}
|
||||
|
||||
type CreateGameSessionParams struct {
|
||||
SessionPublicID string
|
||||
UserID string
|
||||
EventID string
|
||||
EventReleaseID string
|
||||
DeviceKey string
|
||||
ClientType string
|
||||
RouteCode *string
|
||||
SessionTokenHash string
|
||||
SessionTokenExpiresAt time.Time
|
||||
}
|
||||
|
||||
type GameSession struct {
|
||||
ID string
|
||||
SessionPublicID string
|
||||
UserID string
|
||||
EventID string
|
||||
EventReleaseID string
|
||||
DeviceKey string
|
||||
ClientType string
|
||||
RouteCode *string
|
||||
Status string
|
||||
SessionTokenExpiresAt time.Time
|
||||
}
|
||||
|
||||
func (s *Store) GetEventByPublicID(ctx context.Context, eventPublicID string) (*Event, error) {
|
||||
row := s.pool.QueryRow(ctx, `
|
||||
SELECT
|
||||
e.id,
|
||||
e.event_public_id,
|
||||
e.slug,
|
||||
e.display_name,
|
||||
e.summary,
|
||||
e.status,
|
||||
e.current_release_id,
|
||||
er.release_public_id,
|
||||
er.config_label,
|
||||
er.manifest_url,
|
||||
er.manifest_checksum_sha256,
|
||||
er.route_code
|
||||
FROM events e
|
||||
LEFT JOIN event_releases er ON er.id = e.current_release_id
|
||||
WHERE e.event_public_id = $1
|
||||
LIMIT 1
|
||||
`, eventPublicID)
|
||||
|
||||
var event Event
|
||||
err := row.Scan(
|
||||
&event.ID,
|
||||
&event.PublicID,
|
||||
&event.Slug,
|
||||
&event.DisplayName,
|
||||
&event.Summary,
|
||||
&event.Status,
|
||||
&event.CurrentReleaseID,
|
||||
&event.CurrentReleasePubID,
|
||||
&event.ConfigLabel,
|
||||
&event.ManifestURL,
|
||||
&event.ManifestChecksum,
|
||||
&event.RouteCode,
|
||||
)
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return nil, nil
|
||||
}
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("get event by public id: %w", err)
|
||||
}
|
||||
return &event, nil
|
||||
}
|
||||
|
||||
func (s *Store) GetEventByID(ctx context.Context, eventID string) (*Event, error) {
|
||||
row := s.pool.QueryRow(ctx, `
|
||||
SELECT
|
||||
e.id,
|
||||
e.event_public_id,
|
||||
e.slug,
|
||||
e.display_name,
|
||||
e.summary,
|
||||
e.status,
|
||||
e.current_release_id,
|
||||
er.release_public_id,
|
||||
er.config_label,
|
||||
er.manifest_url,
|
||||
er.manifest_checksum_sha256,
|
||||
er.route_code
|
||||
FROM events e
|
||||
LEFT JOIN event_releases er ON er.id = e.current_release_id
|
||||
WHERE e.id = $1
|
||||
LIMIT 1
|
||||
`, eventID)
|
||||
|
||||
var event Event
|
||||
err := row.Scan(
|
||||
&event.ID,
|
||||
&event.PublicID,
|
||||
&event.Slug,
|
||||
&event.DisplayName,
|
||||
&event.Summary,
|
||||
&event.Status,
|
||||
&event.CurrentReleaseID,
|
||||
&event.CurrentReleasePubID,
|
||||
&event.ConfigLabel,
|
||||
&event.ManifestURL,
|
||||
&event.ManifestChecksum,
|
||||
&event.RouteCode,
|
||||
)
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return nil, nil
|
||||
}
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("get event by id: %w", err)
|
||||
}
|
||||
return &event, nil
|
||||
}
|
||||
|
||||
func (s *Store) NextEventReleaseNo(ctx context.Context, eventID string) (int, error) {
|
||||
var next int
|
||||
if err := s.pool.QueryRow(ctx, `
|
||||
SELECT COALESCE(MAX(release_no), 0) + 1
|
||||
FROM event_releases
|
||||
WHERE event_id = $1
|
||||
`, eventID).Scan(&next); err != nil {
|
||||
return 0, fmt.Errorf("next event release no: %w", err)
|
||||
}
|
||||
return next, nil
|
||||
}
|
||||
|
||||
type CreateEventReleaseParams struct {
|
||||
PublicID string
|
||||
EventID string
|
||||
ReleaseNo int
|
||||
ConfigLabel string
|
||||
ManifestURL string
|
||||
ManifestChecksum *string
|
||||
RouteCode *string
|
||||
BuildID *string
|
||||
Status string
|
||||
PayloadJSON string
|
||||
}
|
||||
|
||||
func (s *Store) CreateEventRelease(ctx context.Context, tx Tx, params CreateEventReleaseParams) (*EventRelease, error) {
|
||||
row := tx.QueryRow(ctx, `
|
||||
INSERT INTO event_releases (
|
||||
release_public_id,
|
||||
event_id,
|
||||
release_no,
|
||||
config_label,
|
||||
manifest_url,
|
||||
manifest_checksum_sha256,
|
||||
route_code,
|
||||
build_id,
|
||||
status,
|
||||
payload_jsonb
|
||||
)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10::jsonb)
|
||||
RETURNING id, release_public_id, event_id, release_no, config_label, manifest_url, manifest_checksum_sha256, route_code, build_id, status, published_at
|
||||
`, params.PublicID, params.EventID, params.ReleaseNo, params.ConfigLabel, params.ManifestURL, params.ManifestChecksum, params.RouteCode, params.BuildID, params.Status, params.PayloadJSON)
|
||||
|
||||
var item EventRelease
|
||||
if err := row.Scan(
|
||||
&item.ID,
|
||||
&item.PublicID,
|
||||
&item.EventID,
|
||||
&item.ReleaseNo,
|
||||
&item.ConfigLabel,
|
||||
&item.ManifestURL,
|
||||
&item.ManifestChecksum,
|
||||
&item.RouteCode,
|
||||
&item.BuildID,
|
||||
&item.Status,
|
||||
&item.PublishedAt,
|
||||
); err != nil {
|
||||
return nil, fmt.Errorf("create event release: %w", err)
|
||||
}
|
||||
return &item, nil
|
||||
}
|
||||
|
||||
func (s *Store) SetCurrentEventRelease(ctx context.Context, tx Tx, eventID, releaseID string) error {
|
||||
if _, err := tx.Exec(ctx, `
|
||||
UPDATE events
|
||||
SET current_release_id = $2
|
||||
WHERE id = $1
|
||||
`, eventID, releaseID); err != nil {
|
||||
return fmt.Errorf("set current event release: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Store) CreateGameSession(ctx context.Context, tx Tx, params CreateGameSessionParams) (*GameSession, error) {
|
||||
row := tx.QueryRow(ctx, `
|
||||
INSERT INTO game_sessions (
|
||||
session_public_id,
|
||||
user_id,
|
||||
event_id,
|
||||
event_release_id,
|
||||
device_key,
|
||||
client_type,
|
||||
route_code,
|
||||
session_token_hash,
|
||||
session_token_expires_at
|
||||
)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)
|
||||
RETURNING id, session_public_id, user_id, event_id, event_release_id, device_key, client_type, route_code, status, session_token_expires_at
|
||||
`, params.SessionPublicID, params.UserID, params.EventID, params.EventReleaseID, params.DeviceKey, params.ClientType, params.RouteCode, params.SessionTokenHash, params.SessionTokenExpiresAt)
|
||||
|
||||
var session GameSession
|
||||
err := row.Scan(
|
||||
&session.ID,
|
||||
&session.SessionPublicID,
|
||||
&session.UserID,
|
||||
&session.EventID,
|
||||
&session.EventReleaseID,
|
||||
&session.DeviceKey,
|
||||
&session.ClientType,
|
||||
&session.RouteCode,
|
||||
&session.Status,
|
||||
&session.SessionTokenExpiresAt,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("create game session: %w", err)
|
||||
}
|
||||
return &session, nil
|
||||
}
|
||||
50
backend/internal/store/postgres/identity_store.go
Normal file
50
backend/internal/store/postgres/identity_store.go
Normal file
@@ -0,0 +1,50 @@
|
||||
package postgres
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
)
|
||||
|
||||
type LoginIdentity struct {
|
||||
ID string
|
||||
IdentityType string
|
||||
Provider string
|
||||
ProviderSubject string
|
||||
CountryCode *string
|
||||
Mobile *string
|
||||
Status string
|
||||
}
|
||||
|
||||
func (s *Store) ListIdentitiesByUserID(ctx context.Context, userID string) ([]LoginIdentity, error) {
|
||||
rows, err := s.pool.Query(ctx, `
|
||||
SELECT id, identity_type, provider, provider_subject, country_code, mobile, status
|
||||
FROM login_identities
|
||||
WHERE user_id = $1
|
||||
ORDER BY created_at ASC
|
||||
`, userID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("list identities by user id: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var identities []LoginIdentity
|
||||
for rows.Next() {
|
||||
var identity LoginIdentity
|
||||
if err := rows.Scan(
|
||||
&identity.ID,
|
||||
&identity.IdentityType,
|
||||
&identity.Provider,
|
||||
&identity.ProviderSubject,
|
||||
&identity.CountryCode,
|
||||
&identity.Mobile,
|
||||
&identity.Status,
|
||||
); err != nil {
|
||||
return nil, fmt.Errorf("scan identity: %w", err)
|
||||
}
|
||||
identities = append(identities, identity)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, fmt.Errorf("iterate identities: %w", err)
|
||||
}
|
||||
return identities, nil
|
||||
}
|
||||
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
|
||||
}
|
||||
299
backend/internal/store/postgres/session_store.go
Normal file
299
backend/internal/store/postgres/session_store.go
Normal file
@@ -0,0 +1,299 @@
|
||||
package postgres
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/jackc/pgx/v5"
|
||||
)
|
||||
|
||||
type Session struct {
|
||||
ID string
|
||||
SessionPublicID string
|
||||
UserID string
|
||||
EventID string
|
||||
EventReleaseID string
|
||||
ReleasePublicID *string
|
||||
ConfigLabel *string
|
||||
ManifestURL *string
|
||||
ManifestChecksum *string
|
||||
DeviceKey string
|
||||
ClientType string
|
||||
RouteCode *string
|
||||
Status string
|
||||
SessionTokenHash string
|
||||
SessionTokenExpiresAt time.Time
|
||||
LaunchedAt time.Time
|
||||
StartedAt *time.Time
|
||||
EndedAt *time.Time
|
||||
EventPublicID *string
|
||||
EventDisplayName *string
|
||||
}
|
||||
|
||||
type FinishSessionParams struct {
|
||||
SessionID string
|
||||
Status string
|
||||
}
|
||||
|
||||
func (s *Store) GetSessionByPublicID(ctx context.Context, sessionPublicID string) (*Session, 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
|
||||
FROM game_sessions gs
|
||||
JOIN events e ON e.id = gs.event_id
|
||||
JOIN event_releases er ON er.id = gs.event_release_id
|
||||
WHERE gs.session_public_id = $1
|
||||
LIMIT 1
|
||||
`, sessionPublicID)
|
||||
return scanSession(row)
|
||||
}
|
||||
|
||||
func (s *Store) GetSessionByPublicIDForUpdate(ctx context.Context, tx Tx, sessionPublicID string) (*Session, error) {
|
||||
row := tx.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
|
||||
FROM game_sessions gs
|
||||
JOIN events e ON e.id = gs.event_id
|
||||
JOIN event_releases er ON er.id = gs.event_release_id
|
||||
WHERE gs.session_public_id = $1
|
||||
FOR UPDATE
|
||||
`, sessionPublicID)
|
||||
return scanSession(row)
|
||||
}
|
||||
|
||||
func (s *Store) ListSessionsByUserID(ctx context.Context, userID string, limit int) ([]Session, 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
|
||||
FROM game_sessions gs
|
||||
JOIN events e ON e.id = gs.event_id
|
||||
JOIN event_releases er ON er.id = gs.event_release_id
|
||||
WHERE gs.user_id = $1
|
||||
ORDER BY gs.created_at DESC
|
||||
LIMIT $2
|
||||
`, userID, limit)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("list sessions by user id: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var sessions []Session
|
||||
for rows.Next() {
|
||||
session, err := scanSessionFromRows(rows)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
sessions = append(sessions, *session)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, fmt.Errorf("iterate sessions by user id: %w", err)
|
||||
}
|
||||
return sessions, nil
|
||||
}
|
||||
|
||||
func (s *Store) ListSessionsByUserAndEvent(ctx context.Context, userID, eventID string, limit int) ([]Session, 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
|
||||
FROM game_sessions gs
|
||||
JOIN events e ON e.id = gs.event_id
|
||||
JOIN event_releases er ON er.id = gs.event_release_id
|
||||
WHERE gs.user_id = $1
|
||||
AND gs.event_id = $2
|
||||
ORDER BY gs.created_at DESC
|
||||
LIMIT $3
|
||||
`, userID, eventID, limit)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("list sessions by user and event: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var sessions []Session
|
||||
for rows.Next() {
|
||||
session, err := scanSessionFromRows(rows)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
sessions = append(sessions, *session)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, fmt.Errorf("iterate sessions by user and event: %w", err)
|
||||
}
|
||||
return sessions, nil
|
||||
}
|
||||
|
||||
func (s *Store) StartSession(ctx context.Context, tx Tx, sessionID string) error {
|
||||
_, err := tx.Exec(ctx, `
|
||||
UPDATE game_sessions
|
||||
SET status = CASE WHEN status = 'launched' THEN 'running' ELSE status END,
|
||||
started_at = COALESCE(started_at, NOW())
|
||||
WHERE id = $1
|
||||
`, sessionID)
|
||||
if err != nil {
|
||||
return fmt.Errorf("start session: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Store) FinishSession(ctx context.Context, tx Tx, params FinishSessionParams) error {
|
||||
_, err := tx.Exec(ctx, `
|
||||
UPDATE game_sessions
|
||||
SET status = $2,
|
||||
started_at = COALESCE(started_at, NOW()),
|
||||
ended_at = COALESCE(ended_at, NOW())
|
||||
WHERE id = $1
|
||||
`, params.SessionID, params.Status)
|
||||
if err != nil {
|
||||
return fmt.Errorf("finish session: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func scanSession(row pgx.Row) (*Session, error) {
|
||||
var session Session
|
||||
err := row.Scan(
|
||||
&session.ID,
|
||||
&session.SessionPublicID,
|
||||
&session.UserID,
|
||||
&session.EventID,
|
||||
&session.EventReleaseID,
|
||||
&session.ReleasePublicID,
|
||||
&session.ConfigLabel,
|
||||
&session.ManifestURL,
|
||||
&session.ManifestChecksum,
|
||||
&session.DeviceKey,
|
||||
&session.ClientType,
|
||||
&session.RouteCode,
|
||||
&session.Status,
|
||||
&session.SessionTokenHash,
|
||||
&session.SessionTokenExpiresAt,
|
||||
&session.LaunchedAt,
|
||||
&session.StartedAt,
|
||||
&session.EndedAt,
|
||||
&session.EventPublicID,
|
||||
&session.EventDisplayName,
|
||||
)
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return nil, nil
|
||||
}
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("scan session: %w", err)
|
||||
}
|
||||
return &session, nil
|
||||
}
|
||||
|
||||
func scanSessionFromRows(rows pgx.Rows) (*Session, error) {
|
||||
var session Session
|
||||
err := rows.Scan(
|
||||
&session.ID,
|
||||
&session.SessionPublicID,
|
||||
&session.UserID,
|
||||
&session.EventID,
|
||||
&session.EventReleaseID,
|
||||
&session.ReleasePublicID,
|
||||
&session.ConfigLabel,
|
||||
&session.ManifestURL,
|
||||
&session.ManifestChecksum,
|
||||
&session.DeviceKey,
|
||||
&session.ClientType,
|
||||
&session.RouteCode,
|
||||
&session.Status,
|
||||
&session.SessionTokenHash,
|
||||
&session.SessionTokenExpiresAt,
|
||||
&session.LaunchedAt,
|
||||
&session.StartedAt,
|
||||
&session.EndedAt,
|
||||
&session.EventPublicID,
|
||||
&session.EventDisplayName,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("scan session row: %w", err)
|
||||
}
|
||||
return &session, nil
|
||||
}
|
||||
94
backend/internal/store/postgres/user_store.go
Normal file
94
backend/internal/store/postgres/user_store.go
Normal file
@@ -0,0 +1,94 @@
|
||||
package postgres
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
|
||||
"github.com/jackc/pgx/v5"
|
||||
)
|
||||
|
||||
type User struct {
|
||||
ID string
|
||||
PublicID string
|
||||
Status string
|
||||
Nickname *string
|
||||
AvatarURL *string
|
||||
}
|
||||
|
||||
type CreateUserParams struct {
|
||||
PublicID string
|
||||
Status string
|
||||
}
|
||||
|
||||
type queryRower interface {
|
||||
QueryRow(context.Context, string, ...any) pgx.Row
|
||||
}
|
||||
|
||||
func (s *Store) FindUserByMobile(ctx context.Context, tx Tx, countryCode, mobile string) (*User, error) {
|
||||
row := tx.QueryRow(ctx, `
|
||||
SELECT u.id, u.user_public_id, u.status, u.nickname, u.avatar_url
|
||||
FROM users u
|
||||
JOIN login_identities li ON li.user_id = u.id
|
||||
WHERE li.provider = 'mobile'
|
||||
AND li.country_code = $1
|
||||
AND li.mobile = $2
|
||||
AND li.status = 'active'
|
||||
LIMIT 1
|
||||
`, countryCode, mobile)
|
||||
return scanUser(row)
|
||||
}
|
||||
|
||||
func (s *Store) CreateUser(ctx context.Context, tx Tx, params CreateUserParams) (*User, error) {
|
||||
row := tx.QueryRow(ctx, `
|
||||
INSERT INTO users (user_public_id, status)
|
||||
VALUES ($1, $2)
|
||||
RETURNING id, user_public_id, status, nickname, avatar_url
|
||||
`, params.PublicID, params.Status)
|
||||
return scanUser(row)
|
||||
}
|
||||
|
||||
func (s *Store) TouchUserLogin(ctx context.Context, tx Tx, userID string) error {
|
||||
_, err := tx.Exec(ctx, `
|
||||
UPDATE users
|
||||
SET last_login_at = NOW()
|
||||
WHERE id = $1
|
||||
`, userID)
|
||||
if err != nil {
|
||||
return fmt.Errorf("touch user last login: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Store) DeactivateUser(ctx context.Context, tx Tx, userID string) error {
|
||||
_, err := tx.Exec(ctx, `
|
||||
UPDATE users
|
||||
SET status = 'deleted', updated_at = NOW()
|
||||
WHERE id = $1
|
||||
`, userID)
|
||||
if err != nil {
|
||||
return fmt.Errorf("deactivate user: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Store) GetUserByID(ctx context.Context, db queryRower, userID string) (*User, error) {
|
||||
row := db.QueryRow(ctx, `
|
||||
SELECT id, user_public_id, status, nickname, avatar_url
|
||||
FROM users
|
||||
WHERE id = $1
|
||||
`, userID)
|
||||
return scanUser(row)
|
||||
}
|
||||
|
||||
func scanUser(row pgx.Row) (*User, error) {
|
||||
var user User
|
||||
err := row.Scan(&user.ID, &user.PublicID, &user.Status, &user.Nickname, &user.AvatarURL)
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return nil, nil
|
||||
}
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("scan user: %w", err)
|
||||
}
|
||||
return &user, nil
|
||||
}
|
||||
Reference in New Issue
Block a user