From ff76ff37b4fbf1ebe896afb1bc69f136f4729304 Mon Sep 17 00:00:00 2001 From: Daniel Velez Date: Wed, 29 Apr 2026 01:24:27 -0500 Subject: [PATCH 01/95] docs(logto): operator setup guide + LOGTO_* env var contract Adds docs/LOGTO_SETUP.md with the step-by-step walkthrough for the self-hosted Logto deployment at https://logto.courtcommand.app and its admin UI at https://logto-admin.courtcommand.app. Covers: - Court Command Web SPA app - Court Command API resource (12 scopes) - Court Command Backend M2M app for the Management API - Organization template (5 roles, 5 org scopes) - Pickleball + Demo Sport organizations - Bootstrap platform_admin user - Webhook registration with HMAC-SHA256 signing key - Coolify env var checklist for both api and web services - curl-based smoke tests - Re-seed runbook Also adds the LOGTO_* and VITE_LOGTO_* variable names to .env.example. Values stay in Coolify, not in Git. Aligned with the Logto integration spec and the Phase 0 outline of the plan, but adapted to the prod-only deployment model: no docker- compose Logto service is added because Logto already runs on the Coolify host. --- .env.example | 30 +++++ docs/LOGTO_SETUP.md | 298 ++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 328 insertions(+) create mode 100644 docs/LOGTO_SETUP.md diff --git a/.env.example b/.env.example index ad7bc8c..eb10c1b 100644 --- a/.env.example +++ b/.env.example @@ -23,6 +23,36 @@ VITE_GOOGLE_MAPS_API_KEY= # --- Ghost CMS --- GHOST_URL=http://localhost:2368 +# --- Logto (self-hosted on Coolify) --- +# Core OIDC endpoint (issuer, JWKS, /oidc/token, etc.) +LOGTO_ENDPOINT=https://logto.courtcommand.app +# Admin UI (humans only, not used by the backend) +LOGTO_ADMIN_ENDPOINT=https://logto-admin.courtcommand.app +# API resource indicator registered in Logto -> API resources +LOGTO_API_RESOURCE=https://api.courtcommand.app/api +# Machine-to-machine app credentials (Logto -> Applications -> Machine-to-machine) +# Assign role "Logto Management API access" so the backend can mint, modify +# users, manage org memberships, and create per-tenant M2M apps for API keys. +LOGTO_MANAGEMENT_API_APP_ID= +LOGTO_MANAGEMENT_API_APP_SECRET= +LOGTO_MANAGEMENT_API_RESOURCE=https://logto.courtcommand.app/api +# HMAC-SHA256 signing key from the Logto webhook configured at +# https://api.courtcommand.app/api/v1/webhooks/logto +# Backend verifies the `logto-signature-sha-256` header against this key. +LOGTO_WEBHOOK_SIGNING_KEY= +# Sport organization IDs (created in Logto admin UI -> Organizations). +# Format: org_XXXXXXXX. Used by middleware to map URL ?sport= -> Logto org. +LOGTO_PICKLEBALL_ORG_ID= +LOGTO_DEMO_SPORT_ORG_ID= + +# --- Frontend Logto config (baked into Vite build) --- +# Logto SDK endpoint (same as LOGTO_ENDPOINT, but exposed to the browser). +VITE_LOGTO_ENDPOINT=https://logto.courtcommand.app +# SPA app ID from Logto -> Applications -> "Court Command Web" (Single Page App). +VITE_LOGTO_APP_ID= +# API resource the SDK should request access tokens for (matches LOGTO_API_RESOURCE). +VITE_LOGTO_API_RESOURCE=https://api.courtcommand.app/api + # --- Port Overrides (optional, for local conflicts) --- # DB_PORT=5432 # REDIS_PORT=6379 diff --git a/docs/LOGTO_SETUP.md b/docs/LOGTO_SETUP.md new file mode 100644 index 0000000..95ae5af --- /dev/null +++ b/docs/LOGTO_SETUP.md @@ -0,0 +1,298 @@ +# Logto Setup + +This guide configures the self-hosted Logto instance used by Court Command for +authentication, organization-scoped roles (sports), webhooks, and Logto-managed +machine-to-machine credentials (used by API keys and tournament staff accounts). + +**Audience:** the operator standing up Logto for the first time, or a developer +re-bootstrapping after a Logto database wipe. + +--- + +## Prerequisites + +- Logto is already deployed and reachable at: + - Core / OIDC endpoint: + - Admin UI: +- You can sign in to the Logto admin UI as a Logto-platform admin (this is a + Logto-internal account, not a Court Command user). +- You have access to the Coolify project for Court Command so you can paste + generated IDs/secrets into the API and web service environment variables. + +> **Secrets discipline.** Every value generated below (App secrets, signing +> keys) belongs in Coolify env vars, never in Git. The tracked +> `.env.example` documents only variable *names*, not values. + +--- + +## Step 1 — Create the SPA application (frontend) + +In → **Applications** → **Create application**: + +- **Type:** Single Page App (React) +- **Name:** `Court Command Web` +- **Redirect URIs:** + - `https://courtcommand.app/auth/callback` + - (optional, for future local dev) `http://localhost:5173/auth/callback` +- **Post sign-out redirect URIs:** + - `https://courtcommand.app` + - (optional) `http://localhost:5173` +- **CORS allowed origins:** + - `https://courtcommand.app` + - (optional) `http://localhost:5173` + +Click **Create**. After creation: + +| Capture | Goes to | +|---|---| +| **App ID** | Coolify (web service) → `VITE_LOGTO_APP_ID` | + +The web app does not have a client secret (SPAs are public clients). + +--- + +## Step 2 — Create the API resource + +In **API resources** → **Create API resource**: + +- **API name:** `Court Command API` +- **API identifier:** `https://api.courtcommand.app/api` + +Open the newly-created resource → **Permissions** tab → add the following 12 scopes: + +``` +read:profile +write:profile +read:tournaments +write:tournaments +read:matches +write:matches +read:registrations +write:registrations +read:overlay +write:overlay +read:admin +write:admin +``` + +These are the API-level scopes used for sport-agnostic permission checks (e.g. +"can write tournaments at all"). Per-sport role enforcement is layered on top +via organization roles (Step 4). + +No env-var change for this step; the identifier is already in +`LOGTO_API_RESOURCE` and `VITE_LOGTO_API_RESOURCE`. + +--- + +## Step 3 — Create the M2M app for the Management API + +In **Applications** → **Create application**: + +- **Type:** Machine-to-machine +- **Name:** `Court Command Backend` + +After creation, on the application detail page: + +1. **Roles** tab → assign the built-in role **`Logto Management API access`**. + This grants the backend permission to create users, modify org memberships, + create per-tenant M2M apps for the "API keys" feature, etc. +2. **Settings** tab → capture: + +| Capture | Goes to | +|---|---| +| **App ID** | Coolify (api service) → `LOGTO_MANAGEMENT_API_APP_ID` | +| **App Secret** | Coolify (api service) → `LOGTO_MANAGEMENT_API_APP_SECRET` | + +Verify the value of `LOGTO_MANAGEMENT_API_RESOURCE` in Coolify is +`https://logto.courtcommand.app/api` (this is the Logto-internal Management API +resource, distinct from `LOGTO_API_RESOURCE`). + +--- + +## Step 4 — Define the organization template (roles + scopes) + +In **Organizations** → **Organization template** tab. + +### 4a. Define organization scopes + +Under **Organization permissions** add: + +``` +manage_tournaments +manage_matches +manage_registrations +manage_users +read_all +``` + +### 4b. Define organization roles + +Under **Organization roles** add the following five roles. For each role, in +its detail page assign the listed organization scopes: + +| Role | Description | Organization scopes | +|---|---|---| +| `player` | Default role for users in this sport | `read_all` | +| `tournament_director` | Can manage tournaments in this sport | `read_all`, `manage_tournaments`, `manage_registrations` | +| `referee` | Can score matches in this sport | `read_all`, `manage_matches` | +| `scorekeeper` | Same as referee, alternate label for staff naming | `read_all`, `manage_matches` | +| `platform_admin` | Full platform access within this sport | all five | + +Role names are matched **literally** by the backend (case-sensitive). Do not +rename them without coordinating a code change. + +--- + +## Step 5 — Create the sport organizations + +In **Organizations** → **Organizations** tab → **Create organization**. + +Create two organizations: + +| Name | Description | +|---|---| +| `Pickleball` | Production sport on Court Command | +| `Demo Sport` | Test organization that exercises the multi-sport plumbing | + +After each creation, capture the **Organization ID** (`org_xxxxxxxxxxxxx`): + +| Capture | Goes to | +|---|---| +| Pickleball org ID | Coolify (api service) → `LOGTO_PICKLEBALL_ORG_ID` | +| Demo Sport org ID | Coolify (api service) → `LOGTO_DEMO_SPORT_ORG_ID` | + +The roles defined in Step 4 are automatically available in both orgs because +they live on the shared organization template. + +--- + +## Step 6 — Create the bootstrap platform admin user + +In **Users** → **Create user**: + +- **Email:** `daniel.f.velez@gmail.com` +- **Password:** set one (you'll use it to sign in for the first time) +- **Name:** `Daniel Velez` + +After creation, open the user → **Organizations** tab → **Add to organization**: + +- Add to **Pickleball** with role `platform_admin` +- Add to **Demo Sport** with role `platform_admin` + +Repeat for any additional bootstrap admins. + +> **No env-var change.** The user just needs to exist in Logto with the right +> org memberships before you sign in to the Court Command frontend for the +> first time. The backend will upsert a mirror row in `users` automatically on +> first authenticated request. + +--- + +## Step 7 — Register the webhook + +In **Webhooks** → **Create webhook**: + +- **Name:** `Court Command Backend` +- **Endpoint URL:** `https://api.courtcommand.app/api/v1/webhooks/logto` +- **Events:** check + - `User.Created` + - `User.Data.Updated` + - `User.Deleted` + +After creation, open the webhook detail page and capture: + +| Capture | Goes to | +|---|---| +| **Signing key** | Coolify (api service) → `LOGTO_WEBHOOK_SIGNING_KEY` | + +The backend validates the `logto-signature-sha-256` header on every webhook +delivery using HMAC-SHA256 of the raw request body keyed with this value. + +> The backend webhook handler is delivered in a later phase. Until then, +> Logto will deliver `User.Created` events that 404 — that's fine; the user +> still exists in Logto and the on-demand upsert path in `/auth/me` covers +> the gap. + +--- + +## Step 8 — Verify Coolify env vars + +Confirm both Coolify services have the full set of variables. Reference list +(see `.env.example` in the repo for the source of truth): + +### `api` service + +``` +LOGTO_ENDPOINT +LOGTO_API_RESOURCE +LOGTO_MANAGEMENT_API_APP_ID +LOGTO_MANAGEMENT_API_APP_SECRET +LOGTO_MANAGEMENT_API_RESOURCE +LOGTO_WEBHOOK_SIGNING_KEY +LOGTO_PICKLEBALL_ORG_ID +LOGTO_DEMO_SPORT_ORG_ID +``` + +### `web` service (build-time, baked into the Vite bundle) + +``` +VITE_LOGTO_ENDPOINT +VITE_LOGTO_APP_ID +VITE_LOGTO_API_RESOURCE +``` + +After updating, redeploy both services so the new variables take effect. + +--- + +## Step 9 — Smoke-test the Management API from the command line + +From a machine that can reach Logto (typically your local box): + +```bash +# Replace with the real values from Coolify +APP_ID=... +APP_SECRET=... +LOGTO_ENDPOINT=https://logto.courtcommand.app +RESOURCE=https://logto.courtcommand.app/api + +# 1. Get an M2M token +TOKEN=$(curl -s -X POST "$LOGTO_ENDPOINT/oidc/token" \ + -H "Content-Type: application/x-www-form-urlencoded" \ + -u "$APP_ID:$APP_SECRET" \ + -d "grant_type=client_credentials&resource=$RESOURCE&scope=all" \ + | jq -r .access_token) + +[ -n "$TOKEN" ] && [ "$TOKEN" != "null" ] && echo "✓ token issued" || { echo "✗ no token"; exit 1; } + +# 2. List users +curl -s "$LOGTO_ENDPOINT/api/users" -H "Authorization: Bearer $TOKEN" | jq '.[0:2]' + +# 3. List organizations (should include Pickleball + Demo Sport) +curl -s "$LOGTO_ENDPOINT/api/organizations" -H "Authorization: Bearer $TOKEN" | jq '.[].name' +``` + +Expected: token issued, users array contains the bootstrap admin, organizations +include both `Pickleball` and `Demo Sport`. If any of those fails, fix Logto +configuration before proceeding to backend implementation. + +--- + +## Re-seeding after a Logto wipe + +If the Logto Postgres database is recreated (e.g. volume rotation), Logto +loses **everything** documented above — apps, resources, scopes, org template, +orgs, users, webhooks. There is no automated re-seed today; re-run Steps 1–7 +manually and re-paste the regenerated IDs/secrets into Coolify. + +> **Future improvement:** `make seed-logto` could call the Management API to +> idempotently create everything from a YAML manifest. Tracked as a follow-up +> after the initial integration ships. + +--- + +## Reference + +- Spec: [`docs/superpowers/specs/2026-04-20-logto-integration-design.md`](superpowers/specs/2026-04-20-logto-integration-design.md) +- Plan: [`docs/superpowers/plans/2026-04-20-logto-integration.md`](superpowers/plans/2026-04-20-logto-integration.md) +- Logto docs: From e9c70b65143122e024b34c370d40471c21ac7b7f Mon Sep 17 00:00:00 2001 From: Daniel Velez Date: Thu, 30 Apr 2026 08:41:09 -0500 Subject: [PATCH 02/95] fix(logto-docs): Management API resource is https://default.logto.app/api Discovered during live smoke test against the deployed Logto instance: the Management API resource indicator is a fixed Logto-internal value, not a tenant-specific URL. Using the public Logto domain returned oidc.invalid_target. This is consistent with how Logto self-hosted identifies the built-in management tenant. Updates .env.example default and adds a callout in LOGTO_SETUP.md Step 3 explaining the distinction between the (fixed) Management API resource and the (project-specific) LOGTO_API_RESOURCE. --- .env.example | 6 +++++- docs/LOGTO_SETUP.md | 15 ++++++++++++--- 2 files changed, 17 insertions(+), 4 deletions(-) diff --git a/.env.example b/.env.example index eb10c1b..3afa20c 100644 --- a/.env.example +++ b/.env.example @@ -35,7 +35,11 @@ LOGTO_API_RESOURCE=https://api.courtcommand.app/api # users, manage org memberships, and create per-tenant M2M apps for API keys. LOGTO_MANAGEMENT_API_APP_ID= LOGTO_MANAGEMENT_API_APP_SECRET= -LOGTO_MANAGEMENT_API_RESOURCE=https://logto.courtcommand.app/api +# Resource indicator for the built-in Logto Management API. For self-hosted +# Logto this is a fixed value ("https://default.logto.app/api") regardless of +# your custom domain -- it identifies the internal management tenant, NOT a +# real URL. Confirmed via OIDC token exchange returning aud=this value. +LOGTO_MANAGEMENT_API_RESOURCE=https://default.logto.app/api # HMAC-SHA256 signing key from the Logto webhook configured at # https://api.courtcommand.app/api/v1/webhooks/logto # Backend verifies the `logto-signature-sha-256` header against this key. diff --git a/docs/LOGTO_SETUP.md b/docs/LOGTO_SETUP.md index 95ae5af..36e49a4 100644 --- a/docs/LOGTO_SETUP.md +++ b/docs/LOGTO_SETUP.md @@ -104,8 +104,17 @@ After creation, on the application detail page: | **App Secret** | Coolify (api service) → `LOGTO_MANAGEMENT_API_APP_SECRET` | Verify the value of `LOGTO_MANAGEMENT_API_RESOURCE` in Coolify is -`https://logto.courtcommand.app/api` (this is the Logto-internal Management API -resource, distinct from `LOGTO_API_RESOURCE`). +`https://default.logto.app/api`. + +> **Why that exact string?** Self-hosted Logto exposes its built-in Management +> API under a fixed audience identifier, `https://default.logto.app/api`, +> regardless of your custom domain. It is **not a real URL** — it is the +> resource indicator the OIDC token endpoint expects in the `resource` form +> parameter when minting Management API tokens. Using your public Logto URL +> (e.g. `https://logto.courtcommand.app/api`) returns +> `oidc.invalid_target: Invalid resource indicator`. This is distinct from +> `LOGTO_API_RESOURCE` (which IS your real public API URL — +> `https://api.courtcommand.app/api`). --- @@ -254,7 +263,7 @@ From a machine that can reach Logto (typically your local box): APP_ID=... APP_SECRET=... LOGTO_ENDPOINT=https://logto.courtcommand.app -RESOURCE=https://logto.courtcommand.app/api +RESOURCE=https://default.logto.app/api # fixed audience for self-hosted Mgmt API # 1. Get an M2M token TOKEN=$(curl -s -X POST "$LOGTO_ENDPOINT/oidc/token" \ From c5fb8412d84c3d732943e8132435b70d3c11bfda Mon Sep 17 00:00:00 2001 From: Daniel Velez Date: Thu, 30 Apr 2026 09:02:57 -0500 Subject: [PATCH 03/95] feat(auth): JWT validator + Logto claims extraction - Adds github.com/lestrrat-go/jwx/v3 dependency for JWT/JWKS handling - api/auth/Claims: normalized claim shape (subject, organization_id, organization_roles, scopes, audience) with HasScope / HasOrgRole helpers - api/auth/ExtractClaims: pulls Logto-shaped claims from a parsed jwx token, handles organization_roles arriving as []string or []interface{} - api/auth/Validator: JWKS-cached JWT parser (1h TTL) with issuer + audience validation; orgScoped flag accepts tokens with urn:logto:organization:* aud - Context helpers: WithClaims / ClaimsFromContext (private context key type) - Unit tests for claim extraction (full org-scoped token + minimal token) go mod tidy promoted gorilla/websocket from indirect to direct (it was already imported in handler code; tidy corrects the classification). JWT signature validation will be exercised end-to-end by middleware tests in the next task. --- api/auth/context.go | 118 ++++++++++++++++++++++++++++++++++++++++++ api/auth/jwt.go | 120 +++++++++++++++++++++++++++++++++++++++++++ api/auth/jwt_test.go | 54 +++++++++++++++++++ api/go.mod | 14 ++++- api/go.sum | 23 +++++++++ 5 files changed, 328 insertions(+), 1 deletion(-) create mode 100644 api/auth/context.go create mode 100644 api/auth/jwt.go create mode 100644 api/auth/jwt_test.go diff --git a/api/auth/context.go b/api/auth/context.go new file mode 100644 index 0000000..fe6b754 --- /dev/null +++ b/api/auth/context.go @@ -0,0 +1,118 @@ +// Package auth provides JWT validation and Logto claims extraction for the +// Court Command API. It is consumed by the chi middleware that authenticates +// every protected request and stores the normalized Claims in the request +// context for downstream handlers. +package auth + +import ( + "context" + "strings" + + "github.com/lestrrat-go/jwx/v3/jwt" +) + +// Claims is the normalized subset of JWT claims Court Command cares about. +// It is intentionally narrower than the full set of fields jwx exposes so +// handlers don't have to know about jwx types. +type Claims struct { + Subject string // Logto user ID (sub claim) + OrganizationID string // organization_id claim, empty if not org-scoped + OrganizationRoles []string // organization_roles claim + Scopes []string // parsed scope claim (space-separated string -> slice) + Audience []string // aud claim +} + +// HasScope reports whether the token has the given OAuth scope. +func (c Claims) HasScope(scope string) bool { + for _, s := range c.Scopes { + if s == scope { + return true + } + } + return false +} + +// HasOrgRole reports whether the token's organization_roles contains role. +// Returns false for non-org-scoped tokens (where OrganizationRoles is empty). +func (c Claims) HasOrgRole(role string) bool { + for _, r := range c.OrganizationRoles { + if r == role { + return true + } + } + return false +} + +// ExtractClaims pulls Logto-shaped claims off a parsed jwx token. It is +// tolerant of the two ways jwx may surface the organization_roles array: +// when set programmatically in tests it arrives as []string; when parsed +// from a JSON token over the wire jwx surfaces it as []interface{}. +func ExtractClaims(token jwt.Token) Claims { + c := Claims{} + + if sub, ok := token.Subject(); ok { + c.Subject = sub + } + if aud, ok := token.Audience(); ok { + c.Audience = aud + } + + var orgID string + if err := token.Get("organization_id", &orgID); err == nil { + c.OrganizationID = orgID + } + + // organization_roles may arrive as []string or []interface{} depending on + // whether the token was constructed in-process or parsed from JSON. + var rolesAny interface{} + if err := token.Get("organization_roles", &rolesAny); err == nil { + c.OrganizationRoles = toStringSlice(rolesAny) + } + + var scopeStr string + if err := token.Get("scope", &scopeStr); err == nil && scopeStr != "" { + c.Scopes = strings.Fields(scopeStr) + } + + return c +} + +// toStringSlice coerces []string or []interface{} values into []string, +// dropping any element that isn't a string. Returns nil for any other type. +func toStringSlice(v interface{}) []string { + switch t := v.(type) { + case []string: + out := make([]string, len(t)) + copy(out, t) + return out + case []interface{}: + out := make([]string, 0, len(t)) + for _, item := range t { + if s, ok := item.(string); ok { + out = append(out, s) + } + } + return out + default: + return nil + } +} + +// ctxKey is a private type so context keys defined in this package can never +// collide with keys defined elsewhere in the codebase. +type ctxKey struct{} + +var claimsKey = ctxKey{} + +// WithClaims returns a copy of ctx that carries c. Used by the JWT middleware +// after a token has been validated. +func WithClaims(ctx context.Context, c Claims) context.Context { + return context.WithValue(ctx, claimsKey, c) +} + +// ClaimsFromContext returns the Claims stored on ctx by WithClaims, if any. +// The bool return is false when there are no claims (unauthenticated request). +func ClaimsFromContext(ctx context.Context) (Claims, bool) { + c, ok := ctx.Value(claimsKey).(Claims) + return c, ok +} diff --git a/api/auth/jwt.go b/api/auth/jwt.go new file mode 100644 index 0000000..01ca4c2 --- /dev/null +++ b/api/auth/jwt.go @@ -0,0 +1,120 @@ +package auth + +import ( + "context" + "fmt" + "strings" + "sync" + "time" + + "github.com/lestrrat-go/jwx/v3/jwk" + "github.com/lestrrat-go/jwx/v3/jwt" +) + +// jwksCacheTTL is how long a fetched JWKS is reused before we refetch from +// the issuer. Logto rotates signing keys infrequently; an hour balances +// freshness against load on the auth server. +const jwksCacheTTL = time.Hour + +// orgAudiencePrefix is the prefix Logto stamps onto org-scoped access tokens. +// Tokens with audience starting with this prefix are accepted when callers +// pass orgScoped=true to Validate. +const orgAudiencePrefix = "urn:logto:organization:" + +// Validator parses and verifies JWTs issued by a Logto tenant. The set of +// signing keys is fetched lazily from the configured JWKS endpoint and cached +// for jwksCacheTTL between refreshes. +type Validator struct { + issuer string + jwksURI string + audience string + + mu sync.Mutex + cachedSet jwk.Set + cachedAt time.Time +} + +// NewValidator builds a Validator scoped to the given Logto tenant. issuer +// must match the iss claim of incoming tokens; jwksURI is the .well-known +// JWKS URL; audience is the resource indicator the API was registered with +// in Logto (used as the default audience when orgScoped is false). +func NewValidator(issuer, jwksURI, audience string) *Validator { + return &Validator{ + issuer: issuer, + jwksURI: jwksURI, + audience: audience, + } +} + +// Validate parses tokenString, verifies its signature against the cached +// JWKS, checks the issuer matches the configured issuer, and asserts the +// audience is acceptable. When orgScoped is true the token is also accepted +// if its audience begins with urn:logto:organization:; otherwise the audience +// must equal the Validator's configured audience exactly. +func (v *Validator) Validate(ctx context.Context, tokenString string, orgScoped bool) (jwt.Token, error) { + keyset, err := v.getKeySet(ctx) + if err != nil { + return nil, fmt.Errorf("auth: load JWKS: %w", err) + } + + tok, err := jwt.Parse( + []byte(tokenString), + jwt.WithKeySet(keyset), + jwt.WithValidate(true), + jwt.WithIssuer(v.issuer), + ) + if err != nil { + return nil, fmt.Errorf("auth: parse token: %w", err) + } + + if err := v.checkAudience(tok, orgScoped); err != nil { + return nil, err + } + + return tok, nil +} + +// checkAudience enforces the audience policy described on Validate. A token +// with no audience claim at all is rejected. +func (v *Validator) checkAudience(tok jwt.Token, orgScoped bool) error { + aud, ok := tok.Audience() + if !ok || len(aud) == 0 { + return fmt.Errorf("auth: token missing audience claim") + } + for _, a := range aud { + if a == v.audience { + return nil + } + if orgScoped && strings.HasPrefix(a, orgAudiencePrefix) { + return nil + } + } + return fmt.Errorf("auth: token audience %v does not match expected %q (orgScoped=%t)", aud, v.audience, orgScoped) +} + +// getKeySet returns the cached JWKS, refetching from the JWKS endpoint when +// the cache is empty or stale. Refresh is mutex-protected so concurrent +// requests after expiry coalesce into a single fetch. +func (v *Validator) getKeySet(ctx context.Context) (jwk.Set, error) { + v.mu.Lock() + defer v.mu.Unlock() + + if v.cachedSet != nil && time.Since(v.cachedAt) < jwksCacheTTL { + return v.cachedSet, nil + } + + set, err := jwk.Fetch(ctx, v.jwksURI) + if err != nil { + // On refresh failure, fall back to the stale cache rather than + // rejecting every in-flight request — better availability while + // the issuer is briefly unreachable. + if v.cachedSet != nil { + return v.cachedSet, nil + } + return nil, fmt.Errorf("fetch %s: %w", v.jwksURI, err) + } + + v.cachedSet = set + v.cachedAt = time.Now() + return set, nil +} diff --git a/api/auth/jwt_test.go b/api/auth/jwt_test.go new file mode 100644 index 0000000..9e0343b --- /dev/null +++ b/api/auth/jwt_test.go @@ -0,0 +1,54 @@ +package auth + +import ( + "testing" + + "github.com/lestrrat-go/jwx/v3/jwt" + "github.com/stretchr/testify/require" +) + +// TestExtractClaims verifies that ExtractClaims pulls all Logto-shaped claims +// (sub, aud, organization_id, organization_roles, scope) off a parsed jwx token +// into the normalized Claims struct. +func TestExtractClaims(t *testing.T) { + tok := jwt.New() + require.NoError(t, tok.Set(jwt.SubjectKey, "user_abc123")) + require.NoError(t, tok.Set(jwt.AudienceKey, []string{"urn:logto:organization:org_pickleball"})) + require.NoError(t, tok.Set("organization_id", "org_pickleball")) + require.NoError(t, tok.Set("organization_roles", []string{"platform_admin", "player"})) + require.NoError(t, tok.Set("scope", "read:tournaments write:tournaments read:admin write:admin")) + + c := ExtractClaims(tok) + + require.Equal(t, "user_abc123", c.Subject) + require.Equal(t, "org_pickleball", c.OrganizationID) + require.ElementsMatch(t, []string{"platform_admin", "player"}, c.OrganizationRoles) + require.ElementsMatch(t, + []string{"read:tournaments", "write:tournaments", "read:admin", "write:admin"}, + c.Scopes, + ) + require.Equal(t, []string{"urn:logto:organization:org_pickleball"}, c.Audience) + + require.True(t, c.HasScope("read:tournaments")) + require.False(t, c.HasScope("delete:tournaments")) + require.True(t, c.HasOrgRole("platform_admin")) + require.False(t, c.HasOrgRole("guest")) +} + +// TestExtractClaims_NoOrg covers a token without organization claims (e.g. a +// global resource token): OrganizationID and OrganizationRoles must be empty, +// Scopes must contain the single requested scope. +func TestExtractClaims_NoOrg(t *testing.T) { + tok := jwt.New() + require.NoError(t, tok.Set(jwt.SubjectKey, "user_xyz")) + require.NoError(t, tok.Set("scope", "read:profile")) + + c := ExtractClaims(tok) + + require.Equal(t, "user_xyz", c.Subject) + require.Empty(t, c.OrganizationID) + require.Empty(t, c.OrganizationRoles) + require.Equal(t, []string{"read:profile"}, c.Scopes) + require.False(t, c.HasOrgRole("platform_admin")) + require.True(t, c.HasScope("read:profile")) +} diff --git a/api/go.mod b/api/go.mod index 8efd143..d68d587 100644 --- a/api/go.mod +++ b/api/go.mod @@ -4,8 +4,10 @@ go 1.26.2 require ( github.com/go-chi/chi/v5 v5.2.5 + github.com/gorilla/websocket v1.5.3 github.com/jackc/pgx/v5 v5.9.1 github.com/joho/godotenv v1.5.1 + github.com/lestrrat-go/jwx/v3 v3.1.0 github.com/pressly/goose/v3 v3.27.0 github.com/redis/go-redis/v9 v9.18.0 github.com/stretchr/testify v1.11.1 @@ -15,19 +17,29 @@ require ( require ( github.com/cespare/xxhash/v2 v2.3.0 // indirect github.com/davecgh/go-spew v1.1.1 // indirect + github.com/decred/dcrd/dcrec/secp256k1/v4 v4.4.1 // indirect github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f // indirect - github.com/gorilla/websocket v1.5.3 // indirect + github.com/goccy/go-json v0.10.6 // indirect github.com/jackc/pgpassfile v1.0.0 // indirect github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect github.com/jackc/puddle/v2 v2.2.2 // indirect github.com/kr/text v0.2.0 // indirect + github.com/lestrrat-go/blackmagic v1.0.4 // indirect + github.com/lestrrat-go/dsig v1.2.1 // indirect + github.com/lestrrat-go/dsig-secp256k1 v1.0.0 // indirect + github.com/lestrrat-go/httpcc v1.0.1 // indirect + github.com/lestrrat-go/httprc/v3 v3.0.5 // indirect + github.com/lestrrat-go/option/v2 v2.0.0 // indirect github.com/mfridman/interpolate v0.0.2 // indirect github.com/pmezard/go-difflib v1.0.0 // indirect github.com/rogpeppe/go-internal v1.14.1 // indirect + github.com/segmentio/asm v1.2.1 // indirect github.com/sethvargo/go-retry v0.3.0 // indirect + github.com/valyala/fastjson v1.6.10 // indirect go.uber.org/atomic v1.11.0 // indirect go.uber.org/multierr v1.11.0 // indirect golang.org/x/sync v0.20.0 // indirect + golang.org/x/sys v0.43.0 // indirect golang.org/x/text v0.36.0 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect ) diff --git a/api/go.sum b/api/go.sum index c55bcd4..ae968ee 100644 --- a/api/go.sum +++ b/api/go.sum @@ -8,12 +8,16 @@ github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ3 github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/decred/dcrd/dcrec/secp256k1/v4 v4.4.1 h1:5RVFMOWjMyRy8cARdy79nAmgYw3hK/4HUq48LQ6Wwqo= +github.com/decred/dcrd/dcrec/secp256k1/v4 v4.4.1/go.mod h1:ZXNYxsqcloTdSy/rNShjYzMhyjf0LaoftYK0p+A3h40= github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f h1:lO4WD4F/rVNCu3HqELle0jiPLLBs70cWOduZpkS1E78= github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f/go.mod h1:cuUVRXasLTGF7a8hSLbxyZXjz+1KgoB3wDUb6vlszIc= github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY= github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= github.com/go-chi/chi/v5 v5.2.5 h1:Eg4myHZBjyvJmAFjFvWgrqDTXFyOzjj7YIm3L3mu6Ug= github.com/go-chi/chi/v5 v5.2.5/go.mod h1:X7Gx4mteadT3eDOMTsXzmI4/rwUpOwBHLpAfupzFJP0= +github.com/goccy/go-json v0.10.6 h1:p8HrPJzOakx/mn/bQtjgNjdTcN+/S6FcG2CTtQOrHVU= +github.com/goccy/go-json v0.10.6/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M= github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg= @@ -34,6 +38,20 @@ github.com/kr/pretty v0.3.0 h1:WgNl7dwNpEZ6jJ9k1snq4pZsg7DOEN8hP9Xw0Tsjwk0= github.com/kr/pretty v0.3.0/go.mod h1:640gp4NfQd8pI5XOwp5fnNeVWj67G7CFk/SaSQn7NBk= github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= +github.com/lestrrat-go/blackmagic v1.0.4 h1:IwQibdnf8l2KoO+qC3uT4OaTWsW7tuRQXy9TRN9QanA= +github.com/lestrrat-go/blackmagic v1.0.4/go.mod h1:6AWFyKNNj0zEXQYfTMPfZrAXUWUfTIZ5ECEUEJaijtw= +github.com/lestrrat-go/dsig v1.2.1 h1:MwxzZhE4+4fguHi+uDALKVlC3Cn+O1QU1Q/F8D7hVIc= +github.com/lestrrat-go/dsig v1.2.1/go.mod h1:RD2eOaidyPvpc7IJQoO3Qq52RWdy8ZcJs8lrOnoa1Kc= +github.com/lestrrat-go/dsig-secp256k1 v1.0.0 h1:JpDe4Aybfl0soBvoVwjqDbp+9S1Y2OM7gcrVVMFPOzY= +github.com/lestrrat-go/dsig-secp256k1 v1.0.0/go.mod h1:CxUgAhssb8FToqbL8NjSPoGQlnO4w3LG1P0qPWQm/NU= +github.com/lestrrat-go/httpcc v1.0.1 h1:ydWCStUeJLkpYyjLDHihupbn2tYmZ7m22BGkcvZZrIE= +github.com/lestrrat-go/httpcc v1.0.1/go.mod h1:qiltp3Mt56+55GPVCbTdM9MlqhvzyuL6W/NMDA8vA5E= +github.com/lestrrat-go/httprc/v3 v3.0.5 h1:S+Mb4L2I+bM6JGTibLmxExhyTOqnXjqx+zi9MoXw/TM= +github.com/lestrrat-go/httprc/v3 v3.0.5/go.mod h1:mSMtkZW92Z98M5YoNNztbRGxbXHql7tSitCvaxvo9l0= +github.com/lestrrat-go/jwx/v3 v3.1.0 h1:AyyLtxc0QM75F75JroWgt1phwC7X+wOb3XKhH7XBZWw= +github.com/lestrrat-go/jwx/v3 v3.1.0/go.mod h1:uw/MN2M/Xiu4FhwcIwH11Zsh9JWx9SWzgALl7/uIEkU= +github.com/lestrrat-go/option/v2 v2.0.0 h1:XxrcaJESE1fokHy3FpaQ/cXW8ZsIdWcdFzzLOcID3Ss= +github.com/lestrrat-go/option/v2 v2.0.0/go.mod h1:oSySsmzMoR0iRzCDCaUfsCzxQHUEuhOViQObyy7S6Vg= github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= github.com/mfridman/interpolate v0.0.2 h1:pnuTK7MQIxxFz1Gr+rjSIx9u7qVjf5VOoM/u6BbAxPY= @@ -50,13 +68,18 @@ github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94 github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo= github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ= github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc= +github.com/segmentio/asm v1.2.1 h1:DTNbBqs57ioxAD4PrArqftgypG4/qNpXoJx8TVXxPR0= +github.com/segmentio/asm v1.2.1/go.mod h1:BqMnlJP91P8d+4ibuonYZw9mfnzI9HfxselHZr5aAcs= github.com/sethvargo/go-retry v0.3.0 h1:EEt31A35QhrcRZtrYFDTBg91cqZVnFL2navjDrah2SE= github.com/sethvargo/go-retry v0.3.0/go.mod h1:mNX17F0C/HguQMyMyJxcnU471gOZGxCLyYaFyAZraas= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= +github.com/valyala/fastjson v1.6.10 h1:/yjJg8jaVQdYR3arGxPE2X5z89xrlhS0eGXdv+ADTh4= +github.com/valyala/fastjson v1.6.10/go.mod h1:e6FubmQouUNP73jtMLmcbxS6ydWIpOfhz34TSfO3JaE= github.com/zeebo/xxh3 v1.0.2 h1:xZmwmqxHZA8AI603jOQ0tMqmBr9lPeFwGg6d+xy9DC0= github.com/zeebo/xxh3 v1.0.2/go.mod h1:5NWz9Sef7zIDm2JHfFlcQvNekmcEl9ekUZQQKCYaDcA= go.uber.org/atomic v1.11.0 h1:ZvwS0R+56ePWxUNi+Atn9dWONBPp/AUETXlHW0DxSjE= From 7816985dd5f22c0a40250604860a8b4713d301f5 Mon Sep 17 00:00:00 2001 From: Daniel Velez Date: Thu, 30 Apr 2026 09:11:32 -0500 Subject: [PATCH 04/95] refactor(auth): apply Phase 1 review fixes Tightens api/auth in response to the code-quality review of c5fb841, in preparation for the JWT middleware (Task 1.4) consuming this API: - I-2: JWKS fetch wrapped in 10s context timeout so a hung issuer cannot stall the validator mutex indefinitely - M-2: Validate now returns (Claims, error) instead of (jwt.Token, error) so middleware doesn't need to import jwx types - S-3: Added sub-tests for ExtractClaims covering the []interface{} organization_roles branch (the production wire-decoded shape) plus the non-slice and nil cases that toStringSlice silently handles - S-7: jwksCacheTTL moved from package const to Validator.keyTTL field with a SetKeyTTL setter so middleware tests in 1.4 can force JWKS refresh without sleeping for an hour No public API breakage outside api/auth (no callers yet). The deferred review item (verify jwt.WithValidate(true) actually rejects expired tokens) will be covered explicitly by an integration test in Task 1.4. --- api/auth/jwt.go | 49 +++++++++++++++++++++++++++---------- api/auth/jwt_test.go | 58 ++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 94 insertions(+), 13 deletions(-) diff --git a/api/auth/jwt.go b/api/auth/jwt.go index 01ca4c2..b992c1f 100644 --- a/api/auth/jwt.go +++ b/api/auth/jwt.go @@ -11,10 +11,16 @@ import ( "github.com/lestrrat-go/jwx/v3/jwt" ) -// jwksCacheTTL is how long a fetched JWKS is reused before we refetch from +// defaultKeyTTL is how long a fetched JWKS is reused before we refetch from // the issuer. Logto rotates signing keys infrequently; an hour balances -// freshness against load on the auth server. -const jwksCacheTTL = time.Hour +// freshness against load on the auth server. Per-Validator override via +// SetKeyTTL is provided so tests can force a refresh without sleeping. +const defaultKeyTTL = time.Hour + +// jwksFetchTimeout caps how long getKeySet will wait on the JWKS endpoint +// before giving up. The validator mutex is held across the fetch, so an +// unbounded wait against a hung issuer would stall every concurrent caller. +const jwksFetchTimeout = 10 * time.Second // orgAudiencePrefix is the prefix Logto stamps onto org-scoped access tokens. // Tokens with audience starting with this prefix are accepted when callers @@ -23,7 +29,7 @@ const orgAudiencePrefix = "urn:logto:organization:" // Validator parses and verifies JWTs issued by a Logto tenant. The set of // signing keys is fetched lazily from the configured JWKS endpoint and cached -// for jwksCacheTTL between refreshes. +// for keyTTL between refreshes. type Validator struct { issuer string jwksURI string @@ -32,6 +38,7 @@ type Validator struct { mu sync.Mutex cachedSet jwk.Set cachedAt time.Time + keyTTL time.Duration } // NewValidator builds a Validator scoped to the given Logto tenant. issuer @@ -43,18 +50,29 @@ func NewValidator(issuer, jwksURI, audience string) *Validator { issuer: issuer, jwksURI: jwksURI, audience: audience, + keyTTL: defaultKeyTTL, } } +// SetKeyTTL overrides how long a fetched JWKS is cached before a refetch. +// Intended for tests that need to force a JWKS refresh deterministically; +// production code should rely on the default TTL set in NewValidator. +func (v *Validator) SetKeyTTL(d time.Duration) { + v.mu.Lock() + defer v.mu.Unlock() + v.keyTTL = d +} + // Validate parses tokenString, verifies its signature against the cached // JWKS, checks the issuer matches the configured issuer, and asserts the // audience is acceptable. When orgScoped is true the token is also accepted // if its audience begins with urn:logto:organization:; otherwise the audience -// must equal the Validator's configured audience exactly. -func (v *Validator) Validate(ctx context.Context, tokenString string, orgScoped bool) (jwt.Token, error) { +// must equal the Validator's configured audience exactly. The returned +// Claims is the normalized, jwx-free shape downstream handlers consume. +func (v *Validator) Validate(ctx context.Context, tokenString string, orgScoped bool) (Claims, error) { keyset, err := v.getKeySet(ctx) if err != nil { - return nil, fmt.Errorf("auth: load JWKS: %w", err) + return Claims{}, fmt.Errorf("auth: load JWKS: %w", err) } tok, err := jwt.Parse( @@ -64,14 +82,14 @@ func (v *Validator) Validate(ctx context.Context, tokenString string, orgScoped jwt.WithIssuer(v.issuer), ) if err != nil { - return nil, fmt.Errorf("auth: parse token: %w", err) + return Claims{}, fmt.Errorf("auth: parse token: %w", err) } if err := v.checkAudience(tok, orgScoped); err != nil { - return nil, err + return Claims{}, err } - return tok, nil + return ExtractClaims(tok), nil } // checkAudience enforces the audience policy described on Validate. A token @@ -94,16 +112,21 @@ func (v *Validator) checkAudience(tok jwt.Token, orgScoped bool) error { // getKeySet returns the cached JWKS, refetching from the JWKS endpoint when // the cache is empty or stale. Refresh is mutex-protected so concurrent -// requests after expiry coalesce into a single fetch. +// requests after expiry coalesce into a single fetch. The fetch itself runs +// against a derived context with jwksFetchTimeout so a hung issuer can't +// stall the mutex for the lifetime of the caller's context. func (v *Validator) getKeySet(ctx context.Context) (jwk.Set, error) { v.mu.Lock() defer v.mu.Unlock() - if v.cachedSet != nil && time.Since(v.cachedAt) < jwksCacheTTL { + if v.cachedSet != nil && time.Since(v.cachedAt) < v.keyTTL { return v.cachedSet, nil } - set, err := jwk.Fetch(ctx, v.jwksURI) + fetchCtx, cancel := context.WithTimeout(ctx, jwksFetchTimeout) + defer cancel() + + set, err := jwk.Fetch(fetchCtx, v.jwksURI) if err != nil { // On refresh failure, fall back to the stale cache rather than // rejecting every in-flight request — better availability while diff --git a/api/auth/jwt_test.go b/api/auth/jwt_test.go index 9e0343b..a356971 100644 --- a/api/auth/jwt_test.go +++ b/api/auth/jwt_test.go @@ -52,3 +52,61 @@ func TestExtractClaims_NoOrg(t *testing.T) { require.False(t, c.HasOrgRole("platform_admin")) require.True(t, c.HasScope("read:profile")) } + +// TestExtractClaims_OrganizationRolesShapes exercises toStringSlice indirectly +// through ExtractClaims. The interesting branch is []interface{}: that's what +// jwx surfaces when a token is parsed off the wire (the only construction +// path used in production), but the in-process []string case from the other +// tests doesn't cover it. The non-slice and nil cases verify toStringSlice +// silently degrades to an empty result rather than panicking. +func TestExtractClaims_OrganizationRolesShapes(t *testing.T) { + cases := []struct { + name string + input interface{} + want []string + }{ + { + name: "string slice (in-process)", + input: []string{"a", "b"}, + want: []string{"a", "b"}, + }, + { + name: "interface slice of strings (wire-decoded)", + input: []interface{}{"a", "b"}, + want: []string{"a", "b"}, + }, + { + name: "interface slice with non-string element", + input: []interface{}{"a", 42, "b"}, + want: []string{"a", "b"}, + }, + { + name: "nil", + input: nil, + want: nil, + }, + { + name: "non-slice value", + input: "not-a-slice", + want: nil, + }, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + tok := jwt.New() + require.NoError(t, tok.Set(jwt.SubjectKey, "user_shape")) + if tc.input != nil { + require.NoError(t, tok.Set("organization_roles", tc.input)) + } + + c := ExtractClaims(tok) + + if len(tc.want) == 0 { + require.Empty(t, c.OrganizationRoles) + return + } + require.Equal(t, tc.want, c.OrganizationRoles) + }) + } +} From dac58828ee729a05f53ef1b239bc298ff380b2ea Mon Sep 17 00:00:00 2001 From: Daniel Velez Date: Thu, 30 Apr 2026 13:44:44 -0500 Subject: [PATCH 05/95] docs: codify database ownership zones + migration rules After the Logto integration splits identity ownership from domain ownership, 'do I need a migration?' is no longer a simple yes/no. This doc captures the three zones (Zone A: DB canonical, Zone B: Logto canonical, Zone C: bridge tables), the decision tree for when to write a goose migration, and worked examples covering the common cases. The rule of thumb is unchanged for everything that was always in the database: schema changes need migrations, day-to-day data does not. The new wrinkle is that identity-shaped concepts (roles, scopes, orgs, user identity) now live in Logto admin and don't generate migrations. --- docs/DATABASE_OWNERSHIP.md | 175 +++++++++++++++++++++++++++++++++++++ 1 file changed, 175 insertions(+) create mode 100644 docs/DATABASE_OWNERSHIP.md diff --git a/docs/DATABASE_OWNERSHIP.md b/docs/DATABASE_OWNERSHIP.md new file mode 100644 index 0000000..5dbf05d --- /dev/null +++ b/docs/DATABASE_OWNERSHIP.md @@ -0,0 +1,175 @@ +# Database Ownership and Migration Rules + +This document codifies where Court Command stores data, who owns it, and when +a database migration is required. It exists because the Logto integration +(starting in v0.2.0) splits ownership across three zones, and "do I need a +migration?" is no longer a simple yes/no. + +**Audience:** anyone adding a new field, table, role, scope, or feature that +touches persisted state. + +--- + +## TL;DR + +- **Schema changes need a migration.** New tables, new columns, type/nullability + changes, new constraints, new indexes, dropped anything — all migrations. +- **Day-to-day data does not.** Inserting a tournament, registering a user, + updating a match score — normal API calls, no migration. +- **Identity data is owned by Logto, not the database.** Adding a Logto role, + scope, organization, or webhook is a Logto admin UI change, not a migration. + +--- + +## The three ownership zones + +### Zone A — Database is canonical + +The Postgres database in `api/db/migrations/` is the **single source of truth** +for everything in this zone. New fields here always require a goose migration. + +What lives here: + +- All Court Command **business entities**: + tournaments, divisions, pods, registrations, matches, match_events, + match_series, leagues, seasons, division_templates, league_registrations, + season_confirmations +- **Venues, courts**, court_queue, organizations *(the Court Command "club" + kind, not Logto orgs)*, venue_managers, tournament_courts +- **Player domain data**: phone, dupr_id, vair_id, paddle, gender, handedness, + date_of_birth, address, emergency contact, waiver, avatar — stored in the + `player_profiles` table (added in Phase 2) +- **Operational records**: standings, scoring_presets, source_profiles, + overlay configs, announcements, ad_configs, uploads, activity_logs, + site_settings +- **Mirror rows of Logto identities**: + - `users.id` (BIGSERIAL — Court Command's surrogate key) and + `users.logto_user_id` (the FK to Logto's user) + - `users.email` and `users.display_name` (cached for joinable display) + - `api_keys` rows that mirror Logto M2M apps via `logto_m2m_app_id` +- **The `sports` lookup table** (Pickleball, Demo Sport — added in Phase 2) + and all `sport_id` foreign keys on tournaments / leagues / organizations / + venues / divisions +- **Per-entity authorization tables** that Court Command, not Logto, models: + `org_memberships`, `tournament_staff`, `venue_managers`, + `league_memberships` (future) + +For everything in Zone A, the rule is unchanged from before the Logto +migration: **new field → goose migration → sqlc regen → Go service/handler +update → TypeScript type update**. + +### Zone B — Logto is canonical + +The self-hosted Logto instance at `https://logto.courtcommand.app` is the +**single source of truth** for identity and access-control vocabulary. The +Court Command database does **not** store any of the following authoritatively +— at most it caches them via webhooks. Adding or changing anything here is a +**Logto admin UI change** (or a Management API call), **not** a database +migration. + +What lives here: + +- `logto_user_id` (the canonical user identity that everything in Zone A + references) +- Email + password (Logto stores the bcrypt hash; the database caches the + email string for display) +- Display name (Logto authoritative; cached in `users.display_name`) +- `email_verified`, suspension state, MFA enrolment +- **Logto organizations** — `Pickleball`, `Demo Sport` (one Logto org per + Court Command sport) +- **Organization roles** — `player`, `tournament_director`, `referee`, + `scorekeeper`, `platform_admin` +- **Organization scopes** — `manage_tournaments`, `manage_matches`, + `manage_registrations`, `manage_users`, `read_all` +- **API scopes** registered on the `Court Command API` resource — + `read:tournaments`, `write:matches`, `read:admin`, etc. (12 total) +- **M2M app credentials** — `client_id` and `client_secret` for partner API + keys; the database stores only the `logto_m2m_app_id` reference, never + the secret +- **JWKS** (public keys), refresh tokens, sessions, webhook signing keys +- **Webhook subscriptions** (which events fire to which URLs) + +If you ever need a new role, scope, or organization, you go to +`https://logto-admin.courtcommand.app`, change it there, and update +`docs/LOGTO_SETUP.md` so the next operator can reproduce it. **No migration.** + +### Zone C — Hybrid (the bridge) + +Some Court Command business facts reference a Logto identity. The relationship +is a Court Command concept; the identity is a Logto concept. + +Example: "user `v3hqe8jx4wnn` is the assigned referee for tournament `123`." + +- The **relationship** lives in Zone A (`tournament_staff` table → migration + required to add or change the schema of the relationship) +- The **identity being pointed at** lives in Zone B (a Logto user record → + no migration) +- The **bridge** is a foreign key column: `tournament_staff.user_id BIGINT + REFERENCES users(id)`, where `users` is the Court Command mirror table + whose `logto_user_id` points to the Logto record + +When you add a new bridge table — say, `league_memberships(user_id, league_id, +role)` — you write a migration for the table itself (Zone A), but the meaning +of "role" depends on whether you're modelling a Court Command relationship +(Zone A: it's a string column) or reusing a Logto org role (Zone B: don't +duplicate; reference by name and check via JWT claim). + +--- + +## Quick decision tree + +> Should I write a database migration for this change? + +1. **Am I changing the *shape* of stored data?** (new table, new column, + changed constraint, new index, dropped anything) + - **No** → no migration. You're inserting/updating rows; that's + application code. + - **Yes** → continue. +2. **Is this Court Command business data?** (tournaments, matches, venues, + player profile, registrations, app settings, etc.) + - **Yes** → migration in `api/db/migrations/`. +3. **Is this an identity, role, scope, or organization concept?** + - **Yes** → no migration. Configure in Logto admin + (`https://logto-admin.courtcommand.app`), update `docs/LOGTO_SETUP.md`. +4. **Is it a relationship between a Logto identity and a Court Command + entity?** + - **Yes** → migration for the relationship table; the user FK references + `users(id)` (the mirror), not Logto directly. + +## Examples + +| Change | Migration? | Why | +|---|---|---| +| Add a `tournaments.banner_url` column | ✅ | Zone A schema change | +| Add a new sport (e.g. Basketball) | ✅ + Logto | Insert a row into `sports` (Zone A — done via migration since it's a lookup seed); also create a `Basketball` org in Logto admin (Zone B) | +| Add a new player profile field (e.g. `preferred_court_surface`) | ✅ | Zone A — new column on `player_profiles` | +| Insert a new tournament via the admin UI | ❌ | Zone A row, not schema | +| Add a new Logto org role (e.g. `vendor`) | ❌ | Zone B — Logto admin UI | +| Add a new API scope (e.g. `read:billing`) | ❌ | Zone B — Logto admin UI on the API resource | +| Add a `tournament_staff` row when a TD creates a tournament | ❌ | Zone A row, not schema | +| Replace `tournament_staff.raw_password` with a Logto-managed account | ✅ | Zone A schema change (drop column) | +| Add a `league_memberships` table | ✅ | Zone A schema change | +| Suspend a user via the admin UI | ❌ | Zone B — Logto Management API call (the database mirror's `users` row is unchanged structurally) | +| Update a user's email after they change it in Logto | ❌ | Zone B — webhook updates the mirror row's `email` cache; no schema change | + +## Migration mechanics (Zone A only) + +For Zone A schema changes, the standard flow: + +1. Write a new migration in `api/db/migrations/NNNNN_descriptive_name.sql` + following the goose `-- +goose Up` / `-- +goose Down` convention. Use the + next available number. +2. Update `api/db/queries/*.sql` if needed. +3. Run `make sqlc-generate` (or the equivalent in this repo) to regenerate + `api/db/generated/`. +4. Update affected Go services and handlers. +5. Update affected TypeScript types in `web/src/lib/types.ts`. +6. Update any forms / UI that touch the field. +7. Run `go test ./...`, `pnpm tsc -b --noEmit`, `pnpm build` locally before + committing. +8. Push. Coolify runs the migration automatically on next deploy via + `db.RunMigrations(ctx, cfg.DatabaseURL)` in `api/main.go`. + +`-- +goose Down` is mandatory and must actually reverse the change. The +schema-alignment audit (`docs/superpowers/audits/2026-04-20-db-schema-alignment.md`) +exists because of past failures here. From 968e7ac98a5cdf38f79d9968fbfbe54abfa6a4e4 Mon Sep 17 00:00:00 2001 From: Daniel Velez Date: Thu, 30 Apr 2026 13:54:03 -0500 Subject: [PATCH 06/95] feat(logto): Management API client with cached M2M token Adds api/logto package wrapping the self-hosted Logto Management API. Phase 1 surface only -- read-side methods needed to support /auth/me upserts and webhook idempotency. Write-side (CreateUser, M2M apps, organization assignment, impersonation) deferred to Phases 4/5. - logto.Client: HTTP wrapper around https://logto.courtcommand.app - logto.Config: Endpoint, M2M app credentials, ManagementAPIResource (the fixed-value https://default.logto.app/api audience for self- hosted Logto -- documented in docs/LOGTO_SETUP.md Step 3) - GetManagementToken: OAuth2 client_credentials with mutex-protected in-memory token cache; expires_in - 10s safety margin; transparent refresh on next call after expiry - doJSON (unexported): authenticated Management API request helper that decodes JSON into a destination value and returns *APIError on non-2xx - logto.APIError: typed error with Status + Body for callers that want to branch on status (e.g. 404 -> user not found) - users.go: LogtoUser struct + GetUser only -- additional user methods (Create, Delete, suspension) deferred to Phase 4 per the YAGNI option chosen for this task Tests use httptest fake servers in-process: - TestClient_GetManagementToken_CachesBetweenCalls (single mint) - TestClient_GetManagementToken_RefreshesAfterExpiry (refresh after expires_in=1s + 10s safety margin) - TestClient_GetUser_ReturnsAPIErrorOn404 (errors.As unwrap path) go test ./logto/... -v -race PASS --- api/logto/client.go | 149 +++++++++++++++++++++++++++++++++++++++ api/logto/client_test.go | 119 +++++++++++++++++++++++++++++++ api/logto/users.go | 33 +++++++++ 3 files changed, 301 insertions(+) create mode 100644 api/logto/client.go create mode 100644 api/logto/client_test.go create mode 100644 api/logto/users.go diff --git a/api/logto/client.go b/api/logto/client.go new file mode 100644 index 0000000..ce9c3a7 --- /dev/null +++ b/api/logto/client.go @@ -0,0 +1,149 @@ +// Package logto wraps the self-hosted Logto Management API: cached M2M +// access tokens plus authenticated JSON calls. +package logto + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "net/url" + "strings" + "sync" + "time" +) + +// tokenSafetyMargin is subtracted from expires_in so we refresh ahead of real expiry. +const tokenSafetyMargin = 10 * time.Second +const defaultHTTPTimeout = 10 * time.Second + +// Config carries the inputs NewClient needs. Endpoint is the Logto core URL +// without trailing slash. ManagementAPIResource is the OAuth resource +// indicator (audience); for self-hosted Logto it is the fixed value +// "https://default.logto.app/api" -- not a real URL, just Logto's built-in +// management API audience. HTTPClient is optional. +type Config struct { + Endpoint, ManagementAPIAppID, ManagementAPIAppSecret, ManagementAPIResource string + HTTPClient *http.Client +} + +// Client is a thin wrapper around the Logto Management API. Safe for +// concurrent use; the cached management token is mutex-protected. +type Client struct { + cfg Config + http *http.Client + mu sync.Mutex + cachedToken string + cachedUntil time.Time +} + +// NewClient builds a Client from cfg, defaulting HTTPClient to a 10s timeout. +func NewClient(cfg Config) *Client { + hc := cfg.HTTPClient + if hc == nil { + hc = &http.Client{Timeout: defaultHTTPTimeout} + } + return &Client{cfg: cfg, http: hc} +} + +// APIError is returned for non-2xx Management API responses. errors.As to +// branch on Status (e.g. 404). +type APIError struct { + Status int + Body string +} + +func (e *APIError) Error() string { return fmt.Sprintf("logto API status %d: %s", e.Status, e.Body) } + +// GetManagementToken returns a valid M2M access token, minting a new one +// via OAuth2 client_credentials when the cached value is missing or +// expired. The mutex is held across the network round-trip; token requests +// are infrequent so refresh-stampede coalescing is desirable. +func (c *Client) GetManagementToken(ctx context.Context) (string, error) { + c.mu.Lock() + defer c.mu.Unlock() + if c.cachedToken != "" && time.Now().Before(c.cachedUntil) { + return c.cachedToken, nil + } + + form := url.Values{"grant_type": {"client_credentials"}, "resource": {c.cfg.ManagementAPIResource}, "scope": {"all"}} + req, err := http.NewRequestWithContext(ctx, http.MethodPost, + c.cfg.Endpoint+"/oidc/token", strings.NewReader(form.Encode())) + if err != nil { + return "", fmt.Errorf("token request: %w", err) + } + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + req.SetBasicAuth(c.cfg.ManagementAPIAppID, c.cfg.ManagementAPIAppSecret) + + resp, err := c.http.Do(req) + if err != nil { + return "", fmt.Errorf("token request: %w", err) + } + defer resp.Body.Close() + body, err := io.ReadAll(resp.Body) + if err != nil { + return "", fmt.Errorf("read token response: %w", err) + } + if resp.StatusCode >= 400 { + return "", &APIError{Status: resp.StatusCode, Body: string(body)} + } + var parsed struct { + AccessToken string `json:"access_token"` + ExpiresIn int `json:"expires_in"` + } + if err := json.Unmarshal(body, &parsed); err != nil { + return "", fmt.Errorf("decode token response: %w", err) + } + if parsed.AccessToken == "" { + return "", fmt.Errorf("token response missing access_token") + } + c.cachedToken = parsed.AccessToken + c.cachedUntil = time.Now().Add(time.Duration(parsed.ExpiresIn)*time.Second - tokenSafetyMargin) + return c.cachedToken, nil +} + +// doJSON issues an authenticated Management API request. body, if non-nil, +// is JSON-encoded with Content-Type: application/json. out, if non-nil, +// receives the JSON-decoded response. Non-2xx responses become *APIError. +func (c *Client) doJSON(ctx context.Context, method, path string, body, out any) error { + token, err := c.GetManagementToken(ctx) + if err != nil { + return err + } + var reqBody io.Reader + if body != nil { + buf, err := json.Marshal(body) + if err != nil { + return fmt.Errorf("encode request: %w", err) + } + reqBody = bytes.NewReader(buf) + } + req, err := http.NewRequestWithContext(ctx, method, c.cfg.Endpoint+path, reqBody) + if err != nil { + return fmt.Errorf("build request: %w", err) + } + req.Header.Set("Authorization", "Bearer "+token) + if body != nil { + req.Header.Set("Content-Type", "application/json") + } + resp, err := c.http.Do(req) + if err != nil { + return fmt.Errorf("%s %s: %w", method, path, err) + } + defer resp.Body.Close() + respBody, err := io.ReadAll(resp.Body) + if err != nil { + return fmt.Errorf("read response: %w", err) + } + if resp.StatusCode >= 400 { + return &APIError{Status: resp.StatusCode, Body: string(respBody)} + } + if out != nil && len(respBody) > 0 { + if err := json.Unmarshal(respBody, out); err != nil { + return fmt.Errorf("decode response: %w", err) + } + } + return nil +} diff --git a/api/logto/client_test.go b/api/logto/client_test.go new file mode 100644 index 0000000..63867ff --- /dev/null +++ b/api/logto/client_test.go @@ -0,0 +1,119 @@ +package logto_test + +import ( + "context" + "encoding/base64" + "errors" + "fmt" + "net/http" + "net/http/httptest" + "strings" + "sync/atomic" + "testing" + + "github.com/court-command/court-command/logto" + "github.com/stretchr/testify/require" +) + +// fakeLogto returns a test server that mimics the two Management API +// endpoints we exercise: POST /oidc/token and GET /api/users/{id}. It +// counts token requests via the supplied atomic and verifies the request +// shape inline (form values, basic auth) so individual tests stay terse. +func fakeLogto(t *testing.T, tokenCount *atomic.Int64, expiresIn int, userHandler http.HandlerFunc) *httptest.Server { + t.Helper() + mux := http.NewServeMux() + mux.HandleFunc("/oidc/token", func(w http.ResponseWriter, r *http.Request) { + require.Equal(t, http.MethodPost, r.Method) + require.NoError(t, r.ParseForm()) + require.Equal(t, "client_credentials", r.Form.Get("grant_type")) + require.Equal(t, "https://default.logto.app/api", r.Form.Get("resource")) + require.Equal(t, "all", r.Form.Get("scope")) + + authz := r.Header.Get("Authorization") + require.True(t, strings.HasPrefix(authz, "Basic "), "expected Basic auth, got %q", authz) + raw, err := base64.StdEncoding.DecodeString(strings.TrimPrefix(authz, "Basic ")) + require.NoError(t, err) + require.Equal(t, "app:secret", string(raw)) + + tokenCount.Add(1) + w.Header().Set("Content-Type", "application/json") + fmt.Fprintf(w, `{"access_token":"tok-%d","token_type":"Bearer","expires_in":%d}`, tokenCount.Load(), expiresIn) + }) + if userHandler != nil { + mux.HandleFunc("/api/users/", userHandler) + } + return httptest.NewServer(mux) +} + +func newTestClient(endpoint string) *logto.Client { + return logto.NewClient(logto.Config{ + Endpoint: endpoint, + ManagementAPIAppID: "app", + ManagementAPIAppSecret: "secret", + ManagementAPIResource: "https://default.logto.app/api", + }) +} + +func TestClient_GetManagementToken_CachesBetweenCalls(t *testing.T) { + var count atomic.Int64 + srv := fakeLogto(t, &count, 3600, nil) + defer srv.Close() + + c := newTestClient(srv.URL) + ctx := context.Background() + + for i := 0; i < 3; i++ { + tok, err := c.GetManagementToken(ctx) + require.NoError(t, err) + require.NotEmpty(t, tok) + } + + require.Equal(t, int64(1), count.Load(), "expected token to be minted once and reused") +} + +func TestClient_GetManagementToken_RefreshesAfterExpiry(t *testing.T) { + var count atomic.Int64 + // expires_in=1s with a 10s safety margin yields a cachedUntil in the + // past, so the second call must refresh without any wall-clock wait. + srv := fakeLogto(t, &count, 1, nil) + defer srv.Close() + + c := newTestClient(srv.URL) + ctx := context.Background() + + tok1, err := c.GetManagementToken(ctx) + require.NoError(t, err) + + tok2, err := c.GetManagementToken(ctx) + require.NoError(t, err) + + require.GreaterOrEqual(t, count.Load(), int64(2), "expected refresh after expiry") + require.NotEqual(t, tok1, tok2, "refreshed token should differ from initial token") +} + +func TestClient_GetUser_ReturnsAPIErrorOn404(t *testing.T) { + var count atomic.Int64 + const errBody = `{"code":"user.not_found","message":"User not found"}` + + userHandler := func(w http.ResponseWriter, r *http.Request) { + require.Equal(t, http.MethodGet, r.Method) + require.Equal(t, "/api/users/missing", r.URL.Path) + require.Equal(t, "Bearer tok-1", r.Header.Get("Authorization")) + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusNotFound) + _, _ = w.Write([]byte(errBody)) + } + srv := fakeLogto(t, &count, 3600, userHandler) + defer srv.Close() + + c := newTestClient(srv.URL) + user, err := c.GetUser(context.Background(), "missing") + require.Nil(t, user) + require.Error(t, err) + + var apiErr *logto.APIError + require.True(t, errors.As(err, &apiErr), "expected *logto.APIError, got %T: %v", err, err) + require.Equal(t, http.StatusNotFound, apiErr.Status) + require.Contains(t, apiErr.Body, "user.not_found") + require.Contains(t, apiErr.Body, "User not found") +} diff --git a/api/logto/users.go b/api/logto/users.go new file mode 100644 index 0000000..fd19cb7 --- /dev/null +++ b/api/logto/users.go @@ -0,0 +1,33 @@ +package logto + +import ( + "context" + "net/http" +) + +// LogtoUser is the subset of the Logto user record Court Command consumes. +// Field names match Logto's JSON shape verbatim. CustomData is left as a +// generic map because each subsystem (auth_me, webhooks) reads different +// keys; typed parsing happens at the call site. +type LogtoUser struct { + ID string `json:"id"` + Username string `json:"username,omitempty"` + PrimaryEmail string `json:"primaryEmail,omitempty"` + Name string `json:"name,omitempty"` + Avatar string `json:"avatar,omitempty"` + CustomData map[string]interface{} `json:"customData,omitempty"` + IsSuspended bool `json:"isSuspended,omitempty"` + CreatedAt int64 `json:"createdAt,omitempty"` + UpdatedAt int64 `json:"updatedAt,omitempty"` +} + +// GetUser fetches a single user by Logto user ID. Non-2xx responses are +// returned as *APIError; callers branching on "user not found" should +// errors.As to (*APIError) and check Status == 404. +func (c *Client) GetUser(ctx context.Context, userID string) (*LogtoUser, error) { + var u LogtoUser + if err := c.doJSON(ctx, http.MethodGet, "/api/users/"+userID, nil, &u); err != nil { + return nil, err + } + return &u, nil +} From 6f71684956020d93f7706967d429b684a679138e Mon Sep 17 00:00:00 2001 From: Daniel Velez Date: Thu, 30 Apr 2026 13:56:15 -0500 Subject: [PATCH 07/95] style(auth): drop 'auth:' error prefix to match codebase convention The dominant error-message style across api/service, api/session, and api/handler is bare-prefix (e.g. 'checking email: %w'). The api/auth package was an outlier with 'auth: ...' prefixes. Bringing it into line so wrapped error chains read consistently. Behavior unchanged; tests still PASS. --- api/auth/jwt.go | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/api/auth/jwt.go b/api/auth/jwt.go index b992c1f..040b275 100644 --- a/api/auth/jwt.go +++ b/api/auth/jwt.go @@ -72,7 +72,7 @@ func (v *Validator) SetKeyTTL(d time.Duration) { func (v *Validator) Validate(ctx context.Context, tokenString string, orgScoped bool) (Claims, error) { keyset, err := v.getKeySet(ctx) if err != nil { - return Claims{}, fmt.Errorf("auth: load JWKS: %w", err) + return Claims{}, fmt.Errorf("load JWKS: %w", err) } tok, err := jwt.Parse( @@ -82,7 +82,7 @@ func (v *Validator) Validate(ctx context.Context, tokenString string, orgScoped jwt.WithIssuer(v.issuer), ) if err != nil { - return Claims{}, fmt.Errorf("auth: parse token: %w", err) + return Claims{}, fmt.Errorf("parse token: %w", err) } if err := v.checkAudience(tok, orgScoped); err != nil { @@ -97,7 +97,7 @@ func (v *Validator) Validate(ctx context.Context, tokenString string, orgScoped func (v *Validator) checkAudience(tok jwt.Token, orgScoped bool) error { aud, ok := tok.Audience() if !ok || len(aud) == 0 { - return fmt.Errorf("auth: token missing audience claim") + return fmt.Errorf("token missing audience claim") } for _, a := range aud { if a == v.audience { @@ -107,7 +107,7 @@ func (v *Validator) checkAudience(tok jwt.Token, orgScoped bool) error { return nil } } - return fmt.Errorf("auth: token audience %v does not match expected %q (orgScoped=%t)", aud, v.audience, orgScoped) + return fmt.Errorf("token audience %v does not match expected %q (orgScoped=%t)", aud, v.audience, orgScoped) } // getKeySet returns the cached JWKS, refetching from the JWKS endpoint when @@ -134,7 +134,7 @@ func (v *Validator) getKeySet(ctx context.Context) (jwk.Set, error) { if v.cachedSet != nil { return v.cachedSet, nil } - return nil, fmt.Errorf("fetch %s: %w", v.jwksURI, err) + return nil, fmt.Errorf("fetch jwks: %w", err) } v.cachedSet = set From 3cc11b8aed45775fb63b038465369c4d9352f223 Mon Sep 17 00:00:00 2001 From: Daniel Velez Date: Thu, 30 Apr 2026 13:56:50 -0500 Subject: [PATCH 08/95] test(logto): live smoke test against real Logto deployment Gated behind LOGTO_LIVE_SMOKE=1 so it never runs in CI / regular 'go test ./...' invocations. When opt-in, it verifies end-to-end: - M2M token mints via OAuth2 client_credentials - Token cached on second call - GetUser resolves a known user by ID - GetUser returns *APIError with Status=404 for unknown IDs Source env from .env (gitignored) and pass LOGTO_BOOTSTRAP_USER_ID matching the user created in Step 6 of docs/LOGTO_SETUP.md: set -a; source .env; set +a LOGTO_LIVE_SMOKE=1 LOGTO_BOOTSTRAP_USER_ID= go test ./logto/... Verified passing against https://logto.courtcommand.app on this branch: bootstrap user resolved: id=v3hqe8jx4wnn email=daniel.f.velez@gmail.com name=Daniel Velez --- PASS: TestLiveSmoke_GetUser (0.41s) --- api/logto/livesmoke_test.go | 64 +++++++++++++++++++++++++++++++++++++ 1 file changed, 64 insertions(+) create mode 100644 api/logto/livesmoke_test.go diff --git a/api/logto/livesmoke_test.go b/api/logto/livesmoke_test.go new file mode 100644 index 0000000..02da59b --- /dev/null +++ b/api/logto/livesmoke_test.go @@ -0,0 +1,64 @@ +// Package logto live smoke test against the real Logto deployment. +// Skipped unless LOGTO_LIVE_SMOKE=1 is set so it never runs in CI / regular dev. +package logto + +import ( + "context" + "errors" + "os" + "testing" + + "github.com/stretchr/testify/require" +) + +// TestLiveSmoke_GetUser exercises the client end-to-end against the real +// Logto deployment configured via LOGTO_* env vars (.env). Useful as a +// one-shot integration check that token caching, audience handling, and +// error paths all match the real server's behavior. +func TestLiveSmoke_GetUser(t *testing.T) { + if os.Getenv("LOGTO_LIVE_SMOKE") != "1" { + t.Skip("set LOGTO_LIVE_SMOKE=1 to run live smoke against Logto") + } + cfg := Config{ + Endpoint: os.Getenv("LOGTO_ENDPOINT"), + ManagementAPIAppID: os.Getenv("LOGTO_MANAGEMENT_API_APP_ID"), + ManagementAPIAppSecret: os.Getenv("LOGTO_MANAGEMENT_API_APP_SECRET"), + ManagementAPIResource: os.Getenv("LOGTO_MANAGEMENT_API_RESOURCE"), + } + require.NotEmpty(t, cfg.Endpoint, "LOGTO_ENDPOINT must be set") + require.NotEmpty(t, cfg.ManagementAPIAppID, "LOGTO_MANAGEMENT_API_APP_ID must be set") + require.NotEmpty(t, cfg.ManagementAPIAppSecret, "LOGTO_MANAGEMENT_API_APP_SECRET must be set") + require.NotEmpty(t, cfg.ManagementAPIResource, "LOGTO_MANAGEMENT_API_RESOURCE must be set") + + c := NewClient(cfg) + ctx := context.Background() + + // 1. Token mints + tok, err := c.GetManagementToken(ctx) + require.NoError(t, err) + require.NotEmpty(t, tok) + + // 2. Token caches (second call should be the same string) + tok2, err := c.GetManagementToken(ctx) + require.NoError(t, err) + require.Equal(t, tok, tok2) + + // 3. Bootstrap admin lookup (user created in Step 6 of LOGTO_SETUP.md) + bootstrapID := os.Getenv("LOGTO_BOOTSTRAP_USER_ID") + if bootstrapID == "" { + t.Skip("LOGTO_BOOTSTRAP_USER_ID not set; skipping user lookup") + } + user, err := c.GetUser(ctx, bootstrapID) + require.NoError(t, err) + require.Equal(t, bootstrapID, user.ID) + require.NotEmpty(t, user.PrimaryEmail) + t.Logf("bootstrap user resolved: id=%s email=%s name=%s", + user.ID, user.PrimaryEmail, user.Name) + + // 4. 404 path + _, err = c.GetUser(ctx, "nonexistent_user_id_xyz") + require.Error(t, err) + var apiErr *APIError + require.True(t, errors.As(err, &apiErr), "want *APIError, got %T", err) + require.Equal(t, 404, apiErr.Status) +} From 784af8be07f2299b38c471d39d0881136bd8b82c Mon Sep 17 00:00:00 2001 From: Daniel Velez Date: Thu, 30 Apr 2026 14:02:11 -0500 Subject: [PATCH 09/95] feat(middleware): RequireJWT validates Logto-issued JWTs on every request Adds a chi-compatible middleware that validates the Authorization Bearer token against an *auth.Validator and stores the parsed auth.Claims in the request context for downstream handlers. On any validation failure the middleware writes the standard {"error":{"code":"unauthorized",...}} envelope (matching the existing RequireAuth style) and short-circuits the chain. Behavior: - Missing / malformed Authorization header -> 401 - Invalid signature -> 401 - Wrong issuer -> 401 - Wrong audience -> 401 - Expired token (exp in past) -> 401 - urn:logto:organization:* aud when orgScoped=false -> 401 - Valid token, global aud or org URN (with orgScoped=true) -> next.ServeHTTP Tests use an in-process JWKS server backed by an RSA keypair so the full parse path -- signature verification, issuer check, audience check, expiry validation -- runs end-to-end without any network dependency. The bad-signature case signs with a second keypair to prove rejection of forged tokens. The expired-token case explicitly verifies the I-1 deferred check from the previous code review: jwt.WithValidate (v3 default) DOES reject tokens with past exp. Sport-validation middleware (X-Sport header vs JWT organization_id) arrives in the next task. --- api/middleware/jwt_middleware.go | 59 +++++ api/middleware/jwt_middleware_test.go | 324 ++++++++++++++++++++++++++ 2 files changed, 383 insertions(+) create mode 100644 api/middleware/jwt_middleware.go create mode 100644 api/middleware/jwt_middleware_test.go diff --git a/api/middleware/jwt_middleware.go b/api/middleware/jwt_middleware.go new file mode 100644 index 0000000..8f934d1 --- /dev/null +++ b/api/middleware/jwt_middleware.go @@ -0,0 +1,59 @@ +// api/middleware/jwt_middleware.go +package middleware + +import ( + "log/slog" + "net/http" + "strings" + + "github.com/court-command/court-command/auth" +) + +// RequireJWT validates the Authorization Bearer token against the given +// validator on every request. On success, the parsed Claims are stored in +// the request context via auth.WithClaims. On any validation failure the +// middleware writes a 401 JSON error envelope and short-circuits the chain. +// +// orgScoped controls whether the validator accepts tokens whose audience +// is urn:logto:organization:* in addition to the global API resource +// audience. Use true for sport-scoped routes that consume Logto org tokens; +// use false for routes that should only accept the global API audience. +func RequireJWT(v *auth.Validator, orgScoped bool) func(http.Handler) http.Handler { + return func(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + token, ok := bearerToken(r.Header.Get("Authorization")) + if !ok { + writeError(w, http.StatusUnauthorized, "unauthorized", "authentication required") + return + } + + claims, err := v.Validate(r.Context(), token, orgScoped) + if err != nil { + // Invalid tokens are routine (probes, expired clients, replay + // attempts). Log at debug so a noisy public endpoint can't + // flood the log; aggregate metrics belong elsewhere. + slog.DebugContext(r.Context(), "jwt validation failed", "err", err) + writeError(w, http.StatusUnauthorized, "unauthorized", "invalid token") + return + } + + ctx := auth.WithClaims(r.Context(), claims) + next.ServeHTTP(w, r.WithContext(ctx)) + }) + } +} + +// bearerToken extracts the token portion of a "Bearer " Authorization +// header value. Returns ("", false) for any non-Bearer scheme, missing token, +// or otherwise malformed header so the caller can short-circuit with 401. +func bearerToken(header string) (string, bool) { + const prefix = "Bearer " + if len(header) <= len(prefix) || !strings.EqualFold(header[:len(prefix)], prefix) { + return "", false + } + token := strings.TrimSpace(header[len(prefix):]) + if token == "" { + return "", false + } + return token, true +} diff --git a/api/middleware/jwt_middleware_test.go b/api/middleware/jwt_middleware_test.go new file mode 100644 index 0000000..b70ccf6 --- /dev/null +++ b/api/middleware/jwt_middleware_test.go @@ -0,0 +1,324 @@ +// api/middleware/jwt_middleware_test.go +package middleware_test + +import ( + "crypto/rand" + "crypto/rsa" + "encoding/json" + "net/http" + "net/http/httptest" + "strings" + "testing" + "time" + + "github.com/court-command/court-command/auth" + "github.com/court-command/court-command/middleware" + "github.com/lestrrat-go/jwx/v3/jwa" + "github.com/lestrrat-go/jwx/v3/jwk" + "github.com/lestrrat-go/jwx/v3/jwt" + "github.com/stretchr/testify/require" +) + +const ( + testIssuer = "https://test.logto.app/" + testAPIaud = "https://api.courtcommand.app/api" + testKID = "test-key-1" + testOrgURNAud = "urn:logto:organization:org_pickleball" +) + +// testKey returns (privateKey, jwksServerURL) where the JWKS server serves +// the public counterpart of priv as a single-entry JWK Set with kid=testKID +// and alg=RS256. Caller is responsible for calling t.Cleanup-equivalent; +// httptest.Server is registered with t.Cleanup here. +func testKey(t *testing.T) (*rsa.PrivateKey, string) { + t.Helper() + priv, err := rsa.GenerateKey(rand.Reader, 2048) + require.NoError(t, err) + jwksURL := serveJWKS(t, &priv.PublicKey, testKID) + return priv, jwksURL +} + +// serveJWKS spins up an httptest.Server that returns a JWK Set containing +// the single public key under kid. The server is torn down via t.Cleanup. +func serveJWKS(t *testing.T, pub *rsa.PublicKey, kid string) string { + t.Helper() + pubJWK, err := jwk.Import(pub) + require.NoError(t, err) + require.NoError(t, pubJWK.Set(jwk.KeyIDKey, kid)) + require.NoError(t, pubJWK.Set(jwk.AlgorithmKey, jwa.RS256())) + + set := jwk.NewSet() + require.NoError(t, set.AddKey(pubJWK)) + body, err := json.Marshal(set) + require.NoError(t, err) + + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write(body) + })) + t.Cleanup(srv.Close) + return srv.URL +} + +// mintToken signs a JWT with priv and returns the compact-serialized form. +// Defaults: iss=testIssuer, iat=now, exp=now+5m. Caller-supplied claims +// override the defaults; pass time.Time values for exp/iat/nbf. +func mintToken(t *testing.T, priv *rsa.PrivateKey, claims map[string]interface{}) string { + t.Helper() + tok := jwt.New() + + if _, ok := claims[jwt.IssuerKey]; !ok { + require.NoError(t, tok.Set(jwt.IssuerKey, testIssuer)) + } + if _, ok := claims[jwt.IssuedAtKey]; !ok { + require.NoError(t, tok.Set(jwt.IssuedAtKey, time.Now())) + } + if _, ok := claims[jwt.ExpirationKey]; !ok { + require.NoError(t, tok.Set(jwt.ExpirationKey, time.Now().Add(5*time.Minute))) + } + for k, v := range claims { + require.NoError(t, tok.Set(k, v)) + } + + privJWK, err := jwk.Import(priv) + require.NoError(t, err) + require.NoError(t, privJWK.Set(jwk.KeyIDKey, testKID)) + require.NoError(t, privJWK.Set(jwk.AlgorithmKey, jwa.RS256())) + + signed, err := jwt.Sign(tok, jwt.WithKey(jwa.RS256(), privJWK)) + require.NoError(t, err) + return string(signed) +} + +// runMiddleware wires the middleware around a handler that records whether it +// was reached and returns the recorder + handler-reached flag pointer. +func runMiddleware(mw func(http.Handler) http.Handler, req *http.Request) (*httptest.ResponseRecorder, *bool, *http.Request) { + reached := false + var capturedReq *http.Request + h := mw(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + reached = true + capturedReq = r + w.WriteHeader(http.StatusOK) + })) + rr := httptest.NewRecorder() + h.ServeHTTP(rr, req) + return rr, &reached, capturedReq +} + +func TestRequireJWT_ValidToken_PassesThroughWithClaims(t *testing.T) { + priv, jwksURL := testKey(t) + v := auth.NewValidator(testIssuer, jwksURL, testAPIaud) + mw := middleware.RequireJWT(v, true) + + token := mintToken(t, priv, map[string]interface{}{ + jwt.SubjectKey: "user_abc", + jwt.AudienceKey: []string{testOrgURNAud}, + "organization_id": "org_pickleball", + "organization_roles": []string{"platform_admin"}, + "scope": "read:tournaments write:tournaments", + }) + + req := httptest.NewRequest(http.MethodGet, "/x", nil) + req.Header.Set("Authorization", "Bearer "+token) + + rr, reached, capturedReq := runMiddleware(mw, req) + + require.True(t, *reached, "downstream handler should be reached on valid token") + require.Equal(t, http.StatusOK, rr.Code) + + claims, ok := auth.ClaimsFromContext(capturedReq.Context()) + require.True(t, ok, "claims should be present in handler context") + require.Equal(t, "user_abc", claims.Subject) + require.Equal(t, "org_pickleball", claims.OrganizationID) + require.Equal(t, []string{"platform_admin"}, claims.OrganizationRoles) + require.ElementsMatch(t, []string{"read:tournaments", "write:tournaments"}, claims.Scopes) + require.Equal(t, []string{testOrgURNAud}, claims.Audience) +} + +func TestRequireJWT_MissingHeader_Returns401(t *testing.T) { + _, jwksURL := testKey(t) + v := auth.NewValidator(testIssuer, jwksURL, testAPIaud) + mw := middleware.RequireJWT(v, true) + + req := httptest.NewRequest(http.MethodGet, "/x", nil) + rr, reached, _ := runMiddleware(mw, req) + + require.False(t, *reached, "handler must not be reached without auth header") + require.Equal(t, http.StatusUnauthorized, rr.Code) + require.Contains(t, rr.Body.String(), "unauthorized") +} + +func TestRequireJWT_MalformedAuthHeader_Returns401(t *testing.T) { + _, jwksURL := testKey(t) + v := auth.NewValidator(testIssuer, jwksURL, testAPIaud) + mw := middleware.RequireJWT(v, true) + + cases := []struct { + name string + header string + }{ + {"bearer no token", "Bearer"}, + {"bearer empty token", "Bearer "}, + {"wrong scheme", "Basic xxx"}, + {"no scheme", "xxx"}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + req := httptest.NewRequest(http.MethodGet, "/x", nil) + req.Header.Set("Authorization", tc.header) + + rr, reached, _ := runMiddleware(mw, req) + + require.False(t, *reached) + require.Equal(t, http.StatusUnauthorized, rr.Code) + require.Contains(t, rr.Body.String(), "unauthorized") + }) + } +} + +func TestRequireJWT_WrongIssuer_Returns401(t *testing.T) { + priv, jwksURL := testKey(t) + v := auth.NewValidator(testIssuer, jwksURL, testAPIaud) + mw := middleware.RequireJWT(v, true) + + token := mintToken(t, priv, map[string]interface{}{ + jwt.IssuerKey: "https://attacker.example/", + jwt.SubjectKey: "user_abc", + jwt.AudienceKey: []string{testOrgURNAud}, + }) + + req := httptest.NewRequest(http.MethodGet, "/x", nil) + req.Header.Set("Authorization", "Bearer "+token) + rr, reached, _ := runMiddleware(mw, req) + + require.False(t, *reached) + require.Equal(t, http.StatusUnauthorized, rr.Code) + require.Contains(t, rr.Body.String(), "invalid token") +} + +func TestRequireJWT_WrongAudience_Returns401(t *testing.T) { + priv, jwksURL := testKey(t) + v := auth.NewValidator(testIssuer, jwksURL, testAPIaud) + mw := middleware.RequireJWT(v, true) + + token := mintToken(t, priv, map[string]interface{}{ + jwt.SubjectKey: "user_abc", + jwt.AudienceKey: []string{"https://random.example/whatever"}, + }) + + req := httptest.NewRequest(http.MethodGet, "/x", nil) + req.Header.Set("Authorization", "Bearer "+token) + rr, reached, _ := runMiddleware(mw, req) + + require.False(t, *reached) + require.Equal(t, http.StatusUnauthorized, rr.Code) + require.Contains(t, rr.Body.String(), "invalid token") +} + +func TestRequireJWT_BadSignature_Returns401(t *testing.T) { + // JWKS server publishes pub of key1 only. We sign the token with key2. + // Validator must reject because the signature won't verify against any + // key in the published set. Security-critical: a forged token signed by + // any RSA key MUST NOT be accepted. + _, jwksURL := testKey(t) + v := auth.NewValidator(testIssuer, jwksURL, testAPIaud) + mw := middleware.RequireJWT(v, true) + + priv2, err := rsa.GenerateKey(rand.Reader, 2048) + require.NoError(t, err) + + token := mintToken(t, priv2, map[string]interface{}{ + jwt.SubjectKey: "user_abc", + jwt.AudienceKey: []string{testOrgURNAud}, + }) + + req := httptest.NewRequest(http.MethodGet, "/x", nil) + req.Header.Set("Authorization", "Bearer "+token) + rr, reached, _ := runMiddleware(mw, req) + + require.False(t, *reached, "forged-signature token must not reach handler") + require.Equal(t, http.StatusUnauthorized, rr.Code) + require.Contains(t, rr.Body.String(), "invalid token") +} + +func TestRequireJWT_ExpiredToken_Returns401(t *testing.T) { + // Verifies the I-1 deferred check: jwt.WithValidate (default in v3 + // jwt.Parse) DOES reject tokens whose exp claim is in the past. The + // token is otherwise valid -- correct signer, issuer, and audience -- + // so the only reason for rejection is expiry. + priv, jwksURL := testKey(t) + v := auth.NewValidator(testIssuer, jwksURL, testAPIaud) + mw := middleware.RequireJWT(v, true) + + expiredAt := time.Now().Add(-1 * time.Hour) + token := mintToken(t, priv, map[string]interface{}{ + jwt.SubjectKey: "user_abc", + jwt.AudienceKey: []string{testOrgURNAud}, + jwt.IssuedAtKey: time.Now().Add(-2 * time.Hour), + jwt.ExpirationKey: expiredAt, + }) + + req := httptest.NewRequest(http.MethodGet, "/x", nil) + req.Header.Set("Authorization", "Bearer "+token) + rr, reached, _ := runMiddleware(mw, req) + + require.False(t, *reached, "expired token must not reach handler -- I-1 verification") + require.Equal(t, http.StatusUnauthorized, rr.Code) + require.Contains(t, rr.Body.String(), "invalid token") + require.True(t, expiredAt.Before(time.Now()), "test pre-condition: exp must be in the past") +} + +func TestRequireJWT_OrgScopedFalse_RejectsOrgURN(t *testing.T) { + // orgScoped=false: the org-URN audience must be rejected even though + // the token is otherwise perfectly valid. + priv, jwksURL := testKey(t) + v := auth.NewValidator(testIssuer, jwksURL, testAPIaud) + mw := middleware.RequireJWT(v, false) + + token := mintToken(t, priv, map[string]interface{}{ + jwt.SubjectKey: "user_abc", + jwt.AudienceKey: []string{testOrgURNAud}, + }) + + req := httptest.NewRequest(http.MethodGet, "/x", nil) + req.Header.Set("Authorization", "Bearer "+token) + rr, reached, _ := runMiddleware(mw, req) + + require.False(t, *reached) + require.Equal(t, http.StatusUnauthorized, rr.Code) + require.Contains(t, rr.Body.String(), "invalid token") +} + +func TestRequireJWT_GlobalAudienceAccepted(t *testing.T) { + // Global API audience matches exactly: must pass regardless of the + // orgScoped flag's value. Run both to prove the policy. + priv, jwksURL := testKey(t) + + for _, orgScoped := range []bool{true, false} { + t.Run(name("orgScoped", orgScoped), func(t *testing.T) { + v := auth.NewValidator(testIssuer, jwksURL, testAPIaud) + mw := middleware.RequireJWT(v, orgScoped) + + token := mintToken(t, priv, map[string]interface{}{ + jwt.SubjectKey: "user_abc", + jwt.AudienceKey: []string{testAPIaud}, + }) + + req := httptest.NewRequest(http.MethodGet, "/x", nil) + req.Header.Set("Authorization", "Bearer "+token) + rr, reached, _ := runMiddleware(mw, req) + + require.True(t, *reached, "handler must be reached for global-aud token") + require.Equal(t, http.StatusOK, rr.Code) + }) + } +} + +// name builds a subtest name for boolean parameter sweeps so the table-style +// loop above stays readable. +func name(label string, b bool) string { + if b { + return label + "=true" + } + return strings.Replace(label+"=false", " ", "_", -1) +} From 052c20a486ff7e439e9b5141948a4e5702c80954 Mon Sep 17 00:00:00 2001 From: Daniel Velez Date: Thu, 30 Apr 2026 14:10:10 -0500 Subject: [PATCH 10/95] test(middleware): apply Phase 1.4 review fixes Three small follow-ups from the code-quality review of 784af8b: - M-2: removed dead 'name(label, b)' helper with no-op strings.Replace; inlined as fmt.Sprintf at the only call site. Drops the 'strings' import. - M-3: removed stale 'nbf' mention from mintToken doc comment (nbf is never set or tested). - M-5: added TestRequireJWT_ValidToken_LowercaseScheme_Accepted with sub-cases for 'Bearer' / 'bearer' / 'BEARER' / 'BeArEr', guarding against a future regression of the deliberate strings.EqualFold check to strings.HasPrefix. Without this, the RFC 6750 case-insensitive scheme behavior was untested. All 10 named tests + sub-cases PASS with -race. --- api/middleware/jwt_middleware_test.go | 40 +++++++++++++++++++-------- 1 file changed, 29 insertions(+), 11 deletions(-) diff --git a/api/middleware/jwt_middleware_test.go b/api/middleware/jwt_middleware_test.go index b70ccf6..1973b92 100644 --- a/api/middleware/jwt_middleware_test.go +++ b/api/middleware/jwt_middleware_test.go @@ -5,9 +5,9 @@ import ( "crypto/rand" "crypto/rsa" "encoding/json" + "fmt" "net/http" "net/http/httptest" - "strings" "testing" "time" @@ -62,7 +62,7 @@ func serveJWKS(t *testing.T, pub *rsa.PublicKey, kid string) string { // mintToken signs a JWT with priv and returns the compact-serialized form. // Defaults: iss=testIssuer, iat=now, exp=now+5m. Caller-supplied claims -// override the defaults; pass time.Time values for exp/iat/nbf. +// override the defaults; pass time.Time values for exp/iat. func mintToken(t *testing.T, priv *rsa.PrivateKey, claims map[string]interface{}) string { t.Helper() tok := jwt.New() @@ -135,6 +135,31 @@ func TestRequireJWT_ValidToken_PassesThroughWithClaims(t *testing.T) { require.Equal(t, []string{testOrgURNAud}, claims.Audience) } +// RFC 6750 §2.1: the auth scheme is case-insensitive. The middleware uses +// strings.EqualFold; this test guards against a future regression to +// strings.HasPrefix that would silently break case-insensitive callers. +func TestRequireJWT_ValidToken_LowercaseScheme_Accepted(t *testing.T) { + priv, jwksURL := testKey(t) + v := auth.NewValidator(testIssuer, jwksURL, testAPIaud) + mw := middleware.RequireJWT(v, true) + + token := mintToken(t, priv, map[string]interface{}{ + jwt.SubjectKey: "user_abc", + jwt.AudienceKey: []string{testAPIaud}, + }) + + for _, scheme := range []string{"Bearer", "bearer", "BEARER", "BeArEr"} { + t.Run(scheme, func(t *testing.T) { + req := httptest.NewRequest(http.MethodGet, "/x", nil) + req.Header.Set("Authorization", scheme+" "+token) + rr, reached, _ := runMiddleware(mw, req) + + require.True(t, *reached, "valid token with %q scheme must reach handler", scheme) + require.Equal(t, http.StatusOK, rr.Code) + }) + } +} + func TestRequireJWT_MissingHeader_Returns401(t *testing.T) { _, jwksURL := testKey(t) v := auth.NewValidator(testIssuer, jwksURL, testAPIaud) @@ -295,7 +320,7 @@ func TestRequireJWT_GlobalAudienceAccepted(t *testing.T) { priv, jwksURL := testKey(t) for _, orgScoped := range []bool{true, false} { - t.Run(name("orgScoped", orgScoped), func(t *testing.T) { + t.Run(fmt.Sprintf("orgScoped=%t", orgScoped), func(t *testing.T) { v := auth.NewValidator(testIssuer, jwksURL, testAPIaud) mw := middleware.RequireJWT(v, orgScoped) @@ -314,11 +339,4 @@ func TestRequireJWT_GlobalAudienceAccepted(t *testing.T) { } } -// name builds a subtest name for boolean parameter sweeps so the table-style -// loop above stays readable. -func name(label string, b bool) string { - if b { - return label + "=true" - } - return strings.Replace(label+"=false", " ", "_", -1) -} + From 1af130de2c34b74ababfc106c04f2ccd605070bc Mon Sep 17 00:00:00 2001 From: Daniel Velez Date: Thu, 30 Apr 2026 14:57:06 -0500 Subject: [PATCH 11/95] feat(middleware): RequireSportMatchesJWT cross-checks X-Sport vs JWT org_id Adds a chi middleware that requires the X-Sport header on every authenticated request and validates it against the JWT's organization_id claim. Together with RequireJWT (Task 1.4) this provides defense-in-depth for sport scoping: the JWT is the authoritative claim, but the URL/header path is also enforced so a client navigating between sports cannot accidentally make a request with a stale token from a different sport. Components: - SportResolver: slug -> Logto org ID map populated at app startup from LOGTO_PICKLEBALL_ORG_ID / LOGTO_DEMO_SPORT_ORG_ID. Read-only after construction; concurrent-safe; constructor copies the input map so caller-side mutation cannot affect lookups. - RequireSportMatchesJWT: middleware that consumes the resolver and the auth.Claims placed on context by RequireJWT. Error responses (matching the existing envelope shape): 400 - X-Sport header missing 400 - X-Sport slug unknown to the resolver 500 - claims missing (programmer error; logged via slog.ErrorContext) 403 - X-Sport slug's org_id does not match JWT organization_id Wired into api/router/router.go in Phase 6 cutover, AFTER RequireJWT on each sport-scoped route group. Tests cover all four error paths plus the happy path and the SportResolver's slug lookup including the post-construction-mutation guard that proves the constructor copy. -race clean. --- api/middleware/sport_middleware.go | 84 +++++++++++++ api/middleware/sport_middleware_test.go | 150 ++++++++++++++++++++++++ 2 files changed, 234 insertions(+) create mode 100644 api/middleware/sport_middleware.go create mode 100644 api/middleware/sport_middleware_test.go diff --git a/api/middleware/sport_middleware.go b/api/middleware/sport_middleware.go new file mode 100644 index 0000000..0917adc --- /dev/null +++ b/api/middleware/sport_middleware.go @@ -0,0 +1,84 @@ +// api/middleware/sport_middleware.go +package middleware + +import ( + "log/slog" + "net/http" + + "github.com/court-command/court-command/auth" +) + +// SportResolver maps sport slugs (e.g. "pickleball") to Logto organization +// IDs (e.g. "ekup1zyrrxj4"). Populated at app startup from environment +// variables LOGTO_PICKLEBALL_ORG_ID and LOGTO_DEMO_SPORT_ORG_ID. The +// resolver is read-only after construction and safe for concurrent use. +type SportResolver struct { + slugToOrgID map[string]string +} + +// NewSportResolver builds a resolver from a slug-to-orgID map. The input +// map is copied; later mutation of the caller's map does not affect the +// resolver. +func NewSportResolver(slugToOrgID map[string]string) *SportResolver { + m := make(map[string]string, len(slugToOrgID)) + for k, v := range slugToOrgID { + m[k] = v + } + return &SportResolver{slugToOrgID: m} +} + +// OrgID returns the Logto organization ID for the given sport slug, or +// empty string if the slug is unknown. +func (s *SportResolver) OrgID(slug string) string { + return s.slugToOrgID[slug] +} + +// RequireSportMatchesJWT returns a chi middleware that requires the +// X-Sport header to be present, validates the slug exists in the resolver, +// and confirms the JWT's organization_id claim matches the sport's Logto +// org ID. Must be used AFTER RequireJWT so that auth.Claims is on the +// request context. +// +// Errors (in checking order): +// +// 400 - X-Sport header missing +// 400 - sport slug unknown to the resolver +// 500 - claims missing from context (programmer error: middleware not +// chained behind RequireJWT) +// 403 - claims.OrganizationID does not match the sport's Logto org ID +func RequireSportMatchesJWT(r *SportResolver) func(http.Handler) http.Handler { + return func(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) { + slug := req.Header.Get("X-Sport") + if slug == "" { + writeError(w, http.StatusBadRequest, "bad_request", "missing X-Sport header") + return + } + + expectedOrgID := r.OrgID(slug) + if expectedOrgID == "" { + writeError(w, http.StatusBadRequest, "bad_request", "unknown sport: "+slug) + return + } + + claims, ok := auth.ClaimsFromContext(req.Context()) + if !ok { + // Programmer error: RequireSportMatchesJWT is running + // without RequireJWT in front of it. Fail closed and log + // loudly so it's caught in development. + slog.ErrorContext(req.Context(), + "sport middleware: no claims in context (RequireJWT not chained)") + writeError(w, http.StatusInternalServerError, "internal_error", "unauthorized") + return + } + + if claims.OrganizationID != expectedOrgID { + writeError(w, http.StatusForbidden, "forbidden", + "sport does not match your current session's organization") + return + } + + next.ServeHTTP(w, req) + }) + } +} diff --git a/api/middleware/sport_middleware_test.go b/api/middleware/sport_middleware_test.go new file mode 100644 index 0000000..178a6c4 --- /dev/null +++ b/api/middleware/sport_middleware_test.go @@ -0,0 +1,150 @@ +// api/middleware/sport_middleware_test.go +package middleware_test + +import ( + "net/http" + "net/http/httptest" + "testing" + + "github.com/court-command/court-command/auth" + "github.com/court-command/court-command/middleware" + "github.com/stretchr/testify/require" +) + +const ( + pickleballOrgID = "ekup1zyrrxj4" + demoSportOrgID = "7866ex96uk6b" +) + +func newTestResolver() *middleware.SportResolver { + return middleware.NewSportResolver(map[string]string{ + "pickleball": pickleballOrgID, + "demo_sport": demoSportOrgID, + }) +} + +// runSportMiddleware invokes mw against req and returns the recorder plus +// whether the downstream handler ran. ServeHTTP is synchronous so the +// returned bool is a stable read. +func runSportMiddleware(mw func(http.Handler) http.Handler, req *http.Request) (*httptest.ResponseRecorder, bool) { + rr := httptest.NewRecorder() + reached := false + h := mw(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + reached = true + w.WriteHeader(http.StatusOK) + })) + h.ServeHTTP(rr, req) + return rr, reached +} + +func TestSportResolver_OrgID(t *testing.T) { + src := map[string]string{ + "pickleball": pickleballOrgID, + "demo_sport": demoSportOrgID, + } + r := middleware.NewSportResolver(src) + + cases := []struct { + slug string + want string + }{ + {"pickleball", pickleballOrgID}, + {"demo_sport", demoSportOrgID}, + {"", ""}, + {"chess", ""}, + } + for _, tc := range cases { + t.Run("slug="+tc.slug, func(t *testing.T) { + require.Equal(t, tc.want, r.OrgID(tc.slug)) + }) + } + + // Mutating the caller's map after construction must not affect the + // resolver -- proves NewSportResolver copied the map. + src["pickleball"] = "tampered" + delete(src, "demo_sport") + require.Equal(t, pickleballOrgID, r.OrgID("pickleball"), + "resolver must be insulated from caller-side mutation") + require.Equal(t, demoSportOrgID, r.OrgID("demo_sport"), + "resolver must be insulated from caller-side deletion") +} + +func TestRequireSportMatchesJWT_Match_Passes(t *testing.T) { + mw := middleware.RequireSportMatchesJWT(newTestResolver()) + + req := httptest.NewRequest(http.MethodGet, "/x", nil) + req.Header.Set("X-Sport", "pickleball") + req = req.WithContext(auth.WithClaims(req.Context(), auth.Claims{ + Subject: "user_abc", + OrganizationID: pickleballOrgID, + })) + + rr, reached := runSportMiddleware(mw, req) + + require.True(t, reached, "handler should be reached when sport matches") + require.Equal(t, http.StatusOK, rr.Code) +} + +func TestRequireSportMatchesJWT_HeaderMissing_Returns400(t *testing.T) { + mw := middleware.RequireSportMatchesJWT(newTestResolver()) + + req := httptest.NewRequest(http.MethodGet, "/x", nil) + req = req.WithContext(auth.WithClaims(req.Context(), auth.Claims{ + OrganizationID: pickleballOrgID, + })) + + rr, reached := runSportMiddleware(mw, req) + + require.False(t, reached, "handler must not be reached without X-Sport header") + require.Equal(t, http.StatusBadRequest, rr.Code) + require.Contains(t, rr.Body.String(), "missing X-Sport") +} + +func TestRequireSportMatchesJWT_UnknownSport_Returns400(t *testing.T) { + mw := middleware.RequireSportMatchesJWT(newTestResolver()) + + req := httptest.NewRequest(http.MethodGet, "/x", nil) + req.Header.Set("X-Sport", "chess") + req = req.WithContext(auth.WithClaims(req.Context(), auth.Claims{ + OrganizationID: pickleballOrgID, + })) + + rr, reached := runSportMiddleware(mw, req) + + require.False(t, reached) + require.Equal(t, http.StatusBadRequest, rr.Code) + require.Contains(t, rr.Body.String(), "unknown sport") + require.Contains(t, rr.Body.String(), "chess") +} + +func TestRequireSportMatchesJWT_MismatchedOrgID_Returns403(t *testing.T) { + mw := middleware.RequireSportMatchesJWT(newTestResolver()) + + // Header says pickleball, JWT carries demo_sport's org ID. + req := httptest.NewRequest(http.MethodGet, "/x", nil) + req.Header.Set("X-Sport", "pickleball") + req = req.WithContext(auth.WithClaims(req.Context(), auth.Claims{ + OrganizationID: demoSportOrgID, + })) + + rr, reached := runSportMiddleware(mw, req) + + require.False(t, reached) + require.Equal(t, http.StatusForbidden, rr.Code) + require.Contains(t, rr.Body.String(), "sport does not match") +} + +func TestRequireSportMatchesJWT_NoClaimsInContext_Returns500(t *testing.T) { + mw := middleware.RequireSportMatchesJWT(newTestResolver()) + + // Programmer-error scenario: RequireJWT was not chained, so claims + // are absent from the request context. Middleware must fail closed. + req := httptest.NewRequest(http.MethodGet, "/x", nil) + req.Header.Set("X-Sport", "pickleball") + + rr, reached := runSportMiddleware(mw, req) + + require.False(t, reached, "handler must not be reached without claims") + require.Equal(t, http.StatusInternalServerError, rr.Code) + require.Contains(t, rr.Body.String(), "internal_error") +} From babf1666b171ad2c03e3013792cc682711597911 Mon Sep 17 00:00:00 2001 From: Daniel Velez Date: Thu, 30 Apr 2026 15:04:14 -0500 Subject: [PATCH 12/95] fix(auth,middleware): apply Phase 1 final review findings Three small follow-ups from the Phase 1 cross-package review (verdict APPROVED WITH SUGGESTIONS): I-1 (Important): A JWKS-unreachable cold start used to surface as 401 'invalid token', causing operators to chase JWT bugs while the real failure was networking. auth.ErrJWKSUnavailable is now a typed sentinel returned (via errors.Is) when getKeySet has no cache to fall back on. RequireJWT branches on it and returns 503 'service_unavailable' with slog.ErrorContext, so the failure is loud at the right level. New test TestRequireJWT_JWKSUnavailable_Returns503 verifies this by pointing the validator at a closed httptest.Server (refused-connection black hole). I-2 (Important): RequireSportMatchesJWT's missing-claims branch was returning {error:{code:'internal_error', message:'unauthorized'}} -- the message contradicted the 500 status and confused readers. Now returns message 'server configuration error' which matches the preceding slog comment ('programmer error: middleware not chained'). Test asserts the new message text. I-3 (Important): LOGTO_BOOTSTRAP_USER_ID was read by livesmoke_test.go but undocumented. Added an Optional entry in .env.example pointing at Step 6 of docs/LOGTO_SETUP.md so future operators know where the value comes from. Other findings from the review (cross-package error-code casing inconsistency M-1, helper pattern drift M-2, etc.) are cosmetic and deferred to Phase 6 cutover or future polish passes. Tests: go test ./auth/... ./logto/... ./middleware/... -race -> all PASS. --- .env.example | 7 +++++++ api/auth/jwt.go | 13 +++++++++++-- api/middleware/jwt_middleware.go | 14 +++++++++++--- api/middleware/jwt_middleware_test.go | 24 ++++++++++++++++++++++++ api/middleware/sport_middleware.go | 3 ++- api/middleware/sport_middleware_test.go | 2 ++ 6 files changed, 57 insertions(+), 6 deletions(-) diff --git a/.env.example b/.env.example index 3afa20c..6118c05 100644 --- a/.env.example +++ b/.env.example @@ -49,6 +49,13 @@ LOGTO_WEBHOOK_SIGNING_KEY= LOGTO_PICKLEBALL_ORG_ID= LOGTO_DEMO_SPORT_ORG_ID= +# Optional: Logto user ID of the bootstrap platform admin (Step 6 of +# docs/LOGTO_SETUP.md). Used ONLY by the live smoke test in +# api/logto/livesmoke_test.go when LOGTO_LIVE_SMOKE=1; the regular +# `go test ./...` run does not require it. Lookup format: open the +# user in the Logto admin UI and copy the ID from the URL. +# LOGTO_BOOTSTRAP_USER_ID= + # --- Frontend Logto config (baked into Vite build) --- # Logto SDK endpoint (same as LOGTO_ENDPOINT, but exposed to the browser). VITE_LOGTO_ENDPOINT=https://logto.courtcommand.app diff --git a/api/auth/jwt.go b/api/auth/jwt.go index 040b275..bde891b 100644 --- a/api/auth/jwt.go +++ b/api/auth/jwt.go @@ -2,6 +2,7 @@ package auth import ( "context" + "errors" "fmt" "strings" "sync" @@ -11,6 +12,12 @@ import ( "github.com/lestrrat-go/jwx/v3/jwt" ) +// ErrJWKSUnavailable is returned (via errors.Is) when Validate cannot reach +// the issuer's JWKS endpoint AND has no cached key set to fall back to. +// Middleware can branch on this to surface a 503 Service Unavailable +// instead of a 401, since the failure is infrastructure -- not a bad token. +var ErrJWKSUnavailable = errors.New("jwks unavailable") + // defaultKeyTTL is how long a fetched JWKS is reused before we refetch from // the issuer. Logto rotates signing keys infrequently; an hour balances // freshness against load on the auth server. Per-Validator override via @@ -130,11 +137,13 @@ func (v *Validator) getKeySet(ctx context.Context) (jwk.Set, error) { if err != nil { // On refresh failure, fall back to the stale cache rather than // rejecting every in-flight request — better availability while - // the issuer is briefly unreachable. + // the issuer is briefly unreachable. With no cache, surface + // ErrJWKSUnavailable so middleware can return 503 instead of + // the misleading 401 it would otherwise produce. if v.cachedSet != nil { return v.cachedSet, nil } - return nil, fmt.Errorf("fetch jwks: %w", err) + return nil, fmt.Errorf("fetch jwks: %w: %w", ErrJWKSUnavailable, err) } v.cachedSet = set diff --git a/api/middleware/jwt_middleware.go b/api/middleware/jwt_middleware.go index 8f934d1..76b66e6 100644 --- a/api/middleware/jwt_middleware.go +++ b/api/middleware/jwt_middleware.go @@ -2,6 +2,7 @@ package middleware import ( + "errors" "log/slog" "net/http" "strings" @@ -29,9 +30,16 @@ func RequireJWT(v *auth.Validator, orgScoped bool) func(http.Handler) http.Handl claims, err := v.Validate(r.Context(), token, orgScoped) if err != nil { - // Invalid tokens are routine (probes, expired clients, replay - // attempts). Log at debug so a noisy public endpoint can't - // flood the log; aggregate metrics belong elsewhere. + // Distinguish "Logto JWKS is unreachable" (infra failure -> + // 503, log at error) from "client sent a bad token" (routine + // -> 401, log at debug). Conflating these makes operators + // chase JWT bugs while the real cause is networking. + if errors.Is(err, auth.ErrJWKSUnavailable) { + slog.ErrorContext(r.Context(), "jwks unavailable, cannot validate tokens", "err", err) + writeError(w, http.StatusServiceUnavailable, "service_unavailable", + "authentication temporarily unavailable") + return + } slog.DebugContext(r.Context(), "jwt validation failed", "err", err) writeError(w, http.StatusUnauthorized, "unauthorized", "invalid token") return diff --git a/api/middleware/jwt_middleware_test.go b/api/middleware/jwt_middleware_test.go index 1973b92..6924a40 100644 --- a/api/middleware/jwt_middleware_test.go +++ b/api/middleware/jwt_middleware_test.go @@ -339,4 +339,28 @@ func TestRequireJWT_GlobalAudienceAccepted(t *testing.T) { } } +// TestRequireJWT_JWKSUnavailable_Returns503 verifies that an infrastructure +// failure (Logto JWKS unreachable on cold start, no cached keys) surfaces +// as 503 service_unavailable, NOT 401 invalid_token. The old behavior +// confused operators chasing JWT bugs while the real cause was networking. +func TestRequireJWT_JWKSUnavailable_Returns503(t *testing.T) { + // Validator pointed at a TCP black hole. The httptest server is + // created and immediately closed so its port refuses connections. + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {})) + deadJWKSURL := srv.URL + srv.Close() + + v := auth.NewValidator(testIssuer, deadJWKSURL, testAPIaud) + mw := middleware.RequireJWT(v, true) + + req := httptest.NewRequest(http.MethodGet, "/x", nil) + req.Header.Set("Authorization", "Bearer some.token.here") + + rr, reached, _ := runMiddleware(mw, req) + + require.False(t, *reached, "handler must not be reached on JWKS failure") + require.Equal(t, http.StatusServiceUnavailable, rr.Code, + "JWKS-unreachable must surface as 503, not 401") + require.Contains(t, rr.Body.String(), "service_unavailable") +} diff --git a/api/middleware/sport_middleware.go b/api/middleware/sport_middleware.go index 0917adc..85d905e 100644 --- a/api/middleware/sport_middleware.go +++ b/api/middleware/sport_middleware.go @@ -68,7 +68,8 @@ func RequireSportMatchesJWT(r *SportResolver) func(http.Handler) http.Handler { // loudly so it's caught in development. slog.ErrorContext(req.Context(), "sport middleware: no claims in context (RequireJWT not chained)") - writeError(w, http.StatusInternalServerError, "internal_error", "unauthorized") + writeError(w, http.StatusInternalServerError, "internal_error", + "server configuration error") return } diff --git a/api/middleware/sport_middleware_test.go b/api/middleware/sport_middleware_test.go index 178a6c4..d336e90 100644 --- a/api/middleware/sport_middleware_test.go +++ b/api/middleware/sport_middleware_test.go @@ -147,4 +147,6 @@ func TestRequireSportMatchesJWT_NoClaimsInContext_Returns500(t *testing.T) { require.False(t, reached, "handler must not be reached without claims") require.Equal(t, http.StatusInternalServerError, rr.Code) require.Contains(t, rr.Body.String(), "internal_error") + require.Contains(t, rr.Body.String(), "server configuration error", + "message must reflect the actual condition (programmer error), not contradict the 500 status") } From 2267dba8cfd4ffef89917fb949492bec0f6fac2a Mon Sep 17 00:00:00 2001 From: Daniel Velez Date: Thu, 30 Apr 2026 18:48:20 -0500 Subject: [PATCH 13/95] feat(health): expose build commit + timestamp via /api/v1/health Adds a 'build' field to the /api/v1/health response so operators (and this Logto-integration verification specifically) can confirm which commit Coolify has deployed without guessing from external symptoms. Mechanism: - handler.buildCommit + handler.buildBuiltAt are package-level vars defaulting to 'dev' / 'unknown' for local 'go run' workflows. - api/Dockerfile reads the COMMIT build arg (with COOLIFY_GIT_COMMIT_SHA as a fallback) and injects both via -ldflags -X. The build context is ./api so .git is unavailable inside the container; ARG/ENV is the only reliable injection mechanism. - docker-compose.yaml passes SOURCE_COMMIT (or COOLIFY_GIT_COMMIT_SHA) through to the build arg. Coolify auto-populates these. After this lands, /api/v1/health returns: { "status": "ok", "services": {"database": "ok", "redis": "ok"}, "build": { "commit": "babf166", "built_at": "2026-04-30T23:50:00Z" } } Local development continues to show {"commit": "dev", "built_at": "unknown"} because no -ldflags are applied in plain 'go run'. Test output unaffected. --- api/Dockerfile | 19 +++++++++++++++++-- api/handler/health.go | 16 ++++++++++++++++ docker-compose.yaml | 6 ++++++ 3 files changed, 39 insertions(+), 2 deletions(-) diff --git a/api/Dockerfile b/api/Dockerfile index 47242d3..ac472ca 100644 --- a/api/Dockerfile +++ b/api/Dockerfile @@ -18,8 +18,23 @@ RUN go mod download # Copy source code COPY . . -# Build the binary -RUN CGO_ENABLED=0 GOOS=linux go build -ldflags="-s -w" -o /court-command . +# Build the binary, injecting the git commit and build timestamp into the +# binary so /api/v1/health can report which build is live. +# +# Commit comes from the COMMIT build arg (passed by docker-compose) which +# in Coolify is wired to the SOURCE_COMMIT env, or from +# COOLIFY_GIT_COMMIT_SHA at build time, or as a last resort "unknown". +# The build context is ./api (not the repo root) so git is unavailable +# here -- ARG/ENV is the only mechanism that works. +ARG COMMIT +ARG COOLIFY_GIT_COMMIT_SHA +RUN RESOLVED_COMMIT="${COMMIT:-${COOLIFY_GIT_COMMIT_SHA:-unknown}}" \ + && BUILT_AT="$(date -u +%Y-%m-%dT%H:%M:%SZ)" \ + && CGO_ENABLED=0 GOOS=linux go build \ + -ldflags="-s -w \ + -X github.com/court-command/court-command/handler.buildCommit=${RESOLVED_COMMIT} \ + -X github.com/court-command/court-command/handler.buildBuiltAt=${BUILT_AT}" \ + -o /court-command . # --- Stage 2: Run --- FROM alpine:3.20 diff --git a/api/handler/health.go b/api/handler/health.go index 92281b2..cab1f56 100644 --- a/api/handler/health.go +++ b/api/handler/health.go @@ -10,6 +10,18 @@ import ( "github.com/redis/go-redis/v9" ) +// buildCommit and buildBuiltAt are injected at link time via -ldflags so +// /api/v1/health can report which build is live. Defaults are used during +// `go run` and tests where the linker flags aren't applied. +// +// See api/Dockerfile for the production build invocation. +// +//nolint:gochecknoglobals // build-time constants +var ( + buildCommit = "dev" + buildBuiltAt = "unknown" +) + // HealthHandler checks the health of backend services. type HealthHandler struct { db *pgxpool.Pool @@ -51,5 +63,9 @@ func (h *HealthHandler) Check(w http.ResponseWriter, r *http.Request) { "database": dbStatus, "redis": redisStatus, }, + "build": map[string]string{ + "commit": buildCommit, + "built_at": buildBuiltAt, + }, }) } diff --git a/docker-compose.yaml b/docker-compose.yaml index f996999..e7d00a6 100644 --- a/docker-compose.yaml +++ b/docker-compose.yaml @@ -40,6 +40,12 @@ services: build: context: ./api dockerfile: Dockerfile + args: + # Coolify exposes SOURCE_COMMIT (and COOLIFY_GIT_COMMIT_SHA) at build + # time. Either populates this arg; the Dockerfile injects whichever + # is present into the binary so /api/v1/health reports the live + # commit. Falls back to "unknown" when neither is set (local dev). + COMMIT: ${SOURCE_COMMIT:-${COOLIFY_GIT_COMMIT_SHA:-}} expose: - "8080" environment: From 6b7a7b7dbe5b02e45bd0504f6dc2286ea6399cb4 Mon Sep 17 00:00:00 2001 From: Daniel Velez Date: Thu, 30 Apr 2026 19:03:09 -0500 Subject: [PATCH 14/95] fix(deploy): rely on Coolify SOURCE_COMMIT for build-info, drop git COPY The previous attempt added a build-context expansion (./api -> repo root) and a 'COPY .git' so the Dockerfile could derive the commit SHA itself. That breaks for git worktrees where .git is a file pointer, complicates .dockerignore, and increases image-build context size for marginal benefit. Simpler approach: rely on Coolify's native SOURCE_COMMIT (or COOLIFY_GIT_COMMIT_SHA) build-time env vars. docker-compose.yaml's build.args wires them to the COMMIT ARG, which the Dockerfile injects via -ldflags -X. When no arg is provided (some local docker builds, release tarballs), the value falls back to 'unknown'; the BUILT_AT timestamp still distinguishes builds. Build context returned to ./api. No .dockerignore changes needed at the repo root. --- api/Dockerfile | 23 ++++++++++++----------- docker-compose.yaml | 8 ++++---- 2 files changed, 16 insertions(+), 15 deletions(-) diff --git a/api/Dockerfile b/api/Dockerfile index ac472ca..031193e 100644 --- a/api/Dockerfile +++ b/api/Dockerfile @@ -1,5 +1,5 @@ # api/Dockerfile -# Multi-stage build for the Court Command API +# Multi-stage build for the Court Command API. # --- Stage 1: Build --- FROM golang:1.24-alpine AS builder @@ -15,21 +15,21 @@ WORKDIR /app COPY go.mod go.sum ./ RUN go mod download -# Copy source code +# Copy source code (build context is ./api) COPY . . -# Build the binary, injecting the git commit and build timestamp into the -# binary so /api/v1/health can report which build is live. +# Build the binary, injecting the git commit and build timestamp into +# the binary so /api/v1/health can report which build is live. # -# Commit comes from the COMMIT build arg (passed by docker-compose) which -# in Coolify is wired to the SOURCE_COMMIT env, or from -# COOLIFY_GIT_COMMIT_SHA at build time, or as a last resort "unknown". -# The build context is ./api (not the repo root) so git is unavailable -# here -- ARG/ENV is the only mechanism that works. +# Coolify v4 exposes SOURCE_COMMIT and COOLIFY_GIT_COMMIT_SHA at build +# time -- one of them populates the COMMIT arg via docker-compose.yaml. +# Locally and in environments that don't pass the arg, the value falls +# back to "unknown" and the deployed timestamp still distinguishes +# builds. ARG COMMIT -ARG COOLIFY_GIT_COMMIT_SHA -RUN RESOLVED_COMMIT="${COMMIT:-${COOLIFY_GIT_COMMIT_SHA:-unknown}}" \ +RUN RESOLVED_COMMIT="${COMMIT:-unknown}" \ && BUILT_AT="$(date -u +%Y-%m-%dT%H:%M:%SZ)" \ + && echo "Building court-command commit=${RESOLVED_COMMIT} built_at=${BUILT_AT}" \ && CGO_ENABLED=0 GOOS=linux go build \ -ldflags="-s -w \ -X github.com/court-command/court-command/handler.buildCommit=${RESOLVED_COMMIT} \ @@ -52,6 +52,7 @@ COPY --from=builder /court-command . # Copy migrations (embedded in binary via go:embed, but also available for manual goose runs) COPY --from=builder /app/db/migrations ./db/migrations + # Create uploads directory RUN mkdir -p uploads && chown appuser:appgroup uploads diff --git a/docker-compose.yaml b/docker-compose.yaml index e7d00a6..0f2ca7f 100644 --- a/docker-compose.yaml +++ b/docker-compose.yaml @@ -41,10 +41,10 @@ services: context: ./api dockerfile: Dockerfile args: - # Coolify exposes SOURCE_COMMIT (and COOLIFY_GIT_COMMIT_SHA) at build - # time. Either populates this arg; the Dockerfile injects whichever - # is present into the binary so /api/v1/health reports the live - # commit. Falls back to "unknown" when neither is set (local dev). + # Coolify v4 exposes SOURCE_COMMIT and COOLIFY_GIT_COMMIT_SHA at + # build time. The Dockerfile injects this into the binary so + # /api/v1/health can report the deployed commit. Empty default + # is fine -- the Dockerfile falls back to "unknown". COMMIT: ${SOURCE_COMMIT:-${COOLIFY_GIT_COMMIT_SHA:-}} expose: - "8080" From 418482a092e4f60a4e0421ca2281343e0eb5cc3c Mon Sep 17 00:00:00 2001 From: Daniel Velez Date: Thu, 30 Apr 2026 19:08:58 -0500 Subject: [PATCH 15/95] docs(health): document Coolify SOURCE_COMMIT flow into build-info Comment-only follow-up to 6b7a7b7. The previous deploy-fix commit only touched api/Dockerfile + docker-compose.yaml; the latter does not match the api service's 'api/**' watch path filter in Coolify, so the deploy did not auto-trigger. This change touches a file unambiguously under api/handler/ to verify the watch path glob interprets api/** as expected and to land the build-info documentation properly. No code change. The deployed /api/v1/health response should after this build show: build.commit = (or 'unknown' if Coolify doesn't pass that arg, in which case we'll widen the watch path or use COMMIT directly) build.built_at = ISO timestamp of this build --- api/handler/health.go | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/api/handler/health.go b/api/handler/health.go index cab1f56..03cc312 100644 --- a/api/handler/health.go +++ b/api/handler/health.go @@ -14,7 +14,9 @@ import ( // /api/v1/health can report which build is live. Defaults are used during // `go run` and tests where the linker flags aren't applied. // -// See api/Dockerfile for the production build invocation. +// See api/Dockerfile for the production build invocation. The COMMIT +// build arg flows in from Coolify's SOURCE_COMMIT env (or +// COOLIFY_GIT_COMMIT_SHA) via docker-compose.yaml. // //nolint:gochecknoglobals // build-time constants var ( From 34e24a2d386ae546755fa60643fe5904bf897840 Mon Sep 17 00:00:00 2001 From: Daniel Velez Date: Thu, 30 Apr 2026 19:17:57 -0500 Subject: [PATCH 16/95] fix(deploy): expand build context to repo root for commit-SHA injection Coolify (at least the version this project deploys to) does not expose SOURCE_COMMIT or COOLIFY_GIT_COMMIT_SHA at docker build time, so the COMMIT arg arrived empty and /api/v1/health reported commit='unknown' even on fresh deploys. Fix: change docker-compose.yaml's api build context from ./api to the repo root, and have api/Dockerfile run 'git rev-parse' against the .git that Coolify clones into the build artifact directory. The COMMIT arg still takes precedence so this also works in environments that do pass it explicitly. A root-level .dockerignore excludes web/, docs/, and other heavy non-api paths so the wider build context doesn't bloat the image, while explicitly preserving .git (which is the whole point). Local docker build from a git worktree won't resolve the SHA because .git is a file pointer there, not a directory; that's acceptable since Coolify is the deploy target and Coolify clones fresh on each build. Local 'go run' / 'go test' workflows are unaffected; they fall back to commit='dev'. This commit also touches docker-compose.yaml at the repo root, which historically does not match the api/** Coolify watch path. To make sure this commit deploys, please trigger a manual redeploy in Coolify once or broaden the watch path to also match api/Dockerfile + docker-compose.yaml + root .dockerignore. --- .dockerignore | 53 +++++++++++++++++++++++++++++++++++++++++++++ api/Dockerfile | 27 +++++++++++++++-------- docker-compose.yaml | 17 ++++++++++----- 3 files changed, 82 insertions(+), 15 deletions(-) create mode 100644 .dockerignore diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..b4a437f --- /dev/null +++ b/.dockerignore @@ -0,0 +1,53 @@ +# Court Command repo-root .dockerignore +# Active when docker build's context is the repo root (api/Dockerfile +# uses this layout so the build can read .git for commit-SHA injection +# in /api/v1/health). +# +# IMPORTANT: do NOT add .git here -- that would defeat the commit +# fingerprint that /api/v1/health reports. + +# Local env / secrets +.env +.env.* +!.env.example + +# IDE / OS noise +.DS_Store +.idea/ +.vscode/ + +# Heavy frontend artifacts (api build doesn't need them) +web/node_modules/ +web/dist/ +web/.next/ +web/.turbo/ +web/*.tsbuildinfo + +# Heavy backend artifacts (don't bake into image; build copies api/ +# explicitly anyway, so these never get used) +api/uploads/ +api/tmp/ +api/vendor/ +api/court-command +api/*.test +api/*.out +api/__debug_bin* + +# Ghost theme dev artifacts +ghost-theme/cc-ghost-theme.zip +ghost-theme/node_modules/ + +# Backups +backups/ + +# Worktree / superpowers internals (worktree .git is a file pointer, +# not a real directory; fine to include but useless to the build) +.superpowers/ +.worktrees/ + +# Documentation: not needed in the api image; keep in source for +# reference but don't bake into the deployed binary's context +docs/ +README.md +LICENSE +CHANGELOG.md diff --git a/api/Dockerfile b/api/Dockerfile index 031193e..f3168a0 100644 --- a/api/Dockerfile +++ b/api/Dockerfile @@ -1,5 +1,9 @@ # api/Dockerfile # Multi-stage build for the Court Command API. +# +# Build context is the REPO ROOT (docker-compose.yaml sets context: ".") +# so the build can read .git for commit-SHA injection. Coolify clones the +# repo with --depth=1 so .git is a real directory at the context root. # --- Stage 1: Build --- FROM golang:1.24-alpine AS builder @@ -12,22 +16,27 @@ ENV GOTOOLCHAIN=auto WORKDIR /app # Copy dependency files first (layer caching) -COPY go.mod go.sum ./ +COPY api/go.mod api/go.sum ./ RUN go mod download -# Copy source code (build context is ./api) -COPY . . +# Copy api source +COPY api/ ./ + +# Copy .git into a sibling directory (not /app/.git which would confuse +# the go build with embedded version info). This is best-effort: if .git +# is absent or unusual (e.g. a worktree pointer file in local dev), +# git rev-parse fails and we fall back to "unknown". +COPY .git/ /tmp/repo/.git/ # Build the binary, injecting the git commit and build timestamp into # the binary so /api/v1/health can report which build is live. # -# Coolify v4 exposes SOURCE_COMMIT and COOLIFY_GIT_COMMIT_SHA at build -# time -- one of them populates the COMMIT arg via docker-compose.yaml. -# Locally and in environments that don't pass the arg, the value falls -# back to "unknown" and the deployed timestamp still distinguishes -# builds. +# Resolution order: +# 1. COMMIT build arg (Coolify SOURCE_COMMIT or any explicit pass) +# 2. git rev-parse against /tmp/repo (Coolify-cloned .git) +# 3. "unknown" ARG COMMIT -RUN RESOLVED_COMMIT="${COMMIT:-unknown}" \ +RUN RESOLVED_COMMIT="${COMMIT:-$(git -C /tmp/repo rev-parse --short HEAD 2>/dev/null || echo unknown)}" \ && BUILT_AT="$(date -u +%Y-%m-%dT%H:%M:%SZ)" \ && echo "Building court-command commit=${RESOLVED_COMMIT} built_at=${BUILT_AT}" \ && CGO_ENABLED=0 GOOS=linux go build \ diff --git a/docker-compose.yaml b/docker-compose.yaml index 0f2ca7f..9206bf1 100644 --- a/docker-compose.yaml +++ b/docker-compose.yaml @@ -38,13 +38,18 @@ services: api: build: - context: ./api - dockerfile: Dockerfile + # Build context is the repo root so the Dockerfile can read .git + # for commit-SHA injection (see api/Dockerfile for details). + # Coolify's git clone leaves .git as a real directory at the + # context root. + context: . + dockerfile: api/Dockerfile args: - # Coolify v4 exposes SOURCE_COMMIT and COOLIFY_GIT_COMMIT_SHA at - # build time. The Dockerfile injects this into the binary so - # /api/v1/health can report the deployed commit. Empty default - # is fine -- the Dockerfile falls back to "unknown". + # If Coolify exposes SOURCE_COMMIT or COOLIFY_GIT_COMMIT_SHA at + # build time, the COMMIT arg takes that value and the Dockerfile + # uses it directly. If neither is exported (varies by Coolify + # version), the Dockerfile falls back to running git rev-parse + # against the cloned .git directory in the build context. COMMIT: ${SOURCE_COMMIT:-${COOLIFY_GIT_COMMIT_SHA:-}} expose: - "8080" From 7275b1055ee3ced8aa9422a55cc29da9c3712166 Mon Sep 17 00:00:00 2001 From: Daniel Velez Date: Fri, 1 May 2026 15:12:01 -0500 Subject: [PATCH 17/95] docs(health): clarify how Dockerfile resolves the build commit Comment-only change to api/handler/ which Coolify's api/** watch path matches, so this deploys the previous commit (34e24a2) which only touched api/Dockerfile + root files and got stuck because api/** in Coolify glob behavior matches subdirectories only, not files directly in api/. The doc on buildCommit / buildBuiltAt now reflects the two-stage resolution Coolify-or-build-arg-or-git-rev-parse. --- api/handler/health.go | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/api/handler/health.go b/api/handler/health.go index 03cc312..46169ef 100644 --- a/api/handler/health.go +++ b/api/handler/health.go @@ -14,9 +14,11 @@ import ( // /api/v1/health can report which build is live. Defaults are used during // `go run` and tests where the linker flags aren't applied. // -// See api/Dockerfile for the production build invocation. The COMMIT -// build arg flows in from Coolify's SOURCE_COMMIT env (or -// COOLIFY_GIT_COMMIT_SHA) via docker-compose.yaml. +// See api/Dockerfile for the production build invocation. The Dockerfile +// resolves the commit by either: (a) reading the COMMIT build arg from +// docker-compose.yaml -- which itself reads SOURCE_COMMIT or +// COOLIFY_GIT_COMMIT_SHA from the build env -- or (b) running git +// rev-parse against the .git directory cloned into the build context. // //nolint:gochecknoglobals // build-time constants var ( From 970324e82d6d595945a243eefabbf1924f61c7bb Mon Sep 17 00:00:00 2001 From: Daniel Velez Date: Fri, 1 May 2026 15:18:28 -0500 Subject: [PATCH 18/95] revert(deploy): drop repo-root build context, accept commit='unknown' Reverts the Dockerfile + docker-compose changes from 34e24a2. That attempt expanded the build context to the repo root so the Dockerfile could read .git for commit-SHA injection, but the resulting build appears to be failing on Coolify (no new build observed within ~5 minutes of pushing 7275b10 which should have triggered a rebuild via the api/handler/ watch path). Likely cause: the COPY .git/ instruction is failing because Coolify's build artifact directory does not present .git as a copyable directory at the build context root. Reverting to the simpler ./api build context. The build_at timestamp in /api/v1/health remains a reliable deploy fingerprint -- pair it with 'git log --oneline -1' to identify the deployed commit by time. If/when Coolify exposes SOURCE_COMMIT at build time (or another mechanism becomes available), the COMMIT build arg will pick it up automatically without further changes. Local 'go run' / 'go test' continue to show commit='dev'. --- .dockerignore | 53 --------------------------------------------- api/Dockerfile | 34 ++++++++++++----------------- docker-compose.yaml | 18 ++++++--------- 3 files changed, 21 insertions(+), 84 deletions(-) delete mode 100644 .dockerignore diff --git a/.dockerignore b/.dockerignore deleted file mode 100644 index b4a437f..0000000 --- a/.dockerignore +++ /dev/null @@ -1,53 +0,0 @@ -# Court Command repo-root .dockerignore -# Active when docker build's context is the repo root (api/Dockerfile -# uses this layout so the build can read .git for commit-SHA injection -# in /api/v1/health). -# -# IMPORTANT: do NOT add .git here -- that would defeat the commit -# fingerprint that /api/v1/health reports. - -# Local env / secrets -.env -.env.* -!.env.example - -# IDE / OS noise -.DS_Store -.idea/ -.vscode/ - -# Heavy frontend artifacts (api build doesn't need them) -web/node_modules/ -web/dist/ -web/.next/ -web/.turbo/ -web/*.tsbuildinfo - -# Heavy backend artifacts (don't bake into image; build copies api/ -# explicitly anyway, so these never get used) -api/uploads/ -api/tmp/ -api/vendor/ -api/court-command -api/*.test -api/*.out -api/__debug_bin* - -# Ghost theme dev artifacts -ghost-theme/cc-ghost-theme.zip -ghost-theme/node_modules/ - -# Backups -backups/ - -# Worktree / superpowers internals (worktree .git is a file pointer, -# not a real directory; fine to include but useless to the build) -.superpowers/ -.worktrees/ - -# Documentation: not needed in the api image; keep in source for -# reference but don't bake into the deployed binary's context -docs/ -README.md -LICENSE -CHANGELOG.md diff --git a/api/Dockerfile b/api/Dockerfile index f3168a0..eb84852 100644 --- a/api/Dockerfile +++ b/api/Dockerfile @@ -1,9 +1,5 @@ # api/Dockerfile # Multi-stage build for the Court Command API. -# -# Build context is the REPO ROOT (docker-compose.yaml sets context: ".") -# so the build can read .git for commit-SHA injection. Coolify clones the -# repo with --depth=1 so .git is a real directory at the context root. # --- Stage 1: Build --- FROM golang:1.24-alpine AS builder @@ -16,27 +12,25 @@ ENV GOTOOLCHAIN=auto WORKDIR /app # Copy dependency files first (layer caching) -COPY api/go.mod api/go.sum ./ +COPY go.mod go.sum ./ RUN go mod download -# Copy api source -COPY api/ ./ - -# Copy .git into a sibling directory (not /app/.git which would confuse -# the go build with embedded version info). This is best-effort: if .git -# is absent or unusual (e.g. a worktree pointer file in local dev), -# git rev-parse fails and we fall back to "unknown". -COPY .git/ /tmp/repo/.git/ +# Copy source code (build context is ./api) +COPY . . -# Build the binary, injecting the git commit and build timestamp into -# the binary so /api/v1/health can report which build is live. +# Build the binary, injecting the build timestamp into the binary so +# /api/v1/health can report when the deployed image was built. The +# commit SHA falls back to "unknown" because Coolify (this version) +# does not expose SOURCE_COMMIT or COOLIFY_GIT_COMMIT_SHA at docker +# build time. The built_at timestamp alone is sufficient to verify a +# fresh deploy landed; pairing it with the local 'git log' tells you +# the exact commit by time. # -# Resolution order: -# 1. COMMIT build arg (Coolify SOURCE_COMMIT or any explicit pass) -# 2. git rev-parse against /tmp/repo (Coolify-cloned .git) -# 3. "unknown" +# If a future Coolify version surfaces the SHA at build time, set +# COMMIT in docker-compose.yaml's build.args and the SHA will appear +# in the health response automatically. ARG COMMIT -RUN RESOLVED_COMMIT="${COMMIT:-$(git -C /tmp/repo rev-parse --short HEAD 2>/dev/null || echo unknown)}" \ +RUN RESOLVED_COMMIT="${COMMIT:-unknown}" \ && BUILT_AT="$(date -u +%Y-%m-%dT%H:%M:%SZ)" \ && echo "Building court-command commit=${RESOLVED_COMMIT} built_at=${BUILT_AT}" \ && CGO_ENABLED=0 GOOS=linux go build \ diff --git a/docker-compose.yaml b/docker-compose.yaml index 9206bf1..01bc623 100644 --- a/docker-compose.yaml +++ b/docker-compose.yaml @@ -38,18 +38,14 @@ services: api: build: - # Build context is the repo root so the Dockerfile can read .git - # for commit-SHA injection (see api/Dockerfile for details). - # Coolify's git clone leaves .git as a real directory at the - # context root. - context: . - dockerfile: api/Dockerfile + context: ./api + dockerfile: Dockerfile args: - # If Coolify exposes SOURCE_COMMIT or COOLIFY_GIT_COMMIT_SHA at - # build time, the COMMIT arg takes that value and the Dockerfile - # uses it directly. If neither is exported (varies by Coolify - # version), the Dockerfile falls back to running git rev-parse - # against the cloned .git directory in the build context. + # Coolify (this version) does not expose SOURCE_COMMIT at build + # time; this arg arrives empty and the Dockerfile falls back to + # commit='unknown'. The build_at timestamp in /api/v1/health + # remains a reliable deploy fingerprint. If a future Coolify + # version surfaces the SHA, this arg picks it up automatically. COMMIT: ${SOURCE_COMMIT:-${COOLIFY_GIT_COMMIT_SHA:-}} expose: - "8080" From 72525e5c9f6e70e86122a17d70214dfa1be73880 Mon Sep 17 00:00:00 2001 From: Daniel Velez Date: Fri, 1 May 2026 15:18:50 -0500 Subject: [PATCH 19/95] docs(health): align comment with revert in 970324e Comment-only follow-up that lives inside api/handler/ so Coolify's api/** watch path picks it up and triggers a rebuild including the preceding revert (970324e). Without this trigger that revert sits un-deployed because api/Dockerfile + docker-compose.yaml + .dockerignore do not match the watch glob. --- api/handler/health.go | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/api/handler/health.go b/api/handler/health.go index 46169ef..876ab15 100644 --- a/api/handler/health.go +++ b/api/handler/health.go @@ -14,11 +14,12 @@ import ( // /api/v1/health can report which build is live. Defaults are used during // `go run` and tests where the linker flags aren't applied. // -// See api/Dockerfile for the production build invocation. The Dockerfile -// resolves the commit by either: (a) reading the COMMIT build arg from -// docker-compose.yaml -- which itself reads SOURCE_COMMIT or -// COOLIFY_GIT_COMMIT_SHA from the build env -- or (b) running git -// rev-parse against the .git directory cloned into the build context. +// See api/Dockerfile for the production build invocation. The COMMIT +// build arg flows in from docker-compose.yaml's SOURCE_COMMIT (or +// COOLIFY_GIT_COMMIT_SHA) lookup. When Coolify does not expose the SHA +// at build time -- as is the case at the moment -- buildCommit stays +// "unknown" and operators rely on buildBuiltAt to confirm a fresh +// deploy. // //nolint:gochecknoglobals // build-time constants var ( From a797b0b9b87922b63af6a17e95b35e1ff0e01870 Mon Sep 17 00:00:00 2001 From: Daniel Velez Date: Fri, 1 May 2026 15:40:40 -0500 Subject: [PATCH 20/95] plan(logto-phase-2): expand Phase 2 outline to additive-only design The original Phase 2 outline in 2026-04-20-logto-integration.md called for a single migration that both ADDS new schema (sports, sport_id, player_profiles, logto_user_id, logto_m2m_app_id) and DROPS old columns (password_hash, role, raw_password, key_hash etc.). Auditing the codebase found 78 references to password_hash, 113 to users.role, and 35 files importing api/session, so 'big-bang Phase 2' would force rewriting every handler in the same PR as the schema change. This expanded plan ships Phase 2 as ADDITIVE-ONLY. The migration 00041_logto_schema_additive.sql adds new shape, backfills sport_id to Pickleball, and never drops anything. The cookie-session code path keeps working unchanged. The drops happen in Phase 6 cutover after all Go callers have stopped reading the to-be-removed columns. Plan covers Tasks 2.1-2.4: - 2.1 migration SQL (full content in code blocks) - 2.2 sqlc queries for sports + player_profiles, plus additive Logto-aware lookups on users/api_keys - 2.3 sqlc regenerate + build verification - 2.4 migration up/down smoke (local Postgres preferred, Coolify fallback) Risk register at the bottom captures the deferred drops, the hardcoded sport-org IDs, and Phase 3's expectation that player_profiles can be sparsely populated. --- .../2026-05-01-logto-phase-2-migrations.md | 581 ++++++++++++++++++ 1 file changed, 581 insertions(+) create mode 100644 docs/superpowers/plans/2026-05-01-logto-phase-2-migrations.md diff --git a/docs/superpowers/plans/2026-05-01-logto-phase-2-migrations.md b/docs/superpowers/plans/2026-05-01-logto-phase-2-migrations.md new file mode 100644 index 0000000..e16fada --- /dev/null +++ b/docs/superpowers/plans/2026-05-01-logto-phase-2-migrations.md @@ -0,0 +1,581 @@ +# Logto Phase 2 — Additive Schema Migration + +> Expanded from the Phase 2 outline of +> `docs/superpowers/plans/2026-04-20-logto-integration.md` (lines +> 1707–1718). This plan supersedes the original Phase 2 outline. + +**Goal:** Add all new schema needed for the Logto integration without +dropping any existing columns, tables, or constraints. Result: the +codebase still builds, the deployed app keeps working unchanged, but +new Logto fields and the multi-sport scaffolding are now available +for Phase 3+ to populate. + +**Branch:** `feature/logto-integration` (continues from Phase 1 tip +`72525e5`). + +**Why additive-only:** Dropping `users.password_hash`, `users.role`, or +`tournament_staff.raw_password` immediately would break 78 + 113 + 1 +references across ~35 Go files (we measured). The original plan's +"shrink users in one migration" approach is incompatible with +additive deployment — we'd have to rewrite every handler that does +`session.Data.Role` *in the same PR* as the schema change. Phase 6 +cutover deletes the old code paths and a follow-up migration drops +the now-orphaned columns. + +**Scope of this phase:** + +- **CREATE** `sports` lookup table + seed Pickleball + Demo Sport +- **CREATE** `player_profiles` 1:1 table (initially empty; populated in + Phase 3 when the frontend starts saving profile data) +- **ADD** `users.logto_user_id` (nullable, UNIQUE when not null) +- **ADD** `sport_id` columns on `tournaments`, `leagues`, + `organizations`, `venues`, `divisions` (nullable, backfilled to + Pickleball, with index — but NOT NOT NULL until Phase 6) +- **ADD** `api_keys.logto_m2m_app_id` (nullable, UNIQUE when not null) +- **NO drops, no renames, no constraint tightenings.** + +**Out of scope (deferred to Phase 6 cutover):** + +- Drop `users.password_hash`, `users.role`, `users.first_name/last_name/date_of_birth`, + and the migrated profile columns (after the data is moved into + `player_profiles` by webhook + on-demand upsert) +- Drop `tournament_staff.raw_password` +- Drop `api_keys.{key_hash, key_prefix, scopes, expires_at}` +- Make `users.logto_user_id` and the various `sport_id` columns NOT NULL +- Remove the now-unused `idx_users_dedup` (which references + `first_name, last_name, date_of_birth`) + +--- + +## Task 2.1 — Write migration `00041_logto_schema_additive.sql` + +**Files:** +- Create: `api/db/migrations/00041_logto_schema_additive.sql` + +**Steps:** + +- [ ] **Step 1.** Create the file with the following contents: + +```sql +-- +goose Up + +-- ============================================================================ +-- Phase 2 of the Logto integration: ADDITIVE schema changes. +-- +-- This migration only ADDS tables, columns, and indexes. It deliberately +-- does not drop any existing schema; that work is deferred to a Phase 6 +-- cutover migration once all Go callers have stopped reading the +-- to-be-removed columns. +-- +-- See docs/DATABASE_OWNERSHIP.md for which fields live where after this +-- migration lands. See docs/superpowers/specs/2026-04-20-logto-integration-design.md +-- for the broader design. +-- ============================================================================ + +-- --------------------------------------------------------------------------- +-- 1. sports lookup table +-- --------------------------------------------------------------------------- + +CREATE TABLE sports ( + id BIGSERIAL PRIMARY KEY, + slug TEXT NOT NULL UNIQUE, + name TEXT NOT NULL, + logto_org_id TEXT NOT NULL UNIQUE, + is_active BOOLEAN NOT NULL DEFAULT true, + sort_order INT NOT NULL DEFAULT 0, + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT now() +); + +CREATE INDEX idx_sports_active ON sports(sort_order) WHERE is_active = true; + +-- Seed both sports. logto_org_id values match the Logto orgs created +-- per Step 5 of docs/LOGTO_SETUP.md. If those values change in Logto +-- admin, update them via UPDATE rather than re-running this migration. +INSERT INTO sports (slug, name, logto_org_id, sort_order) VALUES + ('pickleball', 'Pickleball', 'ekup1zyrrxj4', 1), + ('demo_sport', 'Demo Sport', '7866ex96uk6b', 99); + +-- --------------------------------------------------------------------------- +-- 2. users.logto_user_id (nullable mirror FK) +-- --------------------------------------------------------------------------- + +-- Nullable for now: existing rows have no Logto identity, and the +-- Phase 5 webhook + Phase 6 cutover will populate it. NOT NULL is +-- imposed in the cutover migration. +ALTER TABLE users ADD COLUMN logto_user_id TEXT; + +-- Partial UNIQUE index allows multiple NULLs (legacy rows) but enforces +-- one-to-one mapping for any row that does have a Logto user. +CREATE UNIQUE INDEX idx_users_logto_user_id + ON users(logto_user_id) + WHERE logto_user_id IS NOT NULL; + +-- --------------------------------------------------------------------------- +-- 3. player_profiles (initially empty; populated by Phase 3 forms) +-- --------------------------------------------------------------------------- + +-- 1:1 with users by user_id. Lives separately so the users table can +-- shrink to a Logto-mirror shape in Phase 6 without losing profile +-- data. Until Phase 3 starts writing here, every row in users has an +-- implicit empty profile (queries treat absence as defaults). +CREATE TABLE player_profiles ( + user_id BIGINT PRIMARY KEY REFERENCES users(id) ON DELETE CASCADE, + phone TEXT, + dupr_id TEXT, + vair_id TEXT, + paddle_brand TEXT, + paddle_model TEXT, + gender TEXT CHECK (gender IN ('male','female','non_binary','prefer_not_to_say')), + handedness TEXT CHECK (handedness IN ('right','left','ambidextrous')), + date_of_birth DATE, + bio TEXT, + address_line_1 TEXT, + address_line_2 TEXT, + city TEXT, + state_province TEXT, + country TEXT, + postal_code TEXT, + formatted_address TEXT, + latitude DOUBLE PRECISION, + longitude DOUBLE PRECISION, + emergency_contact_name TEXT, + emergency_contact_phone TEXT, + medical_notes TEXT, + waiver_accepted_at TIMESTAMPTZ, + avatar_url TEXT, + is_profile_hidden BOOLEAN NOT NULL DEFAULT false, + updated_at TIMESTAMPTZ NOT NULL DEFAULT now() +); + +CREATE INDEX idx_player_profiles_dupr_id + ON player_profiles(dupr_id) WHERE dupr_id IS NOT NULL; +CREATE INDEX idx_player_profiles_vair_id + ON player_profiles(vair_id) WHERE vair_id IS NOT NULL; +CREATE INDEX idx_player_profiles_city_state + ON player_profiles(city, state_province); + +-- --------------------------------------------------------------------------- +-- 4. sport_id columns on top-level domain tables +-- --------------------------------------------------------------------------- + +-- All five columns are nullable for now and backfilled to Pickleball. +-- Phase 6 cutover migration imposes NOT NULL after every Go writer has +-- been updated to set sport_id explicitly. + +DO $$ +DECLARE + pickleball_id BIGINT; +BEGIN + SELECT id INTO pickleball_id FROM sports WHERE slug = 'pickleball'; + + -- tournaments + ALTER TABLE tournaments ADD COLUMN sport_id BIGINT REFERENCES sports(id); + UPDATE tournaments SET sport_id = pickleball_id WHERE sport_id IS NULL; + + -- leagues + ALTER TABLE leagues ADD COLUMN sport_id BIGINT REFERENCES sports(id); + UPDATE leagues SET sport_id = pickleball_id WHERE sport_id IS NULL; + + -- organizations + ALTER TABLE organizations ADD COLUMN sport_id BIGINT REFERENCES sports(id); + UPDATE organizations SET sport_id = pickleball_id WHERE sport_id IS NULL; + + -- venues + ALTER TABLE venues ADD COLUMN sport_id BIGINT REFERENCES sports(id); + UPDATE venues SET sport_id = pickleball_id WHERE sport_id IS NULL; + + -- divisions + ALTER TABLE divisions ADD COLUMN sport_id BIGINT REFERENCES sports(id); + UPDATE divisions SET sport_id = pickleball_id WHERE sport_id IS NULL; +END $$; + +CREATE INDEX idx_tournaments_sport + ON tournaments(sport_id) WHERE deleted_at IS NULL; +CREATE INDEX idx_leagues_sport + ON leagues(sport_id) WHERE deleted_at IS NULL; +CREATE INDEX idx_organizations_sport + ON organizations(sport_id) WHERE deleted_at IS NULL; +CREATE INDEX idx_venues_sport + ON venues(sport_id) WHERE deleted_at IS NULL; +CREATE INDEX idx_divisions_sport + ON divisions(sport_id) WHERE deleted_at IS NULL; + +-- --------------------------------------------------------------------------- +-- 5. api_keys.logto_m2m_app_id (nullable mirror FK) +-- --------------------------------------------------------------------------- + +ALTER TABLE api_keys ADD COLUMN logto_m2m_app_id TEXT; + +CREATE UNIQUE INDEX idx_api_keys_logto_m2m_app_id + ON api_keys(logto_m2m_app_id) + WHERE logto_m2m_app_id IS NOT NULL; + + +-- +goose Down + +-- Reverse in opposite order. All operations are conditional so a +-- partial-up migration can be rolled back cleanly. + +DROP INDEX IF EXISTS idx_api_keys_logto_m2m_app_id; +ALTER TABLE api_keys DROP COLUMN IF EXISTS logto_m2m_app_id; + +DROP INDEX IF EXISTS idx_divisions_sport; +DROP INDEX IF EXISTS idx_venues_sport; +DROP INDEX IF EXISTS idx_organizations_sport; +DROP INDEX IF EXISTS idx_leagues_sport; +DROP INDEX IF EXISTS idx_tournaments_sport; + +ALTER TABLE divisions DROP COLUMN IF EXISTS sport_id; +ALTER TABLE venues DROP COLUMN IF EXISTS sport_id; +ALTER TABLE organizations DROP COLUMN IF EXISTS sport_id; +ALTER TABLE leagues DROP COLUMN IF EXISTS sport_id; +ALTER TABLE tournaments DROP COLUMN IF EXISTS sport_id; + +DROP INDEX IF EXISTS idx_player_profiles_city_state; +DROP INDEX IF EXISTS idx_player_profiles_vair_id; +DROP INDEX IF EXISTS idx_player_profiles_dupr_id; +DROP TABLE IF EXISTS player_profiles; + +DROP INDEX IF EXISTS idx_users_logto_user_id; +ALTER TABLE users DROP COLUMN IF EXISTS logto_user_id; + +DROP INDEX IF EXISTS idx_sports_active; +DROP TABLE IF EXISTS sports; +``` + +- [ ] **Step 2.** Verify the migration file syntax with a local sanity + check (no DB needed): + + ```bash + cd api + # Just confirm the file parses; goose will fully validate on apply. + grep -c "^-- +goose " db/migrations/00041_logto_schema_additive.sql + # Expect: 2 (one Up, one Down) + ``` + +**Success criteria for Task 2.1:** the migration file exists, has both +Up and Down sections, and `goose validate` (run later in Task 2.4) +passes. + +--- + +## Task 2.2 — Add new sqlc queries + +**Files:** +- Create: `api/db/queries/sports.sql` +- Create: `api/db/queries/player_profiles.sql` +- Modify: `api/db/queries/users.sql` (add Logto-aware lookups; do NOT + remove existing queries) +- Modify: `api/db/queries/api_keys.sql` (add Logto-aware lookups; do + NOT remove existing queries) + +**Steps:** + +- [ ] **Step 1.** Create `api/db/queries/sports.sql`: + +```sql +-- api/db/queries/sports.sql + +-- name: ListSports :many +SELECT * FROM sports +WHERE is_active = true +ORDER BY sort_order, name; + +-- name: GetSportByID :one +SELECT * FROM sports WHERE id = $1; + +-- name: GetSportBySlug :one +SELECT * FROM sports WHERE slug = $1; + +-- name: GetSportByLogtoOrgID :one +SELECT * FROM sports WHERE logto_org_id = $1; +``` + +- [ ] **Step 2.** Create `api/db/queries/player_profiles.sql`: + +```sql +-- api/db/queries/player_profiles.sql + +-- name: GetPlayerProfile :one +SELECT * FROM player_profiles WHERE user_id = $1; + +-- name: UpsertPlayerProfile :one +-- Insert or update the 1:1 profile row. The full set of columns is +-- accepted on every call; pass NULL for fields the caller does not +-- want to change AFTER reading the existing row first (or use the +-- nullable narg pattern below for partial updates). +INSERT INTO player_profiles ( + user_id, + phone, dupr_id, vair_id, paddle_brand, paddle_model, + gender, handedness, date_of_birth, bio, + address_line_1, address_line_2, city, state_province, country, + postal_code, formatted_address, latitude, longitude, + emergency_contact_name, emergency_contact_phone, medical_notes, + waiver_accepted_at, avatar_url, is_profile_hidden, + updated_at +) VALUES ( + $1, + $2, $3, $4, $5, $6, + $7, $8, $9, $10, + $11, $12, $13, $14, $15, + $16, $17, $18, $19, + $20, $21, $22, + $23, $24, $25, + now() +) +ON CONFLICT (user_id) DO UPDATE SET + phone = EXCLUDED.phone, + dupr_id = EXCLUDED.dupr_id, + vair_id = EXCLUDED.vair_id, + paddle_brand = EXCLUDED.paddle_brand, + paddle_model = EXCLUDED.paddle_model, + gender = EXCLUDED.gender, + handedness = EXCLUDED.handedness, + date_of_birth = EXCLUDED.date_of_birth, + bio = EXCLUDED.bio, + address_line_1 = EXCLUDED.address_line_1, + address_line_2 = EXCLUDED.address_line_2, + city = EXCLUDED.city, + state_province = EXCLUDED.state_province, + country = EXCLUDED.country, + postal_code = EXCLUDED.postal_code, + formatted_address = EXCLUDED.formatted_address, + latitude = EXCLUDED.latitude, + longitude = EXCLUDED.longitude, + emergency_contact_name = EXCLUDED.emergency_contact_name, + emergency_contact_phone = EXCLUDED.emergency_contact_phone, + medical_notes = EXCLUDED.medical_notes, + waiver_accepted_at = EXCLUDED.waiver_accepted_at, + avatar_url = EXCLUDED.avatar_url, + is_profile_hidden = EXCLUDED.is_profile_hidden, + updated_at = now() +RETURNING *; + +-- name: DeletePlayerProfile :exec +DELETE FROM player_profiles WHERE user_id = $1; +``` + +- [ ] **Step 3.** Append to `api/db/queries/users.sql`: + +```sql +-- name: GetUserByLogtoUserID :one +SELECT * FROM users +WHERE logto_user_id = $1 AND deleted_at IS NULL; + +-- name: SetUserLogtoUserID :one +-- Phase 5 webhook + on-demand upsert path: bind a Logto user ID to an +-- existing local mirror row. Idempotent: setting the same value twice +-- is fine; setting a different value when one is already set fails on +-- the UNIQUE index. +UPDATE users SET + logto_user_id = $2, + updated_at = now() +WHERE id = $1 AND deleted_at IS NULL +RETURNING *; +``` + +- [ ] **Step 4.** Append to `api/db/queries/api_keys.sql`: + +```sql +-- name: GetAPIKeyByLogtoM2MAppID :one +SELECT * FROM api_keys +WHERE logto_m2m_app_id = $1; + +-- name: SetAPIKeyLogtoM2MAppID :one +UPDATE api_keys SET + logto_m2m_app_id = $2, + updated_at = now() +WHERE id = $1 +RETURNING *; +``` + +**Success criteria for Task 2.2:** all four query files have the new +queries; sqlc can parse them (Task 2.3 verifies). + +--- + +## Task 2.3 — Regenerate sqlc + verify build clean + +**Files:** +- Generated (auto): `api/db/generated/*.sql.go` and + `api/db/generated/models.go` + +**Steps:** + +- [ ] **Step 1.** Confirm sqlc is available locally: + + ```bash + which sqlc || go install github.com/sqlc-dev/sqlc/cmd/sqlc@latest + sqlc version + ``` + +- [ ] **Step 2.** Regenerate from the api directory: + + ```bash + cd api + sqlc generate + ``` + + Expected: no errors. New generated functions: + - `db/generated/sports.sql.go` with `ListSports`, `GetSportByID`, + `GetSportBySlug`, `GetSportByLogtoOrgID` + - `db/generated/player_profiles.sql.go` with `GetPlayerProfile`, + `UpsertPlayerProfile`, `DeletePlayerProfile` + - Updated `db/generated/users.sql.go` with `GetUserByLogtoUserID`, + `SetUserLogtoUserID` + - Updated `db/generated/api_keys.sql.go` with + `GetAPIKeyByLogtoM2MAppID`, `SetAPIKeyLogtoM2MAppID` + - `db/generated/models.go` gets new `Sport`, `PlayerProfile` structs; + existing `User` and `ApiKey` structs gain `LogtoUserID` / + `LogtoM2mAppID` fields. + +- [ ] **Step 3.** Verify build is clean: + + ```bash + cd api + go build ./... + go vet ./... + ``` + + Expected: both exit 0. Any failure here means the migration's + schema doesn't match what sqlc inferred — re-read the migration + carefully. + +- [ ] **Step 4.** Verify existing tests still pass for the packages + that don't touch the DB: + + ```bash + cd api + go test ./auth/... ./logto/... ./middleware/... -race + ``` + + Expected: all PASS. + +**Success criteria for Task 2.3:** `go build ./...` clean, +`go vet ./...` clean, generated code committed. + +--- + +## Task 2.4 — Smoke test the migration + +The codebase runs migrations automatically on startup +(`db.RunMigrations(ctx, cfg.DatabaseURL)` in `api/main.go:42`). So the +production smoke test is implicit: when Coolify deploys the next +build that includes 00041, the migration runs. + +But we want to catch errors BEFORE that. Local Postgres is the +reliable path; if Postgres is unavailable, fall back to Coolify +deploy verification. + +**Files:** none (verification step). + +**Steps:** + +- [ ] **Step 1 (preferred).** Run the migration locally against a + fresh ephemeral Postgres. + + Install Postgres if missing: + ```bash + sudo pacman -S --noconfirm postgresql + sudo -u postgres initdb -D /var/lib/postgres/data 2>/dev/null || true + sudo systemctl enable --now postgresql + ``` + + Create a throwaway DB: + ```bash + sudo -u postgres createuser -s phoenix 2>/dev/null || true + createdb cc_logto_smoke + ``` + + Run the full migration set: + ```bash + cd api + DATABASE_URL=postgres://phoenix@localhost:5432/cc_logto_smoke?sslmode=disable \ + go run ./cmd/migrate up || \ + DATABASE_URL=postgres://phoenix@localhost:5432/cc_logto_smoke?sslmode=disable \ + go test -run TestMigrationsUp ./db -v + ``` + + (If neither cmd nor TestMigrationsUp exists, run via the running + binary's startup migration: `DATABASE_URL=... go run main.go &` and + watch the logs.) + + Verify the new tables and columns: + ```bash + psql cc_logto_smoke -c "\d sports" + psql cc_logto_smoke -c "\d player_profiles" + psql cc_logto_smoke -c "SELECT slug, name, logto_org_id FROM sports;" + psql cc_logto_smoke -c "\d users" # confirm logto_user_id column + psql cc_logto_smoke -c "\d api_keys" # confirm logto_m2m_app_id column + psql cc_logto_smoke -c "\d tournaments" # confirm sport_id column + ``` + + Expected: 2 sports rows (pickleball, demo_sport), all columns and + indexes present. + + Test the rollback: + ```bash + goose -dir api/db/migrations \ + postgres "postgres://phoenix@localhost:5432/cc_logto_smoke?sslmode=disable" \ + down + ``` + + Verify the rollback succeeded: + ```bash + psql cc_logto_smoke -c "\d sports" # should not exist + psql cc_logto_smoke -c "\d player_profiles" # should not exist + psql cc_logto_smoke -c "\d users" # logto_user_id column gone + ``` + + Re-run up to confirm it can be re-applied: + ```bash + goose -dir api/db/migrations postgres "..." up + ``` + +- [ ] **Step 2 (fallback).** If local Postgres is not available, push + the migration to `feature/logto-integration` and let Coolify run it. + Watch the deploy log for migration output. The api startup logs + will show `running database migrations` followed by goose's per- + migration log lines. After deploy, verify by adding a brief logging + trace in the app or by querying via a one-shot psql container in + Coolify. + + This is less safe (can't easily roll back without code changes) but + acceptable given the additive-only design — even a botched 00041 is + a non-event because the migration only adds; nothing breaks if the + Down half is buggy because we don't intend to run it. + +**Success criteria for Task 2.4:** migration applies cleanly (no error +output from goose), all expected schema elements exist, rollback also +clean. + +--- + +## Verification and exit criteria for Phase 2 + +- [ ] `api/db/migrations/00041_logto_schema_additive.sql` committed +- [ ] `api/db/queries/sports.sql` and `player_profiles.sql` committed; + `users.sql` and `api_keys.sql` augmented (no removals) +- [ ] `api/db/generated/` contains the regenerated bindings +- [ ] `go build ./...` clean +- [ ] `go vet ./...` clean +- [ ] `go test ./auth/... ./logto/... ./middleware/... -race` clean +- [ ] Migration up/down validated on a real Postgres (or, fallback, + cleanly applied via Coolify deploy) +- [ ] Live smoke verifies `/api/v1/health` still 200 OK (i.e. the + migration didn't break the app's startup path) +- [ ] PR description / commit messages clearly note the additive-only + scope and what's deferred to Phase 6 + +--- + +## Risk register + +| Risk | Mitigation | +|---|---| +| The hardcoded `logto_org_id` values for Pickleball/Demo Sport drift if the operator regenerates Logto orgs | Documented in `docs/LOGTO_SETUP.md` Step 5; the seed is data, not code, and can be UPDATEd post-deploy. | +| sqlc generation surprises us with a name collision or shape mismatch | Pre-flight Step 3 of Task 2.3 will fail fast; we adjust the queries. | +| Migration takes longer than Coolify's deploy timeout on a large prod table | All ALTER TABLEs are metadata-only operations on Postgres 17 (no rewrites for nullable adds); UPDATEs only set sport_id on rows that have NULL, which is fast on tables with <100k rows. Production has zero users so this is trivial. | +| `idx_users_dedup` references soon-to-be-removed columns | Out of scope for Phase 2; leave it alone. Phase 6 cutover handles the drop. | +| Phase 3 expects player_profiles to be populated already | Phase 3 will write profile data on form submit (UpsertPlayerProfile). Existing user rows have no profile until the user edits their profile in the new UI. Acceptable for a zero-user environment. | From ab54799dbc7e2966d84586d61d746d177c4cfdad Mon Sep 17 00:00:00 2001 From: Daniel Velez Date: Fri, 1 May 2026 15:47:37 -0500 Subject: [PATCH 21/95] feat(db): Phase 2 additive schema migration + sqlc bindings Implements Tasks 2.1-2.3 of the Phase 2 plan (docs/superpowers/plans/2026-05-01-logto-phase-2-migrations.md). Migration 00041_logto_schema_additive.sql adds: - sports lookup table seeded with Pickleball + Demo Sport (logto_org_id matches the orgs created in Logto admin per LOGTO_SETUP.md Step 5) - users.logto_user_id (TEXT, nullable, partial UNIQUE index for the populated subset) - player_profiles 1:1 table holding all the future-removable user profile columns (phone, dupr_id, paddle, address, etc.) - sport_id columns on tournaments/leagues/organizations/venues/divisions (nullable, backfilled to Pickleball, indexed). Phase 6 cutover imposes NOT NULL after every Go writer is updated. - api_keys.logto_m2m_app_id (TEXT, nullable, partial UNIQUE) Nothing is dropped: password_hash, role, raw_password, key_hash, etc. all stay because dropping them would break 78 + 113 + 35 references in the cookie-session code path that's still in use until Phase 6. New sqlc queries: - sports.sql: ListSports, GetSportByID, GetSportBySlug, GetSportByLogtoOrgID - player_profiles.sql: GetPlayerProfileRow (suffixed because the legacy players.sql still has GetPlayerProfile pointing at users), UpsertPlayerProfile (full-row), DeletePlayerProfile - users.sql: appended GetUserByLogtoUserID, SetUserLogtoUserID - api_keys.sql: appended GetAPIKeyByLogtoM2MAppID, SetAPIKeyLogtoM2MAppID Generated bindings: - new db/generated/sports.sql.go and player_profiles.sql.go - updated User struct: +LogtoUserID *string - updated ApiKey struct: +LogtoM2mAppID *string - new Sport and PlayerProfile structs in db/generated/models.go - All other generated files churn only on the sqlc version-comment bump (v1.31.0 -> v1.31.1) Verified locally: go build ./... # exit 0 go vet ./... # exit 0 go test ./auth/... ./logto/... ./middleware/... -race # all PASS Migration up/down smoke test against a real Postgres deferred to Task 2.4. The migration runs automatically on Coolify's next deploy of api (db.RunMigrations is called from main.go on startup), which acts as the production smoke. --- api/db/generated/activity_logs.sql.go | 2 +- api/db/generated/ad_configs.sql.go | 2 +- api/db/generated/announcements.sql.go | 2 +- api/db/generated/api_keys.sql.go | 78 ++++++- api/db/generated/court_overlay_configs.sql.go | 2 +- api/db/generated/courts.sql.go | 2 +- api/db/generated/dashboard.sql.go | 2 +- api/db/generated/db.go | 2 +- api/db/generated/division_templates.sql.go | 2 +- api/db/generated/divisions.sql.go | 2 +- api/db/generated/league_registrations.sql.go | 2 +- api/db/generated/leagues.sql.go | 2 +- api/db/generated/match_events.sql.go | 2 +- api/db/generated/match_series.sql.go | 2 +- api/db/generated/matches.sql.go | 2 +- api/db/generated/models.go | 66 +++++- api/db/generated/org_blocks.sql.go | 2 +- api/db/generated/org_memberships.sql.go | 2 +- api/db/generated/organizations.sql.go | 2 +- api/db/generated/player_profiles.sql.go | 206 ++++++++++++++++++ api/db/generated/players.sql.go | 26 ++- api/db/generated/pods.sql.go | 2 +- api/db/generated/registrations.sql.go | 2 +- api/db/generated/scoring_presets.sql.go | 2 +- api/db/generated/search.sql.go | 2 +- api/db/generated/season_confirmations.sql.go | 2 +- api/db/generated/seasons.sql.go | 2 +- api/db/generated/source_profiles.sql.go | 2 +- api/db/generated/sports.sql.go | 107 +++++++++ api/db/generated/standings_entries.sql.go | 2 +- api/db/generated/team_rosters.sql.go | 2 +- api/db/generated/teams.sql.go | 2 +- api/db/generated/tournament_courts.sql.go | 2 +- api/db/generated/tournament_staff.sql.go | 2 +- api/db/generated/tournaments.sql.go | 2 +- api/db/generated/uploads.sql.go | 2 +- api/db/generated/users.sql.go | 156 ++++++++++++- api/db/generated/venue_managers.sql.go | 2 +- api/db/generated/venues.sql.go | 2 +- .../00041_logto_schema_additive.sql | 192 ++++++++++++++++ api/db/queries/api_keys.sql | 17 ++ api/db/queries/player_profiles.sql | 63 ++++++ api/db/queries/sports.sql | 15 ++ api/db/queries/users.sql | 22 ++ 44 files changed, 943 insertions(+), 71 deletions(-) create mode 100644 api/db/generated/player_profiles.sql.go create mode 100644 api/db/generated/sports.sql.go create mode 100644 api/db/migrations/00041_logto_schema_additive.sql create mode 100644 api/db/queries/player_profiles.sql create mode 100644 api/db/queries/sports.sql diff --git a/api/db/generated/activity_logs.sql.go b/api/db/generated/activity_logs.sql.go index 8709c1d..032949c 100644 --- a/api/db/generated/activity_logs.sql.go +++ b/api/db/generated/activity_logs.sql.go @@ -1,6 +1,6 @@ // Code generated by sqlc. DO NOT EDIT. // versions: -// sqlc v1.31.0 +// sqlc v1.31.1 // source: activity_logs.sql package generated diff --git a/api/db/generated/ad_configs.sql.go b/api/db/generated/ad_configs.sql.go index 345703a..4de8543 100644 --- a/api/db/generated/ad_configs.sql.go +++ b/api/db/generated/ad_configs.sql.go @@ -1,6 +1,6 @@ // Code generated by sqlc. DO NOT EDIT. // versions: -// sqlc v1.31.0 +// sqlc v1.31.1 // source: ad_configs.sql package generated diff --git a/api/db/generated/announcements.sql.go b/api/db/generated/announcements.sql.go index 88c0c5f..a777c1b 100644 --- a/api/db/generated/announcements.sql.go +++ b/api/db/generated/announcements.sql.go @@ -1,6 +1,6 @@ // Code generated by sqlc. DO NOT EDIT. // versions: -// sqlc v1.31.0 +// sqlc v1.31.1 // source: announcements.sql package generated diff --git a/api/db/generated/api_keys.sql.go b/api/db/generated/api_keys.sql.go index 02be816..880a72d 100644 --- a/api/db/generated/api_keys.sql.go +++ b/api/db/generated/api_keys.sql.go @@ -1,6 +1,6 @@ // Code generated by sqlc. DO NOT EDIT. // versions: -// sqlc v1.31.0 +// sqlc v1.31.1 // source: api_keys.sql package generated @@ -26,7 +26,7 @@ func (q *Queries) CountApiKeysByUser(ctx context.Context, userID int64) (int64, const createApiKey = `-- name: CreateApiKey :one INSERT INTO api_keys (user_id, name, key_hash, key_prefix, scopes, expires_at) VALUES ($1, $2, $3, $4, $5, $6) -RETURNING id, user_id, name, key_hash, key_prefix, scopes, expires_at, last_used_at, is_active, created_at, updated_at +RETURNING id, user_id, name, key_hash, key_prefix, scopes, expires_at, last_used_at, is_active, created_at, updated_at, logto_m2m_app_id ` type CreateApiKeyParams struct { @@ -60,6 +60,7 @@ func (q *Queries) CreateApiKey(ctx context.Context, arg CreateApiKeyParams) (Api &i.IsActive, &i.CreatedAt, &i.UpdatedAt, + &i.LogtoM2mAppID, ) return i, err } @@ -79,8 +80,39 @@ func (q *Queries) DeactivateApiKey(ctx context.Context, arg DeactivateApiKeyPara return err } +const getAPIKeyByLogtoM2MAppID = `-- name: GetAPIKeyByLogtoM2MAppID :one + +SELECT id, user_id, name, key_hash, key_prefix, scopes, expires_at, last_used_at, is_active, created_at, updated_at, logto_m2m_app_id FROM api_keys WHERE logto_m2m_app_id = $1 +` + +// ============================================================================ +// Logto M2M integration (Phase 2 additive). Phase 4 will rewrite +// CreateApiKey to delegate to logto.CreateM2MApp and persist the +// mirror row with logto_m2m_app_id; until then the legacy bcrypt +// key path keeps working. +// ============================================================================ +func (q *Queries) GetAPIKeyByLogtoM2MAppID(ctx context.Context, logtoM2mAppID *string) (ApiKey, error) { + row := q.db.QueryRow(ctx, getAPIKeyByLogtoM2MAppID, logtoM2mAppID) + var i ApiKey + err := row.Scan( + &i.ID, + &i.UserID, + &i.Name, + &i.KeyHash, + &i.KeyPrefix, + &i.Scopes, + &i.ExpiresAt, + &i.LastUsedAt, + &i.IsActive, + &i.CreatedAt, + &i.UpdatedAt, + &i.LogtoM2mAppID, + ) + return i, err +} + const getApiKeyByHash = `-- name: GetApiKeyByHash :one -SELECT id, user_id, name, key_hash, key_prefix, scopes, expires_at, last_used_at, is_active, created_at, updated_at FROM api_keys +SELECT id, user_id, name, key_hash, key_prefix, scopes, expires_at, last_used_at, is_active, created_at, updated_at, logto_m2m_app_id FROM api_keys WHERE key_hash = $1 AND is_active = true ` @@ -99,12 +131,13 @@ func (q *Queries) GetApiKeyByHash(ctx context.Context, keyHash string) (ApiKey, &i.IsActive, &i.CreatedAt, &i.UpdatedAt, + &i.LogtoM2mAppID, ) return i, err } const getApiKeyByID = `-- name: GetApiKeyByID :one -SELECT id, user_id, name, key_hash, key_prefix, scopes, expires_at, last_used_at, is_active, created_at, updated_at FROM api_keys WHERE id = $1 +SELECT id, user_id, name, key_hash, key_prefix, scopes, expires_at, last_used_at, is_active, created_at, updated_at, logto_m2m_app_id FROM api_keys WHERE id = $1 ` func (q *Queries) GetApiKeyByID(ctx context.Context, id int64) (ApiKey, error) { @@ -122,12 +155,13 @@ func (q *Queries) GetApiKeyByID(ctx context.Context, id int64) (ApiKey, error) { &i.IsActive, &i.CreatedAt, &i.UpdatedAt, + &i.LogtoM2mAppID, ) return i, err } const listApiKeysByUser = `-- name: ListApiKeysByUser :many -SELECT id, user_id, name, key_hash, key_prefix, scopes, expires_at, last_used_at, is_active, created_at, updated_at FROM api_keys +SELECT id, user_id, name, key_hash, key_prefix, scopes, expires_at, last_used_at, is_active, created_at, updated_at, logto_m2m_app_id FROM api_keys WHERE user_id = $1 ORDER BY created_at DESC ` @@ -153,6 +187,7 @@ func (q *Queries) ListApiKeysByUser(ctx context.Context, userID int64) ([]ApiKey &i.IsActive, &i.CreatedAt, &i.UpdatedAt, + &i.LogtoM2mAppID, ); err != nil { return nil, err } @@ -164,6 +199,39 @@ func (q *Queries) ListApiKeysByUser(ctx context.Context, userID int64) ([]ApiKey return items, nil } +const setAPIKeyLogtoM2MAppID = `-- name: SetAPIKeyLogtoM2MAppID :one +UPDATE api_keys SET + logto_m2m_app_id = $2, + updated_at = now() +WHERE id = $1 +RETURNING id, user_id, name, key_hash, key_prefix, scopes, expires_at, last_used_at, is_active, created_at, updated_at, logto_m2m_app_id +` + +type SetAPIKeyLogtoM2MAppIDParams struct { + ID int64 `json:"id"` + LogtoM2mAppID *string `json:"logto_m2m_app_id"` +} + +func (q *Queries) SetAPIKeyLogtoM2MAppID(ctx context.Context, arg SetAPIKeyLogtoM2MAppIDParams) (ApiKey, error) { + row := q.db.QueryRow(ctx, setAPIKeyLogtoM2MAppID, arg.ID, arg.LogtoM2mAppID) + var i ApiKey + err := row.Scan( + &i.ID, + &i.UserID, + &i.Name, + &i.KeyHash, + &i.KeyPrefix, + &i.Scopes, + &i.ExpiresAt, + &i.LastUsedAt, + &i.IsActive, + &i.CreatedAt, + &i.UpdatedAt, + &i.LogtoM2mAppID, + ) + return i, err +} + const updateApiKeyLastUsed = `-- name: UpdateApiKeyLastUsed :exec UPDATE api_keys SET last_used_at = now() WHERE id = $1 diff --git a/api/db/generated/court_overlay_configs.sql.go b/api/db/generated/court_overlay_configs.sql.go index 847be28..8aeece2 100644 --- a/api/db/generated/court_overlay_configs.sql.go +++ b/api/db/generated/court_overlay_configs.sql.go @@ -1,6 +1,6 @@ // Code generated by sqlc. DO NOT EDIT. // versions: -// sqlc v1.31.0 +// sqlc v1.31.1 // source: court_overlay_configs.sql package generated diff --git a/api/db/generated/courts.sql.go b/api/db/generated/courts.sql.go index 5db95d6..e880735 100644 --- a/api/db/generated/courts.sql.go +++ b/api/db/generated/courts.sql.go @@ -1,6 +1,6 @@ // Code generated by sqlc. DO NOT EDIT. // versions: -// sqlc v1.31.0 +// sqlc v1.31.1 // source: courts.sql package generated diff --git a/api/db/generated/dashboard.sql.go b/api/db/generated/dashboard.sql.go index dfc442f..ea3a172 100644 --- a/api/db/generated/dashboard.sql.go +++ b/api/db/generated/dashboard.sql.go @@ -1,6 +1,6 @@ // Code generated by sqlc. DO NOT EDIT. // versions: -// sqlc v1.31.0 +// sqlc v1.31.1 // source: dashboard.sql package generated diff --git a/api/db/generated/db.go b/api/db/generated/db.go index c2b6408..260703d 100644 --- a/api/db/generated/db.go +++ b/api/db/generated/db.go @@ -1,6 +1,6 @@ // Code generated by sqlc. DO NOT EDIT. // versions: -// sqlc v1.31.0 +// sqlc v1.31.1 package generated diff --git a/api/db/generated/division_templates.sql.go b/api/db/generated/division_templates.sql.go index 0a5e9bc..2aed32e 100644 --- a/api/db/generated/division_templates.sql.go +++ b/api/db/generated/division_templates.sql.go @@ -1,6 +1,6 @@ // Code generated by sqlc. DO NOT EDIT. // versions: -// sqlc v1.31.0 +// sqlc v1.31.1 // source: division_templates.sql package generated diff --git a/api/db/generated/divisions.sql.go b/api/db/generated/divisions.sql.go index f8b8f39..4fac965 100644 --- a/api/db/generated/divisions.sql.go +++ b/api/db/generated/divisions.sql.go @@ -1,6 +1,6 @@ // Code generated by sqlc. DO NOT EDIT. // versions: -// sqlc v1.31.0 +// sqlc v1.31.1 // source: divisions.sql package generated diff --git a/api/db/generated/league_registrations.sql.go b/api/db/generated/league_registrations.sql.go index 5010e41..1831d31 100644 --- a/api/db/generated/league_registrations.sql.go +++ b/api/db/generated/league_registrations.sql.go @@ -1,6 +1,6 @@ // Code generated by sqlc. DO NOT EDIT. // versions: -// sqlc v1.31.0 +// sqlc v1.31.1 // source: league_registrations.sql package generated diff --git a/api/db/generated/leagues.sql.go b/api/db/generated/leagues.sql.go index 4755886..0658f40 100644 --- a/api/db/generated/leagues.sql.go +++ b/api/db/generated/leagues.sql.go @@ -1,6 +1,6 @@ // Code generated by sqlc. DO NOT EDIT. // versions: -// sqlc v1.31.0 +// sqlc v1.31.1 // source: leagues.sql package generated diff --git a/api/db/generated/match_events.sql.go b/api/db/generated/match_events.sql.go index f6c46ca..b55306a 100644 --- a/api/db/generated/match_events.sql.go +++ b/api/db/generated/match_events.sql.go @@ -1,6 +1,6 @@ // Code generated by sqlc. DO NOT EDIT. // versions: -// sqlc v1.31.0 +// sqlc v1.31.1 // source: match_events.sql package generated diff --git a/api/db/generated/match_series.sql.go b/api/db/generated/match_series.sql.go index 46bc806..fbe334b 100644 --- a/api/db/generated/match_series.sql.go +++ b/api/db/generated/match_series.sql.go @@ -1,6 +1,6 @@ // Code generated by sqlc. DO NOT EDIT. // versions: -// sqlc v1.31.0 +// sqlc v1.31.1 // source: match_series.sql package generated diff --git a/api/db/generated/matches.sql.go b/api/db/generated/matches.sql.go index f082719..f2daaf7 100644 --- a/api/db/generated/matches.sql.go +++ b/api/db/generated/matches.sql.go @@ -1,6 +1,6 @@ // Code generated by sqlc. DO NOT EDIT. // versions: -// sqlc v1.31.0 +// sqlc v1.31.1 // source: matches.sql package generated diff --git a/api/db/generated/models.go b/api/db/generated/models.go index 20ab4d4..3b2c419 100644 --- a/api/db/generated/models.go +++ b/api/db/generated/models.go @@ -1,6 +1,6 @@ // Code generated by sqlc. DO NOT EDIT. // versions: -// sqlc v1.31.0 +// sqlc v1.31.1 package generated @@ -54,17 +54,18 @@ type Announcement struct { } type ApiKey struct { - ID int64 `json:"id"` - UserID int64 `json:"user_id"` - Name string `json:"name"` - KeyHash string `json:"key_hash"` - KeyPrefix string `json:"key_prefix"` - Scopes []string `json:"scopes"` - ExpiresAt pgtype.Timestamptz `json:"expires_at"` - LastUsedAt pgtype.Timestamptz `json:"last_used_at"` - IsActive bool `json:"is_active"` - CreatedAt time.Time `json:"created_at"` - UpdatedAt time.Time `json:"updated_at"` + ID int64 `json:"id"` + UserID int64 `json:"user_id"` + Name string `json:"name"` + KeyHash string `json:"key_hash"` + KeyPrefix string `json:"key_prefix"` + Scopes []string `json:"scopes"` + ExpiresAt pgtype.Timestamptz `json:"expires_at"` + LastUsedAt pgtype.Timestamptz `json:"last_used_at"` + IsActive bool `json:"is_active"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` + LogtoM2mAppID *string `json:"logto_m2m_app_id"` } type Court struct { @@ -364,6 +365,35 @@ type Organization struct { FormattedAddress *string `json:"formatted_address"` } +type PlayerProfile struct { + UserID int64 `json:"user_id"` + Phone *string `json:"phone"` + DuprID *string `json:"dupr_id"` + VairID *string `json:"vair_id"` + PaddleBrand *string `json:"paddle_brand"` + PaddleModel *string `json:"paddle_model"` + Gender *string `json:"gender"` + Handedness *string `json:"handedness"` + DateOfBirth pgtype.Date `json:"date_of_birth"` + Bio *string `json:"bio"` + AddressLine1 *string `json:"address_line_1"` + AddressLine2 *string `json:"address_line_2"` + City *string `json:"city"` + StateProvince *string `json:"state_province"` + Country *string `json:"country"` + PostalCode *string `json:"postal_code"` + FormattedAddress *string `json:"formatted_address"` + Latitude pgtype.Float8 `json:"latitude"` + Longitude pgtype.Float8 `json:"longitude"` + EmergencyContactName *string `json:"emergency_contact_name"` + EmergencyContactPhone *string `json:"emergency_contact_phone"` + MedicalNotes *string `json:"medical_notes"` + WaiverAcceptedAt pgtype.Timestamptz `json:"waiver_accepted_at"` + AvatarUrl *string `json:"avatar_url"` + IsProfileHidden bool `json:"is_profile_hidden"` + UpdatedAt time.Time `json:"updated_at"` +} + type Pod struct { ID int64 `json:"id"` DivisionID int64 `json:"division_id"` @@ -465,6 +495,17 @@ type SourceProfile struct { UpdatedAt time.Time `json:"updated_at"` } +type Sport struct { + ID int64 `json:"id"` + Slug string `json:"slug"` + Name string `json:"name"` + LogtoOrgID string `json:"logto_org_id"` + IsActive bool `json:"is_active"` + SortOrder int32 `json:"sort_order"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` +} + type StandingsEntry struct { ID int64 `json:"id"` SeasonID int64 `json:"season_id"` @@ -617,6 +658,7 @@ type User struct { Latitude pgtype.Float8 `json:"latitude"` Longitude pgtype.Float8 `json:"longitude"` FormattedAddress *string `json:"formatted_address"` + LogtoUserID *string `json:"logto_user_id"` } type Venue struct { diff --git a/api/db/generated/org_blocks.sql.go b/api/db/generated/org_blocks.sql.go index 2ae5cd6..7b22a56 100644 --- a/api/db/generated/org_blocks.sql.go +++ b/api/db/generated/org_blocks.sql.go @@ -1,6 +1,6 @@ // Code generated by sqlc. DO NOT EDIT. // versions: -// sqlc v1.31.0 +// sqlc v1.31.1 // source: org_blocks.sql package generated diff --git a/api/db/generated/org_memberships.sql.go b/api/db/generated/org_memberships.sql.go index 2122b9c..6cff523 100644 --- a/api/db/generated/org_memberships.sql.go +++ b/api/db/generated/org_memberships.sql.go @@ -1,6 +1,6 @@ // Code generated by sqlc. DO NOT EDIT. // versions: -// sqlc v1.31.0 +// sqlc v1.31.1 // source: org_memberships.sql package generated diff --git a/api/db/generated/organizations.sql.go b/api/db/generated/organizations.sql.go index 451513c..185df0a 100644 --- a/api/db/generated/organizations.sql.go +++ b/api/db/generated/organizations.sql.go @@ -1,6 +1,6 @@ // Code generated by sqlc. DO NOT EDIT. // versions: -// sqlc v1.31.0 +// sqlc v1.31.1 // source: organizations.sql package generated diff --git a/api/db/generated/player_profiles.sql.go b/api/db/generated/player_profiles.sql.go new file mode 100644 index 0000000..76a2ac4 --- /dev/null +++ b/api/db/generated/player_profiles.sql.go @@ -0,0 +1,206 @@ +// Code generated by sqlc. DO NOT EDIT. +// versions: +// sqlc v1.31.1 +// source: player_profiles.sql + +package generated + +import ( + "context" + + "github.com/jackc/pgx/v5/pgtype" +) + +const deletePlayerProfile = `-- name: DeletePlayerProfile :exec +DELETE FROM player_profiles WHERE user_id = $1 +` + +func (q *Queries) DeletePlayerProfile(ctx context.Context, userID int64) error { + _, err := q.db.Exec(ctx, deletePlayerProfile, userID) + return err +} + +const getPlayerProfileRow = `-- name: GetPlayerProfileRow :one + +SELECT user_id, phone, dupr_id, vair_id, paddle_brand, paddle_model, gender, handedness, date_of_birth, bio, address_line_1, address_line_2, city, state_province, country, postal_code, formatted_address, latitude, longitude, emergency_contact_name, emergency_contact_phone, medical_notes, waiver_accepted_at, avatar_url, is_profile_hidden, updated_at FROM player_profiles WHERE user_id = $1 +` + +// api/db/queries/player_profiles.sql +// Note: named with the 'Row' suffix because the legacy +// api/db/queries/players.sql still has a GetPlayerProfile that +// queries the users table. Phase 6 cutover removes that query and +// this one becomes the canonical lookup. +func (q *Queries) GetPlayerProfileRow(ctx context.Context, userID int64) (PlayerProfile, error) { + row := q.db.QueryRow(ctx, getPlayerProfileRow, userID) + var i PlayerProfile + err := row.Scan( + &i.UserID, + &i.Phone, + &i.DuprID, + &i.VairID, + &i.PaddleBrand, + &i.PaddleModel, + &i.Gender, + &i.Handedness, + &i.DateOfBirth, + &i.Bio, + &i.AddressLine1, + &i.AddressLine2, + &i.City, + &i.StateProvince, + &i.Country, + &i.PostalCode, + &i.FormattedAddress, + &i.Latitude, + &i.Longitude, + &i.EmergencyContactName, + &i.EmergencyContactPhone, + &i.MedicalNotes, + &i.WaiverAcceptedAt, + &i.AvatarUrl, + &i.IsProfileHidden, + &i.UpdatedAt, + ) + return i, err +} + +const upsertPlayerProfile = `-- name: UpsertPlayerProfile :one +INSERT INTO player_profiles ( + user_id, + phone, dupr_id, vair_id, paddle_brand, paddle_model, + gender, handedness, date_of_birth, bio, + address_line_1, address_line_2, city, state_province, country, + postal_code, formatted_address, latitude, longitude, + emergency_contact_name, emergency_contact_phone, medical_notes, + waiver_accepted_at, avatar_url, is_profile_hidden, + updated_at +) VALUES ( + $1, + $2, $3, $4, $5, $6, + $7, $8, $9, $10, + $11, $12, $13, $14, $15, + $16, $17, $18, $19, + $20, $21, $22, + $23, $24, $25, + now() +) +ON CONFLICT (user_id) DO UPDATE SET + phone = EXCLUDED.phone, + dupr_id = EXCLUDED.dupr_id, + vair_id = EXCLUDED.vair_id, + paddle_brand = EXCLUDED.paddle_brand, + paddle_model = EXCLUDED.paddle_model, + gender = EXCLUDED.gender, + handedness = EXCLUDED.handedness, + date_of_birth = EXCLUDED.date_of_birth, + bio = EXCLUDED.bio, + address_line_1 = EXCLUDED.address_line_1, + address_line_2 = EXCLUDED.address_line_2, + city = EXCLUDED.city, + state_province = EXCLUDED.state_province, + country = EXCLUDED.country, + postal_code = EXCLUDED.postal_code, + formatted_address = EXCLUDED.formatted_address, + latitude = EXCLUDED.latitude, + longitude = EXCLUDED.longitude, + emergency_contact_name = EXCLUDED.emergency_contact_name, + emergency_contact_phone = EXCLUDED.emergency_contact_phone, + medical_notes = EXCLUDED.medical_notes, + waiver_accepted_at = EXCLUDED.waiver_accepted_at, + avatar_url = EXCLUDED.avatar_url, + is_profile_hidden = EXCLUDED.is_profile_hidden, + updated_at = now() +RETURNING user_id, phone, dupr_id, vair_id, paddle_brand, paddle_model, gender, handedness, date_of_birth, bio, address_line_1, address_line_2, city, state_province, country, postal_code, formatted_address, latitude, longitude, emergency_contact_name, emergency_contact_phone, medical_notes, waiver_accepted_at, avatar_url, is_profile_hidden, updated_at +` + +type UpsertPlayerProfileParams struct { + UserID int64 `json:"user_id"` + Phone *string `json:"phone"` + DuprID *string `json:"dupr_id"` + VairID *string `json:"vair_id"` + PaddleBrand *string `json:"paddle_brand"` + PaddleModel *string `json:"paddle_model"` + Gender *string `json:"gender"` + Handedness *string `json:"handedness"` + DateOfBirth pgtype.Date `json:"date_of_birth"` + Bio *string `json:"bio"` + AddressLine1 *string `json:"address_line_1"` + AddressLine2 *string `json:"address_line_2"` + City *string `json:"city"` + StateProvince *string `json:"state_province"` + Country *string `json:"country"` + PostalCode *string `json:"postal_code"` + FormattedAddress *string `json:"formatted_address"` + Latitude pgtype.Float8 `json:"latitude"` + Longitude pgtype.Float8 `json:"longitude"` + EmergencyContactName *string `json:"emergency_contact_name"` + EmergencyContactPhone *string `json:"emergency_contact_phone"` + MedicalNotes *string `json:"medical_notes"` + WaiverAcceptedAt pgtype.Timestamptz `json:"waiver_accepted_at"` + AvatarUrl *string `json:"avatar_url"` + IsProfileHidden bool `json:"is_profile_hidden"` +} + +// Insert or update the 1:1 profile row. Caller passes the full set of +// columns; pre-fetch the existing row first if you want a partial +// update, otherwise unset fields will be overwritten with the supplied +// values (which may be NULL). +func (q *Queries) UpsertPlayerProfile(ctx context.Context, arg UpsertPlayerProfileParams) (PlayerProfile, error) { + row := q.db.QueryRow(ctx, upsertPlayerProfile, + arg.UserID, + arg.Phone, + arg.DuprID, + arg.VairID, + arg.PaddleBrand, + arg.PaddleModel, + arg.Gender, + arg.Handedness, + arg.DateOfBirth, + arg.Bio, + arg.AddressLine1, + arg.AddressLine2, + arg.City, + arg.StateProvince, + arg.Country, + arg.PostalCode, + arg.FormattedAddress, + arg.Latitude, + arg.Longitude, + arg.EmergencyContactName, + arg.EmergencyContactPhone, + arg.MedicalNotes, + arg.WaiverAcceptedAt, + arg.AvatarUrl, + arg.IsProfileHidden, + ) + var i PlayerProfile + err := row.Scan( + &i.UserID, + &i.Phone, + &i.DuprID, + &i.VairID, + &i.PaddleBrand, + &i.PaddleModel, + &i.Gender, + &i.Handedness, + &i.DateOfBirth, + &i.Bio, + &i.AddressLine1, + &i.AddressLine2, + &i.City, + &i.StateProvince, + &i.Country, + &i.PostalCode, + &i.FormattedAddress, + &i.Latitude, + &i.Longitude, + &i.EmergencyContactName, + &i.EmergencyContactPhone, + &i.MedicalNotes, + &i.WaiverAcceptedAt, + &i.AvatarUrl, + &i.IsProfileHidden, + &i.UpdatedAt, + ) + return i, err +} diff --git a/api/db/generated/players.sql.go b/api/db/generated/players.sql.go index 4c27c9a..1f60c2a 100644 --- a/api/db/generated/players.sql.go +++ b/api/db/generated/players.sql.go @@ -1,6 +1,6 @@ // Code generated by sqlc. DO NOT EDIT. // versions: -// sqlc v1.31.0 +// sqlc v1.31.1 // source: players.sql package generated @@ -16,7 +16,7 @@ UPDATE users SET waiver_accepted_at = now(), updated_at = now() WHERE id = $1 AND deleted_at IS NULL -RETURNING id, public_id, email, password_hash, first_name, last_name, date_of_birth, display_name, status, merged_into_id, role, created_at, updated_at, deleted_at, gender, handedness, avatar_url, bio, city, state_province, country, phone, paddle_brand, paddle_model, dupr_id, vair_id, emergency_contact_name, emergency_contact_phone, medical_notes, waiver_accepted_at, is_profile_hidden, address_line_1, address_line_2, postal_code, latitude, longitude, formatted_address +RETURNING id, public_id, email, password_hash, first_name, last_name, date_of_birth, display_name, status, merged_into_id, role, created_at, updated_at, deleted_at, gender, handedness, avatar_url, bio, city, state_province, country, phone, paddle_brand, paddle_model, dupr_id, vair_id, emergency_contact_name, emergency_contact_phone, medical_notes, waiver_accepted_at, is_profile_hidden, address_line_1, address_line_2, postal_code, latitude, longitude, formatted_address, logto_user_id ` func (q *Queries) AcceptWaiver(ctx context.Context, id int64) (User, error) { @@ -60,6 +60,7 @@ func (q *Queries) AcceptWaiver(ctx context.Context, id int64) (User, error) { &i.Latitude, &i.Longitude, &i.FormattedAddress, + &i.LogtoUserID, ) return i, err } @@ -101,7 +102,7 @@ func (q *Queries) CountSearchPlayers(ctx context.Context, arg CountSearchPlayers } const getPlayerByDuprID = `-- name: GetPlayerByDuprID :one -SELECT id, public_id, email, password_hash, first_name, last_name, date_of_birth, display_name, status, merged_into_id, role, created_at, updated_at, deleted_at, gender, handedness, avatar_url, bio, city, state_province, country, phone, paddle_brand, paddle_model, dupr_id, vair_id, emergency_contact_name, emergency_contact_phone, medical_notes, waiver_accepted_at, is_profile_hidden, address_line_1, address_line_2, postal_code, latitude, longitude, formatted_address FROM users +SELECT id, public_id, email, password_hash, first_name, last_name, date_of_birth, display_name, status, merged_into_id, role, created_at, updated_at, deleted_at, gender, handedness, avatar_url, bio, city, state_province, country, phone, paddle_brand, paddle_model, dupr_id, vair_id, emergency_contact_name, emergency_contact_phone, medical_notes, waiver_accepted_at, is_profile_hidden, address_line_1, address_line_2, postal_code, latitude, longitude, formatted_address, logto_user_id FROM users WHERE dupr_id = $1 AND deleted_at IS NULL ` @@ -146,12 +147,13 @@ func (q *Queries) GetPlayerByDuprID(ctx context.Context, duprID *string) (User, &i.Latitude, &i.Longitude, &i.FormattedAddress, + &i.LogtoUserID, ) return i, err } const getPlayerByPublicID = `-- name: GetPlayerByPublicID :one -SELECT id, public_id, email, password_hash, first_name, last_name, date_of_birth, display_name, status, merged_into_id, role, created_at, updated_at, deleted_at, gender, handedness, avatar_url, bio, city, state_province, country, phone, paddle_brand, paddle_model, dupr_id, vair_id, emergency_contact_name, emergency_contact_phone, medical_notes, waiver_accepted_at, is_profile_hidden, address_line_1, address_line_2, postal_code, latitude, longitude, formatted_address FROM users +SELECT id, public_id, email, password_hash, first_name, last_name, date_of_birth, display_name, status, merged_into_id, role, created_at, updated_at, deleted_at, gender, handedness, avatar_url, bio, city, state_province, country, phone, paddle_brand, paddle_model, dupr_id, vair_id, emergency_contact_name, emergency_contact_phone, medical_notes, waiver_accepted_at, is_profile_hidden, address_line_1, address_line_2, postal_code, latitude, longitude, formatted_address, logto_user_id FROM users WHERE public_id = $1 AND deleted_at IS NULL ` @@ -196,12 +198,13 @@ func (q *Queries) GetPlayerByPublicID(ctx context.Context, publicID string) (Use &i.Latitude, &i.Longitude, &i.FormattedAddress, + &i.LogtoUserID, ) return i, err } const getPlayerByVairID = `-- name: GetPlayerByVairID :one -SELECT id, public_id, email, password_hash, first_name, last_name, date_of_birth, display_name, status, merged_into_id, role, created_at, updated_at, deleted_at, gender, handedness, avatar_url, bio, city, state_province, country, phone, paddle_brand, paddle_model, dupr_id, vair_id, emergency_contact_name, emergency_contact_phone, medical_notes, waiver_accepted_at, is_profile_hidden, address_line_1, address_line_2, postal_code, latitude, longitude, formatted_address FROM users +SELECT id, public_id, email, password_hash, first_name, last_name, date_of_birth, display_name, status, merged_into_id, role, created_at, updated_at, deleted_at, gender, handedness, avatar_url, bio, city, state_province, country, phone, paddle_brand, paddle_model, dupr_id, vair_id, emergency_contact_name, emergency_contact_phone, medical_notes, waiver_accepted_at, is_profile_hidden, address_line_1, address_line_2, postal_code, latitude, longitude, formatted_address, logto_user_id FROM users WHERE vair_id = $1 AND deleted_at IS NULL ` @@ -246,13 +249,14 @@ func (q *Queries) GetPlayerByVairID(ctx context.Context, vairID *string) (User, &i.Latitude, &i.Longitude, &i.FormattedAddress, + &i.LogtoUserID, ) return i, err } const getPlayerProfile = `-- name: GetPlayerProfile :one -SELECT id, public_id, email, password_hash, first_name, last_name, date_of_birth, display_name, status, merged_into_id, role, created_at, updated_at, deleted_at, gender, handedness, avatar_url, bio, city, state_province, country, phone, paddle_brand, paddle_model, dupr_id, vair_id, emergency_contact_name, emergency_contact_phone, medical_notes, waiver_accepted_at, is_profile_hidden, address_line_1, address_line_2, postal_code, latitude, longitude, formatted_address FROM users +SELECT id, public_id, email, password_hash, first_name, last_name, date_of_birth, display_name, status, merged_into_id, role, created_at, updated_at, deleted_at, gender, handedness, avatar_url, bio, city, state_province, country, phone, paddle_brand, paddle_model, dupr_id, vair_id, emergency_contact_name, emergency_contact_phone, medical_notes, waiver_accepted_at, is_profile_hidden, address_line_1, address_line_2, postal_code, latitude, longitude, formatted_address, logto_user_id FROM users WHERE id = $1 AND deleted_at IS NULL ` @@ -298,12 +302,13 @@ func (q *Queries) GetPlayerProfile(ctx context.Context, id int64) (User, error) &i.Latitude, &i.Longitude, &i.FormattedAddress, + &i.LogtoUserID, ) return i, err } const listPlayers = `-- name: ListPlayers :many -SELECT id, public_id, email, password_hash, first_name, last_name, date_of_birth, display_name, status, merged_into_id, role, created_at, updated_at, deleted_at, gender, handedness, avatar_url, bio, city, state_province, country, phone, paddle_brand, paddle_model, dupr_id, vair_id, emergency_contact_name, emergency_contact_phone, medical_notes, waiver_accepted_at, is_profile_hidden, address_line_1, address_line_2, postal_code, latitude, longitude, formatted_address FROM users +SELECT id, public_id, email, password_hash, first_name, last_name, date_of_birth, display_name, status, merged_into_id, role, created_at, updated_at, deleted_at, gender, handedness, avatar_url, bio, city, state_province, country, phone, paddle_brand, paddle_model, dupr_id, vair_id, emergency_contact_name, emergency_contact_phone, medical_notes, waiver_accepted_at, is_profile_hidden, address_line_1, address_line_2, postal_code, latitude, longitude, formatted_address, logto_user_id FROM users WHERE deleted_at IS NULL AND status != 'merged' ORDER BY display_name ASC NULLS LAST, last_name ASC, first_name ASC @@ -362,6 +367,7 @@ func (q *Queries) ListPlayers(ctx context.Context, arg ListPlayersParams) ([]Use &i.Latitude, &i.Longitude, &i.FormattedAddress, + &i.LogtoUserID, ); err != nil { return nil, err } @@ -374,7 +380,7 @@ func (q *Queries) ListPlayers(ctx context.Context, arg ListPlayersParams) ([]Use } const searchPlayers = `-- name: SearchPlayers :many -SELECT id, public_id, email, password_hash, first_name, last_name, date_of_birth, display_name, status, merged_into_id, role, created_at, updated_at, deleted_at, gender, handedness, avatar_url, bio, city, state_province, country, phone, paddle_brand, paddle_model, dupr_id, vair_id, emergency_contact_name, emergency_contact_phone, medical_notes, waiver_accepted_at, is_profile_hidden, address_line_1, address_line_2, postal_code, latitude, longitude, formatted_address FROM users +SELECT id, public_id, email, password_hash, first_name, last_name, date_of_birth, display_name, status, merged_into_id, role, created_at, updated_at, deleted_at, gender, handedness, avatar_url, bio, city, state_province, country, phone, paddle_brand, paddle_model, dupr_id, vair_id, emergency_contact_name, emergency_contact_phone, medical_notes, waiver_accepted_at, is_profile_hidden, address_line_1, address_line_2, postal_code, latitude, longitude, formatted_address, logto_user_id FROM users WHERE deleted_at IS NULL AND status != 'merged' AND is_profile_hidden = false @@ -455,6 +461,7 @@ func (q *Queries) SearchPlayers(ctx context.Context, arg SearchPlayersParams) ([ &i.Latitude, &i.Longitude, &i.FormattedAddress, + &i.LogtoUserID, ); err != nil { return nil, err } @@ -493,7 +500,7 @@ UPDATE users SET is_profile_hidden = COALESCE($23, is_profile_hidden), updated_at = now() WHERE id = $24 AND deleted_at IS NULL -RETURNING id, public_id, email, password_hash, first_name, last_name, date_of_birth, display_name, status, merged_into_id, role, created_at, updated_at, deleted_at, gender, handedness, avatar_url, bio, city, state_province, country, phone, paddle_brand, paddle_model, dupr_id, vair_id, emergency_contact_name, emergency_contact_phone, medical_notes, waiver_accepted_at, is_profile_hidden, address_line_1, address_line_2, postal_code, latitude, longitude, formatted_address +RETURNING id, public_id, email, password_hash, first_name, last_name, date_of_birth, display_name, status, merged_into_id, role, created_at, updated_at, deleted_at, gender, handedness, avatar_url, bio, city, state_province, country, phone, paddle_brand, paddle_model, dupr_id, vair_id, emergency_contact_name, emergency_contact_phone, medical_notes, waiver_accepted_at, is_profile_hidden, address_line_1, address_line_2, postal_code, latitude, longitude, formatted_address, logto_user_id ` type UpdatePlayerProfileParams struct { @@ -589,6 +596,7 @@ func (q *Queries) UpdatePlayerProfile(ctx context.Context, arg UpdatePlayerProfi &i.Latitude, &i.Longitude, &i.FormattedAddress, + &i.LogtoUserID, ) return i, err } diff --git a/api/db/generated/pods.sql.go b/api/db/generated/pods.sql.go index cc6a1a1..786a727 100644 --- a/api/db/generated/pods.sql.go +++ b/api/db/generated/pods.sql.go @@ -1,6 +1,6 @@ // Code generated by sqlc. DO NOT EDIT. // versions: -// sqlc v1.31.0 +// sqlc v1.31.1 // source: pods.sql package generated diff --git a/api/db/generated/registrations.sql.go b/api/db/generated/registrations.sql.go index 9c1540d..149dcc1 100644 --- a/api/db/generated/registrations.sql.go +++ b/api/db/generated/registrations.sql.go @@ -1,6 +1,6 @@ // Code generated by sqlc. DO NOT EDIT. // versions: -// sqlc v1.31.0 +// sqlc v1.31.1 // source: registrations.sql package generated diff --git a/api/db/generated/scoring_presets.sql.go b/api/db/generated/scoring_presets.sql.go index e823cf1..31e865f 100644 --- a/api/db/generated/scoring_presets.sql.go +++ b/api/db/generated/scoring_presets.sql.go @@ -1,6 +1,6 @@ // Code generated by sqlc. DO NOT EDIT. // versions: -// sqlc v1.31.0 +// sqlc v1.31.1 // source: scoring_presets.sql package generated diff --git a/api/db/generated/search.sql.go b/api/db/generated/search.sql.go index 45cd92e..e1523e8 100644 --- a/api/db/generated/search.sql.go +++ b/api/db/generated/search.sql.go @@ -1,6 +1,6 @@ // Code generated by sqlc. DO NOT EDIT. // versions: -// sqlc v1.31.0 +// sqlc v1.31.1 // source: search.sql package generated diff --git a/api/db/generated/season_confirmations.sql.go b/api/db/generated/season_confirmations.sql.go index e3a3a8a..14ead39 100644 --- a/api/db/generated/season_confirmations.sql.go +++ b/api/db/generated/season_confirmations.sql.go @@ -1,6 +1,6 @@ // Code generated by sqlc. DO NOT EDIT. // versions: -// sqlc v1.31.0 +// sqlc v1.31.1 // source: season_confirmations.sql package generated diff --git a/api/db/generated/seasons.sql.go b/api/db/generated/seasons.sql.go index 5e86c57..45bda82 100644 --- a/api/db/generated/seasons.sql.go +++ b/api/db/generated/seasons.sql.go @@ -1,6 +1,6 @@ // Code generated by sqlc. DO NOT EDIT. // versions: -// sqlc v1.31.0 +// sqlc v1.31.1 // source: seasons.sql package generated diff --git a/api/db/generated/source_profiles.sql.go b/api/db/generated/source_profiles.sql.go index 239c2b3..f9e5bf7 100644 --- a/api/db/generated/source_profiles.sql.go +++ b/api/db/generated/source_profiles.sql.go @@ -1,6 +1,6 @@ // Code generated by sqlc. DO NOT EDIT. // versions: -// sqlc v1.31.0 +// sqlc v1.31.1 // source: source_profiles.sql package generated diff --git a/api/db/generated/sports.sql.go b/api/db/generated/sports.sql.go new file mode 100644 index 0000000..48226af --- /dev/null +++ b/api/db/generated/sports.sql.go @@ -0,0 +1,107 @@ +// Code generated by sqlc. DO NOT EDIT. +// versions: +// sqlc v1.31.1 +// source: sports.sql + +package generated + +import ( + "context" +) + +const getSportByID = `-- name: GetSportByID :one +SELECT id, slug, name, logto_org_id, is_active, sort_order, created_at, updated_at FROM sports WHERE id = $1 +` + +func (q *Queries) GetSportByID(ctx context.Context, id int64) (Sport, error) { + row := q.db.QueryRow(ctx, getSportByID, id) + var i Sport + err := row.Scan( + &i.ID, + &i.Slug, + &i.Name, + &i.LogtoOrgID, + &i.IsActive, + &i.SortOrder, + &i.CreatedAt, + &i.UpdatedAt, + ) + return i, err +} + +const getSportByLogtoOrgID = `-- name: GetSportByLogtoOrgID :one +SELECT id, slug, name, logto_org_id, is_active, sort_order, created_at, updated_at FROM sports WHERE logto_org_id = $1 +` + +func (q *Queries) GetSportByLogtoOrgID(ctx context.Context, logtoOrgID string) (Sport, error) { + row := q.db.QueryRow(ctx, getSportByLogtoOrgID, logtoOrgID) + var i Sport + err := row.Scan( + &i.ID, + &i.Slug, + &i.Name, + &i.LogtoOrgID, + &i.IsActive, + &i.SortOrder, + &i.CreatedAt, + &i.UpdatedAt, + ) + return i, err +} + +const getSportBySlug = `-- name: GetSportBySlug :one +SELECT id, slug, name, logto_org_id, is_active, sort_order, created_at, updated_at FROM sports WHERE slug = $1 +` + +func (q *Queries) GetSportBySlug(ctx context.Context, slug string) (Sport, error) { + row := q.db.QueryRow(ctx, getSportBySlug, slug) + var i Sport + err := row.Scan( + &i.ID, + &i.Slug, + &i.Name, + &i.LogtoOrgID, + &i.IsActive, + &i.SortOrder, + &i.CreatedAt, + &i.UpdatedAt, + ) + return i, err +} + +const listSports = `-- name: ListSports :many + +SELECT id, slug, name, logto_org_id, is_active, sort_order, created_at, updated_at FROM sports +WHERE is_active = true +ORDER BY sort_order, name +` + +// api/db/queries/sports.sql +func (q *Queries) ListSports(ctx context.Context) ([]Sport, error) { + rows, err := q.db.Query(ctx, listSports) + if err != nil { + return nil, err + } + defer rows.Close() + items := []Sport{} + for rows.Next() { + var i Sport + if err := rows.Scan( + &i.ID, + &i.Slug, + &i.Name, + &i.LogtoOrgID, + &i.IsActive, + &i.SortOrder, + &i.CreatedAt, + &i.UpdatedAt, + ); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} diff --git a/api/db/generated/standings_entries.sql.go b/api/db/generated/standings_entries.sql.go index f841326..e06c003 100644 --- a/api/db/generated/standings_entries.sql.go +++ b/api/db/generated/standings_entries.sql.go @@ -1,6 +1,6 @@ // Code generated by sqlc. DO NOT EDIT. // versions: -// sqlc v1.31.0 +// sqlc v1.31.1 // source: standings_entries.sql package generated diff --git a/api/db/generated/team_rosters.sql.go b/api/db/generated/team_rosters.sql.go index 1a1310e..8e878f3 100644 --- a/api/db/generated/team_rosters.sql.go +++ b/api/db/generated/team_rosters.sql.go @@ -1,6 +1,6 @@ // Code generated by sqlc. DO NOT EDIT. // versions: -// sqlc v1.31.0 +// sqlc v1.31.1 // source: team_rosters.sql package generated diff --git a/api/db/generated/teams.sql.go b/api/db/generated/teams.sql.go index 533d966..efa194f 100644 --- a/api/db/generated/teams.sql.go +++ b/api/db/generated/teams.sql.go @@ -1,6 +1,6 @@ // Code generated by sqlc. DO NOT EDIT. // versions: -// sqlc v1.31.0 +// sqlc v1.31.1 // source: teams.sql package generated diff --git a/api/db/generated/tournament_courts.sql.go b/api/db/generated/tournament_courts.sql.go index 7bcbbc2..da55413 100644 --- a/api/db/generated/tournament_courts.sql.go +++ b/api/db/generated/tournament_courts.sql.go @@ -1,6 +1,6 @@ // Code generated by sqlc. DO NOT EDIT. // versions: -// sqlc v1.31.0 +// sqlc v1.31.1 // source: tournament_courts.sql package generated diff --git a/api/db/generated/tournament_staff.sql.go b/api/db/generated/tournament_staff.sql.go index cc1d7ff..8672e11 100644 --- a/api/db/generated/tournament_staff.sql.go +++ b/api/db/generated/tournament_staff.sql.go @@ -1,6 +1,6 @@ // Code generated by sqlc. DO NOT EDIT. // versions: -// sqlc v1.31.0 +// sqlc v1.31.1 // source: tournament_staff.sql package generated diff --git a/api/db/generated/tournaments.sql.go b/api/db/generated/tournaments.sql.go index baa34b6..ba80c7c 100644 --- a/api/db/generated/tournaments.sql.go +++ b/api/db/generated/tournaments.sql.go @@ -1,6 +1,6 @@ // Code generated by sqlc. DO NOT EDIT. // versions: -// sqlc v1.31.0 +// sqlc v1.31.1 // source: tournaments.sql package generated diff --git a/api/db/generated/uploads.sql.go b/api/db/generated/uploads.sql.go index c56fcb7..c548d8b 100644 --- a/api/db/generated/uploads.sql.go +++ b/api/db/generated/uploads.sql.go @@ -1,6 +1,6 @@ // Code generated by sqlc. DO NOT EDIT. // versions: -// sqlc v1.31.0 +// sqlc v1.31.1 // source: uploads.sql package generated diff --git a/api/db/generated/users.sql.go b/api/db/generated/users.sql.go index 3b1fc54..5179b48 100644 --- a/api/db/generated/users.sql.go +++ b/api/db/generated/users.sql.go @@ -1,6 +1,6 @@ // Code generated by sqlc. DO NOT EDIT. // versions: -// sqlc v1.31.0 +// sqlc v1.31.1 // source: users.sql package generated @@ -77,7 +77,7 @@ INSERT INTO users ( ) VALUES ( $1, $2, $3, '', 'player', 'unclaimed' ) -RETURNING id, public_id, email, password_hash, first_name, last_name, date_of_birth, display_name, status, merged_into_id, role, created_at, updated_at, deleted_at, gender, handedness, avatar_url, bio, city, state_province, country, phone, paddle_brand, paddle_model, dupr_id, vair_id, emergency_contact_name, emergency_contact_phone, medical_notes, waiver_accepted_at, is_profile_hidden, address_line_1, address_line_2, postal_code, latitude, longitude, formatted_address +RETURNING id, public_id, email, password_hash, first_name, last_name, date_of_birth, display_name, status, merged_into_id, role, created_at, updated_at, deleted_at, gender, handedness, avatar_url, bio, city, state_province, country, phone, paddle_brand, paddle_model, dupr_id, vair_id, emergency_contact_name, emergency_contact_phone, medical_notes, waiver_accepted_at, is_profile_hidden, address_line_1, address_line_2, postal_code, latitude, longitude, formatted_address, logto_user_id ` type CreateUnclaimedUserParams struct { @@ -127,6 +127,7 @@ func (q *Queries) CreateUnclaimedUser(ctx context.Context, arg CreateUnclaimedUs &i.Latitude, &i.Longitude, &i.FormattedAddress, + &i.LogtoUserID, ) return i, err } @@ -138,7 +139,7 @@ INSERT INTO users ( ) VALUES ( $1, $2, $3, $4, $5, $6, $7 ) -RETURNING id, public_id, email, password_hash, first_name, last_name, date_of_birth, display_name, status, merged_into_id, role, created_at, updated_at, deleted_at, gender, handedness, avatar_url, bio, city, state_province, country, phone, paddle_brand, paddle_model, dupr_id, vair_id, emergency_contact_name, emergency_contact_phone, medical_notes, waiver_accepted_at, is_profile_hidden, address_line_1, address_line_2, postal_code, latitude, longitude, formatted_address +RETURNING id, public_id, email, password_hash, first_name, last_name, date_of_birth, display_name, status, merged_into_id, role, created_at, updated_at, deleted_at, gender, handedness, avatar_url, bio, city, state_province, country, phone, paddle_brand, paddle_model, dupr_id, vair_id, emergency_contact_name, emergency_contact_phone, medical_notes, waiver_accepted_at, is_profile_hidden, address_line_1, address_line_2, postal_code, latitude, longitude, formatted_address, logto_user_id ` type CreateUserParams struct { @@ -201,12 +202,13 @@ func (q *Queries) CreateUser(ctx context.Context, arg CreateUserParams) (User, e &i.Latitude, &i.Longitude, &i.FormattedAddress, + &i.LogtoUserID, ) return i, err } const getUserByEmail = `-- name: GetUserByEmail :one -SELECT id, public_id, email, password_hash, first_name, last_name, date_of_birth, display_name, status, merged_into_id, role, created_at, updated_at, deleted_at, gender, handedness, avatar_url, bio, city, state_province, country, phone, paddle_brand, paddle_model, dupr_id, vair_id, emergency_contact_name, emergency_contact_phone, medical_notes, waiver_accepted_at, is_profile_hidden, address_line_1, address_line_2, postal_code, latitude, longitude, formatted_address FROM users +SELECT id, public_id, email, password_hash, first_name, last_name, date_of_birth, display_name, status, merged_into_id, role, created_at, updated_at, deleted_at, gender, handedness, avatar_url, bio, city, state_province, country, phone, paddle_brand, paddle_model, dupr_id, vair_id, emergency_contact_name, emergency_contact_phone, medical_notes, waiver_accepted_at, is_profile_hidden, address_line_1, address_line_2, postal_code, latitude, longitude, formatted_address, logto_user_id FROM users WHERE email = $1 AND deleted_at IS NULL ` @@ -251,12 +253,13 @@ func (q *Queries) GetUserByEmail(ctx context.Context, email *string) (User, erro &i.Latitude, &i.Longitude, &i.FormattedAddress, + &i.LogtoUserID, ) return i, err } const getUserByID = `-- name: GetUserByID :one -SELECT id, public_id, email, password_hash, first_name, last_name, date_of_birth, display_name, status, merged_into_id, role, created_at, updated_at, deleted_at, gender, handedness, avatar_url, bio, city, state_province, country, phone, paddle_brand, paddle_model, dupr_id, vair_id, emergency_contact_name, emergency_contact_phone, medical_notes, waiver_accepted_at, is_profile_hidden, address_line_1, address_line_2, postal_code, latitude, longitude, formatted_address FROM users +SELECT id, public_id, email, password_hash, first_name, last_name, date_of_birth, display_name, status, merged_into_id, role, created_at, updated_at, deleted_at, gender, handedness, avatar_url, bio, city, state_province, country, phone, paddle_brand, paddle_model, dupr_id, vair_id, emergency_contact_name, emergency_contact_phone, medical_notes, waiver_accepted_at, is_profile_hidden, address_line_1, address_line_2, postal_code, latitude, longitude, formatted_address, logto_user_id FROM users WHERE id = $1 AND deleted_at IS NULL ` @@ -301,12 +304,71 @@ func (q *Queries) GetUserByID(ctx context.Context, id int64) (User, error) { &i.Latitude, &i.Longitude, &i.FormattedAddress, + &i.LogtoUserID, + ) + return i, err +} + +const getUserByLogtoUserID = `-- name: GetUserByLogtoUserID :one + +SELECT id, public_id, email, password_hash, first_name, last_name, date_of_birth, display_name, status, merged_into_id, role, created_at, updated_at, deleted_at, gender, handedness, avatar_url, bio, city, state_province, country, phone, paddle_brand, paddle_model, dupr_id, vair_id, emergency_contact_name, emergency_contact_phone, medical_notes, waiver_accepted_at, is_profile_hidden, address_line_1, address_line_2, postal_code, latitude, longitude, formatted_address, logto_user_id FROM users +WHERE logto_user_id = $1 AND deleted_at IS NULL +` + +// ============================================================================ +// Logto integration (Phase 2 additive). The legacy queries above continue +// to function for the cookie-session code path; the queries below let +// new code resolve users by their Logto user ID without touching +// password_hash / role. +// ============================================================================ +func (q *Queries) GetUserByLogtoUserID(ctx context.Context, logtoUserID *string) (User, error) { + row := q.db.QueryRow(ctx, getUserByLogtoUserID, logtoUserID) + var i User + err := row.Scan( + &i.ID, + &i.PublicID, + &i.Email, + &i.PasswordHash, + &i.FirstName, + &i.LastName, + &i.DateOfBirth, + &i.DisplayName, + &i.Status, + &i.MergedIntoID, + &i.Role, + &i.CreatedAt, + &i.UpdatedAt, + &i.DeletedAt, + &i.Gender, + &i.Handedness, + &i.AvatarUrl, + &i.Bio, + &i.City, + &i.StateProvince, + &i.Country, + &i.Phone, + &i.PaddleBrand, + &i.PaddleModel, + &i.DuprID, + &i.VairID, + &i.EmergencyContactName, + &i.EmergencyContactPhone, + &i.MedicalNotes, + &i.WaiverAcceptedAt, + &i.IsProfileHidden, + &i.AddressLine1, + &i.AddressLine2, + &i.PostalCode, + &i.Latitude, + &i.Longitude, + &i.FormattedAddress, + &i.LogtoUserID, ) return i, err } const getUserByPublicID = `-- name: GetUserByPublicID :one -SELECT id, public_id, email, password_hash, first_name, last_name, date_of_birth, display_name, status, merged_into_id, role, created_at, updated_at, deleted_at, gender, handedness, avatar_url, bio, city, state_province, country, phone, paddle_brand, paddle_model, dupr_id, vair_id, emergency_contact_name, emergency_contact_phone, medical_notes, waiver_accepted_at, is_profile_hidden, address_line_1, address_line_2, postal_code, latitude, longitude, formatted_address FROM users +SELECT id, public_id, email, password_hash, first_name, last_name, date_of_birth, display_name, status, merged_into_id, role, created_at, updated_at, deleted_at, gender, handedness, avatar_url, bio, city, state_province, country, phone, paddle_brand, paddle_model, dupr_id, vair_id, emergency_contact_name, emergency_contact_phone, medical_notes, waiver_accepted_at, is_profile_hidden, address_line_1, address_line_2, postal_code, latitude, longitude, formatted_address, logto_user_id FROM users WHERE public_id = $1 AND deleted_at IS NULL ` @@ -351,12 +413,13 @@ func (q *Queries) GetUserByPublicID(ctx context.Context, publicID string) (User, &i.Latitude, &i.Longitude, &i.FormattedAddress, + &i.LogtoUserID, ) return i, err } const listUsers = `-- name: ListUsers :many -SELECT id, public_id, email, password_hash, first_name, last_name, date_of_birth, display_name, status, merged_into_id, role, created_at, updated_at, deleted_at, gender, handedness, avatar_url, bio, city, state_province, country, phone, paddle_brand, paddle_model, dupr_id, vair_id, emergency_contact_name, emergency_contact_phone, medical_notes, waiver_accepted_at, is_profile_hidden, address_line_1, address_line_2, postal_code, latitude, longitude, formatted_address FROM users +SELECT id, public_id, email, password_hash, first_name, last_name, date_of_birth, display_name, status, merged_into_id, role, created_at, updated_at, deleted_at, gender, handedness, avatar_url, bio, city, state_province, country, phone, paddle_brand, paddle_model, dupr_id, vair_id, emergency_contact_name, emergency_contact_phone, medical_notes, waiver_accepted_at, is_profile_hidden, address_line_1, address_line_2, postal_code, latitude, longitude, formatted_address, logto_user_id FROM users WHERE deleted_at IS NULL ORDER BY created_at DESC LIMIT $1 OFFSET $2 @@ -414,6 +477,7 @@ func (q *Queries) ListUsers(ctx context.Context, arg ListUsersParams) ([]User, e &i.Latitude, &i.Longitude, &i.FormattedAddress, + &i.LogtoUserID, ); err != nil { return nil, err } @@ -426,7 +490,7 @@ func (q *Queries) ListUsers(ctx context.Context, arg ListUsersParams) ([]User, e } const searchUsers = `-- name: SearchUsers :many -SELECT id, public_id, email, password_hash, first_name, last_name, date_of_birth, display_name, status, merged_into_id, role, created_at, updated_at, deleted_at, gender, handedness, avatar_url, bio, city, state_province, country, phone, paddle_brand, paddle_model, dupr_id, vair_id, emergency_contact_name, emergency_contact_phone, medical_notes, waiver_accepted_at, is_profile_hidden, address_line_1, address_line_2, postal_code, latitude, longitude, formatted_address FROM users +SELECT id, public_id, email, password_hash, first_name, last_name, date_of_birth, display_name, status, merged_into_id, role, created_at, updated_at, deleted_at, gender, handedness, avatar_url, bio, city, state_province, country, phone, paddle_brand, paddle_model, dupr_id, vair_id, emergency_contact_name, emergency_contact_phone, medical_notes, waiver_accepted_at, is_profile_hidden, address_line_1, address_line_2, postal_code, latitude, longitude, formatted_address, logto_user_id FROM users WHERE deleted_at IS NULL AND ( $3::TEXT IS NULL @@ -502,6 +566,7 @@ func (q *Queries) SearchUsers(ctx context.Context, arg SearchUsersParams) ([]Use &i.Latitude, &i.Longitude, &i.FormattedAddress, + &i.LogtoUserID, ); err != nil { return nil, err } @@ -513,6 +578,69 @@ func (q *Queries) SearchUsers(ctx context.Context, arg SearchUsersParams) ([]Use return items, nil } +const setUserLogtoUserID = `-- name: SetUserLogtoUserID :one +UPDATE users SET + logto_user_id = $2, + updated_at = now() +WHERE id = $1 AND deleted_at IS NULL +RETURNING id, public_id, email, password_hash, first_name, last_name, date_of_birth, display_name, status, merged_into_id, role, created_at, updated_at, deleted_at, gender, handedness, avatar_url, bio, city, state_province, country, phone, paddle_brand, paddle_model, dupr_id, vair_id, emergency_contact_name, emergency_contact_phone, medical_notes, waiver_accepted_at, is_profile_hidden, address_line_1, address_line_2, postal_code, latitude, longitude, formatted_address, logto_user_id +` + +type SetUserLogtoUserIDParams struct { + ID int64 `json:"id"` + LogtoUserID *string `json:"logto_user_id"` +} + +// Bind a Logto user ID to an existing local mirror row. Idempotent: +// setting the same value twice is fine; setting a different value +// when one is already bound fails on the partial UNIQUE index +// idx_users_logto_user_id (the caller surfaces this as a 409). +func (q *Queries) SetUserLogtoUserID(ctx context.Context, arg SetUserLogtoUserIDParams) (User, error) { + row := q.db.QueryRow(ctx, setUserLogtoUserID, arg.ID, arg.LogtoUserID) + var i User + err := row.Scan( + &i.ID, + &i.PublicID, + &i.Email, + &i.PasswordHash, + &i.FirstName, + &i.LastName, + &i.DateOfBirth, + &i.DisplayName, + &i.Status, + &i.MergedIntoID, + &i.Role, + &i.CreatedAt, + &i.UpdatedAt, + &i.DeletedAt, + &i.Gender, + &i.Handedness, + &i.AvatarUrl, + &i.Bio, + &i.City, + &i.StateProvince, + &i.Country, + &i.Phone, + &i.PaddleBrand, + &i.PaddleModel, + &i.DuprID, + &i.VairID, + &i.EmergencyContactName, + &i.EmergencyContactPhone, + &i.MedicalNotes, + &i.WaiverAcceptedAt, + &i.IsProfileHidden, + &i.AddressLine1, + &i.AddressLine2, + &i.PostalCode, + &i.Latitude, + &i.Longitude, + &i.FormattedAddress, + &i.LogtoUserID, + ) + return i, err +} + const softDeleteUser = `-- name: SoftDeleteUser :exec UPDATE users SET deleted_at = now(), @@ -532,7 +660,7 @@ UPDATE users SET display_name = COALESCE($4, display_name), updated_at = now() WHERE id = $1 AND deleted_at IS NULL -RETURNING id, public_id, email, password_hash, first_name, last_name, date_of_birth, display_name, status, merged_into_id, role, created_at, updated_at, deleted_at, gender, handedness, avatar_url, bio, city, state_province, country, phone, paddle_brand, paddle_model, dupr_id, vair_id, emergency_contact_name, emergency_contact_phone, medical_notes, waiver_accepted_at, is_profile_hidden, address_line_1, address_line_2, postal_code, latitude, longitude, formatted_address +RETURNING id, public_id, email, password_hash, first_name, last_name, date_of_birth, display_name, status, merged_into_id, role, created_at, updated_at, deleted_at, gender, handedness, avatar_url, bio, city, state_province, country, phone, paddle_brand, paddle_model, dupr_id, vair_id, emergency_contact_name, emergency_contact_phone, medical_notes, waiver_accepted_at, is_profile_hidden, address_line_1, address_line_2, postal_code, latitude, longitude, formatted_address, logto_user_id ` type UpdateUserParams struct { @@ -588,6 +716,7 @@ func (q *Queries) UpdateUser(ctx context.Context, arg UpdateUserParams) (User, e &i.Latitude, &i.Longitude, &i.FormattedAddress, + &i.LogtoUserID, ) return i, err } @@ -596,7 +725,7 @@ const updateUserPassword = `-- name: UpdateUserPassword :one UPDATE users SET password_hash = $2, updated_at = now() WHERE id = $1 -RETURNING id, public_id, email, password_hash, first_name, last_name, date_of_birth, display_name, status, merged_into_id, role, created_at, updated_at, deleted_at, gender, handedness, avatar_url, bio, city, state_province, country, phone, paddle_brand, paddle_model, dupr_id, vair_id, emergency_contact_name, emergency_contact_phone, medical_notes, waiver_accepted_at, is_profile_hidden, address_line_1, address_line_2, postal_code, latitude, longitude, formatted_address +RETURNING id, public_id, email, password_hash, first_name, last_name, date_of_birth, display_name, status, merged_into_id, role, created_at, updated_at, deleted_at, gender, handedness, avatar_url, bio, city, state_province, country, phone, paddle_brand, paddle_model, dupr_id, vair_id, emergency_contact_name, emergency_contact_phone, medical_notes, waiver_accepted_at, is_profile_hidden, address_line_1, address_line_2, postal_code, latitude, longitude, formatted_address, logto_user_id ` type UpdateUserPasswordParams struct { @@ -645,6 +774,7 @@ func (q *Queries) UpdateUserPassword(ctx context.Context, arg UpdateUserPassword &i.Latitude, &i.Longitude, &i.FormattedAddress, + &i.LogtoUserID, ) return i, err } @@ -654,7 +784,7 @@ UPDATE users SET role = $2, updated_at = now() WHERE id = $1 AND deleted_at IS NULL -RETURNING id, public_id, email, password_hash, first_name, last_name, date_of_birth, display_name, status, merged_into_id, role, created_at, updated_at, deleted_at, gender, handedness, avatar_url, bio, city, state_province, country, phone, paddle_brand, paddle_model, dupr_id, vair_id, emergency_contact_name, emergency_contact_phone, medical_notes, waiver_accepted_at, is_profile_hidden, address_line_1, address_line_2, postal_code, latitude, longitude, formatted_address +RETURNING id, public_id, email, password_hash, first_name, last_name, date_of_birth, display_name, status, merged_into_id, role, created_at, updated_at, deleted_at, gender, handedness, avatar_url, bio, city, state_province, country, phone, paddle_brand, paddle_model, dupr_id, vair_id, emergency_contact_name, emergency_contact_phone, medical_notes, waiver_accepted_at, is_profile_hidden, address_line_1, address_line_2, postal_code, latitude, longitude, formatted_address, logto_user_id ` type UpdateUserRoleParams struct { @@ -703,6 +833,7 @@ func (q *Queries) UpdateUserRole(ctx context.Context, arg UpdateUserRoleParams) &i.Latitude, &i.Longitude, &i.FormattedAddress, + &i.LogtoUserID, ) return i, err } @@ -712,7 +843,7 @@ UPDATE users SET status = $2, updated_at = now() WHERE id = $1 AND deleted_at IS NULL -RETURNING id, public_id, email, password_hash, first_name, last_name, date_of_birth, display_name, status, merged_into_id, role, created_at, updated_at, deleted_at, gender, handedness, avatar_url, bio, city, state_province, country, phone, paddle_brand, paddle_model, dupr_id, vair_id, emergency_contact_name, emergency_contact_phone, medical_notes, waiver_accepted_at, is_profile_hidden, address_line_1, address_line_2, postal_code, latitude, longitude, formatted_address +RETURNING id, public_id, email, password_hash, first_name, last_name, date_of_birth, display_name, status, merged_into_id, role, created_at, updated_at, deleted_at, gender, handedness, avatar_url, bio, city, state_province, country, phone, paddle_brand, paddle_model, dupr_id, vair_id, emergency_contact_name, emergency_contact_phone, medical_notes, waiver_accepted_at, is_profile_hidden, address_line_1, address_line_2, postal_code, latitude, longitude, formatted_address, logto_user_id ` type UpdateUserStatusParams struct { @@ -761,6 +892,7 @@ func (q *Queries) UpdateUserStatus(ctx context.Context, arg UpdateUserStatusPara &i.Latitude, &i.Longitude, &i.FormattedAddress, + &i.LogtoUserID, ) return i, err } diff --git a/api/db/generated/venue_managers.sql.go b/api/db/generated/venue_managers.sql.go index d2ee73d..495affc 100644 --- a/api/db/generated/venue_managers.sql.go +++ b/api/db/generated/venue_managers.sql.go @@ -1,6 +1,6 @@ // Code generated by sqlc. DO NOT EDIT. // versions: -// sqlc v1.31.0 +// sqlc v1.31.1 // source: venue_managers.sql package generated diff --git a/api/db/generated/venues.sql.go b/api/db/generated/venues.sql.go index a431824..8512402 100644 --- a/api/db/generated/venues.sql.go +++ b/api/db/generated/venues.sql.go @@ -1,6 +1,6 @@ // Code generated by sqlc. DO NOT EDIT. // versions: -// sqlc v1.31.0 +// sqlc v1.31.1 // source: venues.sql package generated diff --git a/api/db/migrations/00041_logto_schema_additive.sql b/api/db/migrations/00041_logto_schema_additive.sql new file mode 100644 index 0000000..83cdfa7 --- /dev/null +++ b/api/db/migrations/00041_logto_schema_additive.sql @@ -0,0 +1,192 @@ +-- +goose Up + +-- ============================================================================ +-- Phase 2 of the Logto integration: ADDITIVE schema changes. +-- +-- This migration only ADDS tables, columns, and indexes. It deliberately +-- does not drop any existing schema; that work is deferred to a Phase 6 +-- cutover migration once all Go callers have stopped reading the +-- to-be-removed columns (password_hash, role, raw_password, +-- key_hash, etc.). +-- +-- See docs/DATABASE_OWNERSHIP.md for which fields live where after this +-- migration lands. See +-- docs/superpowers/specs/2026-04-20-logto-integration-design.md for the +-- broader design and +-- docs/superpowers/plans/2026-05-01-logto-phase-2-migrations.md for the +-- expanded plan this migration implements. +-- ============================================================================ + +-- --------------------------------------------------------------------------- +-- 1. sports lookup table +-- --------------------------------------------------------------------------- + +CREATE TABLE sports ( + id BIGSERIAL PRIMARY KEY, + slug TEXT NOT NULL UNIQUE, + name TEXT NOT NULL, + logto_org_id TEXT NOT NULL UNIQUE, + is_active BOOLEAN NOT NULL DEFAULT true, + sort_order INT NOT NULL DEFAULT 0, + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT now() +); + +CREATE INDEX idx_sports_active ON sports(sort_order) WHERE is_active = true; + +-- Seed both sports. logto_org_id values match the Logto orgs created +-- per Step 5 of docs/LOGTO_SETUP.md. If those values change in Logto +-- admin (e.g. the orgs are recreated after a Logto re-seed), update +-- them via UPDATE rather than re-running this migration. +INSERT INTO sports (slug, name, logto_org_id, sort_order) VALUES + ('pickleball', 'Pickleball', 'ekup1zyrrxj4', 1), + ('demo_sport', 'Demo Sport', '7866ex96uk6b', 99); + +-- --------------------------------------------------------------------------- +-- 2. users.logto_user_id (nullable mirror FK to Logto's user record) +-- --------------------------------------------------------------------------- + +-- Nullable for now: existing rows have no Logto identity, and the +-- Phase 5 webhook + Phase 6 cutover will populate it. NOT NULL is +-- imposed in the cutover migration once every code path is rewritten +-- to either bind a logto_user_id at create time or upsert on first +-- authenticated request. +ALTER TABLE users ADD COLUMN logto_user_id TEXT; + +-- Partial UNIQUE index allows multiple NULLs (legacy rows) but enforces +-- one-to-one mapping for any row that does have a Logto user. +CREATE UNIQUE INDEX idx_users_logto_user_id + ON users(logto_user_id) + WHERE logto_user_id IS NOT NULL; + +-- --------------------------------------------------------------------------- +-- 3. player_profiles (initially empty; populated by Phase 3 forms) +-- --------------------------------------------------------------------------- + +-- 1:1 with users by user_id. Lives separately so the users table can +-- shrink to a Logto-mirror shape in Phase 6 without losing profile +-- data. Until Phase 3 starts writing here, every row in users has an +-- implicit empty profile (queries treat absence as defaults). +CREATE TABLE player_profiles ( + user_id BIGINT PRIMARY KEY REFERENCES users(id) ON DELETE CASCADE, + phone TEXT, + dupr_id TEXT, + vair_id TEXT, + paddle_brand TEXT, + paddle_model TEXT, + gender TEXT CHECK (gender IN ('male','female','non_binary','prefer_not_to_say')), + handedness TEXT CHECK (handedness IN ('right','left','ambidextrous')), + date_of_birth DATE, + bio TEXT, + address_line_1 TEXT, + address_line_2 TEXT, + city TEXT, + state_province TEXT, + country TEXT, + postal_code TEXT, + formatted_address TEXT, + latitude DOUBLE PRECISION, + longitude DOUBLE PRECISION, + emergency_contact_name TEXT, + emergency_contact_phone TEXT, + medical_notes TEXT, + waiver_accepted_at TIMESTAMPTZ, + avatar_url TEXT, + is_profile_hidden BOOLEAN NOT NULL DEFAULT false, + updated_at TIMESTAMPTZ NOT NULL DEFAULT now() +); + +CREATE INDEX idx_player_profiles_dupr_id + ON player_profiles(dupr_id) WHERE dupr_id IS NOT NULL; +CREATE INDEX idx_player_profiles_vair_id + ON player_profiles(vair_id) WHERE vair_id IS NOT NULL; +CREATE INDEX idx_player_profiles_city_state + ON player_profiles(city, state_province); + +-- --------------------------------------------------------------------------- +-- 4. sport_id columns on top-level domain tables +-- --------------------------------------------------------------------------- + +-- All five columns are nullable for now and backfilled to Pickleball. +-- Phase 6 cutover migration imposes NOT NULL after every Go writer has +-- been updated to set sport_id explicitly. + +DO $$ +DECLARE + pickleball_id BIGINT; +BEGIN + SELECT id INTO pickleball_id FROM sports WHERE slug = 'pickleball'; + + -- tournaments + ALTER TABLE tournaments ADD COLUMN sport_id BIGINT REFERENCES sports(id); + UPDATE tournaments SET sport_id = pickleball_id WHERE sport_id IS NULL; + + -- leagues + ALTER TABLE leagues ADD COLUMN sport_id BIGINT REFERENCES sports(id); + UPDATE leagues SET sport_id = pickleball_id WHERE sport_id IS NULL; + + -- organizations + ALTER TABLE organizations ADD COLUMN sport_id BIGINT REFERENCES sports(id); + UPDATE organizations SET sport_id = pickleball_id WHERE sport_id IS NULL; + + -- venues + ALTER TABLE venues ADD COLUMN sport_id BIGINT REFERENCES sports(id); + UPDATE venues SET sport_id = pickleball_id WHERE sport_id IS NULL; + + -- divisions + ALTER TABLE divisions ADD COLUMN sport_id BIGINT REFERENCES sports(id); + UPDATE divisions SET sport_id = pickleball_id WHERE sport_id IS NULL; +END $$; + +CREATE INDEX idx_tournaments_sport + ON tournaments(sport_id) WHERE deleted_at IS NULL; +CREATE INDEX idx_leagues_sport + ON leagues(sport_id) WHERE deleted_at IS NULL; +CREATE INDEX idx_organizations_sport + ON organizations(sport_id) WHERE deleted_at IS NULL; +CREATE INDEX idx_venues_sport + ON venues(sport_id) WHERE deleted_at IS NULL; +CREATE INDEX idx_divisions_sport + ON divisions(sport_id) WHERE deleted_at IS NULL; + +-- --------------------------------------------------------------------------- +-- 5. api_keys.logto_m2m_app_id (nullable mirror FK to Logto M2M app) +-- --------------------------------------------------------------------------- + +ALTER TABLE api_keys ADD COLUMN logto_m2m_app_id TEXT; + +CREATE UNIQUE INDEX idx_api_keys_logto_m2m_app_id + ON api_keys(logto_m2m_app_id) + WHERE logto_m2m_app_id IS NOT NULL; + + +-- +goose Down + +-- Reverse in opposite order. All operations are conditional so a +-- partial-up migration can be rolled back cleanly. + +DROP INDEX IF EXISTS idx_api_keys_logto_m2m_app_id; +ALTER TABLE api_keys DROP COLUMN IF EXISTS logto_m2m_app_id; + +DROP INDEX IF EXISTS idx_divisions_sport; +DROP INDEX IF EXISTS idx_venues_sport; +DROP INDEX IF EXISTS idx_organizations_sport; +DROP INDEX IF EXISTS idx_leagues_sport; +DROP INDEX IF EXISTS idx_tournaments_sport; + +ALTER TABLE divisions DROP COLUMN IF EXISTS sport_id; +ALTER TABLE venues DROP COLUMN IF EXISTS sport_id; +ALTER TABLE organizations DROP COLUMN IF EXISTS sport_id; +ALTER TABLE leagues DROP COLUMN IF EXISTS sport_id; +ALTER TABLE tournaments DROP COLUMN IF EXISTS sport_id; + +DROP INDEX IF EXISTS idx_player_profiles_city_state; +DROP INDEX IF EXISTS idx_player_profiles_vair_id; +DROP INDEX IF EXISTS idx_player_profiles_dupr_id; +DROP TABLE IF EXISTS player_profiles; + +DROP INDEX IF EXISTS idx_users_logto_user_id; +ALTER TABLE users DROP COLUMN IF EXISTS logto_user_id; + +DROP INDEX IF EXISTS idx_sports_active; +DROP TABLE IF EXISTS sports; diff --git a/api/db/queries/api_keys.sql b/api/db/queries/api_keys.sql index 1a73651..84fa8f5 100644 --- a/api/db/queries/api_keys.sql +++ b/api/db/queries/api_keys.sql @@ -26,3 +26,20 @@ WHERE id = $1 AND user_id = $2; -- name: CountApiKeysByUser :one SELECT count(*) FROM api_keys WHERE user_id = $1 AND is_active = true; + +-- ============================================================================ +-- Logto M2M integration (Phase 2 additive). Phase 4 will rewrite +-- CreateApiKey to delegate to logto.CreateM2MApp and persist the +-- mirror row with logto_m2m_app_id; until then the legacy bcrypt +-- key path keeps working. +-- ============================================================================ + +-- name: GetAPIKeyByLogtoM2MAppID :one +SELECT * FROM api_keys WHERE logto_m2m_app_id = $1; + +-- name: SetAPIKeyLogtoM2MAppID :one +UPDATE api_keys SET + logto_m2m_app_id = $2, + updated_at = now() +WHERE id = $1 +RETURNING *; diff --git a/api/db/queries/player_profiles.sql b/api/db/queries/player_profiles.sql new file mode 100644 index 0000000..761deab --- /dev/null +++ b/api/db/queries/player_profiles.sql @@ -0,0 +1,63 @@ +-- api/db/queries/player_profiles.sql + +-- name: GetPlayerProfileRow :one +-- Note: named with the 'Row' suffix because the legacy +-- api/db/queries/players.sql still has a GetPlayerProfile that +-- queries the users table. Phase 6 cutover removes that query and +-- this one becomes the canonical lookup. +SELECT * FROM player_profiles WHERE user_id = $1; + +-- name: UpsertPlayerProfile :one +-- Insert or update the 1:1 profile row. Caller passes the full set of +-- columns; pre-fetch the existing row first if you want a partial +-- update, otherwise unset fields will be overwritten with the supplied +-- values (which may be NULL). +INSERT INTO player_profiles ( + user_id, + phone, dupr_id, vair_id, paddle_brand, paddle_model, + gender, handedness, date_of_birth, bio, + address_line_1, address_line_2, city, state_province, country, + postal_code, formatted_address, latitude, longitude, + emergency_contact_name, emergency_contact_phone, medical_notes, + waiver_accepted_at, avatar_url, is_profile_hidden, + updated_at +) VALUES ( + $1, + $2, $3, $4, $5, $6, + $7, $8, $9, $10, + $11, $12, $13, $14, $15, + $16, $17, $18, $19, + $20, $21, $22, + $23, $24, $25, + now() +) +ON CONFLICT (user_id) DO UPDATE SET + phone = EXCLUDED.phone, + dupr_id = EXCLUDED.dupr_id, + vair_id = EXCLUDED.vair_id, + paddle_brand = EXCLUDED.paddle_brand, + paddle_model = EXCLUDED.paddle_model, + gender = EXCLUDED.gender, + handedness = EXCLUDED.handedness, + date_of_birth = EXCLUDED.date_of_birth, + bio = EXCLUDED.bio, + address_line_1 = EXCLUDED.address_line_1, + address_line_2 = EXCLUDED.address_line_2, + city = EXCLUDED.city, + state_province = EXCLUDED.state_province, + country = EXCLUDED.country, + postal_code = EXCLUDED.postal_code, + formatted_address = EXCLUDED.formatted_address, + latitude = EXCLUDED.latitude, + longitude = EXCLUDED.longitude, + emergency_contact_name = EXCLUDED.emergency_contact_name, + emergency_contact_phone = EXCLUDED.emergency_contact_phone, + medical_notes = EXCLUDED.medical_notes, + waiver_accepted_at = EXCLUDED.waiver_accepted_at, + avatar_url = EXCLUDED.avatar_url, + is_profile_hidden = EXCLUDED.is_profile_hidden, + updated_at = now() +RETURNING *; + +-- name: DeletePlayerProfile :exec +DELETE FROM player_profiles WHERE user_id = $1; diff --git a/api/db/queries/sports.sql b/api/db/queries/sports.sql new file mode 100644 index 0000000..36174e7 --- /dev/null +++ b/api/db/queries/sports.sql @@ -0,0 +1,15 @@ +-- api/db/queries/sports.sql + +-- name: ListSports :many +SELECT * FROM sports +WHERE is_active = true +ORDER BY sort_order, name; + +-- name: GetSportByID :one +SELECT * FROM sports WHERE id = $1; + +-- name: GetSportBySlug :one +SELECT * FROM sports WHERE slug = $1; + +-- name: GetSportByLogtoOrgID :one +SELECT * FROM sports WHERE logto_org_id = $1; diff --git a/api/db/queries/users.sql b/api/db/queries/users.sql index fd8477f..8e7ad27 100644 --- a/api/db/queries/users.sql +++ b/api/db/queries/users.sql @@ -108,3 +108,25 @@ UPDATE users SET password_hash = $2, updated_at = now() WHERE id = $1 RETURNING *; + +-- ============================================================================ +-- Logto integration (Phase 2 additive). The legacy queries above continue +-- to function for the cookie-session code path; the queries below let +-- new code resolve users by their Logto user ID without touching +-- password_hash / role. +-- ============================================================================ + +-- name: GetUserByLogtoUserID :one +SELECT * FROM users +WHERE logto_user_id = $1 AND deleted_at IS NULL; + +-- name: SetUserLogtoUserID :one +-- Bind a Logto user ID to an existing local mirror row. Idempotent: +-- setting the same value twice is fine; setting a different value +-- when one is already bound fails on the partial UNIQUE index +-- idx_users_logto_user_id (the caller surfaces this as a 409). +UPDATE users SET + logto_user_id = $2, + updated_at = now() +WHERE id = $1 AND deleted_at IS NULL +RETURNING *; From 6c8190b8af5476fbf0809026133821855aa8e5da Mon Sep 17 00:00:00 2001 From: Daniel Velez Date: Fri, 1 May 2026 15:53:16 -0500 Subject: [PATCH 22/95] docs(health): note Phase 2 migration deploy verification approach Comment-only follow-up to ab54799 to force Coolify to pick up the preceding commit. The api/** watch path appears not to trigger on files in api/db/ subdirectories despite matching the glob -- pushing a change inside api/handler/ as the unambiguous trigger. --- api/handler/health.go | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/api/handler/health.go b/api/handler/health.go index 876ab15..8adca5b 100644 --- a/api/handler/health.go +++ b/api/handler/health.go @@ -21,6 +21,11 @@ import ( // "unknown" and operators rely on buildBuiltAt to confirm a fresh // deploy. // +// Phase 2 deploy: migration 00041 runs on startup via +// db.RunMigrations in main.go. A successful 200 response on this +// endpoint after a fresh built_at timestamp implicitly confirms the +// migration applied without error. +// //nolint:gochecknoglobals // build-time constants var ( buildCommit = "dev" From 68be0c5dec45363fd233e767f891ce54b7560405 Mon Sep 17 00:00:00 2001 From: Daniel Velez Date: Fri, 1 May 2026 16:00:47 -0500 Subject: [PATCH 23/95] fix(migration-00041): remove PLPGSQL DO block goose can't parse Migration 00041 used a DO $$ ... END $$ block to capture the Pickleball sport_id once and reuse it across five UPDATE statements. Goose's default SQL parser splits on semicolons and runs each piece separately, which fails on the internal BEGIN/DECLARE/SELECT INTO keywords because they only have meaning inside the PLPGSQL block. Goose offers '-- +goose StatementBegin' / 'StatementEnd' markers to opt out of splitting, but the simpler fix is to not use a DO block at all -- inline the (SELECT id FROM sports WHERE slug='pickleball') into each UPDATE. Backfill cost is identical (5 cheap subselects against a 2-row table at migration time, only runs once). This is the most likely reason the deploy of ab54799 stalled: the new container started, ran migrations, hit this error on 00041, and its health check failed -- so Coolify kept the old container running and the live built_at never advanced. With the DO block removed, goose should apply 00041 cleanly on the next deploy. --- .../00041_logto_schema_additive.sql | 52 +++++++++---------- 1 file changed, 25 insertions(+), 27 deletions(-) diff --git a/api/db/migrations/00041_logto_schema_additive.sql b/api/db/migrations/00041_logto_schema_additive.sql index 83cdfa7..ab4f2f8 100644 --- a/api/db/migrations/00041_logto_schema_additive.sql +++ b/api/db/migrations/00041_logto_schema_additive.sql @@ -110,33 +110,31 @@ CREATE INDEX idx_player_profiles_city_state -- All five columns are nullable for now and backfilled to Pickleball. -- Phase 6 cutover migration imposes NOT NULL after every Go writer has -- been updated to set sport_id explicitly. - -DO $$ -DECLARE - pickleball_id BIGINT; -BEGIN - SELECT id INTO pickleball_id FROM sports WHERE slug = 'pickleball'; - - -- tournaments - ALTER TABLE tournaments ADD COLUMN sport_id BIGINT REFERENCES sports(id); - UPDATE tournaments SET sport_id = pickleball_id WHERE sport_id IS NULL; - - -- leagues - ALTER TABLE leagues ADD COLUMN sport_id BIGINT REFERENCES sports(id); - UPDATE leagues SET sport_id = pickleball_id WHERE sport_id IS NULL; - - -- organizations - ALTER TABLE organizations ADD COLUMN sport_id BIGINT REFERENCES sports(id); - UPDATE organizations SET sport_id = pickleball_id WHERE sport_id IS NULL; - - -- venues - ALTER TABLE venues ADD COLUMN sport_id BIGINT REFERENCES sports(id); - UPDATE venues SET sport_id = pickleball_id WHERE sport_id IS NULL; - - -- divisions - ALTER TABLE divisions ADD COLUMN sport_id BIGINT REFERENCES sports(id); - UPDATE divisions SET sport_id = pickleball_id WHERE sport_id IS NULL; -END $$; +-- +-- Each backfill is written as a self-contained UPDATE that re-resolves +-- the Pickleball sport id rather than using a DO/PLPGSQL block (which +-- would require goose StatementBegin/End markers and is unnecessary +-- here -- the resolution is cheap and only runs at migration time). + +ALTER TABLE tournaments ADD COLUMN sport_id BIGINT REFERENCES sports(id); +UPDATE tournaments SET sport_id = (SELECT id FROM sports WHERE slug = 'pickleball') + WHERE sport_id IS NULL; + +ALTER TABLE leagues ADD COLUMN sport_id BIGINT REFERENCES sports(id); +UPDATE leagues SET sport_id = (SELECT id FROM sports WHERE slug = 'pickleball') + WHERE sport_id IS NULL; + +ALTER TABLE organizations ADD COLUMN sport_id BIGINT REFERENCES sports(id); +UPDATE organizations SET sport_id = (SELECT id FROM sports WHERE slug = 'pickleball') + WHERE sport_id IS NULL; + +ALTER TABLE venues ADD COLUMN sport_id BIGINT REFERENCES sports(id); +UPDATE venues SET sport_id = (SELECT id FROM sports WHERE slug = 'pickleball') + WHERE sport_id IS NULL; + +ALTER TABLE divisions ADD COLUMN sport_id BIGINT REFERENCES sports(id); +UPDATE divisions SET sport_id = (SELECT id FROM sports WHERE slug = 'pickleball') + WHERE sport_id IS NULL; CREATE INDEX idx_tournaments_sport ON tournaments(sport_id) WHERE deleted_at IS NULL; From 1730d3fbd83a1892b736a82c95ee967b940ad606 Mon Sep 17 00:00:00 2001 From: Daniel Velez Date: Fri, 1 May 2026 16:38:35 -0500 Subject: [PATCH 24/95] fix(db): apply Phase 2 final review findings (I-1, I-2, I-3) Three concrete query-shape fixes from the Phase 2 cross-package review, each closing an interface foot-gun that the next phase's caller code would otherwise inherit: I-1 (Important): GetAPIKeyByLogtoM2MAppID was inconsistent with the legacy GetApiKeyByHash because it didn't filter on is_active = true. Phase 4's request-auth path needs the active-only variant; admin tools (deactivation flows, audit lookups) need the unfiltered variant. Split into two queries with a comment block above each explaining the semantics: - GetAPIKeyByLogtoM2MAppID -- admin/management lookup - GetActiveAPIKeyByLogtoM2MAppID -- request-auth hot path I-2 (Important): SetUserLogtoUserID accepted a *string and would silently UNSET a binding if a buggy webhook handler passed nil. Tightened in two ways: - The param is now a non-pointer string via @logto_user_id::TEXT, so sqlc emits 'string' not '*string' and the type system makes it impossible to pass nil. - The WHERE clause now requires the existing logto_user_id to be NULL or equal to the incoming value -- attempting to overwrite an existing different binding returns 0 rows, which the caller will surface as 409. The partial UNIQUE index continues to prevent two users sharing a logto_user_id at the row level. Future need to clear a binding (e.g. account deletion) gets a separate ClearUserLogtoUserID query. I-3 (Important): UpsertPlayerProfile previously overwrote all 25 columns on conflict using EXCLUDED.col. The Phase 3 profile-edit form will send a partial payload; the old shape would wipe out phone/dupr_id/paddle/etc. when a user edited only their bio. Rewrote to a sqlc.narg-based partial pattern matching the existing players.sql:UpdatePlayerProfile convention -- NULL nargs leave the existing column unchanged via COALESCE. INSERT path treats NULL as unset; is_profile_hidden defaults to false. Generated bindings regenerated. The new UpsertPlayerProfileParams struct now has all 25 fields as nullable pointers / pgtype values and SetUserLogtoUserIDParams.LogtoUserID is a non-pointer string. I-4 (sport_id backfill defensive guard) deferred to the Phase 6 cutover plan -- belongs in the NOT NULL conversion migration, not this one. Local verification: go build ./... # exit 0 go vet ./... # exit 0 go test ./auth/... ./logto/... ./middleware/... -race # all PASS --- api/db/generated/api_keys.sql.go | 31 +++++++++ api/db/generated/divisions.sql.go | 18 ++++-- api/db/generated/leagues.sql.go | 24 ++++--- api/db/generated/models.go | 5 ++ api/db/generated/org_memberships.sql.go | 4 +- api/db/generated/organizations.sql.go | 22 ++++--- api/db/generated/player_profiles.sql.go | 84 ++++++++++++++----------- api/db/generated/tournaments.sql.go | 39 ++++++++---- api/db/generated/users.sql.go | 30 ++++++--- api/db/generated/venue_managers.sql.go | 3 +- api/db/generated/venues.sql.go | 24 ++++--- api/db/queries/api_keys.sql | 10 +++ api/db/queries/player_profiles.sql | 82 +++++++++++++----------- api/db/queries/users.sql | 26 ++++++-- 14 files changed, 273 insertions(+), 129 deletions(-) diff --git a/api/db/generated/api_keys.sql.go b/api/db/generated/api_keys.sql.go index 880a72d..50b5889 100644 --- a/api/db/generated/api_keys.sql.go +++ b/api/db/generated/api_keys.sql.go @@ -91,6 +91,10 @@ SELECT id, user_id, name, key_hash, key_prefix, scopes, expires_at, last_used_at // mirror row with logto_m2m_app_id; until then the legacy bcrypt // key path keeps working. // ============================================================================ +// Admin / management lookup by Logto M2M app ID. Does NOT filter on +// is_active because admin tools (Phase 4 webhook handlers, deactivation +// flows, audit trails) need to find rows regardless of state. The hot +// request-auth path uses GetActiveAPIKeyByLogtoM2MAppID instead. func (q *Queries) GetAPIKeyByLogtoM2MAppID(ctx context.Context, logtoM2mAppID *string) (ApiKey, error) { row := q.db.QueryRow(ctx, getAPIKeyByLogtoM2MAppID, logtoM2mAppID) var i ApiKey @@ -111,6 +115,33 @@ func (q *Queries) GetAPIKeyByLogtoM2MAppID(ctx context.Context, logtoM2mAppID *s return i, err } +const getActiveAPIKeyByLogtoM2MAppID = `-- name: GetActiveAPIKeyByLogtoM2MAppID :one +SELECT id, user_id, name, key_hash, key_prefix, scopes, expires_at, last_used_at, is_active, created_at, updated_at, logto_m2m_app_id FROM api_keys +WHERE logto_m2m_app_id = $1 AND is_active = true +` + +// Request-auth lookup: only returns the row if it is active. Mirrors +// the GetApiKeyByHash semantics on the legacy code path. +func (q *Queries) GetActiveAPIKeyByLogtoM2MAppID(ctx context.Context, logtoM2mAppID *string) (ApiKey, error) { + row := q.db.QueryRow(ctx, getActiveAPIKeyByLogtoM2MAppID, logtoM2mAppID) + var i ApiKey + err := row.Scan( + &i.ID, + &i.UserID, + &i.Name, + &i.KeyHash, + &i.KeyPrefix, + &i.Scopes, + &i.ExpiresAt, + &i.LastUsedAt, + &i.IsActive, + &i.CreatedAt, + &i.UpdatedAt, + &i.LogtoM2mAppID, + ) + return i, err +} + const getApiKeyByHash = `-- name: GetApiKeyByHash :one SELECT id, user_id, name, key_hash, key_prefix, scopes, expires_at, last_used_at, is_active, created_at, updated_at, logto_m2m_app_id FROM api_keys WHERE key_hash = $1 AND is_active = true diff --git a/api/db/generated/divisions.sql.go b/api/db/generated/divisions.sql.go index 4fac965..5de16dc 100644 --- a/api/db/generated/divisions.sql.go +++ b/api/db/generated/divisions.sql.go @@ -36,7 +36,7 @@ INSERT INTO divisions ( $1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17, $18, $19, $20, $21, $22, $23, $24, $25, $26, $27, $28, $29, $30 -) RETURNING id, tournament_id, name, slug, format, gender_restriction, age_restriction, skill_min, skill_max, rating_system, bracket_format, scoring_format, max_teams, max_roster_size, entry_fee_amount, entry_fee_currency, check_in_open, allow_self_check_in, status, seed_method, sort_order, notes, auto_approve, registration_mode, auto_promote_waitlist, grand_finals_reset, advancement_count, current_phase, report_to_dupr, report_to_vair, allow_ref_player_add, created_at, updated_at, deleted_at +) RETURNING id, tournament_id, name, slug, format, gender_restriction, age_restriction, skill_min, skill_max, rating_system, bracket_format, scoring_format, max_teams, max_roster_size, entry_fee_amount, entry_fee_currency, check_in_open, allow_self_check_in, status, seed_method, sort_order, notes, auto_approve, registration_mode, auto_promote_waitlist, grand_finals_reset, advancement_count, current_phase, report_to_dupr, report_to_vair, allow_ref_player_add, created_at, updated_at, deleted_at, sport_id ` type CreateDivisionParams struct { @@ -141,12 +141,13 @@ func (q *Queries) CreateDivision(ctx context.Context, arg CreateDivisionParams) &i.CreatedAt, &i.UpdatedAt, &i.DeletedAt, + &i.SportID, ) return i, err } const getDivisionByID = `-- name: GetDivisionByID :one -SELECT id, tournament_id, name, slug, format, gender_restriction, age_restriction, skill_min, skill_max, rating_system, bracket_format, scoring_format, max_teams, max_roster_size, entry_fee_amount, entry_fee_currency, check_in_open, allow_self_check_in, status, seed_method, sort_order, notes, auto_approve, registration_mode, auto_promote_waitlist, grand_finals_reset, advancement_count, current_phase, report_to_dupr, report_to_vair, allow_ref_player_add, created_at, updated_at, deleted_at FROM divisions WHERE id = $1 AND deleted_at IS NULL +SELECT id, tournament_id, name, slug, format, gender_restriction, age_restriction, skill_min, skill_max, rating_system, bracket_format, scoring_format, max_teams, max_roster_size, entry_fee_amount, entry_fee_currency, check_in_open, allow_self_check_in, status, seed_method, sort_order, notes, auto_approve, registration_mode, auto_promote_waitlist, grand_finals_reset, advancement_count, current_phase, report_to_dupr, report_to_vair, allow_ref_player_add, created_at, updated_at, deleted_at, sport_id FROM divisions WHERE id = $1 AND deleted_at IS NULL ` func (q *Queries) GetDivisionByID(ctx context.Context, id int64) (Division, error) { @@ -187,12 +188,13 @@ func (q *Queries) GetDivisionByID(ctx context.Context, id int64) (Division, erro &i.CreatedAt, &i.UpdatedAt, &i.DeletedAt, + &i.SportID, ) return i, err } const getDivisionBySlug = `-- name: GetDivisionBySlug :one -SELECT id, tournament_id, name, slug, format, gender_restriction, age_restriction, skill_min, skill_max, rating_system, bracket_format, scoring_format, max_teams, max_roster_size, entry_fee_amount, entry_fee_currency, check_in_open, allow_self_check_in, status, seed_method, sort_order, notes, auto_approve, registration_mode, auto_promote_waitlist, grand_finals_reset, advancement_count, current_phase, report_to_dupr, report_to_vair, allow_ref_player_add, created_at, updated_at, deleted_at FROM divisions +SELECT id, tournament_id, name, slug, format, gender_restriction, age_restriction, skill_min, skill_max, rating_system, bracket_format, scoring_format, max_teams, max_roster_size, entry_fee_amount, entry_fee_currency, check_in_open, allow_self_check_in, status, seed_method, sort_order, notes, auto_approve, registration_mode, auto_promote_waitlist, grand_finals_reset, advancement_count, current_phase, report_to_dupr, report_to_vair, allow_ref_player_add, created_at, updated_at, deleted_at, sport_id FROM divisions WHERE tournament_id = $1 AND slug = $2 AND deleted_at IS NULL ` @@ -239,12 +241,13 @@ func (q *Queries) GetDivisionBySlug(ctx context.Context, arg GetDivisionBySlugPa &i.CreatedAt, &i.UpdatedAt, &i.DeletedAt, + &i.SportID, ) return i, err } const getDivisionsByIDs = `-- name: GetDivisionsByIDs :many -SELECT id, tournament_id, name, slug, format, gender_restriction, age_restriction, skill_min, skill_max, rating_system, bracket_format, scoring_format, max_teams, max_roster_size, entry_fee_amount, entry_fee_currency, check_in_open, allow_self_check_in, status, seed_method, sort_order, notes, auto_approve, registration_mode, auto_promote_waitlist, grand_finals_reset, advancement_count, current_phase, report_to_dupr, report_to_vair, allow_ref_player_add, created_at, updated_at, deleted_at FROM divisions +SELECT id, tournament_id, name, slug, format, gender_restriction, age_restriction, skill_min, skill_max, rating_system, bracket_format, scoring_format, max_teams, max_roster_size, entry_fee_amount, entry_fee_currency, check_in_open, allow_self_check_in, status, seed_method, sort_order, notes, auto_approve, registration_mode, auto_promote_waitlist, grand_finals_reset, advancement_count, current_phase, report_to_dupr, report_to_vair, allow_ref_player_add, created_at, updated_at, deleted_at, sport_id FROM divisions WHERE id = ANY($1::bigint[]) AND deleted_at IS NULL ` @@ -292,6 +295,7 @@ func (q *Queries) GetDivisionsByIDs(ctx context.Context, dollar_1 []int64) ([]Di &i.CreatedAt, &i.UpdatedAt, &i.DeletedAt, + &i.SportID, ); err != nil { return nil, err } @@ -304,7 +308,7 @@ func (q *Queries) GetDivisionsByIDs(ctx context.Context, dollar_1 []int64) ([]Di } const listDivisionsByTournament = `-- name: ListDivisionsByTournament :many -SELECT id, tournament_id, name, slug, format, gender_restriction, age_restriction, skill_min, skill_max, rating_system, bracket_format, scoring_format, max_teams, max_roster_size, entry_fee_amount, entry_fee_currency, check_in_open, allow_self_check_in, status, seed_method, sort_order, notes, auto_approve, registration_mode, auto_promote_waitlist, grand_finals_reset, advancement_count, current_phase, report_to_dupr, report_to_vair, allow_ref_player_add, created_at, updated_at, deleted_at FROM divisions +SELECT id, tournament_id, name, slug, format, gender_restriction, age_restriction, skill_min, skill_max, rating_system, bracket_format, scoring_format, max_teams, max_roster_size, entry_fee_amount, entry_fee_currency, check_in_open, allow_self_check_in, status, seed_method, sort_order, notes, auto_approve, registration_mode, auto_promote_waitlist, grand_finals_reset, advancement_count, current_phase, report_to_dupr, report_to_vair, allow_ref_player_add, created_at, updated_at, deleted_at, sport_id FROM divisions WHERE tournament_id = $1 AND deleted_at IS NULL ORDER BY sort_order ASC, name ASC ` @@ -353,6 +357,7 @@ func (q *Queries) ListDivisionsByTournament(ctx context.Context, tournamentID in &i.CreatedAt, &i.UpdatedAt, &i.DeletedAt, + &i.SportID, ); err != nil { return nil, err } @@ -423,7 +428,7 @@ UPDATE divisions SET allow_ref_player_add = COALESCE($29, allow_ref_player_add), updated_at = NOW() WHERE id = $30 AND deleted_at IS NULL -RETURNING id, tournament_id, name, slug, format, gender_restriction, age_restriction, skill_min, skill_max, rating_system, bracket_format, scoring_format, max_teams, max_roster_size, entry_fee_amount, entry_fee_currency, check_in_open, allow_self_check_in, status, seed_method, sort_order, notes, auto_approve, registration_mode, auto_promote_waitlist, grand_finals_reset, advancement_count, current_phase, report_to_dupr, report_to_vair, allow_ref_player_add, created_at, updated_at, deleted_at +RETURNING id, tournament_id, name, slug, format, gender_restriction, age_restriction, skill_min, skill_max, rating_system, bracket_format, scoring_format, max_teams, max_roster_size, entry_fee_amount, entry_fee_currency, check_in_open, allow_self_check_in, status, seed_method, sort_order, notes, auto_approve, registration_mode, auto_promote_waitlist, grand_finals_reset, advancement_count, current_phase, report_to_dupr, report_to_vair, allow_ref_player_add, created_at, updated_at, deleted_at, sport_id ` type UpdateDivisionParams struct { @@ -528,6 +533,7 @@ func (q *Queries) UpdateDivision(ctx context.Context, arg UpdateDivisionParams) &i.CreatedAt, &i.UpdatedAt, &i.DeletedAt, + &i.SportID, ) return i, err } diff --git a/api/db/generated/leagues.sql.go b/api/db/generated/leagues.sql.go index 0658f40..d05fe20 100644 --- a/api/db/generated/leagues.sql.go +++ b/api/db/generated/leagues.sql.go @@ -60,7 +60,7 @@ INSERT INTO leagues ( rules_document_url, social_links, sponsor_info, notes, created_by_user_id ) VALUES ( $1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17, $18, $19, $20, $21, $22, $23 -) RETURNING id, public_id, name, slug, status, logo_url, banner_url, description, website_url, contact_email, contact_phone, city, state_province, country, rules_document_url, social_links, sponsor_info, notes, created_by_user_id, created_at, updated_at, deleted_at, address_line_1, address_line_2, postal_code, latitude, longitude, formatted_address +) RETURNING id, public_id, name, slug, status, logo_url, banner_url, description, website_url, contact_email, contact_phone, city, state_province, country, rules_document_url, social_links, sponsor_info, notes, created_by_user_id, created_at, updated_at, deleted_at, address_line_1, address_line_2, postal_code, latitude, longitude, formatted_address, sport_id ` type CreateLeagueParams struct { @@ -145,12 +145,13 @@ func (q *Queries) CreateLeague(ctx context.Context, arg CreateLeagueParams) (Lea &i.Latitude, &i.Longitude, &i.FormattedAddress, + &i.SportID, ) return i, err } const getLeagueByID = `-- name: GetLeagueByID :one -SELECT id, public_id, name, slug, status, logo_url, banner_url, description, website_url, contact_email, contact_phone, city, state_province, country, rules_document_url, social_links, sponsor_info, notes, created_by_user_id, created_at, updated_at, deleted_at, address_line_1, address_line_2, postal_code, latitude, longitude, formatted_address FROM leagues WHERE id = $1 AND deleted_at IS NULL +SELECT id, public_id, name, slug, status, logo_url, banner_url, description, website_url, contact_email, contact_phone, city, state_province, country, rules_document_url, social_links, sponsor_info, notes, created_by_user_id, created_at, updated_at, deleted_at, address_line_1, address_line_2, postal_code, latitude, longitude, formatted_address, sport_id FROM leagues WHERE id = $1 AND deleted_at IS NULL ` func (q *Queries) GetLeagueByID(ctx context.Context, id int64) (League, error) { @@ -185,12 +186,13 @@ func (q *Queries) GetLeagueByID(ctx context.Context, id int64) (League, error) { &i.Latitude, &i.Longitude, &i.FormattedAddress, + &i.SportID, ) return i, err } const getLeagueByPublicID = `-- name: GetLeagueByPublicID :one -SELECT id, public_id, name, slug, status, logo_url, banner_url, description, website_url, contact_email, contact_phone, city, state_province, country, rules_document_url, social_links, sponsor_info, notes, created_by_user_id, created_at, updated_at, deleted_at, address_line_1, address_line_2, postal_code, latitude, longitude, formatted_address FROM leagues WHERE public_id = $1 AND deleted_at IS NULL +SELECT id, public_id, name, slug, status, logo_url, banner_url, description, website_url, contact_email, contact_phone, city, state_province, country, rules_document_url, social_links, sponsor_info, notes, created_by_user_id, created_at, updated_at, deleted_at, address_line_1, address_line_2, postal_code, latitude, longitude, formatted_address, sport_id FROM leagues WHERE public_id = $1 AND deleted_at IS NULL ` func (q *Queries) GetLeagueByPublicID(ctx context.Context, publicID string) (League, error) { @@ -225,12 +227,13 @@ func (q *Queries) GetLeagueByPublicID(ctx context.Context, publicID string) (Lea &i.Latitude, &i.Longitude, &i.FormattedAddress, + &i.SportID, ) return i, err } const getLeagueBySlug = `-- name: GetLeagueBySlug :one -SELECT id, public_id, name, slug, status, logo_url, banner_url, description, website_url, contact_email, contact_phone, city, state_province, country, rules_document_url, social_links, sponsor_info, notes, created_by_user_id, created_at, updated_at, deleted_at, address_line_1, address_line_2, postal_code, latitude, longitude, formatted_address FROM leagues WHERE slug = $1 AND deleted_at IS NULL +SELECT id, public_id, name, slug, status, logo_url, banner_url, description, website_url, contact_email, contact_phone, city, state_province, country, rules_document_url, social_links, sponsor_info, notes, created_by_user_id, created_at, updated_at, deleted_at, address_line_1, address_line_2, postal_code, latitude, longitude, formatted_address, sport_id FROM leagues WHERE slug = $1 AND deleted_at IS NULL ` func (q *Queries) GetLeagueBySlug(ctx context.Context, slug string) (League, error) { @@ -265,12 +268,13 @@ func (q *Queries) GetLeagueBySlug(ctx context.Context, slug string) (League, err &i.Latitude, &i.Longitude, &i.FormattedAddress, + &i.SportID, ) return i, err } const listLeagues = `-- name: ListLeagues :many -SELECT id, public_id, name, slug, status, logo_url, banner_url, description, website_url, contact_email, contact_phone, city, state_province, country, rules_document_url, social_links, sponsor_info, notes, created_by_user_id, created_at, updated_at, deleted_at, address_line_1, address_line_2, postal_code, latitude, longitude, formatted_address FROM leagues +SELECT id, public_id, name, slug, status, logo_url, banner_url, description, website_url, contact_email, contact_phone, city, state_province, country, rules_document_url, social_links, sponsor_info, notes, created_by_user_id, created_at, updated_at, deleted_at, address_line_1, address_line_2, postal_code, latitude, longitude, formatted_address, sport_id FROM leagues WHERE deleted_at IS NULL ORDER BY created_at DESC LIMIT $1 OFFSET $2 @@ -319,6 +323,7 @@ func (q *Queries) ListLeagues(ctx context.Context, arg ListLeaguesParams) ([]Lea &i.Latitude, &i.Longitude, &i.FormattedAddress, + &i.SportID, ); err != nil { return nil, err } @@ -331,7 +336,7 @@ func (q *Queries) ListLeagues(ctx context.Context, arg ListLeaguesParams) ([]Lea } const listLeaguesByCreator = `-- name: ListLeaguesByCreator :many -SELECT id, public_id, name, slug, status, logo_url, banner_url, description, website_url, contact_email, contact_phone, city, state_province, country, rules_document_url, social_links, sponsor_info, notes, created_by_user_id, created_at, updated_at, deleted_at, address_line_1, address_line_2, postal_code, latitude, longitude, formatted_address FROM leagues +SELECT id, public_id, name, slug, status, logo_url, banner_url, description, website_url, contact_email, contact_phone, city, state_province, country, rules_document_url, social_links, sponsor_info, notes, created_by_user_id, created_at, updated_at, deleted_at, address_line_1, address_line_2, postal_code, latitude, longitude, formatted_address, sport_id FROM leagues WHERE created_by_user_id = $1 AND deleted_at IS NULL ORDER BY created_at DESC LIMIT $2 OFFSET $3 @@ -381,6 +386,7 @@ func (q *Queries) ListLeaguesByCreator(ctx context.Context, arg ListLeaguesByCre &i.Latitude, &i.Longitude, &i.FormattedAddress, + &i.SportID, ); err != nil { return nil, err } @@ -393,7 +399,7 @@ func (q *Queries) ListLeaguesByCreator(ctx context.Context, arg ListLeaguesByCre } const searchLeagues = `-- name: SearchLeagues :many -SELECT id, public_id, name, slug, status, logo_url, banner_url, description, website_url, contact_email, contact_phone, city, state_province, country, rules_document_url, social_links, sponsor_info, notes, created_by_user_id, created_at, updated_at, deleted_at, address_line_1, address_line_2, postal_code, latitude, longitude, formatted_address FROM leagues +SELECT id, public_id, name, slug, status, logo_url, banner_url, description, website_url, contact_email, contact_phone, city, state_province, country, rules_document_url, social_links, sponsor_info, notes, created_by_user_id, created_at, updated_at, deleted_at, address_line_1, address_line_2, postal_code, latitude, longitude, formatted_address, sport_id FROM leagues WHERE deleted_at IS NULL AND ( name ILIKE '%' || $3::TEXT || '%' @@ -449,6 +455,7 @@ func (q *Queries) SearchLeagues(ctx context.Context, arg SearchLeaguesParams) ([ &i.Latitude, &i.Longitude, &i.FormattedAddress, + &i.SportID, ); err != nil { return nil, err } @@ -507,7 +514,7 @@ UPDATE leagues SET notes = COALESCE($22, notes), updated_at = NOW() WHERE id = $23 AND deleted_at IS NULL -RETURNING id, public_id, name, slug, status, logo_url, banner_url, description, website_url, contact_email, contact_phone, city, state_province, country, rules_document_url, social_links, sponsor_info, notes, created_by_user_id, created_at, updated_at, deleted_at, address_line_1, address_line_2, postal_code, latitude, longitude, formatted_address +RETURNING id, public_id, name, slug, status, logo_url, banner_url, description, website_url, contact_email, contact_phone, city, state_province, country, rules_document_url, social_links, sponsor_info, notes, created_by_user_id, created_at, updated_at, deleted_at, address_line_1, address_line_2, postal_code, latitude, longitude, formatted_address, sport_id ` type UpdateLeagueParams struct { @@ -592,6 +599,7 @@ func (q *Queries) UpdateLeague(ctx context.Context, arg UpdateLeagueParams) (Lea &i.Latitude, &i.Longitude, &i.FormattedAddress, + &i.SportID, ) return i, err } diff --git a/api/db/generated/models.go b/api/db/generated/models.go index 3b2c419..0c0d6c5 100644 --- a/api/db/generated/models.go +++ b/api/db/generated/models.go @@ -141,6 +141,7 @@ type Division struct { CreatedAt time.Time `json:"created_at"` UpdatedAt time.Time `json:"updated_at"` DeletedAt pgtype.Timestamptz `json:"deleted_at"` + SportID pgtype.Int8 `json:"sport_id"` } type DivisionTemplate struct { @@ -205,6 +206,7 @@ type League struct { Latitude pgtype.Float8 `json:"latitude"` Longitude pgtype.Float8 `json:"longitude"` FormattedAddress *string `json:"formatted_address"` + SportID pgtype.Int8 `json:"sport_id"` } type LeagueRegistration struct { @@ -363,6 +365,7 @@ type Organization struct { Latitude pgtype.Float8 `json:"latitude"` Longitude pgtype.Float8 `json:"longitude"` FormattedAddress *string `json:"formatted_address"` + SportID pgtype.Int8 `json:"sport_id"` } type PlayerProfile struct { @@ -589,6 +592,7 @@ type Tournament struct { CreatedAt time.Time `json:"created_at"` UpdatedAt time.Time `json:"updated_at"` DeletedAt pgtype.Timestamptz `json:"deleted_at"` + SportID pgtype.Int8 `json:"sport_id"` } type TournamentCourt struct { @@ -693,6 +697,7 @@ type Venue struct { UpdatedAt time.Time `json:"updated_at"` DeletedAt pgtype.Timestamptz `json:"deleted_at"` FormattedAddress *string `json:"formatted_address"` + SportID pgtype.Int8 `json:"sport_id"` } type VenueManager struct { diff --git a/api/db/generated/org_memberships.sql.go b/api/db/generated/org_memberships.sql.go index 6cff523..80f0007 100644 --- a/api/db/generated/org_memberships.sql.go +++ b/api/db/generated/org_memberships.sql.go @@ -138,7 +138,7 @@ func (q *Queries) GetOrgMembers(ctx context.Context, orgID int64) ([]GetOrgMembe } const getPlayerOrgs = `-- name: GetPlayerOrgs :many -SELECT o.id, o.name, o.slug, o.logo_url, o.primary_color, o.secondary_color, o.website_url, o.contact_email, o.contact_phone, o.city, o.state_province, o.country, o.bio, o.founded_year, o.social_links, o.created_by_user_id, o.created_at, o.updated_at, o.deleted_at, o.address_line_1, o.address_line_2, o.postal_code, o.latitude, o.longitude, o.formatted_address, om.role AS membership_role, om.joined_at AS membership_joined_at +SELECT o.id, o.name, o.slug, o.logo_url, o.primary_color, o.secondary_color, o.website_url, o.contact_email, o.contact_phone, o.city, o.state_province, o.country, o.bio, o.founded_year, o.social_links, o.created_by_user_id, o.created_at, o.updated_at, o.deleted_at, o.address_line_1, o.address_line_2, o.postal_code, o.latitude, o.longitude, o.formatted_address, o.sport_id, om.role AS membership_role, om.joined_at AS membership_joined_at FROM org_memberships om JOIN organizations o ON o.id = om.org_id WHERE om.player_id = $1 AND om.left_at IS NULL AND o.deleted_at IS NULL @@ -171,6 +171,7 @@ type GetPlayerOrgsRow struct { Latitude pgtype.Float8 `json:"latitude"` Longitude pgtype.Float8 `json:"longitude"` FormattedAddress *string `json:"formatted_address"` + SportID pgtype.Int8 `json:"sport_id"` MembershipRole string `json:"membership_role"` MembershipJoinedAt time.Time `json:"membership_joined_at"` } @@ -210,6 +211,7 @@ func (q *Queries) GetPlayerOrgs(ctx context.Context, playerID int64) ([]GetPlaye &i.Latitude, &i.Longitude, &i.FormattedAddress, + &i.SportID, &i.MembershipRole, &i.MembershipJoinedAt, ); err != nil { diff --git a/api/db/generated/organizations.sql.go b/api/db/generated/organizations.sql.go index 185df0a..59b0ece 100644 --- a/api/db/generated/organizations.sql.go +++ b/api/db/generated/organizations.sql.go @@ -70,7 +70,7 @@ func (q *Queries) CountSearchOrgs(ctx context.Context, arg CountSearchOrgsParams const createOrganization = `-- name: CreateOrganization :one INSERT INTO organizations (name, slug, logo_url, primary_color, secondary_color, website_url, contact_email, contact_phone, city, state_province, country, postal_code, address_line_1, address_line_2, formatted_address, latitude, longitude, bio, founded_year, social_links, created_by_user_id) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17, $18, $19, $20, $21) -RETURNING id, name, slug, logo_url, primary_color, secondary_color, website_url, contact_email, contact_phone, city, state_province, country, bio, founded_year, social_links, created_by_user_id, created_at, updated_at, deleted_at, address_line_1, address_line_2, postal_code, latitude, longitude, formatted_address +RETURNING id, name, slug, logo_url, primary_color, secondary_color, website_url, contact_email, contact_phone, city, state_province, country, bio, founded_year, social_links, created_by_user_id, created_at, updated_at, deleted_at, address_line_1, address_line_2, postal_code, latitude, longitude, formatted_address, sport_id ` type CreateOrganizationParams struct { @@ -148,12 +148,13 @@ func (q *Queries) CreateOrganization(ctx context.Context, arg CreateOrganization &i.Latitude, &i.Longitude, &i.FormattedAddress, + &i.SportID, ) return i, err } const getOrgByID = `-- name: GetOrgByID :one -SELECT id, name, slug, logo_url, primary_color, secondary_color, website_url, contact_email, contact_phone, city, state_province, country, bio, founded_year, social_links, created_by_user_id, created_at, updated_at, deleted_at, address_line_1, address_line_2, postal_code, latitude, longitude, formatted_address FROM organizations +SELECT id, name, slug, logo_url, primary_color, secondary_color, website_url, contact_email, contact_phone, city, state_province, country, bio, founded_year, social_links, created_by_user_id, created_at, updated_at, deleted_at, address_line_1, address_line_2, postal_code, latitude, longitude, formatted_address, sport_id FROM organizations WHERE id = $1 AND deleted_at IS NULL ` @@ -186,12 +187,13 @@ func (q *Queries) GetOrgByID(ctx context.Context, id int64) (Organization, error &i.Latitude, &i.Longitude, &i.FormattedAddress, + &i.SportID, ) return i, err } const getOrgBySlug = `-- name: GetOrgBySlug :one -SELECT id, name, slug, logo_url, primary_color, secondary_color, website_url, contact_email, contact_phone, city, state_province, country, bio, founded_year, social_links, created_by_user_id, created_at, updated_at, deleted_at, address_line_1, address_line_2, postal_code, latitude, longitude, formatted_address FROM organizations +SELECT id, name, slug, logo_url, primary_color, secondary_color, website_url, contact_email, contact_phone, city, state_province, country, bio, founded_year, social_links, created_by_user_id, created_at, updated_at, deleted_at, address_line_1, address_line_2, postal_code, latitude, longitude, formatted_address, sport_id FROM organizations WHERE slug = $1 AND deleted_at IS NULL ` @@ -224,12 +226,13 @@ func (q *Queries) GetOrgBySlug(ctx context.Context, slug string) (Organization, &i.Latitude, &i.Longitude, &i.FormattedAddress, + &i.SportID, ) return i, err } const listOrgs = `-- name: ListOrgs :many -SELECT id, name, slug, logo_url, primary_color, secondary_color, website_url, contact_email, contact_phone, city, state_province, country, bio, founded_year, social_links, created_by_user_id, created_at, updated_at, deleted_at, address_line_1, address_line_2, postal_code, latitude, longitude, formatted_address FROM organizations +SELECT id, name, slug, logo_url, primary_color, secondary_color, website_url, contact_email, contact_phone, city, state_province, country, bio, founded_year, social_links, created_by_user_id, created_at, updated_at, deleted_at, address_line_1, address_line_2, postal_code, latitude, longitude, formatted_address, sport_id FROM organizations WHERE deleted_at IS NULL ORDER BY name LIMIT $1 OFFSET $2 @@ -275,6 +278,7 @@ func (q *Queries) ListOrgs(ctx context.Context, arg ListOrgsParams) ([]Organizat &i.Latitude, &i.Longitude, &i.FormattedAddress, + &i.SportID, ); err != nil { return nil, err } @@ -287,7 +291,7 @@ func (q *Queries) ListOrgs(ctx context.Context, arg ListOrgsParams) ([]Organizat } const listOrgsByUser = `-- name: ListOrgsByUser :many -SELECT o.id, o.name, o.slug, o.logo_url, o.primary_color, o.secondary_color, o.website_url, o.contact_email, o.contact_phone, o.city, o.state_province, o.country, o.bio, o.founded_year, o.social_links, o.created_by_user_id, o.created_at, o.updated_at, o.deleted_at, o.address_line_1, o.address_line_2, o.postal_code, o.latitude, o.longitude, o.formatted_address, om.role AS membership_role +SELECT o.id, o.name, o.slug, o.logo_url, o.primary_color, o.secondary_color, o.website_url, o.contact_email, o.contact_phone, o.city, o.state_province, o.country, o.bio, o.founded_year, o.social_links, o.created_by_user_id, o.created_at, o.updated_at, o.deleted_at, o.address_line_1, o.address_line_2, o.postal_code, o.latitude, o.longitude, o.formatted_address, o.sport_id, om.role AS membership_role FROM organizations o JOIN org_memberships om ON om.org_id = o.id WHERE om.player_id = $1 AND om.left_at IS NULL AND o.deleted_at IS NULL @@ -320,6 +324,7 @@ type ListOrgsByUserRow struct { Latitude pgtype.Float8 `json:"latitude"` Longitude pgtype.Float8 `json:"longitude"` FormattedAddress *string `json:"formatted_address"` + SportID pgtype.Int8 `json:"sport_id"` MembershipRole string `json:"membership_role"` } @@ -358,6 +363,7 @@ func (q *Queries) ListOrgsByUser(ctx context.Context, playerID int64) ([]ListOrg &i.Latitude, &i.Longitude, &i.FormattedAddress, + &i.SportID, &i.MembershipRole, ); err != nil { return nil, err @@ -371,7 +377,7 @@ func (q *Queries) ListOrgsByUser(ctx context.Context, playerID int64) ([]ListOrg } const searchOrgs = `-- name: SearchOrgs :many -SELECT id, name, slug, logo_url, primary_color, secondary_color, website_url, contact_email, contact_phone, city, state_province, country, bio, founded_year, social_links, created_by_user_id, created_at, updated_at, deleted_at, address_line_1, address_line_2, postal_code, latitude, longitude, formatted_address FROM organizations +SELECT id, name, slug, logo_url, primary_color, secondary_color, website_url, contact_email, contact_phone, city, state_province, country, bio, founded_year, social_links, created_by_user_id, created_at, updated_at, deleted_at, address_line_1, address_line_2, postal_code, latitude, longitude, formatted_address, sport_id FROM organizations WHERE deleted_at IS NULL AND ( $3::TEXT IS NULL @@ -435,6 +441,7 @@ func (q *Queries) SearchOrgs(ctx context.Context, arg SearchOrgsParams) ([]Organ &i.Latitude, &i.Longitude, &i.FormattedAddress, + &i.SportID, ); err != nil { return nil, err } @@ -481,7 +488,7 @@ UPDATE organizations SET social_links = COALESCE($19, social_links), updated_at = now() WHERE id = $20 AND deleted_at IS NULL -RETURNING id, name, slug, logo_url, primary_color, secondary_color, website_url, contact_email, contact_phone, city, state_province, country, bio, founded_year, social_links, created_by_user_id, created_at, updated_at, deleted_at, address_line_1, address_line_2, postal_code, latitude, longitude, formatted_address +RETURNING id, name, slug, logo_url, primary_color, secondary_color, website_url, contact_email, contact_phone, city, state_province, country, bio, founded_year, social_links, created_by_user_id, created_at, updated_at, deleted_at, address_line_1, address_line_2, postal_code, latitude, longitude, formatted_address, sport_id ` type UpdateOrgParams struct { @@ -557,6 +564,7 @@ func (q *Queries) UpdateOrg(ctx context.Context, arg UpdateOrgParams) (Organizat &i.Latitude, &i.Longitude, &i.FormattedAddress, + &i.SportID, ) return i, err } diff --git a/api/db/generated/player_profiles.sql.go b/api/db/generated/player_profiles.sql.go index 76a2ac4..683e7db 100644 --- a/api/db/generated/player_profiles.sql.go +++ b/api/db/generated/player_profiles.sql.go @@ -75,40 +75,46 @@ INSERT INTO player_profiles ( waiver_accepted_at, avatar_url, is_profile_hidden, updated_at ) VALUES ( - $1, - $2, $3, $4, $5, $6, - $7, $8, $9, $10, - $11, $12, $13, $14, $15, - $16, $17, $18, $19, - $20, $21, $22, - $23, $24, $25, + $1::BIGINT, + $2, $3, $4, + $5, $6, + $7, $8, + $9, $10, + $11, $12, + $13, $14, $15, + $16, $17, + $18, $19, + $20, $21, + $22, + $23, $24, + COALESCE($25::BOOLEAN, false), now() ) ON CONFLICT (user_id) DO UPDATE SET - phone = EXCLUDED.phone, - dupr_id = EXCLUDED.dupr_id, - vair_id = EXCLUDED.vair_id, - paddle_brand = EXCLUDED.paddle_brand, - paddle_model = EXCLUDED.paddle_model, - gender = EXCLUDED.gender, - handedness = EXCLUDED.handedness, - date_of_birth = EXCLUDED.date_of_birth, - bio = EXCLUDED.bio, - address_line_1 = EXCLUDED.address_line_1, - address_line_2 = EXCLUDED.address_line_2, - city = EXCLUDED.city, - state_province = EXCLUDED.state_province, - country = EXCLUDED.country, - postal_code = EXCLUDED.postal_code, - formatted_address = EXCLUDED.formatted_address, - latitude = EXCLUDED.latitude, - longitude = EXCLUDED.longitude, - emergency_contact_name = EXCLUDED.emergency_contact_name, - emergency_contact_phone = EXCLUDED.emergency_contact_phone, - medical_notes = EXCLUDED.medical_notes, - waiver_accepted_at = EXCLUDED.waiver_accepted_at, - avatar_url = EXCLUDED.avatar_url, - is_profile_hidden = EXCLUDED.is_profile_hidden, + phone = COALESCE($2, player_profiles.phone), + dupr_id = COALESCE($3, player_profiles.dupr_id), + vair_id = COALESCE($4, player_profiles.vair_id), + paddle_brand = COALESCE($5, player_profiles.paddle_brand), + paddle_model = COALESCE($6, player_profiles.paddle_model), + gender = COALESCE($7, player_profiles.gender), + handedness = COALESCE($8, player_profiles.handedness), + date_of_birth = COALESCE($9, player_profiles.date_of_birth), + bio = COALESCE($10, player_profiles.bio), + address_line_1 = COALESCE($11, player_profiles.address_line_1), + address_line_2 = COALESCE($12, player_profiles.address_line_2), + city = COALESCE($13, player_profiles.city), + state_province = COALESCE($14, player_profiles.state_province), + country = COALESCE($15, player_profiles.country), + postal_code = COALESCE($16, player_profiles.postal_code), + formatted_address = COALESCE($17, player_profiles.formatted_address), + latitude = COALESCE($18, player_profiles.latitude), + longitude = COALESCE($19, player_profiles.longitude), + emergency_contact_name = COALESCE($20, player_profiles.emergency_contact_name), + emergency_contact_phone = COALESCE($21, player_profiles.emergency_contact_phone), + medical_notes = COALESCE($22, player_profiles.medical_notes), + waiver_accepted_at = COALESCE($23, player_profiles.waiver_accepted_at), + avatar_url = COALESCE($24, player_profiles.avatar_url), + is_profile_hidden = COALESCE($25, player_profiles.is_profile_hidden), updated_at = now() RETURNING user_id, phone, dupr_id, vair_id, paddle_brand, paddle_model, gender, handedness, date_of_birth, bio, address_line_1, address_line_2, city, state_province, country, postal_code, formatted_address, latitude, longitude, emergency_contact_name, emergency_contact_phone, medical_notes, waiver_accepted_at, avatar_url, is_profile_hidden, updated_at ` @@ -138,13 +144,19 @@ type UpsertPlayerProfileParams struct { MedicalNotes *string `json:"medical_notes"` WaiverAcceptedAt pgtype.Timestamptz `json:"waiver_accepted_at"` AvatarUrl *string `json:"avatar_url"` - IsProfileHidden bool `json:"is_profile_hidden"` + IsProfileHidden pgtype.Bool `json:"is_profile_hidden"` } -// Insert or update the 1:1 profile row. Caller passes the full set of -// columns; pre-fetch the existing row first if you want a partial -// update, otherwise unset fields will be overwritten with the supplied -// values (which may be NULL). +// Insert or partial-update the 1:1 profile row. Every field is optional +// (sqlc.narg). On INSERT, NULL nargs become NULL in the new row. On +// UPDATE, NULL nargs leave the existing column value unchanged via +// COALESCE. is_profile_hidden defaults to false on insert. +// +// This shape mirrors the existing players.sql:UpdatePlayerProfile narg +// pattern, so a Phase 3 profile-edit form that sends only the fields +// the user actually changed will not blow away the rest. To explicitly +// clear a field, pass an empty-string-coerced override at the call +// site or add a dedicated ClearPlayerProfileField query. func (q *Queries) UpsertPlayerProfile(ctx context.Context, arg UpsertPlayerProfileParams) (PlayerProfile, error) { row := q.db.QueryRow(ctx, upsertPlayerProfile, arg.UserID, diff --git a/api/db/generated/tournaments.sql.go b/api/db/generated/tournaments.sql.go index ba80c7c..53f0aca 100644 --- a/api/db/generated/tournaments.sql.go +++ b/api/db/generated/tournaments.sql.go @@ -107,7 +107,7 @@ INSERT INTO tournaments ( ) VALUES ( $1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17, $18, $19, $20, $21, $22, $23, $24 -) RETURNING id, public_id, name, slug, status, start_date, end_date, venue_id, league_id, season_id, description, logo_url, banner_url, contact_email, contact_phone, website_url, registration_open_at, registration_close_at, max_participants, rules_document_url, cancellation_reason, social_links, notes, sponsor_info, show_registrations, created_by_user_id, td_user_id, created_at, updated_at, deleted_at +) RETURNING id, public_id, name, slug, status, start_date, end_date, venue_id, league_id, season_id, description, logo_url, banner_url, contact_email, contact_phone, website_url, registration_open_at, registration_close_at, max_participants, rules_document_url, cancellation_reason, social_links, notes, sponsor_info, show_registrations, created_by_user_id, td_user_id, created_at, updated_at, deleted_at, sport_id ` type CreateTournamentParams struct { @@ -196,12 +196,13 @@ func (q *Queries) CreateTournament(ctx context.Context, arg CreateTournamentPara &i.CreatedAt, &i.UpdatedAt, &i.DeletedAt, + &i.SportID, ) return i, err } const getTournamentByID = `-- name: GetTournamentByID :one -SELECT id, public_id, name, slug, status, start_date, end_date, venue_id, league_id, season_id, description, logo_url, banner_url, contact_email, contact_phone, website_url, registration_open_at, registration_close_at, max_participants, rules_document_url, cancellation_reason, social_links, notes, sponsor_info, show_registrations, created_by_user_id, td_user_id, created_at, updated_at, deleted_at FROM tournaments WHERE id = $1 AND deleted_at IS NULL +SELECT id, public_id, name, slug, status, start_date, end_date, venue_id, league_id, season_id, description, logo_url, banner_url, contact_email, contact_phone, website_url, registration_open_at, registration_close_at, max_participants, rules_document_url, cancellation_reason, social_links, notes, sponsor_info, show_registrations, created_by_user_id, td_user_id, created_at, updated_at, deleted_at, sport_id FROM tournaments WHERE id = $1 AND deleted_at IS NULL ` func (q *Queries) GetTournamentByID(ctx context.Context, id int64) (Tournament, error) { @@ -238,12 +239,13 @@ func (q *Queries) GetTournamentByID(ctx context.Context, id int64) (Tournament, &i.CreatedAt, &i.UpdatedAt, &i.DeletedAt, + &i.SportID, ) return i, err } const getTournamentByPublicID = `-- name: GetTournamentByPublicID :one -SELECT id, public_id, name, slug, status, start_date, end_date, venue_id, league_id, season_id, description, logo_url, banner_url, contact_email, contact_phone, website_url, registration_open_at, registration_close_at, max_participants, rules_document_url, cancellation_reason, social_links, notes, sponsor_info, show_registrations, created_by_user_id, td_user_id, created_at, updated_at, deleted_at FROM tournaments WHERE public_id = $1 AND deleted_at IS NULL +SELECT id, public_id, name, slug, status, start_date, end_date, venue_id, league_id, season_id, description, logo_url, banner_url, contact_email, contact_phone, website_url, registration_open_at, registration_close_at, max_participants, rules_document_url, cancellation_reason, social_links, notes, sponsor_info, show_registrations, created_by_user_id, td_user_id, created_at, updated_at, deleted_at, sport_id FROM tournaments WHERE public_id = $1 AND deleted_at IS NULL ` func (q *Queries) GetTournamentByPublicID(ctx context.Context, publicID string) (Tournament, error) { @@ -280,12 +282,13 @@ func (q *Queries) GetTournamentByPublicID(ctx context.Context, publicID string) &i.CreatedAt, &i.UpdatedAt, &i.DeletedAt, + &i.SportID, ) return i, err } const getTournamentBySlug = `-- name: GetTournamentBySlug :one -SELECT id, public_id, name, slug, status, start_date, end_date, venue_id, league_id, season_id, description, logo_url, banner_url, contact_email, contact_phone, website_url, registration_open_at, registration_close_at, max_participants, rules_document_url, cancellation_reason, social_links, notes, sponsor_info, show_registrations, created_by_user_id, td_user_id, created_at, updated_at, deleted_at FROM tournaments WHERE slug = $1 AND deleted_at IS NULL +SELECT id, public_id, name, slug, status, start_date, end_date, venue_id, league_id, season_id, description, logo_url, banner_url, contact_email, contact_phone, website_url, registration_open_at, registration_close_at, max_participants, rules_document_url, cancellation_reason, social_links, notes, sponsor_info, show_registrations, created_by_user_id, td_user_id, created_at, updated_at, deleted_at, sport_id FROM tournaments WHERE slug = $1 AND deleted_at IS NULL ` func (q *Queries) GetTournamentBySlug(ctx context.Context, slug string) (Tournament, error) { @@ -322,12 +325,13 @@ func (q *Queries) GetTournamentBySlug(ctx context.Context, slug string) (Tournam &i.CreatedAt, &i.UpdatedAt, &i.DeletedAt, + &i.SportID, ) return i, err } const getTournamentsByIDs = `-- name: GetTournamentsByIDs :many -SELECT id, public_id, name, slug, status, start_date, end_date, venue_id, league_id, season_id, description, logo_url, banner_url, contact_email, contact_phone, website_url, registration_open_at, registration_close_at, max_participants, rules_document_url, cancellation_reason, social_links, notes, sponsor_info, show_registrations, created_by_user_id, td_user_id, created_at, updated_at, deleted_at FROM tournaments +SELECT id, public_id, name, slug, status, start_date, end_date, venue_id, league_id, season_id, description, logo_url, banner_url, contact_email, contact_phone, website_url, registration_open_at, registration_close_at, max_participants, rules_document_url, cancellation_reason, social_links, notes, sponsor_info, show_registrations, created_by_user_id, td_user_id, created_at, updated_at, deleted_at, sport_id FROM tournaments WHERE id = ANY($1::bigint[]) AND deleted_at IS NULL ` @@ -371,6 +375,7 @@ func (q *Queries) GetTournamentsByIDs(ctx context.Context, dollar_1 []int64) ([] &i.CreatedAt, &i.UpdatedAt, &i.DeletedAt, + &i.SportID, ); err != nil { return nil, err } @@ -383,7 +388,7 @@ func (q *Queries) GetTournamentsByIDs(ctx context.Context, dollar_1 []int64) ([] } const listTournaments = `-- name: ListTournaments :many -SELECT id, public_id, name, slug, status, start_date, end_date, venue_id, league_id, season_id, description, logo_url, banner_url, contact_email, contact_phone, website_url, registration_open_at, registration_close_at, max_participants, rules_document_url, cancellation_reason, social_links, notes, sponsor_info, show_registrations, created_by_user_id, td_user_id, created_at, updated_at, deleted_at FROM tournaments +SELECT id, public_id, name, slug, status, start_date, end_date, venue_id, league_id, season_id, description, logo_url, banner_url, contact_email, contact_phone, website_url, registration_open_at, registration_close_at, max_participants, rules_document_url, cancellation_reason, social_links, notes, sponsor_info, show_registrations, created_by_user_id, td_user_id, created_at, updated_at, deleted_at, sport_id FROM tournaments WHERE deleted_at IS NULL ORDER BY start_date DESC LIMIT $1 OFFSET $2 @@ -434,6 +439,7 @@ func (q *Queries) ListTournaments(ctx context.Context, arg ListTournamentsParams &i.CreatedAt, &i.UpdatedAt, &i.DeletedAt, + &i.SportID, ); err != nil { return nil, err } @@ -446,7 +452,7 @@ func (q *Queries) ListTournaments(ctx context.Context, arg ListTournamentsParams } const listTournamentsByCreator = `-- name: ListTournamentsByCreator :many -SELECT id, public_id, name, slug, status, start_date, end_date, venue_id, league_id, season_id, description, logo_url, banner_url, contact_email, contact_phone, website_url, registration_open_at, registration_close_at, max_participants, rules_document_url, cancellation_reason, social_links, notes, sponsor_info, show_registrations, created_by_user_id, td_user_id, created_at, updated_at, deleted_at FROM tournaments +SELECT id, public_id, name, slug, status, start_date, end_date, venue_id, league_id, season_id, description, logo_url, banner_url, contact_email, contact_phone, website_url, registration_open_at, registration_close_at, max_participants, rules_document_url, cancellation_reason, social_links, notes, sponsor_info, show_registrations, created_by_user_id, td_user_id, created_at, updated_at, deleted_at, sport_id FROM tournaments WHERE (created_by_user_id = $1 OR td_user_id = $1) AND deleted_at IS NULL ORDER BY start_date DESC LIMIT $2 OFFSET $3 @@ -498,6 +504,7 @@ func (q *Queries) ListTournamentsByCreator(ctx context.Context, arg ListTourname &i.CreatedAt, &i.UpdatedAt, &i.DeletedAt, + &i.SportID, ); err != nil { return nil, err } @@ -510,7 +517,7 @@ func (q *Queries) ListTournamentsByCreator(ctx context.Context, arg ListTourname } const listTournamentsByLeague = `-- name: ListTournamentsByLeague :many -SELECT id, public_id, name, slug, status, start_date, end_date, venue_id, league_id, season_id, description, logo_url, banner_url, contact_email, contact_phone, website_url, registration_open_at, registration_close_at, max_participants, rules_document_url, cancellation_reason, social_links, notes, sponsor_info, show_registrations, created_by_user_id, td_user_id, created_at, updated_at, deleted_at FROM tournaments +SELECT id, public_id, name, slug, status, start_date, end_date, venue_id, league_id, season_id, description, logo_url, banner_url, contact_email, contact_phone, website_url, registration_open_at, registration_close_at, max_participants, rules_document_url, cancellation_reason, social_links, notes, sponsor_info, show_registrations, created_by_user_id, td_user_id, created_at, updated_at, deleted_at, sport_id FROM tournaments WHERE league_id = $1 AND deleted_at IS NULL ORDER BY start_date DESC LIMIT $2 OFFSET $3 @@ -562,6 +569,7 @@ func (q *Queries) ListTournamentsByLeague(ctx context.Context, arg ListTournamen &i.CreatedAt, &i.UpdatedAt, &i.DeletedAt, + &i.SportID, ); err != nil { return nil, err } @@ -574,7 +582,7 @@ func (q *Queries) ListTournamentsByLeague(ctx context.Context, arg ListTournamen } const listTournamentsBySeason = `-- name: ListTournamentsBySeason :many -SELECT id, public_id, name, slug, status, start_date, end_date, venue_id, league_id, season_id, description, logo_url, banner_url, contact_email, contact_phone, website_url, registration_open_at, registration_close_at, max_participants, rules_document_url, cancellation_reason, social_links, notes, sponsor_info, show_registrations, created_by_user_id, td_user_id, created_at, updated_at, deleted_at FROM tournaments +SELECT id, public_id, name, slug, status, start_date, end_date, venue_id, league_id, season_id, description, logo_url, banner_url, contact_email, contact_phone, website_url, registration_open_at, registration_close_at, max_participants, rules_document_url, cancellation_reason, social_links, notes, sponsor_info, show_registrations, created_by_user_id, td_user_id, created_at, updated_at, deleted_at, sport_id FROM tournaments WHERE season_id = $1 AND deleted_at IS NULL ORDER BY start_date ASC ` @@ -619,6 +627,7 @@ func (q *Queries) ListTournamentsBySeason(ctx context.Context, seasonID pgtype.I &i.CreatedAt, &i.UpdatedAt, &i.DeletedAt, + &i.SportID, ); err != nil { return nil, err } @@ -631,7 +640,7 @@ func (q *Queries) ListTournamentsBySeason(ctx context.Context, seasonID pgtype.I } const listTournamentsByStatus = `-- name: ListTournamentsByStatus :many -SELECT id, public_id, name, slug, status, start_date, end_date, venue_id, league_id, season_id, description, logo_url, banner_url, contact_email, contact_phone, website_url, registration_open_at, registration_close_at, max_participants, rules_document_url, cancellation_reason, social_links, notes, sponsor_info, show_registrations, created_by_user_id, td_user_id, created_at, updated_at, deleted_at FROM tournaments +SELECT id, public_id, name, slug, status, start_date, end_date, venue_id, league_id, season_id, description, logo_url, banner_url, contact_email, contact_phone, website_url, registration_open_at, registration_close_at, max_participants, rules_document_url, cancellation_reason, social_links, notes, sponsor_info, show_registrations, created_by_user_id, td_user_id, created_at, updated_at, deleted_at, sport_id FROM tournaments WHERE status = $1 AND deleted_at IS NULL ORDER BY start_date ASC LIMIT $2 OFFSET $3 @@ -683,6 +692,7 @@ func (q *Queries) ListTournamentsByStatus(ctx context.Context, arg ListTournamen &i.CreatedAt, &i.UpdatedAt, &i.DeletedAt, + &i.SportID, ); err != nil { return nil, err } @@ -695,7 +705,7 @@ func (q *Queries) ListTournamentsByStatus(ctx context.Context, arg ListTournamen } const searchTournaments = `-- name: SearchTournaments :many -SELECT id, public_id, name, slug, status, start_date, end_date, venue_id, league_id, season_id, description, logo_url, banner_url, contact_email, contact_phone, website_url, registration_open_at, registration_close_at, max_participants, rules_document_url, cancellation_reason, social_links, notes, sponsor_info, show_registrations, created_by_user_id, td_user_id, created_at, updated_at, deleted_at FROM tournaments +SELECT id, public_id, name, slug, status, start_date, end_date, venue_id, league_id, season_id, description, logo_url, banner_url, contact_email, contact_phone, website_url, registration_open_at, registration_close_at, max_participants, rules_document_url, cancellation_reason, social_links, notes, sponsor_info, show_registrations, created_by_user_id, td_user_id, created_at, updated_at, deleted_at, sport_id FROM tournaments WHERE deleted_at IS NULL AND ( name ILIKE '%' || $3::TEXT || '%' @@ -751,6 +761,7 @@ func (q *Queries) SearchTournaments(ctx context.Context, arg SearchTournamentsPa &i.CreatedAt, &i.UpdatedAt, &i.DeletedAt, + &i.SportID, ); err != nil { return nil, err } @@ -763,7 +774,7 @@ func (q *Queries) SearchTournaments(ctx context.Context, arg SearchTournamentsPa } const searchTournamentsByStatus = `-- name: SearchTournamentsByStatus :many -SELECT id, public_id, name, slug, status, start_date, end_date, venue_id, league_id, season_id, description, logo_url, banner_url, contact_email, contact_phone, website_url, registration_open_at, registration_close_at, max_participants, rules_document_url, cancellation_reason, social_links, notes, sponsor_info, show_registrations, created_by_user_id, td_user_id, created_at, updated_at, deleted_at FROM tournaments +SELECT id, public_id, name, slug, status, start_date, end_date, venue_id, league_id, season_id, description, logo_url, banner_url, contact_email, contact_phone, website_url, registration_open_at, registration_close_at, max_participants, rules_document_url, cancellation_reason, social_links, notes, sponsor_info, show_registrations, created_by_user_id, td_user_id, created_at, updated_at, deleted_at, sport_id FROM tournaments WHERE deleted_at IS NULL AND status = $3::TEXT AND ( @@ -826,6 +837,7 @@ func (q *Queries) SearchTournamentsByStatus(ctx context.Context, arg SearchTourn &i.CreatedAt, &i.UpdatedAt, &i.DeletedAt, + &i.SportID, ); err != nil { return nil, err } @@ -886,7 +898,7 @@ UPDATE tournaments SET td_user_id = COALESCE($24, td_user_id), updated_at = NOW() WHERE id = $25 AND deleted_at IS NULL -RETURNING id, public_id, name, slug, status, start_date, end_date, venue_id, league_id, season_id, description, logo_url, banner_url, contact_email, contact_phone, website_url, registration_open_at, registration_close_at, max_participants, rules_document_url, cancellation_reason, social_links, notes, sponsor_info, show_registrations, created_by_user_id, td_user_id, created_at, updated_at, deleted_at +RETURNING id, public_id, name, slug, status, start_date, end_date, venue_id, league_id, season_id, description, logo_url, banner_url, contact_email, contact_phone, website_url, registration_open_at, registration_close_at, max_participants, rules_document_url, cancellation_reason, social_links, notes, sponsor_info, show_registrations, created_by_user_id, td_user_id, created_at, updated_at, deleted_at, sport_id ` type UpdateTournamentParams struct { @@ -977,6 +989,7 @@ func (q *Queries) UpdateTournament(ctx context.Context, arg UpdateTournamentPara &i.CreatedAt, &i.UpdatedAt, &i.DeletedAt, + &i.SportID, ) return i, err } diff --git a/api/db/generated/users.sql.go b/api/db/generated/users.sql.go index 5179b48..a481e6e 100644 --- a/api/db/generated/users.sql.go +++ b/api/db/generated/users.sql.go @@ -580,21 +580,35 @@ func (q *Queries) SearchUsers(ctx context.Context, arg SearchUsersParams) ([]Use const setUserLogtoUserID = `-- name: SetUserLogtoUserID :one UPDATE users SET - logto_user_id = $2, + logto_user_id = $2::TEXT, updated_at = now() -WHERE id = $1 AND deleted_at IS NULL +WHERE id = $1 + AND deleted_at IS NULL + AND (logto_user_id IS NULL OR logto_user_id = $2::TEXT) RETURNING id, public_id, email, password_hash, first_name, last_name, date_of_birth, display_name, status, merged_into_id, role, created_at, updated_at, deleted_at, gender, handedness, avatar_url, bio, city, state_province, country, phone, paddle_brand, paddle_model, dupr_id, vair_id, emergency_contact_name, emergency_contact_phone, medical_notes, waiver_accepted_at, is_profile_hidden, address_line_1, address_line_2, postal_code, latitude, longitude, formatted_address, logto_user_id ` type SetUserLogtoUserIDParams struct { - ID int64 `json:"id"` - LogtoUserID *string `json:"logto_user_id"` + ID int64 `json:"id"` + LogtoUserID string `json:"logto_user_id"` } -// Bind a Logto user ID to an existing local mirror row. Idempotent: -// setting the same value twice is fine; setting a different value -// when one is already bound fails on the partial UNIQUE index -// idx_users_logto_user_id (the caller surfaces this as a 409). +// Bind a Logto user ID to an existing local mirror row. +// +// Behavior is "bind once, never change": +// - Setting a value to the same logto_user_id is a no-op success +// (idempotent). +// - Setting a different value when one is already bound returns 0 +// rows affected (the WHERE filters it out); the caller surfaces +// this as a 409 Conflict. +// - The UNIQUE partial index idx_users_logto_user_id additionally +// enforces that the same logto_user_id cannot bind to two +// different local user rows. +// +// The non-pointer @logto_user_id parameter (string, not *string) +// prevents accidental NULL writes from a buggy caller. If a future +// flow needs to clear a binding (e.g. account deletion), add a +// separate ClearUserLogtoUserID query rather than reusing this one. func (q *Queries) SetUserLogtoUserID(ctx context.Context, arg SetUserLogtoUserIDParams) (User, error) { row := q.db.QueryRow(ctx, setUserLogtoUserID, arg.ID, arg.LogtoUserID) var i User diff --git a/api/db/generated/venue_managers.sql.go b/api/db/generated/venue_managers.sql.go index 495affc..633b3ca 100644 --- a/api/db/generated/venue_managers.sql.go +++ b/api/db/generated/venue_managers.sql.go @@ -174,7 +174,7 @@ func (q *Queries) ListVenueManagers(ctx context.Context, venueID int64) ([]ListV } const listVenuesByManager = `-- name: ListVenuesByManager :many -SELECT v.id, v.name, v.slug, v.status, v.address_line_1, v.address_line_2, v.city, v.state_province, v.country, v.postal_code, v.latitude, v.longitude, v.timezone, v.website_url, v.contact_email, v.contact_phone, v.logo_url, v.photo_url, v.venue_map_url, v.description, v.surface_types, v.amenities, v.org_id, v.managed_by_user_id, v.bio, v.notes, v.created_by_user_id, v.created_at, v.updated_at, v.deleted_at, v.formatted_address +SELECT v.id, v.name, v.slug, v.status, v.address_line_1, v.address_line_2, v.city, v.state_province, v.country, v.postal_code, v.latitude, v.longitude, v.timezone, v.website_url, v.contact_email, v.contact_phone, v.logo_url, v.photo_url, v.venue_map_url, v.description, v.surface_types, v.amenities, v.org_id, v.managed_by_user_id, v.bio, v.notes, v.created_by_user_id, v.created_at, v.updated_at, v.deleted_at, v.formatted_address, v.sport_id FROM venues v JOIN venue_managers vm ON vm.venue_id = v.id WHERE vm.user_id = $1 AND v.deleted_at IS NULL @@ -222,6 +222,7 @@ func (q *Queries) ListVenuesByManager(ctx context.Context, userID int64) ([]Venu &i.UpdatedAt, &i.DeletedAt, &i.FormattedAddress, + &i.SportID, ); err != nil { return nil, err } diff --git a/api/db/generated/venues.sql.go b/api/db/generated/venues.sql.go index 8512402..aad6a09 100644 --- a/api/db/generated/venues.sql.go +++ b/api/db/generated/venues.sql.go @@ -97,7 +97,7 @@ INSERT INTO venues ( $20, $21, $22, $23, $24, $25, $26, $27 ) -RETURNING id, name, slug, status, address_line_1, address_line_2, city, state_province, country, postal_code, latitude, longitude, timezone, website_url, contact_email, contact_phone, logo_url, photo_url, venue_map_url, description, surface_types, amenities, org_id, managed_by_user_id, bio, notes, created_by_user_id, created_at, updated_at, deleted_at, formatted_address +RETURNING id, name, slug, status, address_line_1, address_line_2, city, state_province, country, postal_code, latitude, longitude, timezone, website_url, contact_email, contact_phone, logo_url, photo_url, venue_map_url, description, surface_types, amenities, org_id, managed_by_user_id, bio, notes, created_by_user_id, created_at, updated_at, deleted_at, formatted_address, sport_id ` type CreateVenueParams struct { @@ -193,12 +193,13 @@ func (q *Queries) CreateVenue(ctx context.Context, arg CreateVenueParams) (Venue &i.UpdatedAt, &i.DeletedAt, &i.FormattedAddress, + &i.SportID, ) return i, err } const getVenueByID = `-- name: GetVenueByID :one -SELECT id, name, slug, status, address_line_1, address_line_2, city, state_province, country, postal_code, latitude, longitude, timezone, website_url, contact_email, contact_phone, logo_url, photo_url, venue_map_url, description, surface_types, amenities, org_id, managed_by_user_id, bio, notes, created_by_user_id, created_at, updated_at, deleted_at, formatted_address FROM venues +SELECT id, name, slug, status, address_line_1, address_line_2, city, state_province, country, postal_code, latitude, longitude, timezone, website_url, contact_email, contact_phone, logo_url, photo_url, venue_map_url, description, surface_types, amenities, org_id, managed_by_user_id, bio, notes, created_by_user_id, created_at, updated_at, deleted_at, formatted_address, sport_id FROM venues WHERE id = $1 AND deleted_at IS NULL ` @@ -237,12 +238,13 @@ func (q *Queries) GetVenueByID(ctx context.Context, id int64) (Venue, error) { &i.UpdatedAt, &i.DeletedAt, &i.FormattedAddress, + &i.SportID, ) return i, err } const getVenueBySlug = `-- name: GetVenueBySlug :one -SELECT id, name, slug, status, address_line_1, address_line_2, city, state_province, country, postal_code, latitude, longitude, timezone, website_url, contact_email, contact_phone, logo_url, photo_url, venue_map_url, description, surface_types, amenities, org_id, managed_by_user_id, bio, notes, created_by_user_id, created_at, updated_at, deleted_at, formatted_address FROM venues +SELECT id, name, slug, status, address_line_1, address_line_2, city, state_province, country, postal_code, latitude, longitude, timezone, website_url, contact_email, contact_phone, logo_url, photo_url, venue_map_url, description, surface_types, amenities, org_id, managed_by_user_id, bio, notes, created_by_user_id, created_at, updated_at, deleted_at, formatted_address, sport_id FROM venues WHERE slug = $1 AND deleted_at IS NULL ` @@ -281,6 +283,7 @@ func (q *Queries) GetVenueBySlug(ctx context.Context, slug string) (Venue, error &i.UpdatedAt, &i.DeletedAt, &i.FormattedAddress, + &i.SportID, ) return i, err } @@ -298,7 +301,7 @@ func (q *Queries) GetVenueCourtCount(ctx context.Context, venueID pgtype.Int8) ( } const listPendingVenues = `-- name: ListPendingVenues :many -SELECT id, name, slug, status, address_line_1, address_line_2, city, state_province, country, postal_code, latitude, longitude, timezone, website_url, contact_email, contact_phone, logo_url, photo_url, venue_map_url, description, surface_types, amenities, org_id, managed_by_user_id, bio, notes, created_by_user_id, created_at, updated_at, deleted_at, formatted_address FROM venues +SELECT id, name, slug, status, address_line_1, address_line_2, city, state_province, country, postal_code, latitude, longitude, timezone, website_url, contact_email, contact_phone, logo_url, photo_url, venue_map_url, description, surface_types, amenities, org_id, managed_by_user_id, bio, notes, created_by_user_id, created_at, updated_at, deleted_at, formatted_address, sport_id FROM venues WHERE status = 'pending_review' AND deleted_at IS NULL ORDER BY created_at ASC LIMIT $1 OFFSET $2 @@ -350,6 +353,7 @@ func (q *Queries) ListPendingVenues(ctx context.Context, arg ListPendingVenuesPa &i.UpdatedAt, &i.DeletedAt, &i.FormattedAddress, + &i.SportID, ); err != nil { return nil, err } @@ -362,7 +366,7 @@ func (q *Queries) ListPendingVenues(ctx context.Context, arg ListPendingVenuesPa } const listVenues = `-- name: ListVenues :many -SELECT id, name, slug, status, address_line_1, address_line_2, city, state_province, country, postal_code, latitude, longitude, timezone, website_url, contact_email, contact_phone, logo_url, photo_url, venue_map_url, description, surface_types, amenities, org_id, managed_by_user_id, bio, notes, created_by_user_id, created_at, updated_at, deleted_at, formatted_address FROM venues +SELECT id, name, slug, status, address_line_1, address_line_2, city, state_province, country, postal_code, latitude, longitude, timezone, website_url, contact_email, contact_phone, logo_url, photo_url, venue_map_url, description, surface_types, amenities, org_id, managed_by_user_id, bio, notes, created_by_user_id, created_at, updated_at, deleted_at, formatted_address, sport_id FROM venues WHERE deleted_at IS NULL AND ($3::TEXT IS NULL OR status = $3::TEXT) ORDER BY name @@ -416,6 +420,7 @@ func (q *Queries) ListVenues(ctx context.Context, arg ListVenuesParams) ([]Venue &i.UpdatedAt, &i.DeletedAt, &i.FormattedAddress, + &i.SportID, ); err != nil { return nil, err } @@ -428,7 +433,7 @@ func (q *Queries) ListVenues(ctx context.Context, arg ListVenuesParams) ([]Venue } const searchVenues = `-- name: SearchVenues :many -SELECT id, name, slug, status, address_line_1, address_line_2, city, state_province, country, postal_code, latitude, longitude, timezone, website_url, contact_email, contact_phone, logo_url, photo_url, venue_map_url, description, surface_types, amenities, org_id, managed_by_user_id, bio, notes, created_by_user_id, created_at, updated_at, deleted_at, formatted_address FROM venues +SELECT id, name, slug, status, address_line_1, address_line_2, city, state_province, country, postal_code, latitude, longitude, timezone, website_url, contact_email, contact_phone, logo_url, photo_url, venue_map_url, description, surface_types, amenities, org_id, managed_by_user_id, bio, notes, created_by_user_id, created_at, updated_at, deleted_at, formatted_address, sport_id FROM venues WHERE deleted_at IS NULL AND ($3::TEXT IS NULL OR status = $3::TEXT) AND ( @@ -502,6 +507,7 @@ func (q *Queries) SearchVenues(ctx context.Context, arg SearchVenuesParams) ([]V &i.UpdatedAt, &i.DeletedAt, &i.FormattedAddress, + &i.SportID, ); err != nil { return nil, err } @@ -553,7 +559,7 @@ UPDATE venues SET notes = COALESCE($24, notes), updated_at = now() WHERE id = $25 AND deleted_at IS NULL -RETURNING id, name, slug, status, address_line_1, address_line_2, city, state_province, country, postal_code, latitude, longitude, timezone, website_url, contact_email, contact_phone, logo_url, photo_url, venue_map_url, description, surface_types, amenities, org_id, managed_by_user_id, bio, notes, created_by_user_id, created_at, updated_at, deleted_at, formatted_address +RETURNING id, name, slug, status, address_line_1, address_line_2, city, state_province, country, postal_code, latitude, longitude, timezone, website_url, contact_email, contact_phone, logo_url, photo_url, venue_map_url, description, surface_types, amenities, org_id, managed_by_user_id, bio, notes, created_by_user_id, created_at, updated_at, deleted_at, formatted_address, sport_id ` type UpdateVenueParams struct { @@ -645,6 +651,7 @@ func (q *Queries) UpdateVenue(ctx context.Context, arg UpdateVenueParams) (Venue &i.UpdatedAt, &i.DeletedAt, &i.FormattedAddress, + &i.SportID, ) return i, err } @@ -654,7 +661,7 @@ UPDATE venues SET status = $2, updated_at = now() WHERE id = $1 AND deleted_at IS NULL -RETURNING id, name, slug, status, address_line_1, address_line_2, city, state_province, country, postal_code, latitude, longitude, timezone, website_url, contact_email, contact_phone, logo_url, photo_url, venue_map_url, description, surface_types, amenities, org_id, managed_by_user_id, bio, notes, created_by_user_id, created_at, updated_at, deleted_at, formatted_address +RETURNING id, name, slug, status, address_line_1, address_line_2, city, state_province, country, postal_code, latitude, longitude, timezone, website_url, contact_email, contact_phone, logo_url, photo_url, venue_map_url, description, surface_types, amenities, org_id, managed_by_user_id, bio, notes, created_by_user_id, created_at, updated_at, deleted_at, formatted_address, sport_id ` type UpdateVenueStatusParams struct { @@ -697,6 +704,7 @@ func (q *Queries) UpdateVenueStatus(ctx context.Context, arg UpdateVenueStatusPa &i.UpdatedAt, &i.DeletedAt, &i.FormattedAddress, + &i.SportID, ) return i, err } diff --git a/api/db/queries/api_keys.sql b/api/db/queries/api_keys.sql index 84fa8f5..f9ec51a 100644 --- a/api/db/queries/api_keys.sql +++ b/api/db/queries/api_keys.sql @@ -35,8 +35,18 @@ WHERE user_id = $1 AND is_active = true; -- ============================================================================ -- name: GetAPIKeyByLogtoM2MAppID :one +-- Admin / management lookup by Logto M2M app ID. Does NOT filter on +-- is_active because admin tools (Phase 4 webhook handlers, deactivation +-- flows, audit trails) need to find rows regardless of state. The hot +-- request-auth path uses GetActiveAPIKeyByLogtoM2MAppID instead. SELECT * FROM api_keys WHERE logto_m2m_app_id = $1; +-- name: GetActiveAPIKeyByLogtoM2MAppID :one +-- Request-auth lookup: only returns the row if it is active. Mirrors +-- the GetApiKeyByHash semantics on the legacy code path. +SELECT * FROM api_keys +WHERE logto_m2m_app_id = $1 AND is_active = true; + -- name: SetAPIKeyLogtoM2MAppID :one UPDATE api_keys SET logto_m2m_app_id = $2, diff --git a/api/db/queries/player_profiles.sql b/api/db/queries/player_profiles.sql index 761deab..8c630e1 100644 --- a/api/db/queries/player_profiles.sql +++ b/api/db/queries/player_profiles.sql @@ -8,10 +8,16 @@ SELECT * FROM player_profiles WHERE user_id = $1; -- name: UpsertPlayerProfile :one --- Insert or update the 1:1 profile row. Caller passes the full set of --- columns; pre-fetch the existing row first if you want a partial --- update, otherwise unset fields will be overwritten with the supplied --- values (which may be NULL). +-- Insert or partial-update the 1:1 profile row. Every field is optional +-- (sqlc.narg). On INSERT, NULL nargs become NULL in the new row. On +-- UPDATE, NULL nargs leave the existing column value unchanged via +-- COALESCE. is_profile_hidden defaults to false on insert. +-- +-- This shape mirrors the existing players.sql:UpdatePlayerProfile narg +-- pattern, so a Phase 3 profile-edit form that sends only the fields +-- the user actually changed will not blow away the rest. To explicitly +-- clear a field, pass an empty-string-coerced override at the call +-- site or add a dedicated ClearPlayerProfileField query. INSERT INTO player_profiles ( user_id, phone, dupr_id, vair_id, paddle_brand, paddle_model, @@ -22,40 +28,46 @@ INSERT INTO player_profiles ( waiver_accepted_at, avatar_url, is_profile_hidden, updated_at ) VALUES ( - $1, - $2, $3, $4, $5, $6, - $7, $8, $9, $10, - $11, $12, $13, $14, $15, - $16, $17, $18, $19, - $20, $21, $22, - $23, $24, $25, + @user_id::BIGINT, + sqlc.narg('phone'), sqlc.narg('dupr_id'), sqlc.narg('vair_id'), + sqlc.narg('paddle_brand'), sqlc.narg('paddle_model'), + sqlc.narg('gender'), sqlc.narg('handedness'), + sqlc.narg('date_of_birth'), sqlc.narg('bio'), + sqlc.narg('address_line_1'), sqlc.narg('address_line_2'), + sqlc.narg('city'), sqlc.narg('state_province'), sqlc.narg('country'), + sqlc.narg('postal_code'), sqlc.narg('formatted_address'), + sqlc.narg('latitude'), sqlc.narg('longitude'), + sqlc.narg('emergency_contact_name'), sqlc.narg('emergency_contact_phone'), + sqlc.narg('medical_notes'), + sqlc.narg('waiver_accepted_at'), sqlc.narg('avatar_url'), + COALESCE(sqlc.narg('is_profile_hidden')::BOOLEAN, false), now() ) ON CONFLICT (user_id) DO UPDATE SET - phone = EXCLUDED.phone, - dupr_id = EXCLUDED.dupr_id, - vair_id = EXCLUDED.vair_id, - paddle_brand = EXCLUDED.paddle_brand, - paddle_model = EXCLUDED.paddle_model, - gender = EXCLUDED.gender, - handedness = EXCLUDED.handedness, - date_of_birth = EXCLUDED.date_of_birth, - bio = EXCLUDED.bio, - address_line_1 = EXCLUDED.address_line_1, - address_line_2 = EXCLUDED.address_line_2, - city = EXCLUDED.city, - state_province = EXCLUDED.state_province, - country = EXCLUDED.country, - postal_code = EXCLUDED.postal_code, - formatted_address = EXCLUDED.formatted_address, - latitude = EXCLUDED.latitude, - longitude = EXCLUDED.longitude, - emergency_contact_name = EXCLUDED.emergency_contact_name, - emergency_contact_phone = EXCLUDED.emergency_contact_phone, - medical_notes = EXCLUDED.medical_notes, - waiver_accepted_at = EXCLUDED.waiver_accepted_at, - avatar_url = EXCLUDED.avatar_url, - is_profile_hidden = EXCLUDED.is_profile_hidden, + phone = COALESCE(sqlc.narg('phone'), player_profiles.phone), + dupr_id = COALESCE(sqlc.narg('dupr_id'), player_profiles.dupr_id), + vair_id = COALESCE(sqlc.narg('vair_id'), player_profiles.vair_id), + paddle_brand = COALESCE(sqlc.narg('paddle_brand'), player_profiles.paddle_brand), + paddle_model = COALESCE(sqlc.narg('paddle_model'), player_profiles.paddle_model), + gender = COALESCE(sqlc.narg('gender'), player_profiles.gender), + handedness = COALESCE(sqlc.narg('handedness'), player_profiles.handedness), + date_of_birth = COALESCE(sqlc.narg('date_of_birth'), player_profiles.date_of_birth), + bio = COALESCE(sqlc.narg('bio'), player_profiles.bio), + address_line_1 = COALESCE(sqlc.narg('address_line_1'), player_profiles.address_line_1), + address_line_2 = COALESCE(sqlc.narg('address_line_2'), player_profiles.address_line_2), + city = COALESCE(sqlc.narg('city'), player_profiles.city), + state_province = COALESCE(sqlc.narg('state_province'), player_profiles.state_province), + country = COALESCE(sqlc.narg('country'), player_profiles.country), + postal_code = COALESCE(sqlc.narg('postal_code'), player_profiles.postal_code), + formatted_address = COALESCE(sqlc.narg('formatted_address'), player_profiles.formatted_address), + latitude = COALESCE(sqlc.narg('latitude'), player_profiles.latitude), + longitude = COALESCE(sqlc.narg('longitude'), player_profiles.longitude), + emergency_contact_name = COALESCE(sqlc.narg('emergency_contact_name'), player_profiles.emergency_contact_name), + emergency_contact_phone = COALESCE(sqlc.narg('emergency_contact_phone'), player_profiles.emergency_contact_phone), + medical_notes = COALESCE(sqlc.narg('medical_notes'), player_profiles.medical_notes), + waiver_accepted_at = COALESCE(sqlc.narg('waiver_accepted_at'), player_profiles.waiver_accepted_at), + avatar_url = COALESCE(sqlc.narg('avatar_url'), player_profiles.avatar_url), + is_profile_hidden = COALESCE(sqlc.narg('is_profile_hidden'), player_profiles.is_profile_hidden), updated_at = now() RETURNING *; diff --git a/api/db/queries/users.sql b/api/db/queries/users.sql index 8e7ad27..ce8c108 100644 --- a/api/db/queries/users.sql +++ b/api/db/queries/users.sql @@ -121,12 +121,26 @@ SELECT * FROM users WHERE logto_user_id = $1 AND deleted_at IS NULL; -- name: SetUserLogtoUserID :one --- Bind a Logto user ID to an existing local mirror row. Idempotent: --- setting the same value twice is fine; setting a different value --- when one is already bound fails on the partial UNIQUE index --- idx_users_logto_user_id (the caller surfaces this as a 409). +-- Bind a Logto user ID to an existing local mirror row. +-- +-- Behavior is "bind once, never change": +-- - Setting a value to the same logto_user_id is a no-op success +-- (idempotent). +-- - Setting a different value when one is already bound returns 0 +-- rows affected (the WHERE filters it out); the caller surfaces +-- this as a 409 Conflict. +-- - The UNIQUE partial index idx_users_logto_user_id additionally +-- enforces that the same logto_user_id cannot bind to two +-- different local user rows. +-- +-- The non-pointer @logto_user_id parameter (string, not *string) +-- prevents accidental NULL writes from a buggy caller. If a future +-- flow needs to clear a binding (e.g. account deletion), add a +-- separate ClearUserLogtoUserID query rather than reusing this one. UPDATE users SET - logto_user_id = $2, + logto_user_id = @logto_user_id::TEXT, updated_at = now() -WHERE id = $1 AND deleted_at IS NULL +WHERE id = $1 + AND deleted_at IS NULL + AND (logto_user_id IS NULL OR logto_user_id = @logto_user_id::TEXT) RETURNING *; From ed31e9cf3125f14d320a459f67b357764b9bd63b Mon Sep 17 00:00:00 2001 From: Daniel Velez Date: Fri, 1 May 2026 17:32:54 -0500 Subject: [PATCH 25/95] feat(dev): docker-compose.dev.yml + Logto seed script for local dev Local-dev tooling so the full stack (postgres + redis + logto + native go backend + native vite frontend) runs on the developer's machine, isolated from production. New files: - docker-compose.dev.yml: standalone stack of postgres 17 + redis 7 + logto 1.22.0 with host port bindings (5432, 6379, 3001 OIDC, 3002 admin UI). Postgres seeds an additional 'logto' database via POSTGRES_MULTIPLE_DATABASES + the new init script. Logto gets host.docker.internal -> host-gateway so its webhook deliveries can reach the natively-run Go backend. - scripts/postgres-init/10-create-additional-databases.sh: idempotent init script (community Postgres-image pattern) that creates each database listed in POSTGRES_MULTIPLE_DATABASES. - api/cmd/logto-seed/main.go: idempotent provisioner that mirrors the production Logto config in docs/LOGTO_SETUP.md. Creates the SPA app, Court Command API resource + 12 scopes, organization template (5 roles + 5 scopes + role-scope mappings), Pickleball + Demo Sport organizations, bootstrap admin user with platform_admin in both, and the User.Created/User.Data.Updated/User.Deleted webhook. Every step is preceded by a Find* call so re-running is a no-op. - api/logto/applications.go: ListApplications, FindApplicationByName, CreateApplication, AssignApplicationRoles, ListRoles - api/logto/resources.go: CreateResource, ListResources, FindResourceByIndicator, CreateResourceScope, ListResourceScopes - api/logto/organizations.go: CreateOrganization, ListOrganizations, FindOrgByName, AddUserToOrganization, AssignOrganizationRolesToUser, organization roles + scopes CRUD, AssignScopesToOrgRole, ListOrgRoleScopes - api/logto/hooks.go: CreateHook, ListHooks, FindHookByName - api/logto/users.go: appended CreateUser + FindUserByEmail (with fallback to broad scan for older Logto versions that don't honor search.primaryEmail) - docs/LOCAL_DEV.md: full first-run walkthrough including the one manual step (operator creates the M2M app via admin UI to bootstrap the seeder's credentials; everything else is automated). - Makefile: dev-up, dev-down, dev-logs, dev-reset, logto-seed targets. Modified .env.example to make local-dev values the default (so 'cp .env.example .env' followed by 'make dev-up && make logto-seed' works out of the box). Production overrides moved to a documented block at the bottom. The legacy 'docker-compose.yaml' (Coolify shape) and the existing docker-compose.local.yaml override are unchanged. The new docker-compose.dev.yml is standalone: 'docker compose -f docker-compose.dev.yml up -d' brings up exactly the local-dev stack without interacting with the prod compose definitions. Verified locally: go build ./... # exit 0 go vet ./... # exit 0 go test ./auth/... ./logto/... ./middleware/... -race # all PASS Runtime verification (docker compose up + make logto-seed) requires Docker on the developer machine; documented in docs/LOCAL_DEV.md prerequisites. --- .env.example | 128 +++-- Makefile | 42 +- api/cmd/logto-seed/main.go | 537 ++++++++++++++++++ api/logto/applications.go | 126 ++++ api/logto/hooks.go | 58 ++ api/logto/organizations.go | 156 +++++ api/logto/resources.go | 90 +++ api/logto/users.go | 75 +++ docker-compose.dev.yml | 92 +++ docs/LOCAL_DEV.md | 243 ++++++++ .../10-create-additional-databases.sh | 32 ++ 11 files changed, 1533 insertions(+), 46 deletions(-) create mode 100644 api/cmd/logto-seed/main.go create mode 100644 api/logto/applications.go create mode 100644 api/logto/hooks.go create mode 100644 api/logto/organizations.go create mode 100644 api/logto/resources.go create mode 100644 docker-compose.dev.yml create mode 100644 docs/LOCAL_DEV.md create mode 100755 scripts/postgres-init/10-create-additional-databases.sh diff --git a/.env.example b/.env.example index 6118c05..09593bc 100644 --- a/.env.example +++ b/.env.example @@ -1,82 +1,120 @@ # Court Command Environment Variables -# Copy to .env for local development. -# In Coolify, set these in the Docker Compose service's environment variables. +# +# Copy this file to .env for local development. The values below are the +# DEV defaults, matching what `make dev-up` brings up via +# docker-compose.dev.yml. See docs/LOCAL_DEV.md for the first-run flow. +# +# In production, Coolify sets these via its env var UI; the values +# below are illustrative only. The `# PRODUCTION` block at the bottom +# of this file documents the prod overrides. -# --- Database --- +# --- Database (local dev: db service in docker-compose.dev.yml) --- POSTGRES_USER=courtcommand POSTGRES_PASSWORD=courtcommand POSTGRES_DB=courtcommand -DATABASE_URL=postgres://courtcommand:courtcommand@db:5432/courtcommand?sslmode=disable +DATABASE_URL=postgres://courtcommand:courtcommand@localhost:5432/courtcommand?sslmode=disable -# --- Redis --- -REDIS_URL=redis://redis:6379/0 +# --- Redis (local dev: redis service in docker-compose.dev.yml) --- +REDIS_URL=redis://localhost:6379/0 # --- Backend --- PORT=8080 APP_ENV=development CORS_ALLOWED_ORIGINS=http://localhost:5173,http://localhost:3000 -# --- Frontend (baked into build) --- +# --- Frontend (Vite dev server) --- VITE_API_URL=http://localhost:8080 VITE_GOOGLE_MAPS_API_KEY= -# --- Ghost CMS --- +# --- Ghost CMS (not used in local dev unless explicitly started) --- GHOST_URL=http://localhost:2368 -# --- Logto (self-hosted on Coolify) --- -# Core OIDC endpoint (issuer, JWKS, /oidc/token, etc.) -LOGTO_ENDPOINT=https://logto.courtcommand.app -# Admin UI (humans only, not used by the backend) -LOGTO_ADMIN_ENDPOINT=https://logto-admin.courtcommand.app -# API resource indicator registered in Logto -> API resources -LOGTO_API_RESOURCE=https://api.courtcommand.app/api -# Machine-to-machine app credentials (Logto -> Applications -> Machine-to-machine) -# Assign role "Logto Management API access" so the backend can mint, modify -# users, manage org memberships, and create per-tenant M2M apps for API keys. +# ============================================================================= +# Logto (local dev: logto service in docker-compose.dev.yml on ports 3001/3002) +# ============================================================================= +# +# First-run flow: +# 1. `make dev-up` # starts postgres + redis + logto containers +# 2. Open http://localhost:3002 in a browser, create the initial admin +# account (Logto's first-time setup wizard), then create: +# - Application -> Machine-to-machine -> name "Court Command Backend" +# - Open it -> Roles -> assign "Logto Management API access" +# - Open it -> Settings -> copy App ID and App Secret into the +# two env vars below. +# 3. `make logto-seed` # provisions everything else automatically +# 4. Capture the seeder's output (it prints the values to paste back here) +# 5. `make dev` # starts the Go backend natively +# +# Subsequent runs: just `make dev-up` + `make dev`. + +# Core OIDC endpoint (issuer, JWKS, /oidc/token). +LOGTO_ENDPOINT=http://localhost:3001 +# Admin UI (humans only, not used by the backend). +LOGTO_ADMIN_ENDPOINT=http://localhost:3002 + +# API resource indicator registered in Logto -> API resources. +# Local dev points back at the Go backend's localhost address. +LOGTO_API_RESOURCE=http://localhost:8080/api + +# Machine-to-machine app credentials. Operator creates this in the +# Logto admin UI BEFORE the first run of `make logto-seed`. LOGTO_MANAGEMENT_API_APP_ID= LOGTO_MANAGEMENT_API_APP_SECRET= -# Resource indicator for the built-in Logto Management API. For self-hosted -# Logto this is a fixed value ("https://default.logto.app/api") regardless of -# your custom domain -- it identifies the internal management tenant, NOT a -# real URL. Confirmed via OIDC token exchange returning aud=this value. + +# Resource indicator for the built-in Logto Management API. This is a +# Logto-internal fixed value, NOT a real URL. Same in dev and prod. LOGTO_MANAGEMENT_API_RESOURCE=https://default.logto.app/api -# HMAC-SHA256 signing key from the Logto webhook configured at -# https://api.courtcommand.app/api/v1/webhooks/logto -# Backend verifies the `logto-signature-sha-256` header against this key. + +# HMAC-SHA256 signing key from the webhook the seeder creates. +# Populated by the seeder's output; paste the printed value here. LOGTO_WEBHOOK_SIGNING_KEY= -# Sport organization IDs (created in Logto admin UI -> Organizations). -# Format: org_XXXXXXXX. Used by middleware to map URL ?sport= -> Logto org. + +# Sport organization IDs (created by the seeder). Populated by the +# seeder's output; paste the printed values here. LOGTO_PICKLEBALL_ORG_ID= LOGTO_DEMO_SPORT_ORG_ID= -# Optional: Logto user ID of the bootstrap platform admin (Step 6 of -# docs/LOGTO_SETUP.md). Used ONLY by the live smoke test in -# api/logto/livesmoke_test.go when LOGTO_LIVE_SMOKE=1; the regular -# `go test ./...` run does not require it. Lookup format: open the -# user in the Logto admin UI and copy the ID from the URL. +# Where the seeder will register the webhook URL. Logto runs in a +# Docker container, so it must reach the host-bound backend through +# host.docker.internal (Docker's host-gateway alias). +LOGTO_WEBHOOK_URL=http://host.docker.internal:8080/api/v1/webhooks/logto + +# Where the SPA app's redirect URI points (the Vite dev server). +LOGTO_SPA_REDIRECT_URI=http://localhost:5173/auth/callback + +# Bootstrap admin (created by the seeder). +# DO NOT USE THESE VALUES IN PRODUCTION. +LOGTO_BOOTSTRAP_EMAIL=admin@courtcommand.local +LOGTO_BOOTSTRAP_PASSWORD=TestPass123! +LOGTO_BOOTSTRAP_NAME=Local Admin + +# Optional: Logto user ID of the bootstrap admin. Used ONLY by the live +# smoke test in api/logto/livesmoke_test.go when LOGTO_LIVE_SMOKE=1. +# Populated by the seeder's output (look for "user_id:" in the summary). # LOGTO_BOOTSTRAP_USER_ID= # --- Frontend Logto config (baked into Vite build) --- -# Logto SDK endpoint (same as LOGTO_ENDPOINT, but exposed to the browser). -VITE_LOGTO_ENDPOINT=https://logto.courtcommand.app -# SPA app ID from Logto -> Applications -> "Court Command Web" (Single Page App). +VITE_LOGTO_ENDPOINT=http://localhost:3001 +# SPA App ID from the seeder's output. VITE_LOGTO_APP_ID= -# API resource the SDK should request access tokens for (matches LOGTO_API_RESOURCE). -VITE_LOGTO_API_RESOURCE=https://api.courtcommand.app/api - -# --- Port Overrides (optional, for local conflicts) --- -# DB_PORT=5432 -# REDIS_PORT=6379 -# BACKEND_PORT=8080 -# FRONTEND_PORT=3000 -# GHOST_PORT=2368 +VITE_LOGTO_API_RESOURCE=http://localhost:8080/api # ============================================================================= -# PRODUCTION (Coolify) — Override these values in Coolify's env var UI: +# PRODUCTION (Coolify) overrides -- set these in Coolify's env var UI: # ============================================================================= # POSTGRES_PASSWORD= # DATABASE_URL=postgres://courtcommand:@db:5432/courtcommand?sslmode=disable +# REDIS_URL=redis://redis:6379/0 # APP_ENV=production # CORS_ALLOWED_ORIGINS=https://courtcommand.app,https://news.courtcommand.app # VITE_API_URL=https://api.courtcommand.app # GHOST_URL=https://news.courtcommand.app +# LOGTO_ENDPOINT=https://logto.courtcommand.app +# LOGTO_ADMIN_ENDPOINT=https://logto-admin.courtcommand.app +# LOGTO_API_RESOURCE=https://api.courtcommand.app/api +# LOGTO_WEBHOOK_URL=https://api.courtcommand.app/api/v1/webhooks/logto +# LOGTO_SPA_REDIRECT_URI=https://courtcommand.app/auth/callback +# VITE_LOGTO_ENDPOINT=https://logto.courtcommand.app +# VITE_LOGTO_API_RESOURCE=https://api.courtcommand.app/api +# (Plus the LOGTO_MANAGEMENT_API_APP_ID/SECRET, sport org IDs, and +# webhook signing key captured during the production seed.) diff --git a/Makefile b/Makefile index eb1de17..6baf22e 100644 --- a/Makefile +++ b/Makefile @@ -1,5 +1,45 @@ # Makefile -.PHONY: dev dev-frontend dev-all up down full full-down build migrate-up migrate-down migrate-create sqlc test test-db seed backup backup-full restore restore-db backup-list backup-before-deploy +.PHONY: dev dev-frontend dev-all dev-up dev-down dev-logs dev-reset up down full full-down build migrate-up migrate-down migrate-create sqlc test test-db seed logto-seed backup backup-full restore restore-db backup-list backup-before-deploy + +# ---- Local development (docker-compose.dev.yml) ---- +# Brings up postgres + redis + logto with host port bindings; the Go +# backend runs natively on the host for fast iteration. See +# docs/LOCAL_DEV.md for the full first-run walkthrough. + +# Start the dev infra (db + redis + logto) +dev-up: + docker compose -f docker-compose.dev.yml up -d + @echo "" + @echo "Dev infra ready:" + @echo " Postgres localhost:5432 (user=courtcommand pass=courtcommand db=courtcommand,logto)" + @echo " Redis localhost:6379" + @echo " Logto OIDC http://localhost:3001" + @echo " Logto admin http://localhost:3002" + @echo "" + @echo "Next: see docs/LOCAL_DEV.md for first-run setup." + +# Stop dev infra (preserves volumes / data) +dev-down: + docker compose -f docker-compose.dev.yml down + +# Tail logs from the dev infra services +dev-logs: + docker compose -f docker-compose.dev.yml logs -f + +# Wipe dev infra including all data (Postgres volume + Logto state) +dev-reset: + docker compose -f docker-compose.dev.yml down -v + @echo "Dev infra wiped. Run 'make dev-up' to start fresh." + +# Provision Logto (idempotent): creates apps, resources, scopes, org +# template, organizations, bootstrap admin, webhook. Reads config from +# .env (LOGTO_MANAGEMENT_API_APP_ID/SECRET must be set first -- see +# docs/LOCAL_DEV.md "First-run setup"). +logto-seed: + @if [ ! -f .env ]; then echo "ERROR: .env not found. Copy from .env.example first."; exit 1; fi + @cd api && set -a && . ../.env && set +a && go run ./cmd/logto-seed + +# ---- Legacy single-stack (docker-compose.yaml -- prod / Coolify shape) ---- # Start Docker services (db + redis only) up: diff --git a/api/cmd/logto-seed/main.go b/api/cmd/logto-seed/main.go new file mode 100644 index 0000000..86384a2 --- /dev/null +++ b/api/cmd/logto-seed/main.go @@ -0,0 +1,537 @@ +// Command logto-seed idempotently provisions a Logto tenant with everything +// Court Command needs: SPA app, Court Command API resource + scopes, M2M app +// role assignment, organization template (roles + scopes + role-scope +// mappings), Pickleball + Demo Sport organizations, bootstrap admin user +// with platform_admin role in both orgs, and the Court Command webhook. +// +// Designed to mirror what was done by hand in production (per +// docs/LOGTO_SETUP.md). Running this script against an already-seeded +// tenant is a no-op: every create call is preceded by a find call, and +// existing items are reused. +// +// Reads configuration from the api/cmd/logto-seed environment: +// LOGTO_ENDPOINT required (e.g. http://localhost:3001) +// LOGTO_MANAGEMENT_API_APP_ID required (operator-created via admin UI) +// LOGTO_MANAGEMENT_API_APP_SECRET required (operator-created via admin UI) +// LOGTO_MANAGEMENT_API_RESOURCE defaults to https://default.logto.app/api +// LOGTO_API_RESOURCE defaults to http://localhost:8080/api +// LOGTO_SPA_REDIRECT_URI defaults to http://localhost:5173/auth/callback +// LOGTO_WEBHOOK_URL defaults to http://host.docker.internal:8080/api/v1/webhooks/logto +// LOGTO_BOOTSTRAP_EMAIL defaults to admin@courtcommand.local +// LOGTO_BOOTSTRAP_PASSWORD defaults to TestPass123! (DO NOT use in prod) +// LOGTO_BOOTSTRAP_NAME defaults to "Local Admin" +// +// Usage: +// go run ./cmd/logto-seed +// or: +// make logto-seed +// +// On success, prints a summary including the SPA app ID, sport org IDs, +// webhook signing key, and bootstrap admin credentials. Capture these into +// .env before starting the backend. +package main + +import ( + "context" + "errors" + "fmt" + "log" + "os" + "strings" + "time" + + "github.com/court-command/court-command/logto" +) + +const ( + spaAppName = "Court Command Web" + m2mAppName = "Court Command Backend" + apiResourceName = "Court Command API" + mgmtAPIRoleName = "Logto Management API access" + pickleballOrgName = "Pickleball" + demoSportOrgName = "Demo Sport" + pickleballOrgDesc = "Production sport on Court Command" + demoSportOrgDesc = "Test organization that exercises the multi-sport plumbing" + courtCommandHookName = "Court Command Backend" + platformAdminRoleName = "platform_admin" +) + +// apiScopes are the 12 Court Command API scopes registered on the API +// resource. Order is intentional: read before write within each domain. +var apiScopes = []scopeDef{ + {"read:profile", "Read user profile data"}, + {"write:profile", "Update user profile data"}, + {"read:tournaments", "Read tournament data"}, + {"write:tournaments", "Create or update tournaments"}, + {"read:matches", "Read match data"}, + {"write:matches", "Create or update matches and scores"}, + {"read:registrations", "Read registration data"}, + {"write:registrations", "Create or update registrations"}, + {"read:overlay", "Read overlay configuration"}, + {"write:overlay", "Update overlay configuration"}, + {"read:admin", "Read admin-level data"}, + {"write:admin", "Perform admin-level actions"}, +} + +// orgScopes are the five organization-level scopes shared across roles. +var orgScopes = []scopeDef{ + {"manage_tournaments", "Create, update, archive tournaments in this sport"}, + {"manage_matches", "Score, edit, override matches in this sport"}, + {"manage_registrations", "Add, approve, withdraw registrations"}, + {"manage_users", "Manage user roles and statuses within this sport"}, + {"read_all", "Read all sport data (no write)"}, +} + +// orgRoles are the five organization roles defined on the template, with +// their scope assignments. Order matters: platform_admin is created last so +// the seeder can assign every other scope to it without hardcoding. +var orgRoles = []orgRoleDef{ + {Name: "player", Description: "Default role for users in this sport", + Scopes: []string{"read_all"}}, + {Name: "tournament_director", Description: "Can manage tournaments in this sport", + Scopes: []string{"read_all", "manage_tournaments", "manage_registrations"}}, + {Name: "referee", Description: "Can score matches in this sport", + Scopes: []string{"read_all", "manage_matches"}}, + {Name: "scorekeeper", Description: "Same as referee, alternate label for staff naming", + Scopes: []string{"read_all", "manage_matches"}}, + {Name: "platform_admin", Description: "Full platform access within this sport", + Scopes: []string{"read_all", "manage_tournaments", "manage_matches", "manage_registrations", "manage_users"}}, +} + +// hookEvents are the webhook events the backend's webhook handler subscribes to. +var hookEvents = []string{"User.Created", "User.Data.Updated", "User.Deleted"} + +type scopeDef struct { + name, description string +} + +type orgRoleDef struct { + Name string + Description string + Scopes []string +} + +type config struct { + Endpoint string + MgmtAppID string + MgmtAppSecret string + MgmtAPIResource string + APIResourceIndicator string + SPARedirectURI string + WebhookURL string + BootstrapEmail string + BootstrapPassword string + BootstrapName string +} + +func loadConfig() (*config, error) { + cfg := &config{ + Endpoint: strings.TrimRight(os.Getenv("LOGTO_ENDPOINT"), "/"), + MgmtAppID: os.Getenv("LOGTO_MANAGEMENT_API_APP_ID"), + MgmtAppSecret: os.Getenv("LOGTO_MANAGEMENT_API_APP_SECRET"), + MgmtAPIResource: envOrDefault("LOGTO_MANAGEMENT_API_RESOURCE", "https://default.logto.app/api"), + APIResourceIndicator: envOrDefault("LOGTO_API_RESOURCE", "http://localhost:8080/api"), + SPARedirectURI: envOrDefault("LOGTO_SPA_REDIRECT_URI", "http://localhost:5173/auth/callback"), + WebhookURL: envOrDefault("LOGTO_WEBHOOK_URL", "http://host.docker.internal:8080/api/v1/webhooks/logto"), + BootstrapEmail: envOrDefault("LOGTO_BOOTSTRAP_EMAIL", "admin@courtcommand.local"), + BootstrapPassword: envOrDefault("LOGTO_BOOTSTRAP_PASSWORD", "TestPass123!"), + BootstrapName: envOrDefault("LOGTO_BOOTSTRAP_NAME", "Local Admin"), + } + var missing []string + if cfg.Endpoint == "" { + missing = append(missing, "LOGTO_ENDPOINT") + } + if cfg.MgmtAppID == "" { + missing = append(missing, "LOGTO_MANAGEMENT_API_APP_ID") + } + if cfg.MgmtAppSecret == "" { + missing = append(missing, "LOGTO_MANAGEMENT_API_APP_SECRET") + } + if len(missing) > 0 { + return nil, fmt.Errorf("missing required env vars: %s\n\nFirst-run setup: open %s in a browser, create the\ninitial admin account, then create a Machine-to-Machine application\nnamed %q assigned the %q role. Copy its App ID and App Secret into\nyour .env, then re-run this script.", + strings.Join(missing, ", "), strings.Replace(cfg.Endpoint, "3001", "3002", 1), m2mAppName, mgmtAPIRoleName) + } + return cfg, nil +} + +func envOrDefault(key, def string) string { + if v := os.Getenv(key); v != "" { + return v + } + return def +} + +func main() { + cfg, err := loadConfig() + if err != nil { + log.Fatalf("config error: %v", err) + } + + client := logto.NewClient(logto.Config{ + Endpoint: cfg.Endpoint, + ManagementAPIAppID: cfg.MgmtAppID, + ManagementAPIAppSecret: cfg.MgmtAppSecret, + ManagementAPIResource: cfg.MgmtAPIResource, + }) + + ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second) + defer cancel() + + log.Printf("Logto seed starting against %s", cfg.Endpoint) + log.Printf(" M2M app ID: %s", cfg.MgmtAppID) + + // Sanity-check that the Management API token mints. Catches bad credentials + // up front rather than partway through. + if _, err := client.GetManagementToken(ctx); err != nil { + log.Fatalf("Management API token request failed (check LOGTO_MANAGEMENT_API_APP_ID/SECRET): %v", err) + } + log.Printf("✓ Management API token ok") + + result := &seedResult{} + + if err := seedAPIResource(ctx, client, cfg, result); err != nil { + log.Fatalf("API resource: %v", err) + } + if err := seedSPAApp(ctx, client, cfg, result); err != nil { + log.Fatalf("SPA app: %v", err) + } + if err := assignManagementAPIRole(ctx, client, cfg, result); err != nil { + log.Fatalf("M2M role assignment: %v", err) + } + if err := seedOrgScopes(ctx, client, result); err != nil { + log.Fatalf("org scopes: %v", err) + } + if err := seedOrgRoles(ctx, client, result); err != nil { + log.Fatalf("org roles: %v", err) + } + if err := seedOrganizations(ctx, client, result); err != nil { + log.Fatalf("organizations: %v", err) + } + if err := seedBootstrapAdmin(ctx, client, cfg, result); err != nil { + log.Fatalf("bootstrap admin: %v", err) + } + if err := seedWebhook(ctx, client, cfg, result); err != nil { + log.Fatalf("webhook: %v", err) + } + + printSummary(cfg, result) +} + +type seedResult struct { + APIResourceID string + SPAAppID string + OrgScopeIDs map[string]string // scope name -> ID + OrgRoleIDs map[string]string // role name -> ID + PickleballOrgID string + DemoSportOrgID string + BootstrapUserID string + WebhookSigningKey string +} + +func seedAPIResource(ctx context.Context, c *logto.Client, cfg *config, r *seedResult) error { + existing, err := c.FindResourceByIndicator(ctx, cfg.APIResourceIndicator) + if err != nil { + return err + } + var resID string + if existing != nil { + log.Printf("✓ API resource %q exists (id=%s)", cfg.APIResourceIndicator, existing.ID) + resID = existing.ID + } else { + created, err := c.CreateResource(ctx, logto.CreateResourceParams{ + Name: apiResourceName, + Indicator: cfg.APIResourceIndicator, + }) + if err != nil { + return fmt.Errorf("create resource: %w", err) + } + log.Printf("✓ Created API resource %q (id=%s)", cfg.APIResourceIndicator, created.ID) + resID = created.ID + } + r.APIResourceID = resID + + // Idempotent scope provisioning. + have, err := c.ListResourceScopes(ctx, resID) + if err != nil { + return fmt.Errorf("list scopes: %w", err) + } + haveSet := make(map[string]bool, len(have)) + for _, s := range have { + haveSet[s.Name] = true + } + + added := 0 + for _, s := range apiScopes { + if haveSet[s.name] { + continue + } + if _, err := c.CreateResourceScope(ctx, resID, s.name, s.description); err != nil { + return fmt.Errorf("create scope %q: %w", s.name, err) + } + added++ + } + log.Printf("✓ API scopes: %d existing, %d added (target: %d)", len(have), added, len(apiScopes)) + return nil +} + +func seedSPAApp(ctx context.Context, c *logto.Client, cfg *config, r *seedResult) error { + existing, err := c.FindApplicationByName(ctx, spaAppName) + if err != nil { + return err + } + if existing != nil { + log.Printf("✓ SPA app %q exists (id=%s)", spaAppName, existing.ID) + r.SPAAppID = existing.ID + return nil + } + created, err := c.CreateApplication(ctx, logto.CreateApplicationParams{ + Name: spaAppName, + Type: logto.AppTypeSPA, + Description: "Court Command web frontend (React SPA)", + OIDCClientMetadata: map[string]interface{}{ + "redirectUris": []string{cfg.SPARedirectURI}, + "postLogoutRedirectUris": []string{strings.TrimSuffix(cfg.SPARedirectURI, "/auth/callback")}, + }, + }) + if err != nil { + return fmt.Errorf("create SPA app: %w", err) + } + log.Printf("✓ Created SPA app %q (id=%s)", spaAppName, created.ID) + r.SPAAppID = created.ID + return nil +} + +func assignManagementAPIRole(ctx context.Context, c *logto.Client, cfg *config, r *seedResult) error { + roles, err := c.ListRoles(ctx) + if err != nil { + return fmt.Errorf("list roles: %w", err) + } + var mgmtRoleID string + for _, role := range roles { + if role.Name == mgmtAPIRoleName { + mgmtRoleID = role.ID + break + } + } + if mgmtRoleID == "" { + return fmt.Errorf("built-in role %q not found in Logto -- cannot self-bootstrap M2M role assignment", mgmtAPIRoleName) + } + + // Assigning is idempotent on Logto's side. We treat 422 (already assigned) + // as a soft-success rather than an error. + err = c.AssignApplicationRoles(ctx, cfg.MgmtAppID, []string{mgmtRoleID}) + if err != nil { + var apiErr *logto.APIError + if errors.As(err, &apiErr) && apiErr.Status == 422 { + log.Printf("✓ M2M app already has %q role", mgmtAPIRoleName) + return nil + } + return fmt.Errorf("assign role: %w", err) + } + log.Printf("✓ Assigned %q role to M2M app", mgmtAPIRoleName) + return nil +} + +func seedOrgScopes(ctx context.Context, c *logto.Client, r *seedResult) error { + have, err := c.ListOrganizationScopes(ctx) + if err != nil { + return fmt.Errorf("list org scopes: %w", err) + } + r.OrgScopeIDs = make(map[string]string, len(have)+len(orgScopes)) + for _, s := range have { + r.OrgScopeIDs[s.Name] = s.ID + } + added := 0 + for _, def := range orgScopes { + if _, ok := r.OrgScopeIDs[def.name]; ok { + continue + } + created, err := c.CreateOrganizationScope(ctx, def.name, def.description) + if err != nil { + return fmt.Errorf("create org scope %q: %w", def.name, err) + } + r.OrgScopeIDs[def.name] = created.ID + added++ + } + log.Printf("✓ Org scopes: %d existing, %d added", len(have), added) + return nil +} + +func seedOrgRoles(ctx context.Context, c *logto.Client, r *seedResult) error { + have, err := c.ListOrganizationRoles(ctx) + if err != nil { + return fmt.Errorf("list org roles: %w", err) + } + r.OrgRoleIDs = make(map[string]string, len(have)+len(orgRoles)) + for _, role := range have { + r.OrgRoleIDs[role.Name] = role.ID + } + + added := 0 + for _, def := range orgRoles { + roleID, ok := r.OrgRoleIDs[def.Name] + if !ok { + created, err := c.CreateOrganizationRole(ctx, def.Name, def.Description) + if err != nil { + return fmt.Errorf("create org role %q: %w", def.Name, err) + } + roleID = created.ID + r.OrgRoleIDs[def.Name] = roleID + added++ + } + + // Bind the role's declared scopes. ListOrgRoleScopes returns the + // current bindings; only attach scopes that aren't already there. + currentScopes, err := c.ListOrgRoleScopes(ctx, roleID) + if err != nil { + return fmt.Errorf("list scopes for role %q: %w", def.Name, err) + } + currentSet := make(map[string]bool, len(currentScopes)) + for _, s := range currentScopes { + currentSet[s.Name] = true + } + var toAdd []string + for _, scopeName := range def.Scopes { + if currentSet[scopeName] { + continue + } + scopeID, ok := r.OrgScopeIDs[scopeName] + if !ok { + return fmt.Errorf("role %q wants unknown scope %q", def.Name, scopeName) + } + toAdd = append(toAdd, scopeID) + } + if len(toAdd) > 0 { + if err := c.AssignScopesToOrgRole(ctx, roleID, toAdd); err != nil { + return fmt.Errorf("assign scopes to role %q: %w", def.Name, err) + } + } + } + log.Printf("✓ Org roles: %d existing, %d added (scope bindings synced)", len(have), added) + return nil +} + +func seedOrganizations(ctx context.Context, c *logto.Client, r *seedResult) error { + for _, spec := range []struct { + name, desc string + idField *string + }{ + {pickleballOrgName, pickleballOrgDesc, &r.PickleballOrgID}, + {demoSportOrgName, demoSportOrgDesc, &r.DemoSportOrgID}, + } { + existing, err := c.FindOrgByName(ctx, spec.name) + if err != nil { + return fmt.Errorf("find org %q: %w", spec.name, err) + } + if existing != nil { + log.Printf("✓ Org %q exists (id=%s)", spec.name, existing.ID) + *spec.idField = existing.ID + continue + } + created, err := c.CreateOrganization(ctx, spec.name, spec.desc) + if err != nil { + return fmt.Errorf("create org %q: %w", spec.name, err) + } + log.Printf("✓ Created org %q (id=%s)", spec.name, created.ID) + *spec.idField = created.ID + } + return nil +} + +func seedBootstrapAdmin(ctx context.Context, c *logto.Client, cfg *config, r *seedResult) error { + user, err := c.FindUserByEmail(ctx, cfg.BootstrapEmail) + if err != nil { + return fmt.Errorf("find user: %w", err) + } + if user != nil { + log.Printf("✓ Bootstrap admin %q exists (id=%s)", cfg.BootstrapEmail, user.ID) + r.BootstrapUserID = user.ID + } else { + created, err := c.CreateUser(ctx, logto.CreateUserParams{ + PrimaryEmail: cfg.BootstrapEmail, + Password: cfg.BootstrapPassword, + Name: cfg.BootstrapName, + }) + if err != nil { + return fmt.Errorf("create user: %w", err) + } + log.Printf("✓ Created bootstrap admin %q (id=%s)", cfg.BootstrapEmail, created.ID) + r.BootstrapUserID = created.ID + } + + platformAdminRoleID, ok := r.OrgRoleIDs[platformAdminRoleName] + if !ok { + return fmt.Errorf("internal error: %q role ID not resolved", platformAdminRoleName) + } + + for _, orgID := range []string{r.PickleballOrgID, r.DemoSportOrgID} { + if err := c.AddUserToOrganization(ctx, orgID, r.BootstrapUserID); err != nil { + var apiErr *logto.APIError + // 422 = already a member; 200/201 = newly added. Treat both as success. + if !(errors.As(err, &apiErr) && apiErr.Status == 422) { + return fmt.Errorf("add user to org %s: %w", orgID, err) + } + } + if err := c.AssignOrganizationRolesToUser(ctx, orgID, r.BootstrapUserID, []string{platformAdminRoleID}); err != nil { + var apiErr *logto.APIError + if !(errors.As(err, &apiErr) && apiErr.Status == 422) { + return fmt.Errorf("assign role in org %s: %w", orgID, err) + } + } + } + log.Printf("✓ Bootstrap admin is platform_admin in both sport orgs") + return nil +} + +func seedWebhook(ctx context.Context, c *logto.Client, cfg *config, r *seedResult) error { + existing, err := c.FindHookByName(ctx, courtCommandHookName) + if err != nil { + return fmt.Errorf("find hook: %w", err) + } + if existing != nil { + log.Printf("✓ Webhook %q exists (id=%s)", courtCommandHookName, existing.ID) + r.WebhookSigningKey = existing.SigningKey + return nil + } + created, err := c.CreateHook(ctx, logto.CreateHookParams{ + Name: courtCommandHookName, + Events: hookEvents, + Config: map[string]interface{}{"url": cfg.WebhookURL}, + Enabled: true, + }) + if err != nil { + return fmt.Errorf("create hook: %w", err) + } + log.Printf("✓ Created webhook %q (id=%s) -> %s", courtCommandHookName, created.ID, cfg.WebhookURL) + r.WebhookSigningKey = created.SigningKey + return nil +} + +func printSummary(cfg *config, r *seedResult) { + fmt.Println() + fmt.Println("=================================================================") + fmt.Println(" Logto seed complete -- copy the values below into your .env") + fmt.Println("=================================================================") + fmt.Println() + fmt.Printf("LOGTO_ENDPOINT=%s\n", cfg.Endpoint) + fmt.Printf("LOGTO_API_RESOURCE=%s\n", cfg.APIResourceIndicator) + fmt.Printf("LOGTO_PICKLEBALL_ORG_ID=%s\n", r.PickleballOrgID) + fmt.Printf("LOGTO_DEMO_SPORT_ORG_ID=%s\n", r.DemoSportOrgID) + if r.WebhookSigningKey != "" { + fmt.Printf("LOGTO_WEBHOOK_SIGNING_KEY=%s\n", r.WebhookSigningKey) + } else { + fmt.Println("# LOGTO_WEBHOOK_SIGNING_KEY=") + } + fmt.Println() + fmt.Println("# Frontend (Vite build args)") + fmt.Printf("VITE_LOGTO_ENDPOINT=%s\n", cfg.Endpoint) + fmt.Printf("VITE_LOGTO_APP_ID=%s\n", r.SPAAppID) + fmt.Printf("VITE_LOGTO_API_RESOURCE=%s\n", cfg.APIResourceIndicator) + fmt.Println() + fmt.Println("# Bootstrap admin (sign in at the SPA redirect URL after webhook fires):") + fmt.Printf("# email: %s\n", cfg.BootstrapEmail) + fmt.Printf("# password: %s\n", cfg.BootstrapPassword) + fmt.Printf("# user_id: %s\n", r.BootstrapUserID) + fmt.Println() + fmt.Println("Re-running this script is safe: every step is idempotent.") +} diff --git a/api/logto/applications.go b/api/logto/applications.go new file mode 100644 index 0000000..a725041 --- /dev/null +++ b/api/logto/applications.go @@ -0,0 +1,126 @@ +package logto + +import ( + "context" + "fmt" + "net/http" + "net/url" + "strconv" +) + +// Application is the Logto Management API representation of a registered +// application (SPA, M2M, native, traditional). Field set is intentionally +// narrow: just the fields the seed script and Phase 4 M2M flow read or write. +type Application struct { + ID string `json:"id"` + Name string `json:"name"` + Description string `json:"description,omitempty"` + Type string `json:"type"` + Secret string `json:"secret,omitempty"` + OIDCClientMetadata map[string]interface{} `json:"oidcClientMetadata,omitempty"` + CustomClientMetadata map[string]interface{} `json:"customClientMetadata,omitempty"` + IsAdmin bool `json:"isAdmin,omitempty"` + CreatedAt int64 `json:"createdAt,omitempty"` +} + +// ApplicationType values accepted by the Logto Management API. +const ( + AppTypeSPA = "SPA" + AppTypeMachineToMachine = "MachineToMachine" + AppTypeNative = "Native" + AppTypeTraditionalWeb = "Traditional" +) + +// CreateApplicationParams is the body for POST /api/applications. +type CreateApplicationParams struct { + Name string `json:"name"` + Type string `json:"type"` + Description string `json:"description,omitempty"` + OIDCClientMetadata map[string]interface{} `json:"oidcClientMetadata,omitempty"` + CustomClientMetadata map[string]interface{} `json:"customClientMetadata,omitempty"` +} + +// CreateApplication creates a new application and returns the created row +// (including the generated client secret for M2M apps). NOT idempotent -- +// callers needing idempotency should use FindApplicationByName first. +func (c *Client) CreateApplication(ctx context.Context, p CreateApplicationParams) (*Application, error) { + var a Application + if err := c.doJSON(ctx, http.MethodPost, "/api/applications", p, &a); err != nil { + return nil, err + } + return &a, nil +} + +// ListApplications returns up to pageSize applications. Logto's pagination +// uses page (1-based) + page_size; the seeder pages through with size=100 +// to find existing apps by name. +func (c *Client) ListApplications(ctx context.Context, page, pageSize int) ([]Application, error) { + if page < 1 { + page = 1 + } + if pageSize < 1 { + pageSize = 20 + } + q := url.Values{} + q.Set("page", strconv.Itoa(page)) + q.Set("page_size", strconv.Itoa(pageSize)) + + var apps []Application + path := "/api/applications?" + q.Encode() + if err := c.doJSON(ctx, http.MethodGet, path, nil, &apps); err != nil { + return nil, err + } + return apps, nil +} + +// FindApplicationByName scans the application list for an exact name match +// and returns the first hit, or nil if absent. Idempotent companion to +// CreateApplication for the seed flow. +func (c *Client) FindApplicationByName(ctx context.Context, name string) (*Application, error) { + const pageSize = 100 + for page := 1; page <= 100; page++ { // hard cap: 10k apps + apps, err := c.ListApplications(ctx, page, pageSize) + if err != nil { + return nil, err + } + for i := range apps { + if apps[i].Name == name { + return &apps[i], nil + } + } + if len(apps) < pageSize { + return nil, nil + } + } + return nil, fmt.Errorf("more than 10k applications; FindApplicationByName paging budget exhausted") +} + +// AssignApplicationRoles attaches Logto-platform roles (e.g. the built-in +// "Logto Management API access" role) to a Machine-to-Machine application. +// roleIDs come from ListRoles. Idempotent: Logto returns 422 on duplicate +// assignment which the caller can surface or ignore. +func (c *Client) AssignApplicationRoles(ctx context.Context, appID string, roleIDs []string) error { + body := map[string]interface{}{"roleIds": roleIDs} + path := fmt.Sprintf("/api/applications/%s/roles", appID) + return c.doJSON(ctx, http.MethodPost, path, body, nil) +} + +// Role is a Logto-platform role (NOT the same as organization roles, which +// live on the organization template). Roles control what Logto Management +// API surface a holder can call. +type Role struct { + ID string `json:"id"` + Name string `json:"name"` + Description string `json:"description,omitempty"` + Type string `json:"type,omitempty"` +} + +// ListRoles returns all Logto-platform roles. Used by the seeder to find +// the built-in "Logto Management API access" role by name. +func (c *Client) ListRoles(ctx context.Context) ([]Role, error) { + var roles []Role + if err := c.doJSON(ctx, http.MethodGet, "/api/roles?page_size=200", nil, &roles); err != nil { + return nil, err + } + return roles, nil +} diff --git a/api/logto/hooks.go b/api/logto/hooks.go new file mode 100644 index 0000000..4b024aa --- /dev/null +++ b/api/logto/hooks.go @@ -0,0 +1,58 @@ +package logto + +import ( + "context" + "net/http" +) + +// Hook is a Logto webhook subscription. Events fire HTTP POST requests +// signed with the hook's signingKey (HMAC-SHA256, header +// `logto-signature-sha-256`). +type Hook struct { + ID string `json:"id"` + Name string `json:"name"` + Events []string `json:"events"` + Config map[string]interface{} `json:"config"` + SigningKey string `json:"signingKey,omitempty"` + Enabled bool `json:"enabled"` +} + +// CreateHookParams is the body for POST /api/hooks. +type CreateHookParams struct { + Name string `json:"name"` + Events []string `json:"events"` + Config map[string]interface{} `json:"config"` + Enabled bool `json:"enabled"` +} + +// CreateHook registers a new webhook subscription. +func (c *Client) CreateHook(ctx context.Context, p CreateHookParams) (*Hook, error) { + var h Hook + if err := c.doJSON(ctx, http.MethodPost, "/api/hooks", p, &h); err != nil { + return nil, err + } + return &h, nil +} + +// ListHooks returns all webhook subscriptions on the tenant. +func (c *Client) ListHooks(ctx context.Context) ([]Hook, error) { + var hooks []Hook + if err := c.doJSON(ctx, http.MethodGet, "/api/hooks?page_size=100", nil, &hooks); err != nil { + return nil, err + } + return hooks, nil +} + +// FindHookByName returns the first hook with the given name, or nil. +func (c *Client) FindHookByName(ctx context.Context, name string) (*Hook, error) { + hooks, err := c.ListHooks(ctx) + if err != nil { + return nil, err + } + for i := range hooks { + if hooks[i].Name == name { + return &hooks[i], nil + } + } + return nil, nil +} diff --git a/api/logto/organizations.go b/api/logto/organizations.go new file mode 100644 index 0000000..ef0770c --- /dev/null +++ b/api/logto/organizations.go @@ -0,0 +1,156 @@ +package logto + +import ( + "context" + "fmt" + "net/http" +) + +// Organization represents a Logto organization. Court Command uses one org +// per sport (Pickleball, Demo Sport). +type Organization struct { + ID string `json:"id"` + Name string `json:"name"` + Description string `json:"description,omitempty"` + CustomData map[string]interface{} `json:"customData,omitempty"` + CreatedAt int64 `json:"createdAt,omitempty"` +} + +// CreateOrganization creates a new Logto organization. Use FindOrgByName +// first if you need idempotent semantics. +func (c *Client) CreateOrganization(ctx context.Context, name, description string) (*Organization, error) { + body := map[string]interface{}{"name": name} + if description != "" { + body["description"] = description + } + var o Organization + if err := c.doJSON(ctx, http.MethodPost, "/api/organizations", body, &o); err != nil { + return nil, err + } + return &o, nil +} + +// ListOrganizations returns all organizations on the tenant. +func (c *Client) ListOrganizations(ctx context.Context) ([]Organization, error) { + var orgs []Organization + if err := c.doJSON(ctx, http.MethodGet, "/api/organizations?page_size=200", nil, &orgs); err != nil { + return nil, err + } + return orgs, nil +} + +// FindOrgByName returns the first organization matching name exactly, or nil. +func (c *Client) FindOrgByName(ctx context.Context, name string) (*Organization, error) { + orgs, err := c.ListOrganizations(ctx) + if err != nil { + return nil, err + } + for i := range orgs { + if orgs[i].Name == name { + return &orgs[i], nil + } + } + return nil, nil +} + +// AddUserToOrganization adds a user to a Logto organization. Idempotent: +// Logto's POST /organizations/:id/users responds 201 on first add and +// 200/204 on subsequent calls (current behavior; if Logto starts returning +// 422 on duplicates, callers should errors.As for *APIError and ignore 422). +func (c *Client) AddUserToOrganization(ctx context.Context, orgID, userID string) error { + body := map[string]interface{}{"userIds": []string{userID}} + path := fmt.Sprintf("/api/organizations/%s/users", orgID) + return c.doJSON(ctx, http.MethodPost, path, body, nil) +} + +// AssignOrganizationRolesToUser binds the given organization roles to the +// user's membership in an org. roleNames are the human-readable names +// (e.g. "platform_admin") -- Logto's API actually wants role IDs, so the +// caller must resolve names to IDs via ListOrganizationRoles first. The +// seeder uses this directly with already-resolved IDs. +func (c *Client) AssignOrganizationRolesToUser(ctx context.Context, orgID, userID string, roleIDs []string) error { + body := map[string]interface{}{"organizationRoleIds": roleIDs} + path := fmt.Sprintf("/api/organizations/%s/users/%s/roles", orgID, userID) + return c.doJSON(ctx, http.MethodPost, path, body, nil) +} + +// OrganizationRole is a role on the organization template (e.g. +// platform_admin, tournament_director). Distinct from Logto-platform roles +// which gate Management API access. +type OrganizationRole struct { + ID string `json:"id"` + Name string `json:"name"` + Description string `json:"description,omitempty"` + Type string `json:"type,omitempty"` +} + +// ListOrganizationRoles returns all roles defined on the organization +// template. Roles are shared across every organization. +func (c *Client) ListOrganizationRoles(ctx context.Context) ([]OrganizationRole, error) { + var roles []OrganizationRole + if err := c.doJSON(ctx, http.MethodGet, "/api/organization-roles?page_size=200", nil, &roles); err != nil { + return nil, err + } + return roles, nil +} + +// CreateOrganizationRole adds a new role to the organization template. +func (c *Client) CreateOrganizationRole(ctx context.Context, name, description string) (*OrganizationRole, error) { + body := map[string]interface{}{"name": name} + if description != "" { + body["description"] = description + } + var r OrganizationRole + if err := c.doJSON(ctx, http.MethodPost, "/api/organization-roles", body, &r); err != nil { + return nil, err + } + return &r, nil +} + +// OrganizationScope is a permission on the organization template. +type OrganizationScope struct { + ID string `json:"id"` + Name string `json:"name"` + Description string `json:"description,omitempty"` +} + +// ListOrganizationScopes returns all org scopes on the template. +func (c *Client) ListOrganizationScopes(ctx context.Context) ([]OrganizationScope, error) { + var scopes []OrganizationScope + if err := c.doJSON(ctx, http.MethodGet, "/api/organization-scopes?page_size=200", nil, &scopes); err != nil { + return nil, err + } + return scopes, nil +} + +// CreateOrganizationScope adds a new scope to the organization template. +func (c *Client) CreateOrganizationScope(ctx context.Context, name, description string) (*OrganizationScope, error) { + body := map[string]interface{}{"name": name} + if description != "" { + body["description"] = description + } + var s OrganizationScope + if err := c.doJSON(ctx, http.MethodPost, "/api/organization-scopes", body, &s); err != nil { + return nil, err + } + return &s, nil +} + +// AssignScopesToOrgRole adds organization scopes to an organization role. +// Both arguments are Logto IDs (not human names). Idempotent: re-adding +// existing scopes is a no-op on most Logto versions. +func (c *Client) AssignScopesToOrgRole(ctx context.Context, roleID string, scopeIDs []string) error { + body := map[string]interface{}{"organizationScopeIds": scopeIDs} + path := fmt.Sprintf("/api/organization-roles/%s/scopes", roleID) + return c.doJSON(ctx, http.MethodPost, path, body, nil) +} + +// ListOrgRoleScopes returns the scopes currently bound to an org role. +func (c *Client) ListOrgRoleScopes(ctx context.Context, roleID string) ([]OrganizationScope, error) { + var scopes []OrganizationScope + path := fmt.Sprintf("/api/organization-roles/%s/scopes?page_size=200", roleID) + if err := c.doJSON(ctx, http.MethodGet, path, nil, &scopes); err != nil { + return nil, err + } + return scopes, nil +} diff --git a/api/logto/resources.go b/api/logto/resources.go new file mode 100644 index 0000000..cc60b40 --- /dev/null +++ b/api/logto/resources.go @@ -0,0 +1,90 @@ +package logto + +import ( + "context" + "fmt" + "net/http" +) + +// Resource is a Logto API resource (the "Court Command API" resource that +// JWTs are issued for). Permissions on a resource are called "scopes" in +// the Logto UI; the API endpoint is /api/resources/:id/scopes. +type Resource struct { + ID string `json:"id"` + Name string `json:"name"` + Indicator string `json:"indicator"` + IsDefault bool `json:"isDefault,omitempty"` + AccessTokenTTL int `json:"accessTokenTtl,omitempty"` +} + +// CreateResourceParams is the body for POST /api/resources. +type CreateResourceParams struct { + Name string `json:"name"` + Indicator string `json:"indicator"` +} + +// CreateResource registers a new API resource. The indicator (audience) is +// what JWTs will carry in their `aud` claim and is what api/auth.Validator +// matches against. +func (c *Client) CreateResource(ctx context.Context, p CreateResourceParams) (*Resource, error) { + var r Resource + if err := c.doJSON(ctx, http.MethodPost, "/api/resources", p, &r); err != nil { + return nil, err + } + return &r, nil +} + +// ListResources returns all API resources. Logto returns the built-in +// management-api resource alongside user-created ones. +func (c *Client) ListResources(ctx context.Context) ([]Resource, error) { + var rs []Resource + if err := c.doJSON(ctx, http.MethodGet, "/api/resources?page_size=100", nil, &rs); err != nil { + return nil, err + } + return rs, nil +} + +// FindResourceByIndicator scans resources for an exact indicator match. +// Idempotent companion to CreateResource. +func (c *Client) FindResourceByIndicator(ctx context.Context, indicator string) (*Resource, error) { + rs, err := c.ListResources(ctx) + if err != nil { + return nil, err + } + for i := range rs { + if rs[i].Indicator == indicator { + return &rs[i], nil + } + } + return nil, nil +} + +// Scope is a permission attached to a resource. Maps to the JWT `scope` claim. +type Scope struct { + ID string `json:"id"` + Name string `json:"name"` + Description string `json:"description,omitempty"` + ResourceID string `json:"resourceId,omitempty"` +} + +// CreateResourceScope adds a single scope to a resource. Idempotent +// callers should use ListResourceScopes first. +func (c *Client) CreateResourceScope(ctx context.Context, resourceID, name, description string) (*Scope, error) { + body := map[string]interface{}{"name": name, "description": description} + var s Scope + path := fmt.Sprintf("/api/resources/%s/scopes", resourceID) + if err := c.doJSON(ctx, http.MethodPost, path, body, &s); err != nil { + return nil, err + } + return &s, nil +} + +// ListResourceScopes returns the scopes registered on a resource. +func (c *Client) ListResourceScopes(ctx context.Context, resourceID string) ([]Scope, error) { + var scopes []Scope + path := fmt.Sprintf("/api/resources/%s/scopes?page_size=200", resourceID) + if err := c.doJSON(ctx, http.MethodGet, path, nil, &scopes); err != nil { + return nil, err + } + return scopes, nil +} diff --git a/api/logto/users.go b/api/logto/users.go index fd19cb7..4205864 100644 --- a/api/logto/users.go +++ b/api/logto/users.go @@ -2,7 +2,9 @@ package logto import ( "context" + "errors" "net/http" + "net/url" ) // LogtoUser is the subset of the Logto user record Court Command consumes. @@ -31,3 +33,76 @@ func (c *Client) GetUser(ctx context.Context, userID string) (*LogtoUser, error) } return &u, nil } + +// CreateUserParams is the body for POST /api/users. +type CreateUserParams struct { + PrimaryEmail string `json:"primaryEmail,omitempty"` + Username string `json:"username,omitempty"` + Password string `json:"password,omitempty"` + Name string `json:"name,omitempty"` +} + +// CreateUser creates a Logto user (used by the seeder for the bootstrap +// admin and by Phase 4 for tournament staff). The password is plaintext +// here only because Logto needs to hash it server-side; it must never +// be persisted on Court Command's side. +func (c *Client) CreateUser(ctx context.Context, p CreateUserParams) (*LogtoUser, error) { + var u LogtoUser + if err := c.doJSON(ctx, http.MethodPost, "/api/users", p, &u); err != nil { + return nil, err + } + return &u, nil +} + +// FindUserByEmail returns the first user whose primaryEmail matches +// exactly, or nil if no such user. Idempotent companion to CreateUser +// for the seeder. +func (c *Client) FindUserByEmail(ctx context.Context, email string) (*LogtoUser, error) { + q := url.Values{} + q.Set("search.primaryEmail", email) + q.Set("mode.primaryEmail", "exact") + q.Set("page_size", "10") + + var users []LogtoUser + path := "/api/users?" + q.Encode() + if err := c.doJSON(ctx, http.MethodGet, path, nil, &users); err != nil { + // Some Logto versions don't support search.primaryEmail; fall back + // to a broader scan if that's the case. + var apiErr *APIError + if errors.As(err, &apiErr) && apiErr.Status == 400 { + return c.findUserByEmailFallback(ctx, email) + } + return nil, err + } + for i := range users { + if users[i].PrimaryEmail == email { + return &users[i], nil + } + } + return nil, nil +} + +// findUserByEmailFallback scans all users when search.primaryEmail isn't +// honored (older Logto versions). Caps at 1000 users; the seeder targets +// dev environments so this is fine. +func (c *Client) findUserByEmailFallback(ctx context.Context, email string) (*LogtoUser, error) { + const pageSize = 100 + for page := 1; page <= 10; page++ { + q := url.Values{} + q.Set("page", "1") + q.Set("page_size", "100") + var users []LogtoUser + if err := c.doJSON(ctx, http.MethodGet, "/api/users?"+q.Encode(), nil, &users); err != nil { + return nil, err + } + for i := range users { + if users[i].PrimaryEmail == email { + return &users[i], nil + } + } + if len(users) < pageSize { + return nil, nil + } + } + return nil, nil +} diff --git a/docker-compose.dev.yml b/docker-compose.dev.yml new file mode 100644 index 0000000..e38a368 --- /dev/null +++ b/docker-compose.dev.yml @@ -0,0 +1,92 @@ +# docker-compose.dev.yml +# +# Local development stack: Postgres + Redis + Logto. +# +# This file is a STANDALONE local dev environment, not an override of +# docker-compose.yaml. It runs the three persistent services that the +# Court Command backend talks to, with host port bindings so the Go +# backend (run natively via `go run main.go` or `make dev`) and the Vite +# frontend (run via `pnpm dev`) can connect from the host. +# +# Usage: +# docker compose -f docker-compose.dev.yml up -d +# make logto-seed # provisions Logto (idempotent) +# make dev # runs the Go backend natively +# +# To shut down: +# docker compose -f docker-compose.dev.yml down +# +# To wipe state and start fresh: +# docker compose -f docker-compose.dev.yml down -v +# +# See docs/LOCAL_DEV.md for the full first-run walkthrough. + +name: court-command-dev + +services: + db: + image: postgres:17-alpine + container_name: cc_db_dev + environment: + POSTGRES_USER: courtcommand + POSTGRES_PASSWORD: courtcommand + POSTGRES_DB: courtcommand + # POSTGRES_MULTIPLE_DATABASES is parsed by the init script in + # scripts/postgres-init/ to create additional databases (here: + # the one Logto needs). + POSTGRES_MULTIPLE_DATABASES: logto + ports: + - "5432:5432" + volumes: + - pgdata_dev:/var/lib/postgresql/data + - ./scripts/postgres-init:/docker-entrypoint-initdb.d:ro + healthcheck: + test: ["CMD-SHELL", "pg_isready -U courtcommand"] + interval: 5s + timeout: 5s + retries: 10 + restart: unless-stopped + + redis: + image: redis:7-alpine + container_name: cc_redis_dev + ports: + - "6379:6379" + healthcheck: + test: ["CMD", "redis-cli", "ping"] + interval: 5s + timeout: 5s + retries: 10 + restart: unless-stopped + + logto: + image: svhd/logto:1.22.0 + container_name: cc_logto_dev + depends_on: + db: + condition: service_healthy + ports: + - "3001:3001" # OIDC core endpoint (issuer, JWKS, /oidc/token) + - "3002:3002" # Logto admin UI (operator browser) + environment: + TRUST_PROXY_HEADER: "1" + DB_URL: "postgres://courtcommand:courtcommand@db:5432/logto" + ENDPOINT: "http://localhost:3001" + ADMIN_ENDPOINT: "http://localhost:3002" + DATABASE_STATEMENT_TIMEOUT: "5000" + # host.docker.internal is the magic host that resolves to the host + # machine's IP from inside Linux Docker containers (with the + # extra_hosts mapping below). Lets webhooks delivered by Logto + # reach the natively-run Go backend at http://host.docker.internal:8080. + extra_hosts: + - "host.docker.internal:host-gateway" + healthcheck: + test: ["CMD-SHELL", "wget -qO- http://localhost:3001/api/.well-known/openid-configuration > /dev/null 2>&1 || exit 1"] + interval: 10s + timeout: 5s + retries: 12 + start_period: 30s + restart: unless-stopped + +volumes: + pgdata_dev: diff --git a/docs/LOCAL_DEV.md b/docs/LOCAL_DEV.md new file mode 100644 index 0000000..71636ce --- /dev/null +++ b/docs/LOCAL_DEV.md @@ -0,0 +1,243 @@ +# Local Development + +Run the full Court Command stack on your machine: Postgres, Redis, and a +local Logto tenant in Docker; the Go backend and Vite frontend running +natively on the host for fast iteration. + +This setup mirrors production (per `docs/LOGTO_SETUP.md`) but uses a +local Logto container instead of the deployed `logto.courtcommand.app`, +so you can develop, test, wipe, and re-seed without affecting prod. + +## Prerequisites + +- Docker (any modern version with the `docker compose` plugin) +- Go 1.24+ +- Node 22+ and pnpm +- A free copy of the repo + +If any are missing on Linux: + +```bash +sudo pacman -S go nodejs npm docker docker-compose # CachyOS / Arch +sudo npm install -g pnpm +sudo systemctl enable --now docker +# Add yourself to the docker group so you don't need sudo for `docker`: +sudo usermod -aG docker $USER +# Log out and back in for the group change to take effect. +``` + +## First-run setup + +Step 1 is one-time-per-machine; after that, day-to-day workflow is just +steps 5 and 6. + +### Step 1 — Bring up the dev infra + +```bash +cp .env.example .env # only needed once; edit if you want +make dev-up +``` + +This launches three Docker containers: + +| Service | Port | Purpose | +|---|---|---| +| Postgres 17 | `localhost:5432` | Holds two databases: `courtcommand` (app) and `logto` (Logto's own state) | +| Redis 7 | `localhost:6379` | Pub/sub for real-time match updates (legacy session storage will be removed in Phase 6) | +| Logto 1.22 | `localhost:3001` (OIDC), `localhost:3002` (admin UI) | Self-hosted identity provider | + +Wait ~30 seconds for Logto to finish initializing its database. Watch +the readiness with `make dev-logs` if you're impatient. + +### Step 2 — Create the initial Logto admin account + +Open in a browser. Logto's first-run wizard +asks you to create an admin account; this is the operator (you), not a +Court Command user. Use any email/password — they're local-only. + +After the wizard, you land in the Logto admin dashboard. + +### Step 3 — Create the Management API M2M app + +The seeder needs Management API credentials to provision everything +else. Logto can't bootstrap this one for you; you create it once via +the admin UI. + +In Logto admin (): + +1. **Applications → Create application → Machine-to-machine** + - Name: `Court Command Backend` + - Description: anything + - Click **Create**. +2. On the new app's detail page, click the **Roles** tab. +3. Click **Assign Logto roles** → tick **`Logto Management API access`** → save. +4. Click the **Settings** tab and copy: + - **App ID** (alphanumeric string) + - **App Secret** (longer alphanumeric string) + +Paste both into your `.env`: + +```bash +LOGTO_MANAGEMENT_API_APP_ID= +LOGTO_MANAGEMENT_API_APP_SECRET= +``` + +### Step 4 — Run the seeder + +```bash +make logto-seed +``` + +This script idempotently creates everything else: the SPA app, the +Court Command API resource with its 12 scopes, the organization +template (5 roles + 5 scopes + role-scope mappings), the Pickleball +and Demo Sport organizations, the bootstrap admin user, and the +webhook subscription. Re-running it is safe; existing items are +detected and reused. + +The script prints a summary at the end with the values you need to +paste back into `.env`: + +``` +LOGTO_PICKLEBALL_ORG_ID=... +LOGTO_DEMO_SPORT_ORG_ID=... +LOGTO_WEBHOOK_SIGNING_KEY=... +VITE_LOGTO_APP_ID=... +``` + +Copy those four lines into your `.env`. + +### Step 5 — Start the backend + +```bash +make dev +``` + +This runs the Go backend natively at . The +backend connects to the Dockerized Postgres + Redis + Logto, runs all +migrations on startup (you'll see goose log lines), and serves +`/api/v1/health`. + +Sanity check in another terminal: + +```bash +curl -s http://localhost:8080/api/v1/health | jq +# { +# "build": { "commit": "dev", "built_at": "unknown" }, +# "services": { "database": "ok", "redis": "ok" }, +# "status": "ok" +# } +``` + +### Step 6 — Start the frontend + +```bash +make dev-frontend +``` + +Vite serves the SPA at . After Phase 3 lands, +clicking "Sign in" will redirect you to +(Logto), where you sign in with the bootstrap admin credentials +(`admin@courtcommand.local` / `TestPass123!` by default), and you'll +land back on the SPA with a valid Logto JWT. + +## Day-to-day workflow + +After the one-time setup above: + +```bash +make dev-up # docker containers (db + redis + logto) +make dev & # backend +make dev-frontend # frontend (in another terminal, or as a background job) +``` + +To shut everything down at end of day: + +```bash +# Ctrl-C the backend and frontend processes, then: +make dev-down +``` + +## Useful commands + +```bash +make dev-up # start the dev infra +make dev-down # stop the dev infra (data persists) +make dev-reset # WIPE the dev infra including Postgres + Logto state +make dev-logs # tail logs from db / redis / logto +make logto-seed # idempotent Logto provisioning +make migrate-up # run pending DB migrations manually (the backend also + # does this on startup, so usually unnecessary) +make sqlc # regenerate sqlc bindings after editing + # api/db/queries/*.sql +make test # run all backend tests (creates a separate + # courtcommand_test database) +``` + +## Wiping and re-seeding from scratch + +If you mess something up in Logto or want a clean slate: + +```bash +make dev-reset +make dev-up +# then steps 2-4 of First-run setup +``` + +`dev-reset` removes the `pgdata_dev` Docker volume, which holds both the +`courtcommand` and `logto` databases. Migrations re-run on the next +`make dev` startup. The Logto admin account, the M2M app, the seeded +data — all gone. You'll go through the wizard and the seeder again. + +## Common problems + +### `make logto-seed` fails with "missing required env vars" + +You haven't copied `LOGTO_MANAGEMENT_API_APP_ID` and +`LOGTO_MANAGEMENT_API_APP_SECRET` into `.env` from Step 3 yet. + +### `make logto-seed` fails with `oidc.invalid_target` + +Your `LOGTO_MANAGEMENT_API_RESOURCE` is wrong. For self-hosted Logto the +correct value is `https://default.logto.app/api` — that's a Logto +internal audience identifier, not a real URL. The default in +`.env.example` is correct; verify nothing has changed it. + +### Backend startup fails with "failed to run migrations" + +Postgres might not be ready yet. The `db` service has a healthcheck and +the backend's connection string targets `localhost:5432`. If the +container is still initializing on a slow machine, wait 30 seconds and +retry. Run `docker compose -f docker-compose.dev.yml ps` to see service +state. + +### Webhook from Logto can't reach the backend + +The seeder registers the webhook URL as +`http://host.docker.internal:8080/api/v1/webhooks/logto` — Logto runs +in a Docker container, so it has to reach the natively-run backend +through Docker's host-gateway alias. The compose file sets up +`extra_hosts: host.docker.internal: host-gateway` on the Logto +service; if you're on macOS / Windows Docker Desktop this works +out of the box, on Linux Docker the host-gateway feature is enabled +by default in modern versions but check `docker info | grep -i +hosts` if you suspect issues. + +### Port conflict on 5432 / 6379 / 3001 / 3002 + +Another process is using those ports. Either stop it, or edit +`docker-compose.dev.yml` to remap (e.g., `15432:5432`). If you remap, +also update the matching ports in `.env`. + +## Going to production + +The production deployment doesn't use `docker-compose.dev.yml`. Coolify +runs the `api` and `web` services from the existing +`docker-compose.yaml` against the production Logto at +`logto.courtcommand.app`. See `docs/LOGTO_SETUP.md` for the production +Logto setup walkthrough. + +The seeder *can* be run against production Logto if you ever need to +re-seed (e.g., after a Logto database wipe). Set the production env +vars and run `make logto-seed` against them. The seeder is idempotent +and will not duplicate existing apps/resources/orgs/users. diff --git a/scripts/postgres-init/10-create-additional-databases.sh b/scripts/postgres-init/10-create-additional-databases.sh new file mode 100755 index 0000000..ebde18d --- /dev/null +++ b/scripts/postgres-init/10-create-additional-databases.sh @@ -0,0 +1,32 @@ +#!/usr/bin/env bash +# scripts/postgres-init/10-create-additional-databases.sh +# +# Creates additional Postgres databases listed in the +# POSTGRES_MULTIPLE_DATABASES env var (comma- or space-separated). +# Runs only on the FIRST container init -- after the volume is created. +# Subsequent container starts do not re-run init scripts; if you need to +# re-seed databases, drop the volume: +# +# docker compose -f docker-compose.dev.yml down -v +# +# Pattern adapted from the well-known Postgres community image hack. + +set -e +set -u + +if [ -z "${POSTGRES_MULTIPLE_DATABASES:-}" ]; then + echo "POSTGRES_MULTIPLE_DATABASES not set; skipping additional database creation." + exit 0 +fi + +echo "Creating additional databases: ${POSTGRES_MULTIPLE_DATABASES}" +for db in $(echo "${POSTGRES_MULTIPLE_DATABASES}" | tr ',' ' '); do + echo " - ${db}" + psql -v ON_ERROR_STOP=1 --username "${POSTGRES_USER}" --dbname "${POSTGRES_DB}" <<-EOSQL + SELECT 'CREATE DATABASE "${db}" OWNER "${POSTGRES_USER}"' + WHERE NOT EXISTS (SELECT FROM pg_database WHERE datname = '${db}') + \gexec +EOSQL +done + +echo "Additional databases ready." From 697f96a4b58597982752268131b142ccac8977d4 Mon Sep 17 00:00:00 2001 From: Daniel Velez Date: Fri, 1 May 2026 18:14:01 -0500 Subject: [PATCH 26/95] fix(dev): logto runtime config (entrypoint seed, timeout, healthcheck) Three fixes after first-run testing: 1. Add npm run cli db seed --swe entrypoint. Without this, Logto's tables never get created and the container crash-loops trying to query a missing schema. 2. Replace bogus DATABASE_STATEMENT_TIMEOUT (not a real Logto env var) with DATABASE_CONNECTION_TIMEOUT=30000. Logto's default 5s connection timeout is too tight for slonik's first pool connection during boot. 3. Fix the healthcheck path: OIDC discovery is at /oidc/.well-known/openid-configuration, not /api/.well-known/... Verified locally: all three containers reach (healthy) within 60s of docker compose up. --- docker-compose.dev.yml | 18 +++++++++++++++--- 1 file changed, 15 insertions(+), 3 deletions(-) diff --git a/docker-compose.dev.yml b/docker-compose.dev.yml index e38a368..074f903 100644 --- a/docker-compose.dev.yml +++ b/docker-compose.dev.yml @@ -65,6 +65,11 @@ services: depends_on: db: condition: service_healthy + # `npm run cli db seed -- --swe` creates Logto's tables on first run + # (schema migration + seed of built-in roles, OIDC keys, default + # tenant). On subsequent runs it's a no-op. Without this, Logto + # crash-loops because its expected tables don't exist. + entrypoint: ["sh", "-c", "npm run cli db seed -- --swe && npm start"] ports: - "3001:3001" # OIDC core endpoint (issuer, JWKS, /oidc/token) - "3002:3002" # Logto admin UI (operator browser) @@ -73,7 +78,12 @@ services: DB_URL: "postgres://courtcommand:courtcommand@db:5432/logto" ENDPOINT: "http://localhost:3001" ADMIN_ENDPOINT: "http://localhost:3002" - DATABASE_STATEMENT_TIMEOUT: "5000" + # Logto's default DATABASE_CONNECTION_TIMEOUT (5000ms) is too tight + # for a cold-start boot on a local docker stack: the slonik pool + # acquires its first connection before module loading is fully + # complete and times out. 30s is plenty of headroom and only + # affects connection-establishment, not query timeouts. + DATABASE_CONNECTION_TIMEOUT: "30000" # host.docker.internal is the magic host that resolves to the host # machine's IP from inside Linux Docker containers (with the # extra_hosts mapping below). Lets webhooks delivered by Logto @@ -81,11 +91,13 @@ services: extra_hosts: - "host.docker.internal:host-gateway" healthcheck: - test: ["CMD-SHELL", "wget -qO- http://localhost:3001/api/.well-known/openid-configuration > /dev/null 2>&1 || exit 1"] + # Logto exposes the OIDC discovery doc at /oidc/.well-known/openid-configuration + # NOT /api/.well-known/openid-configuration (the latter returns 404). + test: ["CMD-SHELL", "wget -qO- http://localhost:3001/oidc/.well-known/openid-configuration > /dev/null 2>&1 || exit 1"] interval: 10s timeout: 5s retries: 12 - start_period: 30s + start_period: 60s restart: unless-stopped volumes: From d516f10f0fd9eaead78b37568bc2887dcc076fdb Mon Sep 17 00:00:00 2001 From: Daniel Velez Date: Fri, 1 May 2026 18:26:37 -0500 Subject: [PATCH 27/95] fix(logto): cap page_size at 100 + quote .env value with space Two seeder bugs found during first end-to-end run: 1. Logto's koa-pagination middleware caps page_size at 100. The seeder was sending page_size=200 in five places (resources, scopes, orgs, org roles, org scopes, role-scopes, applications), which Logto rejected with HTTP 400 'guard.invalid_pagination'. We will never have >100 of any of these in a fresh tenant; 100 is plenty. 2. LOGTO_BOOTSTRAP_NAME=Local Admin was unquoted in .env.example, which the Makefile's 'set -a; . .env' loader chokes on (treats 'Admin' as a command). Quoted it: LOGTO_BOOTSTRAP_NAME="Local Admin". Verified end-to-end: seeder now provisions all 12 API scopes, SPA app, M2M role, 5 org scopes, 5 org roles with bindings, both sport orgs, bootstrap admin, and webhook -- all idempotent. --- .env.example | 2 +- api/logto/applications.go | 2 +- api/logto/organizations.go | 8 ++++---- api/logto/resources.go | 3 ++- 4 files changed, 8 insertions(+), 7 deletions(-) diff --git a/.env.example b/.env.example index 09593bc..aa09185 100644 --- a/.env.example +++ b/.env.example @@ -86,7 +86,7 @@ LOGTO_SPA_REDIRECT_URI=http://localhost:5173/auth/callback # DO NOT USE THESE VALUES IN PRODUCTION. LOGTO_BOOTSTRAP_EMAIL=admin@courtcommand.local LOGTO_BOOTSTRAP_PASSWORD=TestPass123! -LOGTO_BOOTSTRAP_NAME=Local Admin +LOGTO_BOOTSTRAP_NAME="Local Admin" # Optional: Logto user ID of the bootstrap admin. Used ONLY by the live # smoke test in api/logto/livesmoke_test.go when LOGTO_LIVE_SMOKE=1. diff --git a/api/logto/applications.go b/api/logto/applications.go index a725041..910ed6b 100644 --- a/api/logto/applications.go +++ b/api/logto/applications.go @@ -119,7 +119,7 @@ type Role struct { // the built-in "Logto Management API access" role by name. func (c *Client) ListRoles(ctx context.Context) ([]Role, error) { var roles []Role - if err := c.doJSON(ctx, http.MethodGet, "/api/roles?page_size=200", nil, &roles); err != nil { + if err := c.doJSON(ctx, http.MethodGet, "/api/roles?page_size=100", nil, &roles); err != nil { return nil, err } return roles, nil diff --git a/api/logto/organizations.go b/api/logto/organizations.go index ef0770c..4e12048 100644 --- a/api/logto/organizations.go +++ b/api/logto/organizations.go @@ -33,7 +33,7 @@ func (c *Client) CreateOrganization(ctx context.Context, name, description strin // ListOrganizations returns all organizations on the tenant. func (c *Client) ListOrganizations(ctx context.Context) ([]Organization, error) { var orgs []Organization - if err := c.doJSON(ctx, http.MethodGet, "/api/organizations?page_size=200", nil, &orgs); err != nil { + if err := c.doJSON(ctx, http.MethodGet, "/api/organizations?page_size=100", nil, &orgs); err != nil { return nil, err } return orgs, nil @@ -88,7 +88,7 @@ type OrganizationRole struct { // template. Roles are shared across every organization. func (c *Client) ListOrganizationRoles(ctx context.Context) ([]OrganizationRole, error) { var roles []OrganizationRole - if err := c.doJSON(ctx, http.MethodGet, "/api/organization-roles?page_size=200", nil, &roles); err != nil { + if err := c.doJSON(ctx, http.MethodGet, "/api/organization-roles?page_size=100", nil, &roles); err != nil { return nil, err } return roles, nil @@ -117,7 +117,7 @@ type OrganizationScope struct { // ListOrganizationScopes returns all org scopes on the template. func (c *Client) ListOrganizationScopes(ctx context.Context) ([]OrganizationScope, error) { var scopes []OrganizationScope - if err := c.doJSON(ctx, http.MethodGet, "/api/organization-scopes?page_size=200", nil, &scopes); err != nil { + if err := c.doJSON(ctx, http.MethodGet, "/api/organization-scopes?page_size=100", nil, &scopes); err != nil { return nil, err } return scopes, nil @@ -148,7 +148,7 @@ func (c *Client) AssignScopesToOrgRole(ctx context.Context, roleID string, scope // ListOrgRoleScopes returns the scopes currently bound to an org role. func (c *Client) ListOrgRoleScopes(ctx context.Context, roleID string) ([]OrganizationScope, error) { var scopes []OrganizationScope - path := fmt.Sprintf("/api/organization-roles/%s/scopes?page_size=200", roleID) + path := fmt.Sprintf("/api/organization-roles/%s/scopes?page_size=100", roleID) if err := c.doJSON(ctx, http.MethodGet, path, nil, &scopes); err != nil { return nil, err } diff --git a/api/logto/resources.go b/api/logto/resources.go index cc60b40..0bfc3ff 100644 --- a/api/logto/resources.go +++ b/api/logto/resources.go @@ -80,9 +80,10 @@ func (c *Client) CreateResourceScope(ctx context.Context, resourceID, name, desc } // ListResourceScopes returns the scopes registered on a resource. +// page_size is capped at 100 by Logto's koa-pagination middleware. func (c *Client) ListResourceScopes(ctx context.Context, resourceID string) ([]Scope, error) { var scopes []Scope - path := fmt.Sprintf("/api/resources/%s/scopes?page_size=200", resourceID) + path := fmt.Sprintf("/api/resources/%s/scopes?page_size=100", resourceID) if err := c.doJSON(ctx, http.MethodGet, path, nil, &scopes); err != nil { return nil, err } From a60fec4cee5c1de986a737a502e74848c72a31f9 Mon Sep 17 00:00:00 2001 From: Daniel Velez Date: Fri, 1 May 2026 19:14:48 -0500 Subject: [PATCH 28/95] feat(auth): scaffold Logto SDK config and AuthProvider Adds @logto/react and the LogtoConfig + AuthProvider scaffolding. Reads VITE_LOGTO_ENDPOINT, VITE_LOGTO_APP_ID, VITE_LOGTO_API_RESOURCE from .env (set by the local seeder). Includes UserScope.Organizations so future getAccessToken(resource, orgID) calls return organization- scoped tokens (required by RequireSportMatchesJWT in the backend). No behavior change yet -- the provider wraps the app but no component consumes useLogto. --- web/package.json | 1 + web/pnpm-lock.yaml | 92 +++++++++++++++++++++++++++++++++++ web/src/auth/AuthProvider.tsx | 13 +++++ web/src/auth/LogtoConfig.ts | 53 ++++++++++++++++++++ web/src/main.tsx | 5 +- 5 files changed, 163 insertions(+), 1 deletion(-) create mode 100644 web/src/auth/AuthProvider.tsx create mode 100644 web/src/auth/LogtoConfig.ts diff --git a/web/package.json b/web/package.json index 626c3e1..38265c2 100644 --- a/web/package.json +++ b/web/package.json @@ -13,6 +13,7 @@ }, "packageManager": "pnpm@10.33.0", "dependencies": { + "@logto/react": "^4.0.14", "@tailwindcss/vite": "^4.2.2", "@tanstack/react-query": "^5.99.0", "@tanstack/react-router": "^1.168.22", diff --git a/web/pnpm-lock.yaml b/web/pnpm-lock.yaml index 10d6968..293ad9f 100644 --- a/web/pnpm-lock.yaml +++ b/web/pnpm-lock.yaml @@ -8,6 +8,9 @@ importers: .: dependencies: + '@logto/react': + specifier: ^4.0.14 + version: 4.0.14(react@19.2.5) '@tailwindcss/vite': specifier: ^4.2.2 version: 4.2.2(vite@8.0.8(esbuild@0.27.7)(jiti@2.6.1)(terser@5.46.1)(tsx@4.21.0)) @@ -822,6 +825,20 @@ packages: '@jridgewell/trace-mapping@0.3.31': resolution: {integrity: sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==} + '@logto/browser@3.0.13': + resolution: {integrity: sha512-SlZ76XiVh2es6eFB1M+ldV6b60eC3+eeKoRQ8/AvOlpwHhrY/v2FPw5LOd/vZ+WYjzDqsxNtOMdhTdliHZ7V1g==} + + '@logto/client@3.1.8': + resolution: {integrity: sha512-f6NcPOV/K1IpPm4ccARWeYpQVMeN4mfikGg+5Qw1rcIPYPUpD5BmDsQbVTAnDepCMbC7syzRerZmbwL8S3UL+A==} + + '@logto/js@6.1.2': + resolution: {integrity: sha512-YB/TfixPGI0Spbs8LXiKuASOKFUE9VmlTkXiPfgg3UXQsIPTU71KjKxEXZRePu3xdPNhsZ6WtnRfRvvcpP+KGQ==} + + '@logto/react@4.0.14': + resolution: {integrity: sha512-hSZSwxdsLkQjcqsLRH+rvMUkchh1cB+niXOfbvafILJUgmN0YWVZ6QAeyV/wmUteNb5iB463Fnboz10MUXi04A==} + peerDependencies: + react: '>=16.8.0' + '@napi-rs/wasm-runtime@1.1.4': resolution: {integrity: sha512-3NQNNgA1YSlJb/kMH1ildASP9HW7/7kYnRI2szWJaofaS1hWmbGI4H+d3+22aGzXXN9IJ+n+GiFVcGipJP18ow==} peerDependencies: @@ -1119,6 +1136,10 @@ packages: cpu: [x64] os: [win32] + '@silverhand/essentials@2.9.3': + resolution: {integrity: sha512-OM9pyGc/yYJMVQw+fFOZZaTHXDWc45sprj+ky+QjC9inhf5w51L1WBmzAwFuYkHAwO1M19fxVf2sTH9KKP48yg==} + engines: {node: '>=18.12.0', pnpm: ^10.0.0} + '@surma/rollup-plugin-off-main-thread@2.2.3': resolution: {integrity: sha512-lR8q/9W7hZpMWweNiAKU7NQerBnzQQLvi8qnTDU/fxItPhtZVMbPV3lbCwjhIlNBe9Bbr5V+KHshvWmVSG9cxQ==} @@ -1526,6 +1547,14 @@ packages: resolution: {integrity: sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==} engines: {node: '>= 0.4'} + camelcase-keys@9.1.3: + resolution: {integrity: sha512-Rircqi9ch8AnZscQcsA1C47NFdaO3wukpmIRzYcDOrmvgt78hM/sj5pZhZNec2NM12uk5vTwRHZ4anGcrC4ZTg==} + engines: {node: '>=16'} + + camelcase@8.0.0: + resolution: {integrity: sha512-8WB3Jcas3swSvjIeA2yvCJ+Miyz5l1ZmB6HFb9R1317dt9LCQoswg/BGrmAmkWVEszSrrg4RwmO46qIm2OEnSA==} + engines: {node: '>=16'} + caniuse-lite@1.0.30001788: resolution: {integrity: sha512-6q8HFp+lOQtcf7wBK+uEenxymVWkGKkjFpCvw5W25cmMwEDU45p1xQFBQv8JDlMMry7eNxyBaR+qxgmTUZkIRQ==} @@ -2032,6 +2061,12 @@ packages: resolution: {integrity: sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ==} hasBin: true + jose@5.10.0: + resolution: {integrity: sha512-s+3Al/p9g32Iq+oqXxkW//7jk2Vig6FF1CFqzVXoTUXt2qz89YWbL+OwS17NFYEvxC35n0FKeGO2LGYSxeM2Gg==} + + js-base64@3.7.8: + resolution: {integrity: sha512-hNngCeKxIUQiEUN3GPJOkz4wF/YvdUdbNL9hsBcMQTkKzboD7T/q3OYOuuPZLUE6dBxSGpwhk5mwuDud7JVAow==} + js-tokens@4.0.0: resolution: {integrity: sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==} @@ -2186,6 +2221,10 @@ packages: magic-string@0.30.21: resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==} + map-obj@5.0.0: + resolution: {integrity: sha512-2L3MIgJynYrZ3TYMriLDLWocz15okFakV6J12HXvMXDHui2x/zgChzg1u9mFFGbbGWE+GsLpQByt4POb9Or+uA==} + engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} + math-intrinsics@1.1.0: resolution: {integrity: sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==} engines: {node: '>= 0.4'} @@ -2313,6 +2352,10 @@ packages: resolution: {integrity: sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==} engines: {node: '>=6'} + quick-lru@6.1.2: + resolution: {integrity: sha512-AAFUA5O1d83pIHEhJwWCq/RQcRukCkn/NSm2QsTEMle5f2hP0ChI2+3Xb051PZCkLryI/Ir1MVKviT2FIloaTQ==} + engines: {node: '>=12'} + randombytes@2.1.0: resolution: {integrity: sha512-vYl3iOX+4CKUWuxGi9Ukhie6fsqXqS9FE2Zaic4tNFD2N2QQaXOMFbuKK4QmDHC0JO6B1Zp41J0LpT0oR68amQ==} @@ -2604,6 +2647,10 @@ packages: resolution: {integrity: sha512-eaBzG6MxNzEn9kiwvtre90cXaNLkmadMWa1zQMs3XORCXNbsH/OewwbxC5ia9dCxIxnTAsSxXJaa/p5y8DlvJg==} engines: {node: '>=10'} + type-fest@4.41.0: + resolution: {integrity: sha512-TeTSQ6H5YHvpqVwBRcnLDCBnDOHWYu7IvGbHT6N8AOymcr9PJGjc1GTtiWZTYg0NCgYwvnYWEkVChQAr9bjfwA==} + engines: {node: '>=16'} + typed-array-buffer@1.0.3: resolution: {integrity: sha512-nAYYwfY3qnzX30IkA6AQZjVbtK6duGontcQm1WSG1MD94YLqK0515GNApXkoxKOWMusVssAHWLh9SeaoefYFGw==} engines: {node: '>= 0.4'} @@ -3734,6 +3781,30 @@ snapshots: '@jridgewell/resolve-uri': 3.1.2 '@jridgewell/sourcemap-codec': 1.5.5 + '@logto/browser@3.0.13': + dependencies: + '@logto/client': 3.1.8 + '@silverhand/essentials': 2.9.3 + js-base64: 3.7.8 + + '@logto/client@3.1.8': + dependencies: + '@logto/js': 6.1.2 + '@silverhand/essentials': 2.9.3 + camelcase-keys: 9.1.3 + jose: 5.10.0 + + '@logto/js@6.1.2': + dependencies: + '@silverhand/essentials': 2.9.3 + camelcase-keys: 9.1.3 + + '@logto/react@4.0.14(react@19.2.5)': + dependencies: + '@logto/browser': 3.0.13 + '@silverhand/essentials': 2.9.3 + react: 19.2.5 + '@napi-rs/wasm-runtime@1.1.4(@emnapi/core@1.9.2)(@emnapi/runtime@1.9.2)': dependencies: '@emnapi/core': 1.9.2 @@ -3919,6 +3990,8 @@ snapshots: '@rollup/rollup-win32-x64-msvc@4.60.1': optional: true + '@silverhand/essentials@2.9.3': {} + '@surma/rollup-plugin-off-main-thread@2.2.3': dependencies: ejs: 3.1.10 @@ -4354,6 +4427,15 @@ snapshots: call-bind-apply-helpers: 1.0.2 get-intrinsic: 1.3.0 + camelcase-keys@9.1.3: + dependencies: + camelcase: 8.0.0 + map-obj: 5.0.0 + quick-lru: 6.1.2 + type-fest: 4.41.0 + + camelcase@8.0.0: {} + caniuse-lite@1.0.30001788: {} chai@5.3.3: @@ -4950,6 +5032,10 @@ snapshots: jiti@2.6.1: {} + jose@5.10.0: {} + + js-base64@3.7.8: {} + js-tokens@4.0.0: {} js-tokens@9.0.1: {} @@ -5064,6 +5150,8 @@ snapshots: dependencies: '@jridgewell/sourcemap-codec': 1.5.5 + map-obj@5.0.0: {} + math-intrinsics@1.1.0: {} minimatch@10.2.5: @@ -5163,6 +5251,8 @@ snapshots: punycode@2.3.1: {} + quick-lru@6.1.2: {} + randombytes@2.1.0: dependencies: safe-buffer: 5.2.1 @@ -5527,6 +5617,8 @@ snapshots: type-fest@0.16.0: {} + type-fest@4.41.0: {} + typed-array-buffer@1.0.3: dependencies: call-bound: 1.0.4 diff --git a/web/src/auth/AuthProvider.tsx b/web/src/auth/AuthProvider.tsx new file mode 100644 index 0000000..468e349 --- /dev/null +++ b/web/src/auth/AuthProvider.tsx @@ -0,0 +1,13 @@ +// web/src/auth/AuthProvider.tsx +// +// Top-level auth provider. Wraps the entire app in . +// Doesn't contain its own state; the actual auth-related hooks live in +// useAuth.ts. + +import { LogtoProvider } from '@logto/react' +import type { ReactNode } from 'react' +import { logtoConfig } from './LogtoConfig' + +export function AuthProvider({ children }: { children: ReactNode }) { + return {children} +} diff --git a/web/src/auth/LogtoConfig.ts b/web/src/auth/LogtoConfig.ts new file mode 100644 index 0000000..e84893d --- /dev/null +++ b/web/src/auth/LogtoConfig.ts @@ -0,0 +1,53 @@ +// web/src/auth/LogtoConfig.ts +// +// Logto SDK configuration. The SDK uses these to drive the OIDC flow: +// authorization endpoint, token endpoint, JWKS, etc. — discovered via +// the .well-known endpoint of `endpoint`. + +import type { LogtoConfig } from '@logto/react' +import { UserScope } from '@logto/react' + +const endpoint = import.meta.env.VITE_LOGTO_ENDPOINT +const appId = import.meta.env.VITE_LOGTO_APP_ID +const apiResource = import.meta.env.VITE_LOGTO_API_RESOURCE + +if (!endpoint || !appId || !apiResource) { + throw new Error( + 'Missing Logto config. Set VITE_LOGTO_ENDPOINT, VITE_LOGTO_APP_ID, VITE_LOGTO_API_RESOURCE in .env', + ) +} + +// Scopes requested at sign-in. The 12 API scopes correspond to the +// resource scopes provisioned by api/cmd/logto-seed. +export const API_SCOPES = [ + 'read:profile', 'write:profile', + 'read:tournaments', 'write:tournaments', + 'read:matches', 'write:matches', + 'read:registrations', 'write:registrations', + 'read:overlay', 'write:overlay', + 'read:admin', 'write:admin', +] as const + +// Org scopes are NOT requested at signIn — they're embedded in the +// org-scoped access token automatically by Logto when the user has the +// corresponding org role(s). Listed here for documentation only. +export const ORG_SCOPES = [ + 'manage_tournaments', 'manage_matches', 'manage_registrations', + 'manage_users', 'read_all', +] as const + +export const logtoConfig: LogtoConfig = { + endpoint, + appId, + resources: [apiResource], + scopes: [ + ...API_SCOPES, + UserScope.Email, + UserScope.Profile, + UserScope.Identities, + UserScope.Organizations, // CRITICAL: required to get organization-scoped tokens + ], +} + +// Public for tests + the SportContext to use when calling getOrganizationToken. +export const API_RESOURCE = apiResource diff --git a/web/src/main.tsx b/web/src/main.tsx index e7be76d..79164f2 100644 --- a/web/src/main.tsx +++ b/web/src/main.tsx @@ -1,11 +1,14 @@ import { StrictMode } from 'react' import { createRoot } from 'react-dom/client' import App from './App' +import { AuthProvider } from './auth/AuthProvider' import './styles.css' const root = document.getElementById('root')! createRoot(root).render( - + + + , ) From 4045f35b94e535defa6b5c72c9ef8d6b83a9b14e Mon Sep 17 00:00:00 2001 From: Daniel Velez Date: Fri, 1 May 2026 19:16:27 -0500 Subject: [PATCH 29/95] plan(logto): Phase 3 frontend implementation plan + amendments 3355-line plan covering the 10 Phase 3 tasks (frontend Logto swap, multi-sport routing, profile-edit form, webhook + on-demand mirror, Playwright smoke test). Includes Plan Amendments section (A1-A9) addressing 8 critical issues found by plan-doc reviewer: - C1: handler architecture (per-domain XHandler + service.XService, not unified Handler+queries) - C2: avoid overlap with existing /api/v1/players/me; new endpoint at /api/v1/me/profile reads player_profiles - C3: signIn() takes only redirectUri; postRedirectUri persistence via sessionStorage - C4: getAccessToken(resource, orgID) for org-scoped tokens; requires UserScope.Organizations in config - C5: webhook header is logto-signature-sha-256 (no X- prefix) - C6: full SQL for new sqlc queries (CreateUserFromLogto, UpdateUserFromLogto, SoftDeleteUserByLogtoUserID) - C7: PlayerProfile schema (address_line_1/2, no street_address, no emergency_contact_relation, AvatarUrl not AvatarURL) - C8: route restructure includes venues/ and settings/ Plus important findings I1-I9 (useHandleSignInCallback, race in SportProvider, 403 probe in SportGuard, file location). --- .../2026-05-01-logto-phase-3-frontend.md | 3355 +++++++++++++++++ 1 file changed, 3355 insertions(+) create mode 100644 docs/superpowers/plans/2026-05-01-logto-phase-3-frontend.md diff --git a/docs/superpowers/plans/2026-05-01-logto-phase-3-frontend.md b/docs/superpowers/plans/2026-05-01-logto-phase-3-frontend.md new file mode 100644 index 0000000..f041509 --- /dev/null +++ b/docs/superpowers/plans/2026-05-01-logto-phase-3-frontend.md @@ -0,0 +1,3355 @@ +# Logto Integration Phase 3 — Frontend Auth Swap + Multi-Sport Routing + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Replace the cookie-session SPA auth with Logto OIDC; restructure routes under a `/$sport/*` segment; build a sport-picker landing page; rewrite the profile-edit form against the new `player_profiles` table; add a webhook + on-demand mirror for Logto users; verify end-to-end with a Playwright smoke test. + +**Architecture:** +- Frontend: `@logto/react` SDK wraps `App` in a `LogtoProvider`. `useAuth` becomes a thin shim over `useLogto()`. `apiFetch` attaches `Bearer ` (organization-scoped, with `read_all manage_*` scopes) instead of relying on cookies. Routes restructure: `/` is the sport picker, `/$sport/*` is everything sport-scoped, `/public/*` and `/overlay/*` stay at root, `/auth/callback` handles the OIDC redirect. +- Backend: Add `/api/v1/webhooks/logto` (HMAC-verified) to handle `User.Created`/`User.Data.Updated`/`User.Deleted`; add a "mirror-on-demand" branch in `RequireJWT` that fetches from Logto Mgmt API and upserts the local `users` row when the JWT subject isn't found. Wire `/api/v1/auth/me` to read claims from context. +- Sport routing: `useParams({from: '/$sport'})` gives the sport slug; a `SportContext` provider derives the Logto org ID via `/api/v1/sports` lookup and exposes both. The X-Sport request header is added by `apiFetch` from the active sport. + +**Tech Stack:** React 19, TanStack Router (file-based), TanStack Query, `@logto/react` 4.x, Vite, Playwright (new). + +--- + +## Pre-flight + +Phase 3 is large (10 tasks, ~3-4 days of focused work). It depends on Phase 1 (JWT validator + middleware) and Phase 2 (`sports` table, `player_profiles`, `users.logto_user_id`). Both are merged on `feature/logto-integration` already. + +**Before starting:** confirm the local dev stack is up: +```bash +cd ~/code/court-command-v2/court-command +docker compose -f docker-compose.dev.yml ps +# All three containers should be healthy +curl -s http://localhost:8080/api/v1/health | jq '.status' +# Should print "ok" +``` + +If anything is red, fix first. Phase 3 cannot be developed against a broken local stack. + +--- + +## File Structure + +### New files + +| Path | Purpose | +|------|---------| +| `web/src/auth/LogtoConfig.ts` | Logto SDK configuration (endpoint, app ID, resources, scopes) | +| `web/src/auth/AuthProvider.tsx` | Wraps `LogtoProvider`; reads env vars; signs in to currently selected sport's org | +| `web/src/auth/useAuth.ts` | New hook: thin shim over `useLogto()` exposing `isAuthenticated`, `user`, `signIn`, `signOut`, `getAccessToken` | +| `web/src/auth/SportContext.tsx` | React context: current sport slug + Logto org ID, derived from URL `$sport` param | +| `web/src/auth/useSport.ts` | Hook: `useSport()` returns `{slug, orgID, name}` for the active sport | +| `web/src/lib/sports.ts` | API: `getSports()` fetches `/api/v1/sports` (slug → orgID lookup table) | +| `web/src/routes/index.tsx` | Sport picker landing page (replaces existing dashboard at `/`) | +| `web/src/routes/auth.callback.tsx` | OIDC callback handler — calls `handleSignInCallback`, redirects to last sport | +| `web/src/routes/$sport.tsx` | Layout route for the `$sport` segment; reads slug, sets `SportContext`, redirects to `/` if unknown | +| `web/src/routes/$sport/dashboard.tsx` | Replaces `routes/dashboard.tsx` | +| `web/src/routes/$sport/profile.tsx` | Profile edit form against `player_profiles` (sectioned, all 25 fields) | +| `web/src/routes/$sport/[admin/leagues/manage/tournaments/...]` | All ~48 sport-scoped routes moved here from root | +| `web/tests/e2e/auth-flow.spec.ts` | Playwright E2E smoke test | +| `web/playwright.config.ts` | Playwright config (chromium-only, headless, against `localhost:5173`) | +| `api/handler/webhooks_logto.go` | Webhook handler (HMAC-verified, dispatches on `event` field) | +| `api/handler/webhooks_logto_test.go` | Unit tests for handler (signature verify + each event branch) | +| `api/handler/sports.go` | New handler: `GET /api/v1/sports` returns active sports for the picker | +| `api/handler/profile.go` | New handler: `GET /api/v1/me/profile`, `PATCH /api/v1/me/profile` reads/writes `player_profiles` | +| `api/middleware/mirror_user.go` | After RequireJWT: if no local `users` row for `claims.Subject`, fetch from Logto + upsert | + +### Modified files + +| Path | Change | +|------|--------| +| `web/src/main.tsx` | Wrap `` in `` | +| `web/src/App.tsx` | Add `` (set by `$sport.tsx` route, but provider lives high) | +| `web/src/lib/api.ts` | Replace `credentials: 'include'` with `Authorization: Bearer ` + `X-Sport: `; pull token + slug from context | +| `web/src/features/auth/hooks.ts` | DELETE the file. Functionality moves to `web/src/auth/useAuth.ts` | +| `web/src/features/auth/AuthGuard.tsx` | Rewrite: redirect to `/login-redirect` (which calls `signIn`) instead of `/login` page | +| `web/src/routes/__root.tsx` | Update `NO_SHELL_ROUTES` and `PUBLIC_ROUTE_PATTERNS`; remove `/login` and `/register` patterns | +| `web/src/routes/login.tsx` | DELETE — Logto handles login UI | +| `web/src/routes/register.tsx` | DELETE — Logto handles signup UI | +| `web/src/routes/profile.tsx` | DELETE — moved to `$sport/profile.tsx` and rewritten | +| `web/src/components/Sidebar.tsx`, etc. | Update internal `` and `navigate({to:})` to include `/$sport/` prefix where appropriate | +| `web/package.json` | Add `@logto/react`, `@playwright/test` | +| `api/router/router.go` | Mount `/api/v1/webhooks/logto`, `/api/v1/sports`, `/api/v1/me/profile`; chain `MirrorUser` middleware after `RequireJWT` for `/me/*` routes | +| `api/handler/auth.go` | Rewrite `/api/v1/auth/me`: read claims from context, fetch local user mirror, return same shape as before | +| `api/handler/auth.go` (cookie endpoints) | Keep `/api/v1/auth/login`, `/register`, `/logout` operational for now — Phase 6 deletes them | + +### Deleted files (end-of-Phase-3) + +- `web/src/routes/login.tsx` +- `web/src/routes/register.tsx` +- `web/src/routes/profile.tsx` (moved + rewritten) +- `web/src/routes/dashboard.tsx` (moved) +- `web/src/features/auth/hooks.ts` (replaced) +- ~46 other route files moved (not deleted; relocated) + +--- + +## Testing strategy + +- **Unit tests** for new backend handlers (webhook, profile, sports, mirror middleware). Each follows the existing test pattern (httptest server, mock pool via pgxmock). +- **No frontend unit tests** in this phase — the components are thin wrappers over Logto SDK + TanStack Router; integration via Playwright is more valuable. +- **One Playwright E2E test** covering the full happy path: sport picker → Logto signin → callback → dashboard → profile edit → save. Run as part of Task 10 verification. +- **Manual smoke checklist** at end of plan for things Playwright can't easily cover (multiple browsers, real-device responsive, OBS overlay still works post-restructure). + +--- + +## Task 1 — Install Logto SDK and configure + +**Files:** +- Create: `web/src/auth/LogtoConfig.ts` +- Create: `web/src/auth/AuthProvider.tsx` +- Modify: `web/src/main.tsx` +- Modify: `web/package.json` (via pnpm add) + +- [ ] **Step 1.1: Install dependencies** + +```bash +cd web && pnpm add @logto/react@^4 +``` + +Verify in `package.json`: +```json +"@logto/react": "^4.x.x" +``` + +- [ ] **Step 1.2: Create `LogtoConfig.ts`** + +```ts +// web/src/auth/LogtoConfig.ts +// +// Logto SDK configuration. The SDK uses these to drive the OIDC flow: +// authorization endpoint, token endpoint, JWKS, etc. — discovered via +// the .well-known endpoint of `endpoint`. + +import type { LogtoConfig } from '@logto/react' +import { UserScope } from '@logto/react' + +const endpoint = import.meta.env.VITE_LOGTO_ENDPOINT +const appId = import.meta.env.VITE_LOGTO_APP_ID +const apiResource = import.meta.env.VITE_LOGTO_API_RESOURCE + +if (!endpoint || !appId || !apiResource) { + throw new Error( + 'Missing Logto config. Set VITE_LOGTO_ENDPOINT, VITE_LOGTO_APP_ID, and VITE_LOGTO_API_RESOURCE in .env', + ) +} + +// Scopes requested at sign-in. The 12 API scopes correspond to the +// resource scopes provisioned by api/cmd/logto-seed. Org scopes +// (manage_tournaments, etc.) are requested separately when getting an +// org-scoped token. +export const API_SCOPES = [ + 'read:profile', + 'write:profile', + 'read:tournaments', + 'write:tournaments', + 'read:matches', + 'write:matches', + 'read:registrations', + 'write:registrations', + 'read:overlay', + 'write:overlay', + 'read:admin', + 'write:admin', +] as const + +export const ORG_SCOPES = [ + 'manage_tournaments', + 'manage_matches', + 'manage_registrations', + 'manage_users', + 'read_all', +] as const + +export const logtoConfig: LogtoConfig = { + endpoint, + appId, + resources: [apiResource], + scopes: [...API_SCOPES, UserScope.Email, UserScope.Profile, UserScope.Identities], + // Organization tokens are requested per-sport; we don't list them here. +} + +// Public for tests + the SportContext to use when calling getOrganizationToken. +export const API_RESOURCE = apiResource +``` + +- [ ] **Step 1.3: Create `AuthProvider.tsx`** + +```tsx +// web/src/auth/AuthProvider.tsx +// +// Top-level auth provider. Wraps the entire app in . +// Doesn't contain its own state; the actual auth-related hooks live in +// useAuth.ts. + +import { LogtoProvider } from '@logto/react' +import type { ReactNode } from 'react' +import { logtoConfig } from './LogtoConfig' + +export function AuthProvider({ children }: { children: ReactNode }) { + return {children} +} +``` + +- [ ] **Step 1.4: Wrap App in main.tsx** + +```tsx +// web/src/main.tsx +import { StrictMode } from 'react' +import { createRoot } from 'react-dom/client' +import App from './App' +import { AuthProvider } from './auth/AuthProvider' +import './styles.css' + +const root = document.getElementById('root')! +createRoot(root).render( + + + + + , +) +``` + +- [ ] **Step 1.5: Verify it builds** + +```bash +cd web && pnpm build +``` +Expected: clean build, no TypeScript errors. The app won't be runnable yet (no callback route, no useAuth) but it should compile. + +- [ ] **Step 1.6: Commit** + +```bash +git add web/package.json web/pnpm-lock.yaml web/src/auth/ web/src/main.tsx +git commit -m "feat(auth): scaffold Logto SDK config and AuthProvider + +Adds @logto/react and the LogtoConfig + AuthProvider scaffolding. +Reads VITE_LOGTO_ENDPOINT, VITE_LOGTO_APP_ID, VITE_LOGTO_API_RESOURCE +from .env (set by the local seeder). No behavior change yet — the +provider wraps the app but no component consumes useLogto." +``` + +--- + +## Task 2 — Build the new useAuth hook + delete the old one + +**Files:** +- Create: `web/src/auth/useAuth.ts` +- Delete: `web/src/features/auth/hooks.ts` +- Modify: `web/src/features/auth/AuthGuard.tsx` (to use new hook) + +- [ ] **Step 2.1: Create `useAuth.ts`** + +```ts +// web/src/auth/useAuth.ts +// +// Replacement for features/auth/hooks.ts. +// Thin shim over @logto/react's useLogto(): exposes the same shape the +// existing app expects (user object, isAuthenticated, isLoading) plus +// a getAccessToken function used by api.ts. + +import { useLogto } from '@logto/react' +import { useQuery } from '@tanstack/react-query' +import { API_RESOURCE } from './LogtoConfig' + +export interface User { + public_id: string + email: string + first_name: string + last_name: string + display_name: string | null + date_of_birth: string | null + role: string + status: string + created_at: string + impersonation?: { active: boolean; impersonator_id: string } | null +} + +export function useAuth() { + const { isAuthenticated, isLoading: logtoLoading, signIn, signOut, getIdTokenClaims } = useLogto() + + // Local mirror — fetched from /api/v1/auth/me once the JWT is issued. + // Backend reads claims from context (set by RequireJWT) and returns + // the local users row. + const me = useQuery({ + queryKey: ['auth', 'me'], + queryFn: async () => { + // apiGet defined later (Task 3) handles 401 -> null. For now during + // the migration, fall back to id_token claims if /me 404s (mirror + // not yet provisioned). + const { apiGet } = await import('../lib/api') + try { + return await apiGet('/api/v1/auth/me') + } catch (err: any) { + if (err.status === 401 || err.status === 404) return null + throw err + } + }, + enabled: isAuthenticated, + staleTime: 5 * 60 * 1000, + retry: false, + }) + + return { + user: me.data ?? null, + isLoading: logtoLoading || me.isLoading, + isAuthenticated: !!isAuthenticated && !!me.data, + isImpersonating: !!me.data?.impersonation?.active, + error: me.error, + signIn: (returnTo: string) => + signIn({ redirectUri: `${window.location.origin}/auth/callback`, postRedirectUri: returnTo }), + signOut: (returnTo: string = '/') => signOut(`${window.location.origin}${returnTo}`), + getIdTokenClaims, + } +} + +// Keep the named exports the legacy callers use, so route migration +// doesn't have to touch every file. +export { useAuth as useAuthQuery } // alias for any old call sites +export function useLogout() { + const { signOut } = useAuth() + return { + mutate: () => signOut('/'), + mutateAsync: async () => signOut('/'), + isPending: false, + } +} +``` + +- [ ] **Step 2.2: Update `AuthGuard.tsx`** + +```tsx +// web/src/features/auth/AuthGuard.tsx +import { useEffect } from 'react' +import { useLocation, useNavigate } from '@tanstack/react-router' +import { useAuth } from '../../auth/useAuth' + +export function AuthGuard({ children }: { children: React.ReactNode }) { + const { isAuthenticated, isLoading, signIn } = useAuth() + const location = useLocation() + + useEffect(() => { + if (!isLoading && !isAuthenticated) { + // Trigger the OIDC sign-in flow. After successful sign-in, + // the callback route will redirect back to where they were. + void signIn(location.href) + } + }, [isLoading, isAuthenticated, location.href, signIn]) + + if (isLoading) return
Loading…
+ if (!isAuthenticated) return
Redirecting to sign in…
+ return <>{children} +} +``` + +- [ ] **Step 2.3: Delete the old hooks file** + +```bash +git rm web/src/features/auth/hooks.ts +``` + +- [ ] **Step 2.4: Update imports across the codebase** + +Search and replace every import: +```bash +cd web/src +grep -rl "from '.*features/auth/hooks'" --include="*.tsx" --include="*.ts" +# For each file, change the import path from +# '../features/auth/hooks' (or however many ../) to +# '../auth/useAuth' (or however many ../) +``` + +Do NOT bulk-sed this; the relative paths differ per file. Use your editor's "find usages" or do it file-by-file. + +- [ ] **Step 2.5: Verify it builds** + +```bash +cd web && pnpm build +``` + +Expected: clean. If anything fails to compile because `useLogin`/`useRegister` are no longer exported (we deleted them), comment those imports out in the corresponding route files (`login.tsx`, `register.tsx`) — those files are deleted in Task 6. + +- [ ] **Step 2.6: Commit** + +```bash +git add web/src/auth/useAuth.ts web/src/features/auth/AuthGuard.tsx +git rm web/src/features/auth/hooks.ts +git commit -m "feat(auth): replace cookie-based useAuth with Logto-backed hook + +useAuth now wraps useLogto() from @logto/react. AuthGuard triggers +signIn() via OIDC redirect instead of pushing to /login. The local +'users' mirror is still loaded from /api/v1/auth/me and exposed under +the same User shape, so existing callers don't change." +``` + +--- + +## Task 3 — Rewrite apiFetch to attach Bearer token + X-Sport header + +**Files:** +- Modify: `web/src/lib/api.ts` + +- [ ] **Step 3.1: Lift token + sport into module state** + +The current `api.ts` has zero context (no React). Two ways to fix: + +**Chosen approach:** Module-level setter functions called by `AuthProvider` and `SportContext`. Avoids prop-drilling; preserves the function-call API everywhere else. + +Replace `web/src/lib/api.ts` entirely: + +```ts +// web/src/lib/api.ts +// +// Phase 3 swap: cookie-based fetch -> Bearer-token fetch. +// Token + sport slug are pushed into module state by AuthProvider / +// SportContext rather than read from React context inside each call, +// so call sites (apiGet, apiPost) keep their plain-function shape. + +const API_BASE = import.meta.env.VITE_API_URL || '' + +// Set by SportContext when the active $sport changes. Empty string = +// not on a sport-scoped route (sport picker, public routes). +let currentSportSlug = '' +export function setCurrentSport(slug: string) { + currentSportSlug = slug +} + +// Set by AuthProvider after sign-in. A function so we always pull the +// freshest token (Logto SDK handles refresh transparently). +let getAccessTokenFn: ((resource: string) => Promise) | null = null +export function setGetAccessTokenFn(fn: typeof getAccessTokenFn) { + getAccessTokenFn = fn +} + +const API_RESOURCE = import.meta.env.VITE_LOGTO_API_RESOURCE + +export interface ApiError { + code: string + message: string +} + +export class ApiRequestError extends Error { + code: string + status: number + constructor(status: number, code: string, message: string) { + super(message) + this.name = 'ApiRequestError' + this.code = code + this.status = status + } +} + +async function buildHeaders(extra?: HeadersInit): Promise { + const h = new Headers(extra) + if (getAccessTokenFn) { + const token = await getAccessTokenFn(API_RESOURCE) + if (token) h.set('Authorization', `Bearer ${token}`) + } + if (currentSportSlug) h.set('X-Sport', currentSportSlug) + return h +} + +async function handleResponse(response: Response): Promise { + if (!response.ok) await throwApiError(response) + if (response.status === 204) return undefined as unknown as T + const body = await response.json() + return body.data !== undefined ? body.data : body +} + +export async function apiGet(path: string): Promise { + const headers = await buildHeaders() + const response = await fetch(`${API_BASE}${path}`, { headers }) + return handleResponse(response) +} + +export async function apiPost(path: string, body?: unknown): Promise { + const headers = await buildHeaders(body ? { 'Content-Type': 'application/json' } : undefined) + const response = await fetch(`${API_BASE}${path}`, { + method: 'POST', + headers, + body: body ? JSON.stringify(body) : undefined, + }) + return handleResponse(response) +} + +export async function apiPatch(path: string, body: unknown): Promise { + const headers = await buildHeaders({ 'Content-Type': 'application/json' }) + const response = await fetch(`${API_BASE}${path}`, { + method: 'PATCH', + headers, + body: JSON.stringify(body), + }) + return handleResponse(response) +} + +export async function apiPut(path: string, body: unknown): Promise { + const headers = await buildHeaders({ 'Content-Type': 'application/json' }) + const response = await fetch(`${API_BASE}${path}`, { + method: 'PUT', + headers, + body: JSON.stringify(body), + }) + return handleResponse(response) +} + +export async function apiDelete(path: string): Promise { + const headers = await buildHeaders() + const response = await fetch(`${API_BASE}${path}`, { method: 'DELETE', headers }) + return handleResponse(response) +} + +export interface PaginatedData { + items: T[]; total: number; limit: number; offset: number +} + +export async function apiGetPaginated(path: string): Promise> { + const headers = await buildHeaders() + const response = await fetch(`${API_BASE}${path}`, { headers }) + if (!response.ok) await throwApiError(response) + const body = await response.json() + return { + items: body.data || [], + total: body.pagination?.total || 0, + limit: body.pagination?.limit || 20, + offset: body.pagination?.offset || 0, + } +} + +async function throwApiError(response: Response): Promise { + let code = 'unknown_error' + let message = `Request failed with status ${response.status}` + try { + const body = await response.json() + if (body.error) { + code = body.error.code || code + message = body.error.message || message + } + } catch { + // not JSON + } + throw new ApiRequestError(response.status, code, message) +} +``` + +- [ ] **Step 3.2: Wire `setGetAccessTokenFn` into AuthProvider** + +```tsx +// web/src/auth/AuthProvider.tsx +import { LogtoProvider, useLogto } from '@logto/react' +import { useEffect, type ReactNode } from 'react' +import { logtoConfig } from './LogtoConfig' +import { setGetAccessTokenFn } from '../lib/api' + +function TokenWiring({ children }: { children: ReactNode }) { + const { getAccessToken } = useLogto() + useEffect(() => { + setGetAccessTokenFn(async (resource) => { + try { + return (await getAccessToken(resource)) ?? null + } catch { + return null + } + }) + return () => setGetAccessTokenFn(null) + }, [getAccessToken]) + return <>{children} +} + +export function AuthProvider({ children }: { children: ReactNode }) { + return ( + + {children} + + ) +} +``` + +- [ ] **Step 3.3: Verify it builds** + +```bash +cd web && pnpm build +``` + +- [ ] **Step 3.4: Commit** + +```bash +git add web/src/lib/api.ts web/src/auth/AuthProvider.tsx +git commit -m "feat(auth): apiFetch attaches Bearer token + X-Sport header + +Replaces credentials: 'include' with Authorization: Bearer +and X-Sport: . Token comes from useLogto().getAccessToken() (freshly +minted per request, refreshed transparently by the SDK). Sport slug is +set by SportContext (Task 5)." +``` + +--- + +## Task 4 — Build SportContext + sports API + +**Files:** +- Create: `api/handler/sports.go` +- Create: `api/handler/sports_test.go` +- Modify: `api/router/router.go` (mount `/api/v1/sports`) +- Create: `web/src/lib/sports.ts` +- Create: `web/src/auth/SportContext.tsx` +- Create: `web/src/auth/useSport.ts` + +- [ ] **Step 4.1: Backend — `GET /api/v1/sports` handler** + +```go +// api/handler/sports.go +package handler + +import ( + "net/http" + + "github.com/court-command/court-command/db/generated" +) + +type SportDTO struct { + ID int64 `json:"id"` + Slug string `json:"slug"` + Name string `json:"name"` + LogtoOrgID string `json:"logto_org_id"` +} + +// ListSports returns active sports ordered by sort_order. +// Public (no auth required) — the sport picker lives at the unauthenticated +// landing page. +func (h *Handler) ListSports(w http.ResponseWriter, r *http.Request) { + sports, err := h.queries.ListSports(r.Context()) + if err != nil { + writeError(w, http.StatusInternalServerError, "internal_error", "list sports") + return + } + out := make([]SportDTO, len(sports)) + for i, s := range sports { + out[i] = SportDTO{ + ID: s.ID, + Slug: s.Slug, + Name: s.Name, + LogtoOrgID: s.LogtoOrgID, + } + } + writeJSON(w, http.StatusOK, map[string]any{"data": out}) +} + +// guard against unused import if generated isn't referenced directly elsewhere +var _ = generated.Sport{} +``` + +- [ ] **Step 4.2: Test the handler** + +```go +// api/handler/sports_test.go +package handler_test + +import ( + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "testing" + + "github.com/court-command/court-command/db/generated" + "github.com/court-command/court-command/handler" + "github.com/jackc/pgx/v5/pgconn" + "github.com/stretchr/testify/require" +) + +type fakeQueries struct { + sports []generated.Sport + err error +} + +func (f *fakeQueries) ListSports(ctx context.Context) ([]generated.Sport, error) { + return f.sports, f.err +} + +// Plus stubs for any other queries the Handler depends on; copy from +// existing handler tests' fake queries fixture. + +func TestListSports_ReturnsActiveSports(t *testing.T) { + q := &fakeQueries{sports: []generated.Sport{ + {ID: 1, Slug: "pickleball", Name: "Pickleball", LogtoOrgID: "ekup1zyrrxj4"}, + {ID: 2, Slug: "demo_sport", Name: "Demo Sport", LogtoOrgID: "7866ex96uk6b"}, + }} + h := handler.NewWithQueries(q) // assumes a test constructor; if not present, add one + + req := httptest.NewRequest(http.MethodGet, "/api/v1/sports", nil) + rr := httptest.NewRecorder() + h.ListSports(rr, req) + + require.Equal(t, http.StatusOK, rr.Code) + var resp map[string][]map[string]any + require.NoError(t, json.Unmarshal(rr.Body.Bytes(), &resp)) + require.Len(t, resp["data"], 2) + require.Equal(t, "pickleball", resp["data"][0]["slug"]) +} + +// silence unused +var _ = pgconn.PgError{} +``` + +If the existing handler package doesn't have a test-friendly constructor, add one: +```go +// api/handler/handler.go (add at bottom) +// +//nolint:revive // exported for test +func NewWithQueries(q QueriesIface) *Handler { + return &Handler{queries: q} +} +``` + +- [ ] **Step 4.3: Run the test** + +```bash +cd api && go test ./handler/... -run TestListSports -v +``` +Expected: PASS. + +- [ ] **Step 4.4: Mount the route** + +In `api/router/router.go`, find the public-route block and add: +```go +r.Get("/api/v1/sports", h.ListSports) +``` + +Verify route placement: this is **public** (no `RequireJWT`). + +- [ ] **Step 4.5: Frontend — `lib/sports.ts`** + +```ts +// web/src/lib/sports.ts +import { apiGet } from './api' + +export interface Sport { + id: number + slug: string + name: string + logto_org_id: string +} + +export async function listSports(): Promise { + return apiGet('/api/v1/sports') +} +``` + +- [ ] **Step 4.6: Frontend — `SportContext.tsx`** + +```tsx +// web/src/auth/SportContext.tsx +import { createContext, useContext, useEffect, type ReactNode } from 'react' +import { useQuery } from '@tanstack/react-query' +import { listSports, type Sport } from '../lib/sports' +import { setCurrentSport } from '../lib/api' + +interface SportCtx { + sport: Sport | null + sports: Sport[] + isLoading: boolean +} + +const Ctx = createContext({ sport: null, sports: [], isLoading: false }) + +export function SportProvider({ slug, children }: { slug: string | null; children: ReactNode }) { + const sportsQuery = useQuery({ + queryKey: ['sports'], + queryFn: listSports, + staleTime: 60 * 60 * 1000, // 1 hour — sports rarely change + }) + + const sport = slug ? sportsQuery.data?.find((s) => s.slug === slug) ?? null : null + + // Push slug into the module-level api state so apiFetch attaches X-Sport. + useEffect(() => { + setCurrentSport(sport?.slug ?? '') + }, [sport?.slug]) + + return ( + + {children} + + ) +} + +export function useSport() { + return useContext(Ctx) +} +``` + +- [ ] **Step 4.7: Verify build + test pass** + +```bash +cd api && go test ./handler/... -run TestListSports -v +cd ../web && pnpm build +``` + +- [ ] **Step 4.8: Commit** + +```bash +git add api/handler/sports.go api/handler/sports_test.go api/handler/handler.go api/router/router.go web/src/lib/sports.ts web/src/auth/SportContext.tsx web/src/auth/useSport.ts +git commit -m "feat(sport): GET /api/v1/sports + SportContext provider + +Backend: public ListSports handler returns active sports ordered by +sort_order. Frontend: SportContext reads the URL \$sport slug, looks +up the matching sport row from the API, pushes the slug into apiFetch +module state so X-Sport header is attached on every request." +``` + +--- + +## Task 5 — Restructure routes under `/$sport/*` + +This is the largest mechanical change in Phase 3. Move ~48 routes under a new `$sport` segment, add a `$sport.tsx` layout, and update every internal link. + +**Files:** ~48 route files moved, 1 layout file created, ~20 link callers updated. + +- [ ] **Step 5.1: Create the `$sport` layout route** + +```tsx +// web/src/routes/$sport.tsx +import { createFileRoute, Outlet, useParams, useNavigate } from '@tanstack/react-router' +import { useEffect } from 'react' +import { SportProvider, useSport } from '../auth/SportContext' + +export const Route = createFileRoute('/$sport')({ + component: SportLayout, +}) + +function SportLayout() { + const { sport: slug } = useParams({ from: '/$sport' }) + return ( + + + + ) +} + +function SportGuard() { + const { sport, sports, isLoading } = useSport() + const navigate = useNavigate() + useEffect(() => { + // Once sports list loads, if the slug isn't a known sport, bounce home. + if (!isLoading && sports.length > 0 && !sport) { + void navigate({ to: '/' }) + } + }, [isLoading, sports, sport, navigate]) + + if (isLoading) return
Loading sport…
+ if (!sport) return null + return +} +``` + +- [ ] **Step 5.2: Decide which routes are sport-scoped** + +| Path family | Goes under `$sport`? | +|---|---| +| `dashboard.tsx` | YES | +| `profile.tsx` | YES | +| `admin/*` | YES | +| `courts/*` | YES | +| `leagues/*` | YES | +| `manage/*` | YES | +| `match-series/*` | YES | +| `matches/*` | YES | +| `organizations/*` | YES | +| `players/*` | YES | +| `quick-match/*` | YES | +| `ref/*` | YES | +| `scorekeeper/*` | YES | +| `teams/*` | YES | +| `tournaments/*` | YES | +| `index.tsx` (currently dashboard redirect) | DELETE — replaced with sport picker | +| `login.tsx`, `register.tsx` | DELETE — Logto handles | +| `auth.callback.tsx` | NEW — root level, not sport-scoped | +| `public/*` | NO — stays at `/public/*` | +| `overlay/*` | NO — stays at `/overlay/*` (renders inside OBS, no sport context) | +| `tv/*` (if exists) | NO | +| `__root.tsx` | NO — root layout | + +Search for any not in this list and decide: +```bash +cd web/src/routes && ls *.tsx +``` + +- [ ] **Step 5.3: Move the routes (mechanical)** + +```bash +cd web/src/routes +mkdir -p '$sport' + +# Move directories +for d in admin courts leagues manage match-series matches organizations players quick-match ref scorekeeper teams tournaments; do + if [ -d "$d" ]; then + git mv "$d" '$sport/'"$d" + fi +done + +# Move standalone files (rename to fit new path) +for f in dashboard.tsx profile.tsx; do + if [ -f "$f" ]; then + git mv "$f" '$sport/'"$f" + fi +done +``` + +TanStack Router uses **file path** as route — so `routes/$sport/dashboard.tsx` becomes route `/$sport/dashboard`. The router will regenerate `routeTree.gen.ts` on next dev start. + +- [ ] **Step 5.4: Update internal `` and `navigate({to:})` calls** + +Find every internal navigation: +```bash +cd web/src +grep -rEn "to=['\"]/(dashboard|profile|admin|leagues|manage|matches|tournaments|teams|players|courts|match-series|organizations|ref|scorekeeper|quick-match)" --include="*.tsx" --include="*.ts" +``` + +For each match, change: +```tsx + +navigate({ to: '/profile' }) → navigate({ to: '/$sport/profile', params: { sport: currentSlug } }) +``` + +`currentSlug` comes from `useSport()` (already in context for any component under a `$sport` route). + +**Component pattern** for components used in many places: +```tsx +import { useSport } from '../auth/useSport' +const { sport } = useSport() +const sportSlug = sport?.slug ?? '' +// ... +Dashboard +``` + +This is mechanical but tedious. Plan to spend ~2 hours on it. Verify by running `pnpm dev` after each batch of files updated and clicking through the affected pages. + +- [ ] **Step 5.5: Update `__root.tsx` patterns** + +```tsx +// web/src/routes/__root.tsx +const NO_SHELL_ROUTES = ['/', '/auth/callback'] + +const PUBLIC_ROUTE_PATTERNS: RegExp[] = [ + /^\/$/, // sport picker + /^\/auth\//, // OIDC callback + /^\/public(\/|$)/, + /^\/[^/]+\/matches\/[^/]+$/, // /$sport/matches/$publicId is public + /^\/[^/]+\/match-series\/[^/]+$/, + // Old patterns: + // /^\/matches\/[^/]+$/, /^\/match-series\/[^/]+$/, ... +] + +const NO_SHELL_PATTERNS: RegExp[] = [ + /^\/[^/]+\/matches\/[^/]+\/scoreboard$/, + /^\/overlay\/court\/[^/]+$/, + /^\/overlay\/demo\/[^/]+$/, + /^\/tv\/tournaments\/[^/]+$/, + /^\/tv\/courts\/[^/]+$/, +] +``` + +- [ ] **Step 5.6: Verify dev server starts and route tree regenerates** + +```bash +cd web && pnpm dev +``` +Open http://localhost:5173 — expect a 404 (sport picker doesn't exist yet — Task 6) but the route tree should compile cleanly. + +- [ ] **Step 5.7: Commit** + +```bash +git add -A +git commit -m "feat(routing): move sport-scoped routes under /\$sport/* segment + +Restructures ~48 routes under the new /\$sport/* path. Adds a layout +route (\$sport.tsx) that: +- reads slug from URL +- wraps subtree in +- bounces to / if slug is unknown + +Public routes (/public/*) and OBS overlay routes (/overlay/*) stay at +root. Deleted: routes/dashboard.tsx (replaced by /$sport/dashboard), +routes/profile.tsx (replaced by /$sport/profile + Task 8 rewrite). + +Internal navigations updated: ~120 attrs and ~28 +navigate({to:}) calls now use { to: '/\$sport/...', params: {sport: slug} }." +``` + +--- + +## Task 6 — Sport picker landing page + +**Files:** +- Create: `web/src/routes/index.tsx` (replaces deleted dashboard redirect) +- Modify: `web/src/components/Sidebar.tsx` to add a "switch sport" link + +- [ ] **Step 6.1: Build the picker** + +```tsx +// web/src/routes/index.tsx +import { createFileRoute, useNavigate } from '@tanstack/react-router' +import { useQuery } from '@tanstack/react-query' +import { listSports, type Sport } from '../lib/sports' +import { useAuth } from '../auth/useAuth' + +export const Route = createFileRoute('/')({ + component: SportPicker, +}) + +function SportPicker() { + const navigate = useNavigate() + const { isAuthenticated, signIn } = useAuth() + const { data: sports, isLoading, isError } = useQuery({ + queryKey: ['sports'], + queryFn: listSports, + }) + + const choose = (s: Sport) => { + if (!isAuthenticated) { + // Sign in scoped to this sport's organization. After callback, + // we'll land on /$sport/dashboard. + void signIn(`/${s.slug}/dashboard`) + return + } + void navigate({ to: '/$sport/dashboard', params: { sport: s.slug } }) + } + + if (isLoading) return
Loading sports…
+ if (isError) return
Failed to load sports
+ if (!sports || sports.length === 0) { + return
No sports configured yet.
+ } + + return ( +
+

Court Command

+

Choose your sport

+
+ {sports.map((s) => ( + + ))} +
+ {!isAuthenticated && ( +

+ You'll be asked to sign in after choosing. +

+ )} +
+ ) +} +``` + +- [ ] **Step 6.2: Add "switch sport" to sidebar** + +In `web/src/components/Sidebar.tsx`, near the user/profile section, add: +```tsx +import { Link } from '@tanstack/react-router' +// ... + + Switch sport + +``` + +- [ ] **Step 6.3: Delete `routes/login.tsx` and `routes/register.tsx`** + +```bash +cd web/src/routes +git rm login.tsx register.tsx +``` + +These pages no longer exist; OIDC handles sign-in/sign-up. + +- [ ] **Step 6.4: Verify dev server** + +```bash +cd web && pnpm dev +``` +- Visit `/`. Should render a picker with two buttons: Pickleball, Demo Sport. +- Click Pickleball without being signed in. Browser redirects to Logto sign-in page (http://localhost:3001/...). +- Sign in as `admin@courtcommand.local` / `TestPass123!`. +- Browser redirects back to `/auth/callback` (Task 7 handles this). + +For now Task 7 isn't done so the callback will 404. That's fine — Task 7 is next. + +- [ ] **Step 6.5: Commit** + +```bash +git add web/src/routes/index.tsx web/src/components/Sidebar.tsx +git rm web/src/routes/login.tsx web/src/routes/register.tsx +git commit -m "feat(picker): sport picker landing page replaces /login + +Renders active sports as buttons. Choosing a sport while not signed in +triggers Logto OIDC sign-in scoped to that sport's organization; +post-callback, user lands on /\$sport/dashboard. Deletes legacy login +and register routes." +``` + +--- + +## Task 7 — OIDC callback route + +**Files:** +- Create: `web/src/routes/auth.callback.tsx` + +- [ ] **Step 7.1: Build the callback handler** + +```tsx +// web/src/routes/auth.callback.tsx +import { createFileRoute, useNavigate } from '@tanstack/react-router' +import { useLogto } from '@logto/react' +import { useEffect, useState } from 'react' + +export const Route = createFileRoute('/auth/callback')({ + component: AuthCallback, +}) + +function AuthCallback() { + const { handleSignInCallback, isAuthenticated } = useLogto() + const navigate = useNavigate() + const [error, setError] = useState(null) + + useEffect(() => { + // Logto SDK reads the code/state from the URL and exchanges them + // for a token. Call once on mount. + void (async () => { + try { + await handleSignInCallback(window.location.href) + } catch (e) { + setError(e instanceof Error ? e.message : 'Sign-in failed') + } + })() + }, [handleSignInCallback]) + + useEffect(() => { + if (isAuthenticated) { + // postRedirectUri (set when calling signIn) is honored by Logto + // automatically — the callback page only renders briefly. + // If for some reason we end up here without a target, default to /. + void navigate({ to: '/' }) + } + }, [isAuthenticated, navigate]) + + if (error) { + return ( +
+

Sign-in failed

+

{error}

+ +
+ ) + } + return
Completing sign-in…
+} +``` + +- [ ] **Step 7.2: Add `/auth/callback` to `NO_SHELL_ROUTES` in `__root.tsx`** + +(Should already be done in Task 5.5; double-check.) + +- [ ] **Step 7.3: Manual smoke test** + +```bash +# Backend running on :8080 +# Frontend running on :5173 +cd web && pnpm dev +``` +Browser flow: +1. Visit `http://localhost:5173/` +2. Click "Pickleball" +3. Redirected to `http://localhost:3001/sign-in?...` +4. Sign in as admin@courtcommand.local / TestPass123! +5. Redirected to `http://localhost:5173/auth/callback?code=...&state=...` +6. Should see "Completing sign-in…" briefly, then redirect to `/pickleball/dashboard` +7. `/pickleball/dashboard` will 404 because we haven't built the protected dashboard yet — but the auth flow is working if you got here. + +Inspect dev-tools Network tab: first `XHR` to `/api/v1/auth/me` should include `Authorization: Bearer eyJ...` and `X-Sport: pickleball`. + +- [ ] **Step 7.4: Commit** + +```bash +git add web/src/routes/auth.callback.tsx +git commit -m "feat(auth): OIDC callback route + +Calls handleSignInCallback() with the current URL on mount. Logto SDK +parses ?code= and ?state=, exchanges them at the token endpoint, and +fires the postRedirectUri honor. We redirect to / as fallback if no +post-redirect was set." +``` + +--- + +## Task 8 — Profile edit form against `player_profiles` + +**Files:** +- Create: `api/handler/profile.go` — `GET /api/v1/me/profile`, `PATCH /api/v1/me/profile` +- Create: `api/handler/profile_test.go` +- Modify: `api/router/router.go` — mount under RequireJWT +- Create: `web/src/routes/$sport/profile.tsx` + +The `/api/v1/me/*` endpoints take the JWT subject as the user identity (no path param). The handler reads `Claims` from context. + +- [ ] **Step 8.1: Backend handler** + +```go +// api/handler/profile.go +package handler + +import ( + "encoding/json" + "net/http" + + "github.com/court-command/court-command/auth" + "github.com/court-command/court-command/db/generated" +) + +type PlayerProfileDTO struct { + UserID int64 `json:"user_id"` + Phone *string `json:"phone,omitempty"` + DuprID *string `json:"dupr_id,omitempty"` + VairID *string `json:"vair_id,omitempty"` + PaddleBrand *string `json:"paddle_brand,omitempty"` + PaddleModel *string `json:"paddle_model,omitempty"` + Gender *string `json:"gender,omitempty"` + Handedness *string `json:"handedness,omitempty"` + DateOfBirth *string `json:"date_of_birth,omitempty"` // YYYY-MM-DD + Bio *string `json:"bio,omitempty"` + StreetAddress *string `json:"street_address,omitempty"` + City *string `json:"city,omitempty"` + StateProvince *string `json:"state_province,omitempty"` + PostalCode *string `json:"postal_code,omitempty"` + Country *string `json:"country,omitempty"` + Latitude *float64 `json:"latitude,omitempty"` + Longitude *float64 `json:"longitude,omitempty"` + EmergencyContactName *string `json:"emergency_contact_name,omitempty"` + EmergencyContactPhone *string `json:"emergency_contact_phone,omitempty"` + EmergencyContactRelation *string `json:"emergency_contact_relation,omitempty"` + MedicalNotes *string `json:"medical_notes,omitempty"` + WaiverAcceptedAt *string `json:"waiver_accepted_at,omitempty"` + AvatarURL *string `json:"avatar_url,omitempty"` + IsProfileHidden bool `json:"is_profile_hidden"` +} + +// GetMyProfile returns the player_profiles row for the authenticated user. +// Returns an empty profile (with user_id only) if no row exists yet. +func (h *Handler) GetMyProfile(w http.ResponseWriter, r *http.Request) { + claims, ok := auth.ClaimsFromContext(r.Context()) + if !ok { + writeError(w, http.StatusInternalServerError, "internal_error", "missing claims") + return + } + user, err := h.queries.GetUserByLogtoUserID(r.Context(), &claims.Subject) + if err != nil { + writeError(w, http.StatusNotFound, "user_not_found", "user mirror missing") + return + } + profile, err := h.queries.GetPlayerProfileRow(r.Context(), user.ID) + if err != nil { + // Row doesn't exist yet — return defaults. + writeJSON(w, http.StatusOK, map[string]any{"data": PlayerProfileDTO{UserID: user.ID}}) + return + } + writeJSON(w, http.StatusOK, map[string]any{"data": profileToDTO(profile)}) +} + +// PatchMyProfile upserts the player_profiles row. Every field is optional; +// NULL leaves existing column unchanged (sqlc.narg COALESCE pattern). +func (h *Handler) PatchMyProfile(w http.ResponseWriter, r *http.Request) { + claims, ok := auth.ClaimsFromContext(r.Context()) + if !ok { + writeError(w, http.StatusInternalServerError, "internal_error", "missing claims") + return + } + if !claims.HasScope("write:profile") { + writeError(w, http.StatusForbidden, "forbidden", "missing write:profile scope") + return + } + var in PlayerProfileDTO + if err := json.NewDecoder(r.Body).Decode(&in); err != nil { + writeError(w, http.StatusBadRequest, "bad_json", "decode body") + return + } + user, err := h.queries.GetUserByLogtoUserID(r.Context(), &claims.Subject) + if err != nil { + writeError(w, http.StatusNotFound, "user_not_found", "user mirror missing") + return + } + params := dtoToUpsertParams(user.ID, in) + if _, err := h.queries.UpsertPlayerProfile(r.Context(), params); err != nil { + writeError(w, http.StatusInternalServerError, "internal_error", "upsert profile") + return + } + // Return the freshly-saved row. + profile, err := h.queries.GetPlayerProfileRow(r.Context(), user.ID) + if err != nil { + writeError(w, http.StatusInternalServerError, "internal_error", "reload profile") + return + } + writeJSON(w, http.StatusOK, map[string]any{"data": profileToDTO(profile)}) +} + +func profileToDTO(p generated.PlayerProfile) PlayerProfileDTO { + // Field-by-field mapping. All nullable text fields are *string already. + // Date/timestamp fields are pgtype.Date / pgtype.Timestamptz — convert + // to *string with format "2006-01-02" / RFC3339. + dto := PlayerProfileDTO{ + UserID: p.UserID, + Phone: p.Phone, + DuprID: p.DuprID, + VairID: p.VairID, + PaddleBrand: p.PaddleBrand, + PaddleModel: p.PaddleModel, + Gender: p.Gender, + Handedness: p.Handedness, + Bio: p.Bio, + StreetAddress: p.StreetAddress, + City: p.City, + StateProvince: p.StateProvince, + PostalCode: p.PostalCode, + Country: p.Country, + EmergencyContactName: p.EmergencyContactName, + EmergencyContactPhone: p.EmergencyContactPhone, + EmergencyContactRelation: p.EmergencyContactRelation, + MedicalNotes: p.MedicalNotes, + AvatarURL: p.AvatarURL, + IsProfileHidden: p.IsProfileHidden, + } + if p.DateOfBirth.Valid { + s := p.DateOfBirth.Time.Format("2006-01-02") + dto.DateOfBirth = &s + } + if p.WaiverAcceptedAt.Valid { + s := p.WaiverAcceptedAt.Time.Format("2006-01-02T15:04:05Z07:00") + dto.WaiverAcceptedAt = &s + } + if p.Latitude.Valid { + v := p.Latitude.Float64 + dto.Latitude = &v + } + if p.Longitude.Valid { + v := p.Longitude.Float64 + dto.Longitude = &v + } + return dto +} + +func dtoToUpsertParams(userID int64, in PlayerProfileDTO) generated.UpsertPlayerProfileParams { + // Convert DTO -> sqlc params. NULLs in DTO map to nil pointers + // in params, which the COALESCE() in the query treats as "leave + // existing value alone" on UPDATE. + p := generated.UpsertPlayerProfileParams{ + UserID: userID, + Phone: in.Phone, + DuprID: in.DuprID, + VairID: in.VairID, + PaddleBrand: in.PaddleBrand, + PaddleModel: in.PaddleModel, + Gender: in.Gender, + Handedness: in.Handedness, + Bio: in.Bio, + StreetAddress: in.StreetAddress, + City: in.City, + StateProvince: in.StateProvince, + PostalCode: in.PostalCode, + Country: in.Country, + EmergencyContactName: in.EmergencyContactName, + EmergencyContactPhone: in.EmergencyContactPhone, + EmergencyContactRelation: in.EmergencyContactRelation, + MedicalNotes: in.MedicalNotes, + AvatarURL: in.AvatarURL, + IsProfileHidden: in.IsProfileHidden, + } + // Date / float / timestamp conversions: parse strings, set Valid=true. + // Implementation detail; copy from existing handlers that already + // do pgtype.Date / pgtype.Timestamptz conversions (e.g. handler/users.go + // for date_of_birth on the legacy users table). + return p +} +``` + +- [ ] **Step 8.2: Test** + +Write `api/handler/profile_test.go`: +- `TestGetMyProfile_NoRowReturnsEmpty` — fake queries returns `pgx.ErrNoRows`, handler returns `{user_id: X, is_profile_hidden: false}` +- `TestGetMyProfile_ExistingRow` — fake returns populated profile, handler returns DTO +- `TestPatchMyProfile_RejectsWithoutScope` — claims with no scopes → 403 +- `TestPatchMyProfile_UpsertsAndReturns` — happy path + +Reuse the `runHandler(claims, ...)` test helper if it exists; if not, build one. + +```bash +cd api && go test ./handler/... -run TestGetMyProfile -v +go test ./handler/... -run TestPatchMyProfile -v +``` + +- [ ] **Step 8.3: Mount the routes** + +In `api/router/router.go`: +```go +r.With(middleware.RequireJWT(validator, true), middleware.MirrorUser(...)).Group(func(r chi.Router) { + r.Get("/api/v1/me/profile", h.GetMyProfile) + r.Patch("/api/v1/me/profile", h.PatchMyProfile) +}) +``` + +(`MirrorUser` is built in Task 9. For now, you can scaffold a no-op middleware and replace it in Task 9.) + +- [ ] **Step 8.4: Frontend — profile route** + +```tsx +// web/src/routes/$sport/profile.tsx +import { createFileRoute } from '@tanstack/react-router' +import { useState } from 'react' +import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query' +import { apiGet, apiPatch } from '../../lib/api' +import { AuthGuard } from '../../features/auth/AuthGuard' + +export const Route = createFileRoute('/$sport/profile')({ + component: () => ( + + + + ), +}) + +interface PlayerProfile { + user_id: number + phone?: string | null + dupr_id?: string | null + vair_id?: string | null + paddle_brand?: string | null + paddle_model?: string | null + gender?: 'male' | 'female' | 'other' | null + handedness?: 'left' | 'right' | 'ambidextrous' | null + date_of_birth?: string | null + bio?: string | null + street_address?: string | null + city?: string | null + state_province?: string | null + postal_code?: string | null + country?: string | null + latitude?: number | null + longitude?: number | null + emergency_contact_name?: string | null + emergency_contact_phone?: string | null + emergency_contact_relation?: string | null + medical_notes?: string | null + waiver_accepted_at?: string | null + avatar_url?: string | null + is_profile_hidden: boolean +} + +function ProfileEdit() { + const qc = useQueryClient() + const { data: profile, isLoading, error } = useQuery({ + queryKey: ['me', 'profile'], + queryFn: () => apiGet('/api/v1/me/profile'), + }) + + const [draft, setDraft] = useState>({}) + const set = (k: K, v: PlayerProfile[K]) => + setDraft((d) => ({ ...d, [k]: v })) + + const mut = useMutation({ + mutationFn: (data: Partial) => + apiPatch('/api/v1/me/profile', data), + onSuccess: (saved) => { + qc.setQueryData(['me', 'profile'], saved) + setDraft({}) + }, + }) + + if (isLoading) return
Loading…
+ if (error) return
Failed to load profile
+ + // Merge profile + draft for form display. + const view: Partial = { ...profile, ...draft } + + return ( +
+

Profile

+ +
+ + set('phone', e.target.value || null)} className="border rounded p-2 w-full" /> + +
+ +
+ + set('dupr_id', e.target.value || null)} className="border rounded p-2 w-full" /> + + + set('vair_id', e.target.value || null)} className="border rounded p-2 w-full" /> + +
+ +
+ + set('paddle_brand', e.target.value || null)} className="border rounded p-2 w-full" /> + + + set('paddle_model', e.target.value || null)} className="border rounded p-2 w-full" /> + +
+ +
+ + + + + + + + set('date_of_birth', e.target.value || null)} className="border rounded p-2 w-full" /> + + +