{
  "openapi": "3.1.0",
  "info": {
    "title": "Rankly API",
    "version": "1.9.4",
    "description": "API REST di Rankly — torneistica + ranking per community 1v1. Versionata `/api/v1/`.\n\n## Autenticazione\n\nTre modalità:\n\n1. **Session cookie** (browser-based, usato dalla web app stessa). Login via `/api/auth/sign-in/...`.\n2. **API key Bearer** (per integratori esterni). Genera una chiave da `https://rankly.it/account/api`, poi:\n   ```\n   Authorization: Bearer rk_live_<token>\n   ```\n   Scope `read` (GET pubblici) o `write` (mutation su risorse dell'org).\n3. **OIDC access_token** (Sign in with Rankly, v1.8.27+). Token opaco ottenuto via authorization code flow (`/api/auth/oauth2/*`). Stesso header Bearer. Scope `tournaments:read` / `tournaments:write`. Vedi `/docs/oauth` per quickstart.\n\n## Rate limit\n\nNon ancora attivo. Pianificato: 60 req/min per IP+endpoint, 600 req/min per API key autenticata. Adeguati limiti per overlay streaming live.\n\n## Errori\n\nFormato standard: `{ \"message\": \"error_code\" }` + status HTTP appropriato (400 invalid body, 401 missing auth, 402 subscription required, 403 forbidden, 404 not found, 409 conflict, 500 server error).\n\n## Novità\n\n- **v1.9.1**: `bracketSeedingMode = performance-cross` (MtG Top-8 style: ordinamento globale per performance Swiss).\n- **v1.9.0**: Setting `phase.settings.bracketSeedingMode` per phase Single Elim post-Swiss. Default `group-cross` (UEFA-style, minimizza rincontri intra-girone). Vedi `PhaseSettings` e `BracketSeedingMode` per dettagli.\n- **v1.8.27**: Sign in with Rankly (OIDC provider) — vedi `/docs/oauth`.",
    "contact": {
      "name": "Rankly support",
      "url": "https://rankly.it"
    },
    "license": {
      "name": "Proprietary"
    }
  },
  "servers": [
    {
      "url": "https://api.rankly.it",
      "description": "Production"
    }
  ],
  "components": {
    "securitySchemes": {
      "ApiKey": {
        "type": "http",
        "scheme": "bearer",
        "bearerFormat": "rk_live_*",
        "description": "API key emessa da `/account/api`. Header: `Authorization: Bearer rk_live_<token>`."
      },
      "Session": {
        "type": "apiKey",
        "in": "cookie",
        "name": "better-auth.session_token",
        "description": "Cookie di sessione better-auth (browser web app)."
      }
    },
    "schemas": {
      "TournamentStatus": {
        "type": "string",
        "enum": [
          "draft",
          "registration",
          "check_in",
          "running",
          "completed",
          "cancelled"
        ]
      },
      "TournamentFormat": {
        "type": "string",
        "enum": [
          "swiss",
          "single_elim",
          "double_elim",
          "round_robin"
        ]
      },
      "PhaseStatus": {
        "type": "string",
        "enum": [
          "pending",
          "running",
          "completed"
        ]
      },
      "ParticipantStatus": {
        "type": "string",
        "enum": [
          "active",
          "waitlist",
          "dropped",
          "disqualified"
        ]
      },
      "ParticipantRole": {
        "type": "string",
        "enum": [
          "player",
          "referee",
          "head_judge"
        ]
      },
      "TiebreakerKey": {
        "type": "string",
        "enum": [
          "point_diff",
          "points_scored",
          "buchholz",
          "buchholz_cut1",
          "median_buchholz",
          "sos",
          "cumulative",
          "omw_pct",
          "omw_pct_strict",
          "oomw_pct",
          "h2h"
        ],
        "description": "Chiavi tiebreaker disponibili per le fasi Swiss/Round Robin. Vedi `GET /public/tiebreakers` per la documentazione completa di ciascuna."
      },
      "BracketSeedingMode": {
        "type": "string",
        "enum": ["group-cross", "performance-cross", "seed-cross"],
        "description": "Modalità di seeding del bracket di una phase Single Elim che segue una phase Swiss/RoundRobin (v1.9.0+). Default `group-cross`.\n\n- **group-cross** (UEFA/Challonge-style): minimizza il rincontro tra qualificati dello stesso girone. Per K=4 qualificati per girone, 1° vs 2° stesso girone solo in finale; pattern bit-identico Challonge.\n- **performance-cross** (MtG Top-8 style, v1.9.1+): combina tutti i qualificati in un ordinamento globale per performance Swiss applicando l'**intera catena tiebreaker configurata sulla phase** (v1.9.3+) — es. `point_diff → buchholz → buchholz_cut1 → median_buchholz → ...`. Poi accoppia 1° vs ultimo, 2° vs penultimo, ecc. h2h escluso cross-group (undefined tra giocatori di gironi diversi).\n- **seed-cross** (NCAA classic): bracket 1 vs N, 2 vs N-1 dove il seed deriva da posizione_in_girone × G + indice_girone."
      },
      "PhaseSettings": {
        "type": "object",
        "description": "Settings di una phase. Campi presenti dipendono da `phase.format`.",
        "properties": {
          "groupCount": {
            "type": "integer",
            "minimum": 1,
            "description": "Numero gironi paralleli (Swiss / RoundRobin only)."
          },
          "rounds": {
            "type": "integer",
            "minimum": 1,
            "description": "Numero di round (Swiss only)."
          },
          "passes": {
            "type": "integer",
            "minimum": 1,
            "description": "Numero di pass round-robin (1=single, 2=double, ...). RoundRobin only."
          },
          "qualifiersPerGroup": {
            "type": "integer",
            "minimum": 1,
            "description": "Top-N per girone che avanza alla phase successiva (Swiss / RoundRobin only)."
          },
          "pointsToWin": {
            "type": "integer",
            "minimum": 1,
            "description": "Punti necessari a vincere un match (es. 4 per Beyblade X bo7)."
          },
          "pointsToWinUpgradeFromEnd": {
            "type": "integer",
            "minimum": 0,
            "description": "Solo Single Elim: round (contando dal fondo: 0=finale, 1=semi, 2=quarti, ...) da cui il target sale a `pointsToWinUpgrade`."
          },
          "pointsToWinUpgrade": {
            "type": "integer",
            "minimum": 1,
            "description": "Nuovo target per i round finali (vedi `pointsToWinUpgradeFromEnd`)."
          },
          "placementMatches": {
            "type": "string",
            "enum": ["none", "top4", "top6", "top8"],
            "default": "none",
            "description": "Single Elim only. Finaline di piazzamento extra. `top4`=+3°/4°, `top6`=+3°/4°+5°/6° (v1.5.2+), `top8`=+3°/4°+5°/6°+7°/8°."
          },
          "bracketSeedingMode": {
            "$ref": "#/components/schemas/BracketSeedingMode"
          },
          "tiebreakers": {
            "type": "array",
            "items": { "$ref": "#/components/schemas/TiebreakerKey" },
            "description": "Override custom della catena tiebreaker (Swiss / RoundRobin). Se omesso, default del format."
          },
          "winnerCount": {
            "type": "integer",
            "minimum": 1,
            "description": "Raffle only: numero di premi/estratti."
          },
          "prizes": {
            "type": "array",
            "items": { "type": "string" },
            "description": "Raffle only: label per posizione (es. ['1° premio', '2° premio'])."
          }
        }
      },
      "TiebreakerPreset": {
        "type": "object",
        "properties": {
          "id": { "type": "string" },
          "orgId": { "type": "string" },
          "name": { "type": "string" },
          "slug": { "type": "string", "pattern": "^[a-z0-9-]+$" },
          "description": { "type": "string", "nullable": true },
          "chain": {
            "type": "array",
            "items": { "$ref": "#/components/schemas/TiebreakerKey" }
          },
          "isDefault": { "type": "boolean", "description": "Quando true e` la chain pre-selezionata nel wizard nuovo torneo. Max 1 default per org." },
          "createdBy": { "type": "string", "nullable": true },
          "createdAt": { "type": "string", "format": "date-time" },
          "updatedAt": { "type": "string", "format": "date-time" }
        }
      },
      "TiebreakerPresetBody": {
        "type": "object",
        "required": ["name", "slug", "chain"],
        "properties": {
          "name": { "type": "string", "minLength": 1, "maxLength": 100, "description": "Nome user-facing (es. 'Federazione Standard')." },
          "slug": { "type": "string", "pattern": "^[a-z0-9-]+$", "minLength": 1, "maxLength": 60, "description": "Unique per org. Auto-derivato dal nome lato UI ma override possibile." },
          "description": { "type": "string", "nullable": true, "maxLength": 500 },
          "chain": {
            "type": "array",
            "minItems": 1,
            "maxItems": 10,
            "items": { "$ref": "#/components/schemas/TiebreakerKey" },
            "description": "Catena di chiavi in ordine di priorita`. La prima e` il piu` discriminante."
          },
          "isDefault": { "type": "boolean", "default": false }
        }
      },
      "SubOrg": {
        "type": "object",
        "description": "Sub-org (club affiliato) sotto una parent (federazione). Eredita subscription e admin dalla parent (v1.8.0+). Max 2 livelli.",
        "properties": {
          "id": { "type": "string" },
          "slug": { "type": "string", "pattern": "^[a-z0-9-]+$" },
          "name": { "type": "string" },
          "logoUrl": { "type": "string", "nullable": true, "format": "uri" },
          "parentOrgId": { "type": "string", "description": "ID della parent (federazione) sotto cui vive questa sub-org." },
          "ownerUserId": { "type": "string", "description": "Owner del sub-org. Quando la sub e` shell (nessun membership esplicita), e` lo stesso owner della parent." },
          "createdAt": { "type": "string", "format": "date-time" }
        }
      },
      "SubOrgBody": {
        "type": "object",
        "required": ["name"],
        "properties": {
          "name": { "type": "string", "minLength": 2, "maxLength": 120 },
          "slug": { "type": "string", "pattern": "^[a-z0-9-]+$", "minLength": 2, "maxLength": 40, "description": "Globalmente unique (stesso namespace delle org top-level). Auto-derivato dal nome lato UI." },
          "ownerUserId": { "type": "string", "description": "Owner sub iniziale opzionale. Omesso → sub 'shell' (no member esplicito, gestita da admin parent). Quando esplicito, crea membership owner per quell'user." }
        }
      },
      "Tournament": {
        "type": "object",
        "properties": {
          "id": {
            "type": "string",
            "example": "trn_abc123"
          },
          "slug": {
            "type": "string",
            "example": "summer-cup-2026"
          },
          "name": {
            "type": "string"
          },
          "description": {
            "type": "string",
            "nullable": true
          },
          "orgId": {
            "type": "string"
          },
          "status": {
            "$ref": "#/components/schemas/TournamentStatus"
          },
          "startsAt": {
            "type": "string",
            "format": "date-time",
            "nullable": true
          },
          "maxParticipants": {
            "type": "integer",
            "nullable": true
          },
          "venueName": {
            "type": "string",
            "nullable": true,
            "description": "Nome della sede dell'evento"
          },
          "venueAddress": {
            "type": "string",
            "nullable": true,
            "description": "Indirizzo della sede"
          },
          "venueCity": {
            "type": "string",
            "nullable": true,
            "description": "Citta` della sede"
          },
          "venueCountry": {
            "type": "string",
            "nullable": true,
            "description": "Codice paese ISO-3166-1 alpha-2"
          },
          "openScoring": {
            "type": "boolean",
            "description": "Se true, oltre agli admin dell'org possono refertare anche i partecipanti registrati (arbitri+player non guest). Player normali con openScoring on sono scoped ai loro match."
          },
          "twitchChannel": {
            "type": "string",
            "nullable": true,
            "pattern": "^[a-z0-9_]{3,25}$",
            "description": "Slug del canale Twitch (es. 'ilmiocanale'). Quando il torneo e` in stato 'running' la vista pubblica /t/orgSlug/tournamentSlug mostra l'embed player.twitch.tv in alto."
          },
          "hostOrgId": {
            "type": "string",
            "nullable": true,
            "description": "ID opzionale dell'org host del torneo. Deve essere l'org del torneo stessa OR una sub-org della stessa (parent_org_id = orgId del torneo). Quando settato, il logo dell'org host appare come watermark nelle liste e nell'header del torneo."
          },
          "lockSelfUnenroll": {
            "type": "boolean",
            "description": "Quando true il participant NON puo` cancellare la propria iscrizione: solo admin/owner dell'org o API key Bearer (considerata amministrativa). Default false."
          },
          "entryFeeCents": {
            "type": "integer",
            "nullable": true,
            "minimum": 0,
            "maximum": 1000000,
            "description": "Quota di iscrizione in centesimi (intero, no float). null o 0 = torneo gratuito → la UI organizer nasconde i badge 'pagato/da incassare' e il bottone 'Marca €' sui participant. > 0 = torneo a pagamento → l'organizer marca a mano `participant.paidAt` quando incassa. Pagamento OFF-platform (bonifico/PayPal/cash gestiti dall'organizer); Rankly NON si interpone sui pagamenti, solo traccia lo stato."
          },
          "isHidden": {
            "type": "boolean",
            "description": "Torneo nascosto: escluso da lista pubblica `/tornei`, sitemap, landing org. Visibile solo a admin/owner/member dell'org e ai co-organizer (tournament_admin) tramite dashboard. La vista pubblica by-slug ritorna 404. Default false. Vedi migration 0044."
          },
          "registrationOpensAt": {
            "type": "string",
            "format": "date-time",
            "nullable": true,
            "description": "Apertura iscrizioni schedulata. Quando set + ora corrente < val + status='registration': POST `/tournaments/:id/register` ritorna 409 `registration_not_yet_open`; UI vista pubblica mostra data apertura. null = iscrizioni aperte immediatamente. Vedi migration 0044."
          },
          "selfRegistrationOpen": {
            "type": "boolean",
            "description": "Iscrizione libera dei player. Quando false → POST `/tournaments/:id/register` ritorna 403 `self_registration_disabled`. Solo admin/owner/api-key può aggiungere participants tramite POST `/tournaments/:id/participants`. Use case: lista chiusa stile Challonge import. Default true. Vedi migration 0044."
          },
          "createdAt": {
            "type": "string",
            "format": "date-time"
          },
          "updatedAt": {
            "type": "string",
            "format": "date-time"
          }
        }
      },
      "Participant": {
        "type": "object",
        "properties": {
          "id": {
            "type": "string",
            "example": "ptp_abc123"
          },
          "tournamentId": {
            "type": "string"
          },
          "name": {
            "type": "string"
          },
          "username": {
            "type": "string",
            "nullable": true,
            "description": "Username dell'user collegato (se non guest)"
          },
          "image": {
            "type": "string",
            "nullable": true,
            "description": "URL avatar dell'user collegato (custom upload prevale su quello OAuth). null per guest o per chi non ha mai impostato un'immagine."
          },
          "userId": {
            "type": "string",
            "nullable": true
          },
          "isGuest": {
            "type": "boolean"
          },
          "seed": {
            "type": "integer",
            "nullable": true
          },
          "role": {
            "$ref": "#/components/schemas/ParticipantRole"
          },
          "status": {
            "$ref": "#/components/schemas/ParticipantStatus"
          },
          "checkedInAt": {
            "type": "string",
            "format": "date-time",
            "nullable": true
          },
          "createdAt": {
            "type": "string",
            "format": "date-time"
          }
        }
      },
      "Match": {
        "type": "object",
        "properties": {
          "id": {
            "type": "string"
          },
          "groupId": {
            "type": "string"
          },
          "round": {
            "type": "integer"
          },
          "position": {
            "type": "integer"
          },
          "bracketSide": {
            "type": "string",
            "enum": [
              "winners",
              "losers",
              "grand_final"
            ],
            "nullable": true
          },
          "p1Id": {
            "type": "string",
            "nullable": true
          },
          "p2Id": {
            "type": "string",
            "nullable": true
          },
          "winnerId": {
            "type": "string",
            "nullable": true
          },
          "status": {
            "type": "string",
            "enum": [
              "pending",
              "completed"
            ]
          },
          "score": {
            "type": "object",
            "nullable": true,
            "properties": {
              "p1Points": {
                "type": "integer",
                "minimum": 0,
                "maximum": 9
              },
              "p2Points": {
                "type": "integer",
                "minimum": 0,
                "maximum": 9
              }
            }
          }
        }
      },
      "Error": {
        "type": "object",
        "properties": {
          "message": {
            "type": "string",
            "example": "tournament_not_found"
          }
        }
      },
      "TicketCode": {
        "type": "string",
        "description": "Codice biglietto univoco (16 char alfanumerici). Encodato nel QR del biglietto, usato per check-in via scan.",
        "example": "k7m2x9p1q4n8r3"
      },
      "MyTicketResponse": {
        "type": "object",
        "properties": {
          "ticketCode": {
            "$ref": "#/components/schemas/TicketCode"
          },
          "participant": {
            "type": "object",
            "properties": {
              "id": {
                "type": "string"
              },
              "name": {
                "type": "string"
              },
              "status": {
                "$ref": "#/components/schemas/ParticipantStatus"
              },
              "checkedInAt": {
                "type": "string",
                "format": "date-time",
                "nullable": true
              },
              "paidAt": {
                "type": "string",
                "format": "date-time",
                "nullable": true
              }
            }
          },
          "tournament": {
            "type": "object",
            "properties": {
              "id": {
                "type": "string"
              },
              "name": {
                "type": "string"
              },
              "slug": {
                "type": "string"
              },
              "status": {
                "$ref": "#/components/schemas/TournamentStatus"
              },
              "startsAt": {
                "type": "string",
                "format": "date-time",
                "nullable": true
              },
              "venueName": {
                "type": "string",
                "nullable": true
              },
              "venueAddress": {
                "type": "string",
                "nullable": true
              }
            }
          },
          "org": {
            "type": "object",
            "properties": {
              "slug": {
                "type": "string"
              },
              "name": {
                "type": "string"
              },
              "logoUrl": {
                "type": "string",
                "nullable": true
              }
            }
          }
        }
      },
      "ScanCheckinResponse": {
        "description": "Risposta scan QR: ramo `alreadyCheckedIn` quando il participant aveva già fatto check-in, ramo `checkedInAt` per il check-in appena eseguito.",
        "oneOf": [
          {
            "type": "object",
            "required": [
              "alreadyCheckedIn",
              "participant"
            ],
            "properties": {
              "alreadyCheckedIn": {
                "type": "boolean",
                "enum": [
                  true
                ]
              },
              "participant": {
                "type": "object",
                "properties": {
                  "id": {
                    "type": "string"
                  },
                  "name": {
                    "type": "string"
                  },
                  "paidAt": {
                    "type": "string",
                    "format": "date-time",
                    "nullable": true
                  }
                }
              }
            }
          },
          {
            "type": "object",
            "required": [
              "checkedInAt",
              "participant"
            ],
            "properties": {
              "checkedInAt": {
                "type": "string",
                "format": "date-time"
              },
              "assignedGroupId": {
                "type": "string",
                "nullable": true
              },
              "participant": {
                "type": "object",
                "properties": {
                  "id": {
                    "type": "string"
                  },
                  "name": {
                    "type": "string"
                  },
                  "paidAt": {
                    "type": "string",
                    "format": "date-time",
                    "nullable": true
                  }
                }
              }
            }
          }
        ]
      },
      "PublicOrgCard": {
        "type": "object",
        "description": "Card org per la pagina pubblica /community.",
        "properties": {
          "id": {
            "type": "string"
          },
          "slug": {
            "type": "string"
          },
          "name": {
            "type": "string"
          },
          "logoUrl": {
            "type": "string",
            "nullable": true
          },
          "campionatoActive": {
            "type": "boolean"
          },
          "tournamentsActive": {
            "type": "integer",
            "description": "Tornei in registration/check_in/running"
          },
          "tournamentsPublic": {
            "type": "integer",
            "description": "Tornei non-draft totali"
          },
          "followerCount": {
            "type": "integer"
          },
          "lastTournamentAt": {
            "type": "string",
            "format": "date-time",
            "nullable": true
          }
        }
      },
      "FollowedOrg": {
        "type": "object",
        "description": "Org seguita dall'utente loggato + anteprima prossimo torneo aperto.",
        "properties": {
          "id": {
            "type": "string"
          },
          "slug": {
            "type": "string"
          },
          "name": {
            "type": "string"
          },
          "logoUrl": {
            "type": "string",
            "nullable": true
          },
          "followedAt": {
            "type": "string",
            "format": "date-time"
          },
          "nextTournamentId": {
            "type": "string",
            "nullable": true
          },
          "nextTournamentSlug": {
            "type": "string",
            "nullable": true
          },
          "nextTournamentName": {
            "type": "string",
            "nullable": true
          },
          "nextTournamentStartsAt": {
            "type": "string",
            "format": "date-time",
            "nullable": true
          }
        }
      },
      "MarketingStats": {
        "type": "object",
        "description": "Contatori aggregati per la homepage marketing (counter \"matches managed\" animato).",
        "properties": {
          "matchesCompleted": {
            "type": "integer"
          },
          "tournamentsTotal": {
            "type": "integer"
          },
          "participantsTotal": {
            "type": "integer"
          }
        }
      }
    },
    "responses": {
      "Unauthorized": {
        "description": "Manca o e` invalido il token / la sessione",
        "content": {
          "application/json": {
            "schema": {
              "$ref": "#/components/schemas/Error"
            }
          }
        }
      },
      "Forbidden": {
        "description": "Auth ok ma manca permesso (es. non sei admin)",
        "content": {
          "application/json": {
            "schema": {
              "$ref": "#/components/schemas/Error"
            }
          }
        }
      },
      "NotFound": {
        "description": "Risorsa non trovata",
        "content": {
          "application/json": {
            "schema": {
              "$ref": "#/components/schemas/Error"
            }
          }
        }
      }
    }
  },
  "paths": {
    "/api/v1/health": {
      "get": {
        "summary": "Health check versionato",
        "tags": [
          "Meta"
        ],
        "responses": {
          "200": {
            "description": "OK",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "properties": {
                    "status": {
                      "type": "string",
                      "example": "ok"
                    },
                    "service": {
                      "type": "string"
                    },
                    "version": {
                      "type": "string"
                    },
                    "timestamp": {
                      "type": "string",
                      "format": "date-time"
                    }
                  }
                }
              }
            }
          }
        }
      }
    },
    "/api/v1/public/tournaments": {
      "get": {
        "summary": "Lista pubblica tornei aperti a iscrizioni",
        "description": "Stato `registration`. Espone solo info safe: id, slug, name, org, conteggi iscritti/waitlist.",
        "tags": [
          "Public"
        ],
        "responses": {
          "200": {
            "description": "Lista tornei",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "properties": {
                    "tournaments": {
                      "type": "array",
                      "items": {
                        "$ref": "#/components/schemas/Tournament"
                      }
                    }
                  }
                }
              }
            }
          }
        }
      }
    },
    "/api/v1/public/tournaments/by-slug/{orgSlug}/{tournamentSlug}": {
      "get": {
        "summary": "Dettaglio pubblico torneo per slug",
        "description": "Include fasi + conteggi. Per i live snapshot dei match, usa `/live`.",
        "tags": [
          "Public"
        ],
        "parameters": [
          {
            "name": "orgSlug",
            "in": "path",
            "required": true,
            "schema": {
              "type": "string"
            }
          },
          {
            "name": "tournamentSlug",
            "in": "path",
            "required": true,
            "schema": {
              "type": "string"
            }
          }
        ],
        "responses": {
          "200": {
            "description": "Detail torneo",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "properties": {
                    "tournament": {
                      "$ref": "#/components/schemas/Tournament"
                    },
                    "phases": {
                      "type": "array",
                      "description": "Phase configurate per il torneo. Ogni phase ha `id`, `tournamentId`, `format`, `order`, `status`, `qualifierCount` e un oggetto `settings` (vedi `PhaseSettings`).",
                      "items": {
                        "type": "object",
                        "properties": {
                          "id": { "type": "string" },
                          "tournamentId": { "type": "string" },
                          "format": { "$ref": "#/components/schemas/TournamentFormat" },
                          "order": { "type": "integer" },
                          "status": { "$ref": "#/components/schemas/PhaseStatus" },
                          "qualifierCount": { "type": "integer", "nullable": true },
                          "settings": { "$ref": "#/components/schemas/PhaseSettings" }
                        }
                      }
                    },
                    "activeCount": {
                      "type": "integer"
                    },
                    "waitlistCount": {
                      "type": "integer"
                    }
                  }
                }
              }
            }
          },
          "404": {
            "$ref": "#/components/responses/NotFound"
          }
        }
      }
    },
    "/api/v1/public/tournaments/{tournamentId}/live": {
      "get": {
        "summary": "Snapshot live del torneo (per spettatori)",
        "description": "Ritorna stato corrente con tutti i match + standings + participants. Disponibile solo se `status >= running`. Per aggiornamenti push, vedi `/live/stream` (SSE).\n\n**Score live derivato dai match_event**: per i match non `completed` con eventi granulari registrati (`POST /matches/:id/events`), il campo `match.score` viene popolato dal server sommando i `pointsValue` per p1 e p2 — il client vede lo score in tempo reale anche prima che l'arbitro chiuda il match col `POST /report` finale. Match `completed` mantengono lo score reale scritto in DB (snapshot definitivo, eventualmente sovrascritto manualmente).",
        "tags": [
          "Public"
        ],
        "parameters": [
          {
            "name": "tournamentId",
            "in": "path",
            "required": true,
            "schema": {
              "type": "string"
            }
          }
        ],
        "responses": {
          "200": {
            "description": "Snapshot live"
          },
          "409": {
            "description": "Torneo non ancora avviato",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          }
        }
      }
    },
    "/api/v1/public/tournaments/{tournamentId}/live/stream": {
      "get": {
        "summary": "SSE stream — invalidate events",
        "description": "EventSource che pubblica eventi `invalidate` ad ogni cambio di stato del torneo. Pattern client: ad ogni invalidate, refetch `/live` per il nuovo snapshot. Niente diff, snapshot completi.",
        "tags": [
          "Public"
        ],
        "parameters": [
          {
            "name": "tournamentId",
            "in": "path",
            "required": true,
            "schema": {
              "type": "string"
            }
          }
        ],
        "responses": {
          "200": {
            "description": "text/event-stream",
            "content": {
              "text/event-stream": {
                "schema": {
                  "type": "string"
                }
              }
            }
          }
        }
      }
    },
    "/api/v1/public/users/by-username/{username}": {
      "get": {
        "summary": "Profilo pubblico player",
        "description": "Username case-insensitive (lookup su `usernameNormalized`). Restituisce info safe + lista partecipazioni a tornei (registration/check_in/running/completed).",
        "tags": [
          "Public"
        ],
        "parameters": [
          {
            "name": "username",
            "in": "path",
            "required": true,
            "schema": {
              "type": "string"
            }
          }
        ],
        "responses": {
          "200": {
            "description": "Profilo"
          },
          "404": {
            "$ref": "#/components/responses/NotFound"
          }
        }
      }
    },
    "/api/v1/public/tournaments/{tournamentId}/final-standings": {
      "get": {
        "summary": "Classifica finale aggregata del torneo",
        "description": "Classifica finale dell'intero torneo (tutte le fasi aggregate). I primi M posti derivano dall'ultima phase (di solito le finali SE/DE + le finaline di piazzamento 3°/4° e 5°-8°); i restanti dai non-qualificati ordinati per le standings della prima phase a gironi.\n\nIl campo `notice` indica l'affidabilita`: `final` (torneo `completed`, classifica definitiva), `partial` (torneo ancora `running`, classifica provvisoria), `no_phases` (nessuna fase configurata). Il campo `tied` segnala posizioni a pari merito (es. i due perdenti di semifinale senza finalina 3°/4°). `source` indica da dove arriva il piazzamento.",
        "tags": [
          "Public"
        ],
        "parameters": [
          {
            "name": "tournamentId",
            "in": "path",
            "required": true,
            "schema": {
              "type": "string"
            }
          }
        ],
        "responses": {
          "200": {
            "description": "Classifica finale",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "properties": {
                    "tournament": {
                      "$ref": "#/components/schemas/Tournament"
                    },
                    "standings": {
                      "type": "array",
                      "items": {
                        "type": "object",
                        "properties": {
                          "rank": {
                            "type": "integer",
                            "example": 1
                          },
                          "tied": {
                            "type": "boolean",
                            "description": "true se la posizione e` a pari merito con altri"
                          },
                          "source": {
                            "type": "string",
                            "example": "final_winner",
                            "description": "Origine del piazzamento (final_winner, placement_3rd_loser, sf_loser_tied, swiss_non_qualifier, ...)"
                          },
                          "participantId": {
                            "type": "string"
                          },
                          "name": {
                            "type": "string"
                          },
                          "username": {
                            "type": "string",
                            "nullable": true
                          },
                          "image": {
                            "type": "string",
                            "nullable": true
                          }
                        }
                      }
                    },
                    "notice": {
                      "type": "string",
                      "enum": [
                        "final",
                        "partial",
                        "no_phases"
                      ]
                    }
                  }
                }
              }
            }
          },
          "404": {
            "$ref": "#/components/responses/NotFound"
          }
        }
      }
    },
    "/api/v1/public/tournaments/{tournamentId}/stats": {
      "get": {
        "summary": "Statistiche di fine torneo",
        "description": "Recap completo del torneo: stats aggregate, per giocatore, per girone e per fase, mix dei tipi di finish Beyblade X (spin/over/burst/extreme) e premi MVP.\n\nIl breakdown per tipo di finish è calcolato dagli eventi arbitrali granulari (`match_event`): `recap.eventsCoverage` (0-1) indica la quota di match coperti. Per i match refertati col solo punteggio finale restano disponibili le stats da W/L e punti. Funziona anche a torneo in corso (valori parziali).",
        "tags": [
          "Public"
        ],
        "parameters": [
          {
            "name": "tournamentId",
            "in": "path",
            "required": true,
            "schema": {
              "type": "string"
            }
          }
        ],
        "responses": {
          "200": {
            "description": "Statistiche torneo",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "properties": {
                    "tournament": {
                      "$ref": "#/components/schemas/Tournament"
                    },
                    "stats": {
                      "type": "object",
                      "properties": {
                        "recap": {
                          "type": "object",
                          "description": "matchesPlayed, pointsTotal, finishMix/finishMixPct (spin/over/burst/extreme), penaltiesTotal, eventsCoverage (0-1), mostContestedMatch, mostDecisiveMatch"
                        },
                        "players": {
                          "type": "array",
                          "items": {
                            "type": "object",
                            "description": "Per giocatore: wins, losses, winRate, pointsFor/Against, pointDiff, finishes per tipo, favoriteFinish"
                          }
                        },
                        "byGroup": {
                          "type": "array",
                          "items": {
                            "type": "object"
                          }
                        },
                        "byPhase": {
                          "type": "array",
                          "items": {
                            "type": "object"
                          }
                        },
                        "awards": {
                          "type": "array",
                          "items": {
                            "type": "object",
                            "description": "Premi MVP: key, label, emoji, playerId, playerName, valueLabel"
                          }
                        }
                      }
                    }
                  }
                }
              }
            }
          },
          "404": {
            "$ref": "#/components/responses/NotFound"
          }
        }
      }
    },
    "/api/v1/public/orgs/by-slug/{orgSlug}": {
      "get": {
        "summary": "Profilo pubblico organizzazione per slug",
        "description": "Vetrina pubblica di un'organizzazione: dati org + lista tornei pubblici (esclusi i `draft`) + lista campionati. Nessuna auth richiesta.",
        "tags": [
          "Public"
        ],
        "parameters": [
          {
            "name": "orgSlug",
            "in": "path",
            "required": true,
            "schema": {
              "type": "string"
            }
          }
        ],
        "responses": {
          "200": {
            "description": "Org + tornei + campionati",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "properties": {
                    "org": {
                      "type": "object",
                      "properties": {
                        "id": { "type": "string" },
                        "slug": { "type": "string" },
                        "name": { "type": "string" },
                        "logoUrl": { "type": "string", "nullable": true },
                        "parentOrgId": { "type": "string", "nullable": true, "description": "Quando non null, questa org e` una sub di un'altra (federazione)." }
                      }
                    },
                    "parent": {
                      "type": "object",
                      "nullable": true,
                      "description": "Quando l'org e` una sub-org, espone slug+name della parent per il badge 'Federato a X' sulla landing.",
                      "properties": {
                        "slug": { "type": "string" },
                        "name": { "type": "string" }
                      }
                    },
                    "subOrgs": {
                      "type": "array",
                      "description": "Sub-org affiliate (= club federati a questa parent). Vuoto se l'org non e` una parent. Usato per la sezione 'Club affiliati' sulla landing.",
                      "items": {
                        "type": "object",
                        "properties": {
                          "slug": { "type": "string" },
                          "name": { "type": "string" },
                          "logoUrl": { "type": "string", "nullable": true }
                        }
                      }
                    },
                    "tournaments": {
                      "type": "array",
                      "items": {
                        "$ref": "#/components/schemas/Tournament"
                      }
                    },
                    "championships": {
                      "type": "array",
                      "items": {
                        "type": "object",
                        "properties": {
                          "id": {
                            "type": "string"
                          },
                          "slug": {
                            "type": "string"
                          },
                          "name": {
                            "type": "string"
                          },
                          "season": {
                            "type": "string",
                            "nullable": true
                          },
                          "lastComputedAt": {
                            "type": "string",
                            "format": "date-time",
                            "nullable": true
                          }
                        }
                      }
                    }
                  }
                }
              }
            }
          },
          "404": {
            "$ref": "#/components/responses/NotFound"
          }
        }
      }
    },
    "/api/v1/orgs/{orgId}/tournaments": {
      "get": {
        "summary": "Lista tornei dell'org",
        "tags": [
          "Tournaments"
        ],
        "security": [
          {
            "ApiKey": []
          },
          {
            "Session": []
          }
        ],
        "parameters": [
          {
            "name": "orgId",
            "in": "path",
            "required": true,
            "schema": {
              "type": "string"
            }
          },
          {
            "name": "includeSubs",
            "in": "query",
            "required": false,
            "schema": { "type": "string", "enum": ["true", "false"] },
            "description": "Se `true`, include i tornei delle sub-org affiliate (org con `parent_org_id = orgId`). Usato dal picker campionato cross-sub. Default false."
          }
        ],
        "responses": {
          "200": {
            "description": "Lista tornei",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "properties": {
                    "tournaments": {
                      "type": "array",
                      "items": {
                        "$ref": "#/components/schemas/Tournament"
                      }
                    }
                  }
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "403": {
            "$ref": "#/components/responses/Forbidden"
          }
        }
      },
      "post": {
        "summary": "Crea nuovo torneo (in stato draft)",
        "tags": [
          "Tournaments"
        ],
        "security": [
          {
            "ApiKey": [
              "write"
            ]
          },
          {
            "Session": []
          }
        ],
        "parameters": [
          {
            "name": "orgId",
            "in": "path",
            "required": true,
            "schema": {
              "type": "string"
            }
          }
        ],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "required": [
                  "name",
                  "startsAt"
                ],
                "properties": {
                  "name": {
                    "type": "string",
                    "minLength": 2,
                    "maxLength": 120
                  },
                  "slug": {
                    "type": "string",
                    "pattern": "^[a-z0-9-]+$",
                    "description": "Generato da `name` se omesso"
                  },
                  "description": {
                    "type": "string",
                    "maxLength": 2000
                  },
                  "startsAt": {
                    "type": "string",
                    "format": "date-time",
                    "description": "Obbligatoria. ISO 8601 datetime (es. `2026-06-15T19:30:00.000Z`). Editabile in PATCH finche` il torneo e` in draft/registration."
                  },
                  "maxParticipants": {
                    "type": "integer",
                    "minimum": 2,
                    "maximum": 1024,
                    "nullable": true
                  },
                  "openScoring": {
                    "type": "boolean",
                    "default": false
                  },
                  "venueName": {
                    "type": "string",
                    "maxLength": 200
                  },
                  "venueAddress": {
                    "type": "string",
                    "maxLength": 300
                  },
                  "venueCity": {
                    "type": "string",
                    "maxLength": 160
                  },
                  "venueCountry": {
                    "type": "string",
                    "maxLength": 2,
                    "description": "Codice paese ISO-3166-1 alpha-2"
                  },
                  "twitchChannel": {
                    "type": "string",
                    "pattern": "^[a-zA-Z0-9_]{3,25}$",
                    "description": "Slug del canale Twitch (es. 'ilmiocanale'). Niente URL completo. Quando set + status=running, la vista pubblica del torneo mostra il player embed."
                  },
                  "hostOrgId": {
                    "type": "string",
                    "nullable": true,
                    "description": "ID dell'org host del torneo. Deve essere l'org del torneo stessa OR una sub-org della stessa (parent_org_id = orgId del torneo). 400 `host_org_not_affiliated` altrimenti."
                  },
                  "lockSelfUnenroll": {
                    "type": "boolean",
                    "default": false,
                    "description": "Se true il participant non puo` ritirarsi da solo: serve admin/owner dell'org (o API key Bearer)."
                  },
                  "entryFeeCents": {
                    "type": "integer",
                    "nullable": true,
                    "minimum": 0,
                    "maximum": 1000000,
                    "description": "Quota iscrizione in centesimi. null o 0 = torneo gratis (UI nasconde i badge pagamento). > 0 = a pagamento (pagamento OFF-platform: organizer incassa e marca `paidAt` manualmente)."
                  },
                  "isHidden": {
                    "type": "boolean",
                    "default": false,
                    "description": "Torneo nascosto da liste pubbliche. Vedi schema Tournament."
                  },
                  "registrationOpensAt": {
                    "type": "string",
                    "format": "date-time",
                    "nullable": true,
                    "description": "Apertura iscrizioni schedulata (ISO 8601). null = subito."
                  },
                  "selfRegistrationOpen": {
                    "type": "boolean",
                    "default": true,
                    "description": "False = lista chiusa, solo admin/api-key aggiunge participants."
                  }
                }
              }
            }
          }
        },
        "responses": {
          "201": {
            "description": "Torneo creato"
          },
          "400": {
            "description": "Body invalido",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "402": {
            "description": "Subscription richiesta",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          }
        }
      }
    },
    "/api/v1/tournaments/{id}": {
      "get": {
        "summary": "Dettaglio torneo + fasi + iscritti",
        "tags": [
          "Tournaments"
        ],
        "security": [
          {
            "ApiKey": []
          },
          {
            "Session": []
          }
        ],
        "parameters": [
          {
            "name": "id",
            "in": "path",
            "required": true,
            "schema": {
              "type": "string"
            }
          }
        ],
        "responses": {
          "200": {
            "description": "Detail"
          },
          "404": {
            "$ref": "#/components/responses/NotFound"
          }
        }
      },
      "patch": {
        "summary": "Modifica torneo (name/description/startsAt/maxParticipants/status)",
        "description": "Transizioni di status valide:\n- `draft ↔ registration`\n- `registration ↔ check_in`\n- `check_in → running` — bloccata se nessun partecipante ha fatto check-in (vedi 409 `checkin_not_done`)\n- `running → check_in` — recovery \"annulla avvio\": consentita solo se nessun match e` stato giocato; resetta le fasi a `pending` e cancella i match generati (vedi 409 `cannot_revert_results_exist`)\n- `running → cancelled`\n- `completed → running` — recovery (riapertura torneo chiuso)\n\n`running → completed` NON e` manuale: avviene solo per auto-close quando l'ultimo match dell'ultima phase viene reportato.",
        "tags": [
          "Tournaments"
        ],
        "security": [
          {
            "ApiKey": [
              "write"
            ]
          },
          {
            "Session": []
          }
        ],
        "parameters": [
          {
            "name": "id",
            "in": "path",
            "required": true,
            "schema": {
              "type": "string"
            }
          }
        ],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "properties": {
                  "name": {
                    "type": "string"
                  },
                  "description": {
                    "type": "string",
                    "nullable": true
                  },
                  "status": {
                    "$ref": "#/components/schemas/TournamentStatus"
                  },
                  "startsAt": {
                    "type": "string",
                    "format": "date-time",
                    "nullable": true,
                    "description": "ISO 8601. Modificabile finche` il torneo e` in draft/registration. Null per scollegare (compat tornei legacy)."
                  },
                  "maxParticipants": {
                    "type": "integer",
                    "nullable": true
                  },
                  "openScoring": {
                    "type": "boolean"
                  },
                  "venueName": {
                    "type": "string",
                    "maxLength": 200,
                    "nullable": true
                  },
                  "venueAddress": {
                    "type": "string",
                    "maxLength": 300,
                    "nullable": true
                  },
                  "venueCity": {
                    "type": "string",
                    "maxLength": 160,
                    "nullable": true
                  },
                  "venueCountry": {
                    "type": "string",
                    "maxLength": 2,
                    "nullable": true
                  },
                  "twitchChannel": {
                    "type": "string",
                    "pattern": "^[a-zA-Z0-9_]{3,25}$",
                    "nullable": true,
                    "description": "Slug del canale Twitch. Null per scollegare."
                  },
                  "hostOrgId": {
                    "type": "string",
                    "nullable": true,
                    "description": "ID org host. null per scollegare. Modificabile a qualsiasi stato del torneo. Stesso vincolo del POST: deve essere l'org del torneo o una sub-org affiliata."
                  },
                  "lockSelfUnenroll": {
                    "type": "boolean"
                  },
                  "entryFeeCents": {
                    "type": "integer",
                    "nullable": true,
                    "minimum": 0,
                    "maximum": 1000000,
                    "description": "null o 0 per riportare il torneo a 'gratis'."
                  },
                  "isHidden": {
                    "type": "boolean"
                  },
                  "registrationOpensAt": {
                    "type": "string",
                    "format": "date-time",
                    "nullable": true,
                    "description": "null per togliere lo scheduling (apertura immediata)."
                  },
                  "selfRegistrationOpen": {
                    "type": "boolean"
                  }
                }
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "Aggiornato"
          },
          "409": {
            "description": "Transizione non valida. Codici `message`: `invalid_status_transition_<from>_to_<to>` (salto illegale); `checkin_not_done:<checked>/<total>` (avvio bloccato, nessun check-in fatto); `cannot_revert_results_exist:<n>` (annulla avvio bloccato, ci sono gia` match giocati); oppure phase struttura locked.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          }
        }
      },
      "delete": {
        "summary": "Soft-delete torneo (solo se draft)",
        "tags": [
          "Tournaments"
        ],
        "security": [
          {
            "ApiKey": [
              "write"
            ]
          },
          {
            "Session": []
          }
        ],
        "parameters": [
          {
            "name": "id",
            "in": "path",
            "required": true,
            "schema": {
              "type": "string"
            }
          }
        ],
        "responses": {
          "200": {
            "description": "OK"
          },
          "409": {
            "description": "Tournament not draft"
          }
        }
      }
    },
    "/api/v1/tournaments/{id}/participants": {
      "post": {
        "summary": "Aggiungi partecipante",
        "description": "3 modalità mutex: `username` (lookup user esistente) | `email` (lookup user) | `guestName` (guest senza account).",
        "tags": [
          "Participants"
        ],
        "security": [
          {
            "ApiKey": [
              "write"
            ]
          },
          {
            "Session": []
          }
        ],
        "parameters": [
          {
            "name": "id",
            "in": "path",
            "required": true,
            "schema": {
              "type": "string"
            }
          }
        ],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "properties": {
                  "username": {
                    "type": "string"
                  },
                  "email": {
                    "type": "string",
                    "format": "email"
                  },
                  "guestName": {
                    "type": "string"
                  },
                  "seed": {
                    "type": "integer"
                  },
                  "role": {
                    "$ref": "#/components/schemas/ParticipantRole"
                  }
                }
              }
            }
          }
        },
        "responses": {
          "201": {
            "description": "Iscritto",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "properties": {
                    "participant": {
                      "$ref": "#/components/schemas/Participant"
                    }
                  }
                }
              }
            }
          }
        }
      }
    },
    "/api/v1/phases/{phaseId}/start": {
      "post": {
        "summary": "Avvia phase — genera tutti i match",
        "description": "Phase passa da `pending` a `running`. Genera il bracket SE/DE oppure il pairing del primo round Swiss. I match-bye (girone con numero dispari di giocatori) vengono generati gia` `completed` con `winnerId` = giocatore in bye.\n\nBody opzionale: `acceptByes` — lista di **group ID** per cui l'head judge accetta esplicitamente la presenza di un bye (girone dispari). Senza questo consenso, un girone dispari fa fallire l'avvio con 409 `odd_group_count_needs_accept_bye`.",
        "tags": [
          "Phases"
        ],
        "security": [
          {
            "ApiKey": [
              "write"
            ]
          },
          {
            "Session": []
          }
        ],
        "parameters": [
          {
            "name": "phaseId",
            "in": "path",
            "required": true,
            "schema": {
              "type": "string"
            }
          }
        ],
        "requestBody": {
          "required": false,
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "properties": {
                  "acceptByes": {
                    "type": "array",
                    "items": {
                      "type": "string"
                    },
                    "description": "Group ID per cui si accetta esplicitamente il bye (gironi con numero dispari di giocatori)."
                  }
                }
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "Generati N match"
          },
          "409": {
            "description": "`message`: `phase_not_pending`, `tournament_not_running`, oppure `odd_group_count_needs_accept_bye:<groupId=name,...>` (girone dispari senza consenso al bye)."
          }
        }
      }
    },
    "/api/v1/phases/{phaseId}/matches": {
      "get": {
        "summary": "Lista match della phase",
        "tags": [
          "Phases"
        ],
        "security": [
          {
            "ApiKey": []
          },
          {
            "Session": []
          }
        ],
        "parameters": [
          {
            "name": "phaseId",
            "in": "path",
            "required": true,
            "schema": {
              "type": "string"
            }
          }
        ],
        "responses": {
          "200": {
            "description": "Matches + groups"
          }
        }
      }
    },
    "/api/v1/phases/{phaseId}/standings": {
      "get": {
        "summary": "Standings della phase (Swiss/RR) o stato bracket (SE/DE)",
        "description": "Per Swiss/Round-Robin ritorna le classifiche per girone ordinate con la catena tiebreaker configurata, piu` il campo `tiebreakers` con la chain applicata. Con `?debug=1` ogni standing include anche `decidedBy` (quale criterio ha rotto la parita` col player sopra) per la modalita` debug spareggi.",
        "tags": [
          "Phases"
        ],
        "security": [
          {
            "ApiKey": []
          },
          {
            "Session": []
          }
        ],
        "parameters": [
          {
            "name": "phaseId",
            "in": "path",
            "required": true,
            "schema": {
              "type": "string"
            }
          },
          {
            "name": "debug",
            "in": "query",
            "required": false,
            "schema": {
              "type": "string",
              "enum": [
                "1"
              ]
            },
            "description": "Se `1`, aggiunge il breakdown spareggi (decidedBy per ogni player)."
          }
        ],
        "responses": {
          "200": {
            "description": "Standings"
          }
        }
      }
    },
    "/api/v1/public/tiebreakers": {
      "get": {
        "summary": "Catalogo tiebreaker",
        "description": "Catalogo read-only degli 11 tiebreaker disponibili (`point_diff`, `points_scored`, `buchholz`, `buchholz_cut1`, `median_buchholz`, `sos`, `cumulative`, `omw_pct`, `omw_pct_strict`, `oomw_pct`, `h2h`): chiave, label, descrizione, formula, gestione bye, parametri. SSOT documentale dei criteri di spareggio. Nessuna auth richiesta. Cache 1h.",
        "tags": [
          "Public"
        ],
        "responses": {
          "200": {
            "description": "Array `tiebreakers` con key, label, description, formula, byeHandling, params, valueFormat, appliesTo, defaultSwissOrder, defaultRrOrder."
          }
        }
      }
    },
    "/api/v1/matches/{matchId}": {
      "get": {
        "summary": "Dettaglio match + eventi (per UI arbitro)",
        "tags": [
          "Matches"
        ],
        "security": [
          {
            "ApiKey": []
          },
          {
            "Session": []
          }
        ],
        "parameters": [
          {
            "name": "matchId",
            "in": "path",
            "required": true,
            "schema": {
              "type": "string"
            }
          }
        ],
        "responses": {
          "200": {
            "description": "Match con events timeline"
          }
        }
      }
    },
    "/api/v1/matches/{matchId}/report": {
      "post": {
        "summary": "Reporta risultato match",
        "description": "Body: `{ p1Points: int, p2Points: int }` (entrambi 0-9). Vincitore = chi ha più punti (no draw permesso). Trigger automatici: bracket SE advance al round successivo, completePhaseIfReady se ultimo match della phase, auto-close tournament se ultima phase.",
        "tags": [
          "Matches"
        ],
        "security": [
          {
            "ApiKey": [
              "write"
            ]
          },
          {
            "Session": []
          }
        ],
        "parameters": [
          {
            "name": "matchId",
            "in": "path",
            "required": true,
            "schema": {
              "type": "string"
            }
          }
        ],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "required": [
                  "p1Points",
                  "p2Points"
                ],
                "properties": {
                  "p1Points": {
                    "type": "integer",
                    "minimum": 0,
                    "maximum": 9
                  },
                  "p2Points": {
                    "type": "integer",
                    "minimum": 0,
                    "maximum": 9
                  }
                }
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "Match completato"
          },
          "400": {
            "description": "Score invalido (draw o fuori range)"
          },
          "409": {
            "description": "Match gia` completato"
          }
        }
      },
      "patch": {
        "summary": "Correggi punteggio di un match già completato",
        "description": "PATCH al posto di POST `/report` quando il match è già `status = completed` e va corretto.\n\n**Single Elim**: se il vincitore cambia, ri-deriva il bracket a valle (`reResolveSingleElimBracket`) e riapre la phase se era completed.\n\n**Swiss**: se il match corretto è in un round precedente al `currentRound` del girone (= max round generato), gli accoppiamenti del round successivo (e oltre) NON sono più validi → cascade re-pair:\n  - se NESSUN match nei round > `m.round` è `completed` → cascade automatico (DELETE i match a valle + `pairRoundSwiss` rigenera R{`m.round`+1} dagli standings aggiornati);\n  - se ci sono N match `completed` a valle → 409 `cascade_requires_force:N_completed_ahead`. Per procedere, riemettere la richiesta con `forceRePair: true` (cancella i match completed + ricalcola gli accoppiamenti = i risultati a valle vengono PERSI).\n\n**Round corrente**: nessun cascade. Solo update dello score+winner.\n\n**Double Elim**: non supportato (`correction_not_supported_for_de`).\n\n**Gate per Swiss round precedente**: solo head_judge del torneo o admin/owner org (`head_judge_or_admin_required`).",
        "tags": [
          "Matches"
        ],
        "security": [
          { "ApiKey": ["write"] },
          { "Session": [] }
        ],
        "parameters": [
          { "name": "matchId", "in": "path", "required": true, "schema": { "type": "string" } }
        ],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "required": ["score"],
                "properties": {
                  "score": {
                    "type": "object",
                    "required": ["p1Points", "p2Points"],
                    "properties": {
                      "p1Points": { "type": "integer", "minimum": 0, "maximum": 9 },
                      "p2Points": { "type": "integer", "minimum": 0, "maximum": 9 }
                    }
                  },
                  "winnerId": {
                    "type": "string",
                    "description": "Opzionale. Se passato, deve essere coerente con score (chi ha più punti). Se omesso, derivato dallo score."
                  },
                  "forceRePair": {
                    "type": "boolean",
                    "description": "Solo Swiss. Quando true accetta il reset dei match completati nei round successivi (pairing rifatto). Senza, il backend ritorna 409 se ci sono match completati ahead."
                  }
                }
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "Score corretto. Response include flag e contatori del cascade applicato.",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "properties": {
                    "ok": { "type": "boolean" },
                    "winnerChanged": { "type": "boolean" },
                    "cascadeApplied": { "type": "boolean", "description": "True se sono stati toccati match a valle (SE bracket re-derive o Swiss re-pair)." },
                    "cascadeRePairedRound": { "type": "integer", "nullable": true, "description": "Solo Swiss: numero del round ri-paired (= m.round + 1)." },
                    "cascadeMatchesReplaced": { "type": "integer", "description": "Solo Swiss: numero di match dei round successivi che sono stati cancellati e sostituiti." },
                    "phaseCompleted": { "type": "boolean" },
                    "tournamentCompleted": { "type": "boolean" }
                  }
                }
              }
            }
          },
          "400": { "description": "Score invalido, draw, winner_inconsistent_with_score, score_exceeds_max_for_format." },
          "403": { "description": "head_judge_or_admin_required — Swiss round precedente richiede ruolo head_judge o admin/owner org." },
          "409": {
            "description": "match_not_completed, cannot_correct_bye, correction_not_supported_for_de, o `cascade_requires_force:N_completed_ahead` (Swiss). Per quest'ultimo, riemettere con `forceRePair: true`."
          }
        }
      }
    },
    "/api/v1/tournaments/{id}/admins": {
      "parameters": [
        { "name": "id", "in": "path", "required": true, "schema": { "type": "string" } }
      ],
      "get": {
        "summary": "Lista co-organizer del torneo",
        "description": "Ritorna gli user che sono `tournament_admin` per questo torneo. Accessibile a chiunque abbia accesso al torneo (org member o co-organizer).",
        "tags": ["Tournaments"],
        "security": [
          { "ApiKey": [] },
          { "Session": [] }
        ],
        "responses": {
          "200": {
            "description": "Lista co-organizer",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "properties": {
                    "admins": {
                      "type": "array",
                      "items": {
                        "type": "object",
                        "properties": {
                          "userId": { "type": "string" },
                          "grantedAt": { "type": "string", "format": "date-time" },
                          "grantedBy": { "type": "string", "nullable": true },
                          "username": { "type": "string", "nullable": true },
                          "name": { "type": "string" },
                          "email": { "type": "string" },
                          "image": { "type": "string", "nullable": true }
                        }
                      }
                    }
                  }
                }
              }
            }
          }
        }
      },
      "post": {
        "summary": "Promuovi un user a co-organizer del torneo",
        "description": "Solo `admin` o `owner` dell'org del torneo può aggiungere co-organizer (un co-organizer NON può invitare altri co-organizer). Un co-organizer ha accesso identico a un member dell'org ma SCOPED a questo singolo torneo (non vede gli altri tornei dell'org).",
        "tags": ["Tournaments"],
        "security": [
          { "ApiKey": ["write"] },
          { "Session": [] }
        ],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "required": ["userId"],
                "properties": { "userId": { "type": "string" } }
              }
            }
          }
        },
        "responses": {
          "200": { "description": "Co-organizer aggiunto (idempotent)." },
          "403": { "description": "admin_or_owner_required_for_tournament_admin_management" },
          "404": { "description": "user_not_found" }
        }
      }
    },
    "/api/v1/tournaments/{id}/admins/{userId}": {
      "parameters": [
        { "name": "id", "in": "path", "required": true, "schema": { "type": "string" } },
        { "name": "userId", "in": "path", "required": true, "schema": { "type": "string" } }
      ],
      "delete": {
        "summary": "Rimuovi co-organizer dal torneo",
        "description": "Solo admin/owner org. Il co-organizer rimosso perde immediatamente accesso a questo torneo (se non è anche member dell'org).",
        "tags": ["Tournaments"],
        "security": [
          { "ApiKey": ["write"] },
          { "Session": [] }
        ],
        "responses": {
          "200": { "description": "Rimosso (idempotent)." },
          "403": { "description": "admin_or_owner_required_for_tournament_admin_management" }
        }
      }
    },
    "/api/v1/orgs/{orgId}/tiebreaker-presets": {
      "parameters": [
        { "name": "orgId", "in": "path", "required": true, "schema": { "type": "string" } }
      ],
      "get": {
        "summary": "Lista tiebreaker preset dell'org",
        "description": "Preset di catene tiebreaker riusabili creati dagli admin org. Servono a standardizzare la configurazione delle fasi Swiss/Round Robin e ridurre il rischio di errore dei co-organizer. Lettura accessibile a tutti i member dell'org.",
        "tags": ["Tournaments"],
        "security": [
          { "ApiKey": [] },
          { "Session": [] }
        ],
        "responses": {
          "200": {
            "description": "Lista preset (default sempre prima)",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "properties": {
                    "presets": {
                      "type": "array",
                      "items": { "$ref": "#/components/schemas/TiebreakerPreset" }
                    }
                  }
                }
              }
            }
          }
        }
      },
      "post": {
        "summary": "Crea tiebreaker preset",
        "description": "Solo `admin` o `owner` dell'org. Se `isDefault: true` viene passato, l'eventuale preset default precedente dell'org viene automaticamente unset (max 1 default per org).",
        "tags": ["Tournaments"],
        "security": [
          { "ApiKey": ["write"] },
          { "Session": [] }
        ],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": { "$ref": "#/components/schemas/TiebreakerPresetBody" }
            }
          }
        },
        "responses": {
          "201": {
            "description": "Preset creato",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "properties": {
                    "preset": { "$ref": "#/components/schemas/TiebreakerPreset" }
                  }
                }
              }
            }
          },
          "400": { "description": "Body invalido (Zod). Slug deve matchare ^[a-z0-9-]+$." },
          "403": { "description": "admin_or_owner_required" },
          "409": { "description": "slug_already_used per quest'org" }
        }
      }
    },
    "/api/v1/tiebreaker-presets/{id}": {
      "parameters": [
        { "name": "id", "in": "path", "required": true, "schema": { "type": "string" } }
      ],
      "patch": {
        "summary": "Modifica tiebreaker preset",
        "description": "Solo admin/owner dell'org del preset. Tutti i campi sono opzionali; setta `isDefault: true` per renderlo il default dell'org (gli altri preset default vengono sganciati).",
        "tags": ["Tournaments"],
        "security": [
          { "ApiKey": ["write"] },
          { "Session": [] }
        ],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "properties": {
                  "name": { "type": "string", "minLength": 1, "maxLength": 100 },
                  "slug": { "type": "string", "pattern": "^[a-z0-9-]+$", "minLength": 1, "maxLength": 60 },
                  "description": { "type": "string", "nullable": true, "maxLength": 500 },
                  "chain": {
                    "type": "array",
                    "minItems": 1,
                    "maxItems": 10,
                    "items": { "$ref": "#/components/schemas/TiebreakerKey" }
                  },
                  "isDefault": { "type": "boolean" }
                }
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "Aggiornato",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "properties": {
                    "preset": { "$ref": "#/components/schemas/TiebreakerPreset" }
                  }
                }
              }
            }
          },
          "403": { "description": "admin_or_owner_required" },
          "404": { "description": "preset_not_found" },
          "409": { "description": "slug_already_used" }
        }
      },
      "delete": {
        "summary": "Elimina tiebreaker preset",
        "description": "Solo admin/owner dell'org. Le fasi che usavano questo preset NON vengono modificate (la chain è già stata copiata in `phase.settings.tiebreakers` al momento della creazione fase): cancellare un preset rimuove solo l'opzione dal dropdown wizard.",
        "tags": ["Tournaments"],
        "security": [
          { "ApiKey": ["write"] },
          { "Session": [] }
        ],
        "responses": {
          "200": { "description": "Eliminato" },
          "403": { "description": "admin_or_owner_required" },
          "404": { "description": "preset_not_found" }
        }
      }
    },
    "/api/v1/orgs/{parentOrgId}/sub-orgs": {
      "get": {
        "summary": "Lista sub-org di una parent",
        "description": "Restituisce le sub-org (club affiliati) sotto una parent. Solo admin/owner della parent vede questa lista (member NON vede sub privati, simmetria delle decisioni di permission del modello federazione).",
        "tags": ["Orgs"],
        "security": [{ "ApiKey": [] }, { "Session": [] }],
        "parameters": [
          { "name": "parentOrgId", "in": "path", "required": true, "schema": { "type": "string" } }
        ],
        "responses": {
          "200": {
            "description": "OK",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "properties": {
                    "subOrgs": { "type": "array", "items": { "$ref": "#/components/schemas/SubOrg" } }
                  }
                }
              }
            }
          },
          "403": { "description": "admin_required" }
        }
      },
      "post": {
        "summary": "Crea sub-org (richiede Federation Pack attivo)",
        "description": "Crea una sub-org sotto la parent. Gate: admin/owner di parentOrgId + `parent.federation_pack_active=true` (addon €299 lifetime, vedi `/billing/checkout/federation-pack`). Vincoli: parent NON deve essere a sua volta sub (max 2 livelli → 400 `max_two_levels`). Slug globalmente unico (409 `slug_already_taken`). Se `ownerUserId` omesso, sub è 'shell' (no membership esplicita, amministrata da admin parent per ereditarietà).",
        "tags": ["Orgs"],
        "security": [{ "ApiKey": ["write"] }, { "Session": [] }],
        "parameters": [
          { "name": "parentOrgId", "in": "path", "required": true, "schema": { "type": "string" } }
        ],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": { "schema": { "$ref": "#/components/schemas/SubOrgBody" } }
          }
        },
        "responses": {
          "201": {
            "description": "Creata",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "properties": { "subOrg": { "$ref": "#/components/schemas/SubOrg" } }
                }
              }
            }
          },
          "400": { "description": "invalid_body | max_two_levels | owner_user_not_found" },
          "402": { "description": "federation_pack_required" },
          "403": { "description": "admin_required" },
          "404": { "description": "parent_not_found" },
          "409": { "description": "slug_already_taken" }
        }
      }
    },
    "/api/v1/sub-orgs/{id}": {
      "patch": {
        "summary": "Aggiorna sub-org",
        "description": "Modifica name/slug della sub-org. Gate: admin/owner del sub OR admin/owner della parent (helper canAdminOrg).",
        "tags": ["Orgs"],
        "security": [{ "ApiKey": ["write"] }, { "Session": [] }],
        "parameters": [
          { "name": "id", "in": "path", "required": true, "schema": { "type": "string" } }
        ],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "properties": {
                  "name": { "type": "string", "minLength": 2, "maxLength": 120 },
                  "slug": { "type": "string", "pattern": "^[a-z0-9-]+$" }
                }
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "Aggiornata",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "properties": { "subOrg": { "$ref": "#/components/schemas/SubOrg" } }
                }
              }
            }
          },
          "400": { "description": "invalid_body | no_fields_to_update | not_a_sub_org" },
          "403": { "description": "admin_required" },
          "404": { "description": "sub_org_not_found" },
          "409": { "description": "slug_already_taken" }
        }
      },
      "delete": {
        "summary": "Soft-delete sub-org",
        "description": "Cancella la sub-org (soft-delete via deleted_at). Hard-block se esistono tornei attivi (`org_id` o `host_org_id` puntano al sub e non sono deleted) → 409 `tournaments_attached:N`. Riassegna o cancella prima i tornei.",
        "tags": ["Orgs"],
        "security": [{ "ApiKey": ["write"] }, { "Session": [] }],
        "parameters": [
          { "name": "id", "in": "path", "required": true, "schema": { "type": "string" } }
        ],
        "responses": {
          "200": { "description": "Eliminata" },
          "400": { "description": "not_a_sub_org" },
          "403": { "description": "admin_required" },
          "404": { "description": "sub_org_not_found" },
          "409": { "description": "tournaments_attached:N (sostituisci N col numero)" }
        }
      }
    },
    "/api/v1/billing/checkout/federation-pack": {
      "post": {
        "summary": "Stripe Checkout — Federation Pack €299 lifetime",
        "description": "Apre Stripe Checkout per acquistare l'addon Federation Pack (one-time €299 lifetime) sulla parent. Sblocca creazione sub-org illimitate. Gate: owner della org + sub Standard `trialing|active` + pack non già attivo + org NON sub (sub non possono comprare il pack). Restituisce `{ url }` per redirect a Stripe.",
        "tags": ["Billing"],
        "security": [{ "Session": [] }],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "required": ["orgId"],
                "properties": { "orgId": { "type": "string" } }
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "Checkout session creata",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "properties": { "url": { "type": "string", "format": "uri" } }
                }
              }
            }
          },
          "400": { "description": "sub_org_cannot_buy_pack" },
          "402": { "description": "standard_subscription_required" },
          "403": { "description": "owner_required" },
          "404": { "description": "org_not_found" },
          "409": { "description": "federation_pack_already_active" },
          "503": { "description": "federation_pack_price_not_configured (env var STRIPE_PRICE_FEDERATION_PACK_LIFETIME mancante)" }
        }
      }
    },
    "/api/v1/orgs/{orgId}/player-cards": {
      "get": {
        "summary": "Lista player card dell'org",
        "tags": [
          "Cards"
        ],
        "security": [
          {
            "ApiKey": []
          },
          {
            "Session": []
          }
        ],
        "parameters": [
          {
            "name": "orgId",
            "in": "path",
            "required": true,
            "schema": {
              "type": "string"
            }
          }
        ],
        "responses": {
          "200": {
            "description": "Lista card",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "properties": {
                    "playerCards": {
                      "type": "array",
                      "items": {
                        "$ref": "#/components/schemas/PlayerCardSummary"
                      }
                    }
                  }
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          }
        }
      },
      "post": {
        "summary": "Crea nuova player card (admin/owner)",
        "tags": [
          "Cards"
        ],
        "security": [
          {
            "ApiKey": [
              "write"
            ]
          },
          {
            "Session": []
          }
        ],
        "parameters": [
          {
            "name": "orgId",
            "in": "path",
            "required": true,
            "schema": {
              "type": "string"
            }
          }
        ],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "required": [
                  "slug",
                  "playerName"
                ],
                "properties": {
                  "slug": {
                    "type": "string",
                    "pattern": "^[a-z0-9-]+$",
                    "minLength": 2,
                    "maxLength": 60,
                    "description": "URL slug univoco per org"
                  },
                  "playerName": {
                    "type": "string",
                    "minLength": 1,
                    "maxLength": 120
                  },
                  "userId": {
                    "type": "string",
                    "nullable": true,
                    "description": "Linka la card a un user Rankly esistente (opzionale)"
                  },
                  "template": {
                    "type": "string",
                    "enum": [
                      "classic",
                      "neon",
                      "gold"
                    ],
                    "default": "classic"
                  },
                  "customFields": {
                    "$ref": "#/components/schemas/PlayerCardCustomFields"
                  }
                }
              }
            }
          }
        },
        "responses": {
          "201": {
            "description": "Card creata"
          },
          "400": {
            "$ref": "#/components/responses/Forbidden"
          },
          "403": {
            "description": "admin_required"
          },
          "409": {
            "description": "slug_already_taken"
          }
        }
      }
    },
    "/api/v1/player-cards/{id}": {
      "get": {
        "summary": "Detail player card (admin)",
        "tags": [
          "Cards"
        ],
        "security": [
          {
            "ApiKey": []
          },
          {
            "Session": []
          }
        ],
        "parameters": [
          {
            "name": "id",
            "in": "path",
            "required": true,
            "schema": {
              "type": "string"
            }
          }
        ],
        "responses": {
          "200": {
            "description": "Card detail"
          },
          "404": {
            "description": "card_not_found"
          }
        }
      },
      "patch": {
        "summary": "Edit player card (admin/owner)",
        "tags": [
          "Cards"
        ],
        "security": [
          {
            "ApiKey": [
              "write"
            ]
          },
          {
            "Session": []
          }
        ],
        "parameters": [
          {
            "name": "id",
            "in": "path",
            "required": true,
            "schema": {
              "type": "string"
            }
          }
        ],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "properties": {
                  "slug": {
                    "type": "string",
                    "pattern": "^[a-z0-9-]+$",
                    "minLength": 2,
                    "maxLength": 60
                  },
                  "playerName": {
                    "type": "string",
                    "minLength": 1,
                    "maxLength": 120
                  },
                  "userId": {
                    "type": "string",
                    "nullable": true
                  },
                  "template": {
                    "type": "string",
                    "enum": [
                      "classic",
                      "neon",
                      "gold"
                    ]
                  },
                  "customFields": {
                    "$ref": "#/components/schemas/PlayerCardCustomFields"
                  }
                }
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "Card aggiornata"
          },
          "400": {
            "description": "Body invalido"
          },
          "404": {
            "description": "card_not_found"
          },
          "409": {
            "description": "slug_already_taken"
          }
        }
      },
      "delete": {
        "summary": "Soft-delete player card (admin/owner)",
        "tags": [
          "Cards"
        ],
        "security": [
          {
            "ApiKey": [
              "write"
            ]
          },
          {
            "Session": []
          }
        ],
        "parameters": [
          {
            "name": "id",
            "in": "path",
            "required": true,
            "schema": {
              "type": "string"
            }
          }
        ],
        "responses": {
          "200": {
            "description": "Cancellata"
          },
          "404": {
            "description": "card_not_found"
          }
        }
      }
    },
    "/api/v1/player-cards/{id}/photo": {
      "post": {
        "summary": "Upload foto della card (multipart, max 5MB, PNG/JPG/WebP)",
        "tags": [
          "Cards"
        ],
        "security": [
          {
            "ApiKey": [
              "write"
            ]
          },
          {
            "Session": []
          }
        ],
        "parameters": [
          {
            "name": "id",
            "in": "path",
            "required": true,
            "schema": {
              "type": "string"
            }
          }
        ],
        "requestBody": {
          "required": true,
          "content": {
            "multipart/form-data": {
              "schema": {
                "type": "object",
                "required": [
                  "photo"
                ],
                "properties": {
                  "photo": {
                    "type": "string",
                    "format": "binary"
                  }
                }
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "Foto caricata, photoUrl aggiornato"
          },
          "400": {
            "description": "photo_required"
          },
          "413": {
            "description": "photo_too_large"
          },
          "415": {
            "description": "unsupported_image_type"
          }
        }
      },
      "delete": {
        "summary": "Rimuovi foto card",
        "tags": [
          "Cards"
        ],
        "security": [
          {
            "ApiKey": [
              "write"
            ]
          },
          {
            "Session": []
          }
        ],
        "parameters": [
          {
            "name": "id",
            "in": "path",
            "required": true,
            "schema": {
              "type": "string"
            }
          }
        ],
        "responses": {
          "200": {
            "description": "Foto rimossa"
          }
        }
      }
    },
    "/api/v1/public/cards/{orgSlug}/{cardSlug}": {
      "get": {
        "summary": "Viewer pubblico player card (no auth)",
        "tags": [
          "Public"
        ],
        "parameters": [
          {
            "name": "orgSlug",
            "in": "path",
            "required": true,
            "schema": {
              "type": "string"
            }
          },
          {
            "name": "cardSlug",
            "in": "path",
            "required": true,
            "schema": {
              "type": "string"
            }
          }
        ],
        "responses": {
          "200": {
            "description": "Card + info org",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "properties": {
                    "card": {
                      "$ref": "#/components/schemas/PlayerCardPublic"
                    },
                    "org": {
                      "type": "object",
                      "properties": {
                        "id": {
                          "type": "string"
                        },
                        "slug": {
                          "type": "string"
                        },
                        "name": {
                          "type": "string"
                        },
                        "logoUrl": {
                          "type": "string",
                          "nullable": true
                        }
                      }
                    }
                  }
                }
              }
            }
          },
          "404": {
            "description": "card_not_found"
          }
        }
      }
    },
    "/api/v1/public/orgs": {
      "get": {
        "summary": "Indice pubblico organizzazioni",
        "description": "Lista delle org con almeno un torneo pubblico. Sort: prima quelle con tornei attivi, poi per ultimo torneo creato. Cache 5min. Pensato per pagina /community e discovery LLM.",
        "tags": [
          "Public"
        ],
        "responses": {
          "200": {
            "description": "Lista organizzazioni",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "properties": {
                    "orgs": {
                      "type": "array",
                      "items": {
                        "$ref": "#/components/schemas/PublicOrgCard"
                      }
                    }
                  }
                }
              }
            }
          }
        }
      }
    },
    "/api/v1/public/marketing-stats": {
      "get": {
        "summary": "Contatori aggregati per homepage",
        "description": "Counter \"match gestiti\", \"tornei totali\", \"partecipanti totali\". Cache 5min. Usato per il counter animato in homepage.",
        "tags": [
          "Public"
        ],
        "responses": {
          "200": {
            "description": "Stats",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/MarketingStats"
                }
              }
            }
          }
        }
      }
    },
    "/api/v1/tournaments/{id}/register": {
      "parameters": [
        {
          "name": "id",
          "in": "path",
          "required": true,
          "schema": {
            "type": "string"
          }
        }
      ],
      "post": {
        "summary": "Iscrizione self-service al torneo",
        "description": "L'utente loggato si iscrive come participant. Richiede `tournament.status = registration` e `tournament.selfRegistrationOpen = true`. Se `tournament.registrationOpensAt` è nel futuro: 409 `registration_not_yet_open`. Se `selfRegistrationOpen = false`: 403 `self_registration_disabled` (l'organizer gestisce la lista da solo). 409 se già iscritto.",
        "tags": [
          "Registration"
        ],
        "responses": {
          "201": {
            "description": "Iscrizione creata",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "properties": {
                    "participant": {
                      "$ref": "#/components/schemas/Participant"
                    }
                  }
                }
              }
            }
          },
          "401": {
            "description": "Non loggato",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "403": {
            "description": "self_registration_disabled — `tournament.selfRegistrationOpen = false`. L'organizer deve aggiungere i participants tramite POST `/tournaments/:id/participants`."
          },
          "404": {
            "description": "Tournament non trovato"
          },
          "409": {
            "description": "registration_closed (status ≠ registration), registration_not_yet_open (`registrationOpensAt` futuro) o already_registered."
          }
        }
      },
      "delete": {
        "summary": "Withdraw dall'iscrizione",
        "description": "L'utente ritira la propria iscrizione. Se era active, promuove il primo waitlist. Se il torneo ha `lockSelfUnenroll = true`, l'endpoint ritorna 403 `self_unenroll_locked` → il participant deve essere rimosso da un admin/owner dell'org via `DELETE /tournaments/{id}/participants/{participantId}` (utilizzabile anche da API key, considerata amministrativa).",
        "tags": [
          "Registration"
        ],
        "responses": {
          "200": {
            "description": "Withdrawn"
          },
          "403": {
            "description": "self_unenroll_locked — il torneo ha lockSelfUnenroll attivo"
          },
          "404": {
            "description": "Not registered"
          },
          "409": {
            "description": "tournament_locked (post check-in)"
          }
        }
      }
    },
    "/api/v1/tournaments/{id}/my-registration": {
      "parameters": [
        {
          "name": "id",
          "in": "path",
          "required": true,
          "schema": {
            "type": "string"
          }
        }
      ],
      "get": {
        "summary": "Stato registrazione del logged user al torneo",
        "description": "Ritorna `status` (active/waitlist/dropped/none), `participantId` se iscritto e `role` (`player` / `referee` / `head_judge`). Il `role` consente al frontend di scegliere il CTA refertazione corretto: `player` → schermata Quick Score (form score finale), `referee`/`head_judge` → schermata Arbitro (hotkey events granulari).",
        "tags": [
          "Registration"
        ],
        "responses": {
          "200": {
            "description": "Stato",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "properties": {
                    "status": {
                      "type": "string",
                      "enum": [
                        "active",
                        "waitlist",
                        "dropped",
                        "disqualified",
                        "none"
                      ]
                    },
                    "participantId": {
                      "type": "string",
                      "nullable": true
                    },
                    "role": {
                      "type": "string",
                      "nullable": true,
                      "enum": [
                        "player",
                        "referee",
                        "head_judge"
                      ],
                      "description": "Ruolo del logged user in questo torneo. null quando l'user non e` participant."
                    }
                  }
                }
              }
            }
          },
          "401": {
            "description": "Non loggato"
          }
        }
      }
    },
    "/api/v1/tournaments/{id}/my-ticket": {
      "parameters": [
        {
          "name": "id",
          "in": "path",
          "required": true,
          "schema": {
            "type": "string"
          }
        }
      ],
      "get": {
        "summary": "Biglietto digitale del logged user",
        "description": "Ritorna il biglietto QR del logged user per il torneo a cui è iscritto. Include `ticketCode`, info participant, tournament, org. 404 se non iscritto.",
        "tags": [
          "Tickets"
        ],
        "responses": {
          "200": {
            "description": "Biglietto",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/MyTicketResponse"
                }
              }
            }
          },
          "401": {
            "description": "Non loggato"
          },
          "404": {
            "description": "ticket_not_found (non iscritto)"
          }
        }
      }
    },
    "/api/v1/tournaments/{id}/my-ticket.svg": {
      "parameters": [
        {
          "name": "id",
          "in": "path",
          "required": true,
          "schema": {
            "type": "string"
          }
        }
      ],
      "get": {
        "summary": "QR code SVG del biglietto",
        "description": "Solo il QR del biglietto del logged user come image/svg+xml. ~1KB, scala perfetto. Pensato per `<img src>` lato browser.",
        "tags": [
          "Tickets"
        ],
        "responses": {
          "200": {
            "description": "QR SVG",
            "content": {
              "image/svg+xml": {
                "schema": {
                  "type": "string"
                }
              }
            }
          },
          "401": {
            "description": "Non loggato"
          },
          "404": {
            "description": "ticket_not_found"
          }
        }
      }
    },
    "/api/v1/tournaments/{id}/my-ticket.pdf": {
      "parameters": [
        {
          "name": "id",
          "in": "path",
          "required": true,
          "schema": {
            "type": "string"
          }
        }
      ],
      "get": {
        "summary": "Biglietto PDF stampabile",
        "description": "Biglietto A6 stampabile con QR + nome torneo + sede + data + codice testuale fallback. Application/pdf.",
        "tags": [
          "Tickets"
        ],
        "responses": {
          "200": {
            "description": "PDF",
            "content": {
              "application/pdf": {
                "schema": {
                  "type": "string",
                  "format": "binary"
                }
              }
            }
          },
          "401": {
            "description": "Non loggato"
          },
          "404": {
            "description": "ticket_not_found"
          }
        }
      }
    },
    "/api/v1/tournaments/{id}/checkin/scan": {
      "parameters": [
        {
          "name": "id",
          "in": "path",
          "required": true,
          "schema": {
            "type": "string"
          }
        }
      ],
      "post": {
        "summary": "Check-in via scan QR (organizer)",
        "description": "L'organizer scansiona il QR di un biglietto, il backend risolve ticket → participant + esegue check-in (con lottery girone). Idempotente: ramo `alreadyCheckedIn` se già fatto. Errore 409 se il ticket è di un altro torneo.",
        "tags": [
          "Tickets"
        ],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "required": [
                  "code"
                ],
                "properties": {
                  "code": {
                    "$ref": "#/components/schemas/TicketCode"
                  }
                }
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "Check-in eseguito (o già fatto)",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ScanCheckinResponse"
                }
              }
            }
          },
          "400": {
            "description": "invalid_code"
          },
          "402": {
            "description": "subscription_required"
          },
          "403": {
            "description": "not_a_member"
          },
          "404": {
            "description": "ticket_not_found"
          },
          "409": {
            "description": "ticket_wrong_tournament o check_in_not_open"
          }
        }
      }
    },
    "/api/v1/tournaments/{id}/participants/{participantId}": {
      "parameters": [
        {
          "name": "id",
          "in": "path",
          "required": true,
          "schema": { "type": "string" }
        },
        {
          "name": "participantId",
          "in": "path",
          "required": true,
          "schema": { "type": "string" }
        }
      ],
      "delete": {
        "summary": "Rimuovi un participant (amministrativo)",
        "description": "Cancella un'iscrizione. Ammesso solo in `draft` / `registration` (post check-in ritorna 409 `tournament_locked`). Se era active, promuove il primo della waitlist.\n\nGate `lockSelfUnenroll`: se il torneo ha il flag attivo e la chiamata viene fatta da una **sessione** del participant stesso (no admin role), ritorna 403 `self_unenroll_locked`. **API key** Bearer e session di admin/owner dell'org bypassano sempre il gate (sono considerate amministrative).",
        "tags": ["Tournaments"],
        "security": [{ "ApiKeyAuth": [] }, { "SessionAuth": [] }],
        "responses": {
          "200": { "description": "Removed" },
          "403": { "description": "self_unenroll_locked" },
          "404": { "description": "participant_not_found" },
          "409": { "description": "tournament_locked (post check-in)" }
        }
      }
    },
    "/api/v1/tournaments/{id}/participants/{participantId}/payment": {
      "parameters": [
        {
          "name": "id",
          "in": "path",
          "required": true,
          "schema": {
            "type": "string"
          }
        },
        {
          "name": "participantId",
          "in": "path",
          "required": true,
          "schema": {
            "type": "string"
          }
        }
      ],
      "patch": {
        "summary": "Toggle pagamento participant (organizer)",
        "description": "Marca/smarca un participant come \"pagato\". NON gate il check-in (il QR resta valido a prescindere). Tracker organizer-side per pagamenti gestiti fuori da Rankly (bonifico, PayPal, cash).",
        "tags": [
          "Tickets"
        ],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "required": [
                  "paid"
                ],
                "properties": {
                  "paid": {
                    "type": "boolean"
                  }
                }
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "Aggiornato",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "properties": {
                    "participantId": {
                      "type": "string"
                    },
                    "paidAt": {
                      "type": "string",
                      "format": "date-time",
                      "nullable": true
                    }
                  }
                }
              }
            }
          },
          "400": {
            "description": "invalid_body:paid_required"
          },
          "404": {
            "description": "participant_not_found"
          }
        }
      }
    },
    "/api/v1/orgs/{id}/follow": {
      "parameters": [
        {
          "name": "id",
          "in": "path",
          "required": true,
          "schema": {
            "type": "string"
          }
        }
      ],
      "post": {
        "summary": "Segui un'organizzazione",
        "description": "Crea relazione user→org. NON dà permessi (solo discovery + base per notifiche future). Idempotente.",
        "tags": [
          "Follower"
        ],
        "responses": {
          "200": {
            "description": "Followed",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "properties": {
                    "ok": {
                      "type": "boolean"
                    },
                    "following": {
                      "type": "boolean"
                    }
                  }
                }
              }
            }
          },
          "401": {
            "description": "Non loggato"
          },
          "404": {
            "description": "org_not_found"
          }
        }
      },
      "delete": {
        "summary": "Unfollow organizzazione",
        "description": "Rimuove la relazione. Idempotente.",
        "tags": [
          "Follower"
        ],
        "responses": {
          "200": {
            "description": "Unfollowed"
          },
          "401": {
            "description": "Non loggato"
          }
        }
      }
    },
    "/api/v1/me/following-set": {
      "get": {
        "summary": "Set di orgId seguiti dal logged user",
        "description": "Risposta compatta — solo gli ID. Pensato per popolare velocemente i bottoni Segui/Seguendo in liste di org (es. /community).",
        "tags": [
          "Me"
        ],
        "responses": {
          "200": {
            "description": "Set di orgId",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "properties": {
                    "orgIds": {
                      "type": "array",
                      "items": {
                        "type": "string"
                      }
                    }
                  }
                }
              }
            }
          },
          "401": {
            "description": "Non loggato"
          }
        }
      }
    },
    "/api/v1/me/followed-orgs": {
      "get": {
        "summary": "Org seguite + anteprima prossimo torneo",
        "description": "Lista delle org seguite dal logged user con anteprima del prossimo torneo aperto (registration/check_in/running) per ognuna. Pensato per la sezione \"Le tue community\" della dashboard player.",
        "tags": [
          "Me"
        ],
        "responses": {
          "200": {
            "description": "Lista org seguite",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "properties": {
                    "orgs": {
                      "type": "array",
                      "items": {
                        "$ref": "#/components/schemas/FollowedOrg"
                      }
                    }
                  }
                }
              }
            }
          },
          "401": {
            "description": "Non loggato"
          }
        }
      }
    },
    "/api/v1/me/preferred-role": {
      "post": {
        "summary": "Bivio onboarding player/organizer",
        "description": "Salva la preferenza dichiarata al primo signup. Influenza UI dashboard e CTA (non permessi). Cambiabile in qualsiasi momento.",
        "tags": [
          "Me"
        ],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "required": [
                  "role"
                ],
                "properties": {
                  "role": {
                    "type": "string",
                    "enum": [
                      "player",
                      "organizer"
                    ]
                  }
                }
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "Preferenza salvata",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "properties": {
                    "ok": {
                      "type": "boolean"
                    },
                    "preferredRole": {
                      "type": "string",
                      "enum": [
                        "player",
                        "organizer"
                      ]
                    }
                  }
                }
              }
            }
          },
          "400": {
            "description": "invalid_role"
          },
          "401": {
            "description": "Non loggato"
          }
        }
      }
    },
    "/api/v1/orgs/{id}/switch": {
      "parameters": [
        {
          "name": "id",
          "in": "path",
          "required": true,
          "schema": {
            "type": "string"
          }
        }
      ],
      "post": {
        "summary": "Cambia org corrente nel cookie",
        "description": "Setta il cookie `rankly_current_org`. Consentito ai membri dell'org + ai platform admin (ghost mode read-only).",
        "tags": [
          "Org"
        ],
        "responses": {
          "200": {
            "description": "OK",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "properties": {
                    "ok": {
                      "type": "boolean"
                    },
                    "currentOrgId": {
                      "type": "string"
                    }
                  }
                }
              }
            }
          },
          "401": {
            "description": "Non loggato"
          },
          "403": {
            "description": "not_a_member (e non platform admin)"
          },
          "404": {
            "description": "org_not_found"
          }
        }
      }
    },
    "/api/v1/mcp": {
      "post": {
        "summary": "MCP server (Model Context Protocol)",
        "description": "Endpoint Streamable HTTP transport per MCP clients (Claude Desktop, Cursor, ChatGPT custom connectors). 5 tool pubblici: `list_organizations`, `list_open_tournaments`, `get_organization`, `get_tournament`, `get_tournament_live`. Stateless. Server Card: https://rankly.it/.well-known/mcp.json",
        "tags": [
          "Agent"
        ],
        "requestBody": {
          "required": true,
          "description": "JSON-RPC 2.0 message (MCP spec)",
          "content": {
            "application/json": {
              "schema": {
                "type": "object"
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "JSON-RPC response (o SSE stream)",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object"
                }
              }
            }
          }
        }
      }
    },
    "/api/v1/agent/claim": {
      "post": {
        "summary": "Agent claim ceremony (auth.md WorkOS spec)",
        "description": "Stub: la registrazione automated non è ancora attiva su Rankly. Ritorna 501 con redirect a /account/api per emissione manuale API key.",
        "tags": [
          "Agent"
        ],
        "responses": {
          "501": {
            "description": "not_implemented (vedi /auth.md per flow attuale)"
          }
        }
      }
    },
    "/api/v1/agent/revoke": {
      "post": {
        "summary": "Agent token revocation (auth.md WorkOS spec)",
        "description": "Stub: la revoca via API non è ancora attiva. Ritorna 501 con redirect a /account/api.",
        "tags": [
          "Agent"
        ],
        "responses": {
          "501": {
            "description": "not_implemented"
          }
        }
      }
    }
  },
  "tags": [
    {
      "name": "Meta",
      "description": "Health, info versione"
    },
    {
      "name": "Public",
      "description": "Endpoint pubblici, no auth richiesta"
    },
    {
      "name": "Tournaments",
      "description": "Gestione tornei dell'org"
    },
    {
      "name": "Participants",
      "description": "Iscritti / arbitri"
    },
    {
      "name": "Phases",
      "description": "Fasi del torneo (Swiss, SE, DE)"
    },
    {
      "name": "Matches",
      "description": "Partite + reporting"
    },
    {
      "name": "Cards",
      "description": "Player cards collezionabili emesse dall'org"
    },
    {
      "name": "Registration",
      "description": "Self-service iscrizione utente loggato a torneo"
    },
    {
      "name": "Tickets",
      "description": "Biglietti digitali QR — emissione, scan check-in, paid tracker"
    },
    {
      "name": "Follower",
      "description": "Relazione user↔org \"di interesse\" (non dà permessi)"
    },
    {
      "name": "Me",
      "description": "Risorse del logged user (preferenze, follow, tickets, dashboard data)"
    },
    {
      "name": "Org",
      "description": "Gestione organizzazione (membership, switch, settings)"
    },
    {
      "name": "Agent",
      "description": "Endpoint per agent (LLM, MCP clients, auth.md spec)"
    }
  ]
}
