openapi: 3.1.0

info:
  title: MTG Community Rankings API
  version: 0.1.0
  summary: "Rankings, tournaments, and achievements for a Magic: The Gathering community."
  description: |
    Community ranking platform for Magic: The Gathering.

    Core model
    ----------
    * **Dual Elo per player per season** — one rating per Format, plus a "general"
      season rating fed by every match. Both are Elo. Both are scoped to a Season
      and reset when a new season opens. `Rating.format_id` is `null` for the
      general season rating.
    * **Match ledger** — every result enters through one of four sources
      (`direct` pickup, `internal` tournament, `challonge`, `melee`) and is
      stored append-only with a `RatingChange` audit trail.
    * **Identity join key** — `PlayerExternalAccount` links a player to Discord,
      Melee, and Challonge. Imported tournaments resolve participants through it;
      unresolved participants park in `ImportQueueItem` until an admin maps them.
    * **Achievements** — automatic (criteria evaluated on match commit) or manual
      (player submits proof, admin grants). Rewards are free text.

    Multiplayer (Commander pod) scoring
    -----------------------------------
    Each Season declares `multiplayer_scoring: placement | kill`. Placement pods
    record `MatchParticipant.placement`. Kill pods record `MatchElimination` rows,
    one per elimination, each of which is a pairwise Elo exchange. The Season
    config is immutable after the season opens.

    Authentication
    --------------
    Bearer JWT issued by `POST /auth/discord`. Admin-only endpoints are marked
    in their description and return `403` for non-admin players.

    BYO integration keys
    --------------------
    Challonge and Melee credentials are configured per-deployment by an admin via
    `PUT /admin/integrations/{provider}`. Credentials are stored encrypted and
    are **never** returned by any endpoint.

servers:
  - url: /api/v1
    description: Versioned API root

tags:
  - name: Auth
    description: Discord OAuth exchange, token refresh, current player.
  - name: Players
    description: Public player profiles and self-service account settings.
  - name: Accounts
    description: Linking external identities (Discord, Melee, Challonge).
  - name: Formats
    description: Admin-defined play formats.
  - name: Seasons
    description: Admin-defined competitive seasons and multiplayer scoring rules.
  - name: Matches
    description: Pickup submissions, confirmations, disputes, and pod elimination reports.
  - name: Tournaments
    description: In-tool tournaments (Swiss / Top 8) and imports from Challonge / Melee.
  - name: Decklists
    description: Per-tournament decklists with Moxfield-style graphic and list views.
  - name: Rankings
    description: Format and general season leaderboards.
  - name: Achievements
    description: Achievement definitions and awards (automatic and manual).
  - name: Admin
    description: Integration credentials, orphaned import mapping, rating repairs.

security:
  - bearerAuth: []

paths:

  # ------------------------------------------------------------------ AUTH
  /auth/discord:
    post:
      tags: [Auth]
      summary: Exchange a Discord OAuth code for an API token
      description: |
        Public. Accepts the authorization code from the Discord OAuth redirect.
        Creates the Player on first login and records the Discord
        `PlayerExternalAccount` link automatically.
      security: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [code, redirect_uri]
              properties:
                code:
                  type: string
                  description: Authorization code from Discord.
                redirect_uri:
                  type: string
                  format: uri
                  description: Must match the redirect URI registered with Discord.
                code_verifier:
                  type: string
                  description: PKCE verifier, if PKCE was used.
      responses:
        '200':
          description: Token pair and the authenticated player.
          content:
            application/json:
              schema:
                type: object
                required: [access_token, refresh_token, expires_in, player]
                properties:
                  access_token:
                    type: string
                  refresh_token:
                    type: string
                  expires_in:
                    type: integer
                    description: Access token lifetime in seconds.
                  player:
                    $ref: '#/components/schemas/Player'
        '400':
          $ref: '#/components/responses/BadRequest'
        '401':
          $ref: '#/components/responses/Unauthorized'

  /auth/refresh:
    post:
      tags: [Auth]
      summary: Refresh an access token
      security: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [refresh_token]
              properties:
                refresh_token:
                  type: string
      responses:
        '200':
          description: New token pair.
          content:
            application/json:
              schema:
                type: object
                required: [access_token, refresh_token, expires_in]
                properties:
                  access_token: { type: string }
                  refresh_token: { type: string }
                  expires_in: { type: integer }
        '401':
          $ref: '#/components/responses/Unauthorized'

  /auth/me:
    get:
      tags: [Auth]
      summary: Get the authenticated player
      responses:
        '200':
          description: The current player, with admin flag.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Player'
        '401':
          $ref: '#/components/responses/Unauthorized'

  # -------------------------------------------------------------- PLAYERS
  /players:
    get:
      tags: [Players]
      summary: List or search players
      parameters:
        - name: q
          in: query
          description: Case-insensitive substring match on display name.
          schema: { type: string }
        - name: season_id
          in: query
          description: Restrict to players with a rating in this season.
          schema: { type: string, format: uuid }
        - name: limit
          in: query
          schema: { type: integer, minimum: 1, maximum: 100, default: 25 }
        - name: offset
          in: query
          schema: { type: integer, minimum: 0, default: 0 }
      responses:
        '200':
          description: Page of players.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/PlayerPage'

  /players/{id}:
    get:
      tags: [Players]
      summary: Get a player profile
      description: Returns the profile with ratings, recent matches, and unlocked achievements.
      parameters:
        - $ref: '#/components/parameters/PlayerId'
      responses:
        '200':
          description: Full player profile.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/PlayerProfile'
        '404':
          $ref: '#/components/responses/NotFound'

  /players/me:
    patch:
      tags: [Players]
      summary: Update own profile
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              properties:
                display_name:
                  type: string
                  minLength: 2
                  maxLength: 40
                avatar_url:
                  type: string
                  format: uri
      responses:
        '200':
          description: Updated player.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Player'
        '400':
          $ref: '#/components/responses/BadRequest'
        '401':
          $ref: '#/components/responses/Unauthorized'

  # ------------------------------------------------------------- ACCOUNTS
  /players/me/accounts:
    get:
      tags: [Accounts]
      summary: List linked external accounts
      description: |
        Returns every `PlayerExternalAccount` for the caller. A provider may be
        linked without an API-verified id; in that case `external_user_id` is
        null and `external_username` is a manually declared handle used for
        identity resolution on import.
      responses:
        '200':
          description: Linked accounts.
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: array
                    items:
                      $ref: '#/components/schemas/PlayerExternalAccount'
        '401':
          $ref: '#/components/responses/Unauthorized'

    post:
      tags: [Accounts]
      summary: Link an external account
      description: |
        Link a Discord, Melee, or Challonge identity. Discord is normally linked
        as a side effect of login and is idempotent. Melee has no public profile
        API, so a username-only link is the expected shape. Challonge accepts
        either an OAuth-verified user id or a username-only link.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [provider]
              properties:
                provider:
                  type: string
                  enum: [discord, melee, challonge]
                external_user_id:
                  type: string
                  description: Platform-assigned id, when known or verified via OAuth.
                external_username:
                  type: string
                  description: Human handle. Used for identity resolution on import.
      responses:
        '201':
          description: Account link created.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/PlayerExternalAccount'
        '400':
          $ref: '#/components/responses/BadRequest'
        '409':
          $ref: '#/components/responses/Conflict'

  /players/me/accounts/{id}:
    delete:
      tags: [Accounts]
      summary: Unlink an external account
      parameters:
        - name: id
          in: path
          required: true
          schema: { type: string, format: uuid }
      responses:
        '204':
          description: Unlinked. Future imports will no longer resolve to this player via that handle.
        '404':
          $ref: '#/components/responses/NotFound'

  # -------------------------------------------------------------- FORMATS
  /formats:
    get:
      tags: [Formats]
      summary: List formats
      parameters:
        - name: active_only
          in: query
          schema: { type: boolean, default: true }
      responses:
        '200':
          description: Formats.
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: array
                    items:
                      $ref: '#/components/schemas/Format'

    post:
      tags: [Formats]
      summary: Create a format
      description: Admin only.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [name]
              properties:
                name:
                  type: string
                  example: Modern
                slug:
                  type: string
                  pattern: '^[a-z0-9-]+$'
                  description: Derived from name when omitted.
                is_active:
                  type: boolean
                  default: true
      responses:
        '201':
          description: Created.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Format'
        '400':
          $ref: '#/components/responses/BadRequest'
        '403':
          $ref: '#/components/responses/Forbidden'
        '409':
          $ref: '#/components/responses/Conflict'

  /formats/{id}:
    patch:
      tags: [Formats]
      summary: Update a format
      description: Admin only.
      parameters:
        - $ref: '#/components/parameters/FormatId'
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              properties:
                name: { type: string }
                is_active: { type: boolean }
      responses:
        '200':
          description: Updated.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Format'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/NotFound'

  # -------------------------------------------------------------- SEASONS
  /seasons:
    get:
      tags: [Seasons]
      summary: List seasons
      parameters:
        - name: status
          in: query
          schema:
            type: string
            enum: [draft, active, closed]
      responses:
        '200':
          description: Seasons, newest first.
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: array
                    items:
                      $ref: '#/components/schemas/Season'

    post:
      tags: [Seasons]
      summary: Create a season
      description: |
        Admin only. `multiplayer_scoring` is required and **immutable** once the
        season moves to `active`; pick it deliberately.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [name, starts_at, multiplayer_scoring]
              properties:
                name:
                  type: string
                  example: Season 2027
                slug:
                  type: string
                  pattern: '^[a-z0-9-]+$'
                starts_at:
                  type: string
                  format: date-time
                ends_at:
                  type: string
                  format: date-time
                multiplayer_scoring:
                  type: string
                  enum: [placement, kill]
                  description: How Commander / multiplayer pods are scored for Elo this season.
      responses:
        '201':
          description: Created.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Season'
        '400':
          $ref: '#/components/responses/BadRequest'
        '403':
          $ref: '#/components/responses/Forbidden'

  /seasons/{id}:
    patch:
      tags: [Seasons]
      summary: Update a season
      description: |
        Admin only. `multiplayer_scoring` may only be changed while the season is
        still `draft`. Changing it after activation returns `409`.
      parameters:
        - name: id
          in: path
          required: true
          schema: { type: string, format: uuid }
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              properties:
                name: { type: string }
                ends_at: { type: string, format: date-time }
                status:
                  type: string
                  enum: [draft, active, closed]
                multiplayer_scoring:
                  type: string
                  enum: [placement, kill]
      responses:
        '200':
          description: Updated.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Season'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/NotFound'
        '409':
          description: Attempted to change `multiplayer_scoring` on a non-draft season.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'

  # -------------------------------------------------------------- MATCHES
  /matches:
    post:
      tags: [Matches]
      summary: Submit a pickup match
      description: |
        Player A submits a result. The match is created with status `pending` and
        **does not** touch any rating until the opponent confirms.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [format_id, season_id, kind, participants]
              properties:
                format_id: { type: string, format: uuid }
                season_id: { type: string, format: uuid }
                kind:
                  type: string
                  enum: [duel, pod]
                  description: |
                    `duel` is 1v1. `pod` is multiplayer; supply `placement` on
                    participants when the season is placement-scored, or submit
                    eliminations separately via `POST /matches/{id}/eliminations`
                    when it is kill-scored.
                played_at:
                  type: string
                  format: date-time
                  description: Defaults to now.
                participants:
                  type: array
                  minItems: 2
                  items:
                    type: object
                    required: [player_id]
                    properties:
                      player_id: { type: string, format: uuid }
                      placement:
                        type: integer
                        minimum: 1
                        description: Finishing position for pods. Omit for duels.
                      is_winner:
                        type: boolean
                        description: Required for `duel`. Exactly one participant must be true.
                      games_won: { type: integer, minimum: 0 }
                      games_lost: { type: integer, minimum: 0 }
      responses:
        '201':
          description: Match created in `pending` state.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Match'
        '400':
          $ref: '#/components/responses/BadRequest'
        '404':
          description: Unknown player, format, or season.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'

    get:
      tags: [Matches]
      summary: List matches
      parameters:
        - name: season_id
          in: query
          schema: { type: string, format: uuid }
        - name: format_id
          in: query
          schema: { type: string, format: uuid }
        - name: tournament_id
          in: query
          schema: { type: string, format: uuid }
        - name: player_id
          in: query
          schema: { type: string, format: uuid }
        - name: status
          in: query
          schema:
            type: string
            enum: [pending, confirmed, applied, disputed]
        - name: source
          in: query
          schema:
            type: string
            enum: [direct, internal, challonge, melee]
        - name: limit
          in: query
          schema: { type: integer, minimum: 1, maximum: 100, default: 25 }
        - name: offset
          in: query
          schema: { type: integer, minimum: 0, default: 0 }
      responses:
        '200':
          description: Page of matches.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/MatchPage'

  /matches/{id}:
    get:
      tags: [Matches]
      summary: Get a match
      parameters:
        - $ref: '#/components/parameters/MatchId'
      responses:
        '200':
          description: Match with participants and eliminations.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/MatchDetail'
        '404':
          $ref: '#/components/responses/NotFound'

  /matches/{id}/confirm:
    post:
      tags: [Matches]
      summary: Confirm a pickup match
      description: |
        Player B accepts the reported result. On confirmation the match moves to
        `applied`, ratings update, and the response returns the resulting
        `RatingChange` rows so the UI can show Elo deltas immediately.

        Only a participant other than the submitter may confirm. A participant
        may also confirm their own imported match to clear the `pending` state.
      parameters:
        - $ref: '#/components/parameters/MatchId'
      responses:
        '200':
          description: Match applied, with rating deltas.
          content:
            application/json:
              schema:
                type: object
                required: [match, rating_changes]
                properties:
                  match:
                    $ref: '#/components/schemas/Match'
                  rating_changes:
                    type: array
                    items:
                      $ref: '#/components/schemas/RatingChange'
        '403':
          description: Caller is not a participant, or is the original submitter.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
        '404':
          $ref: '#/components/responses/NotFound'
        '409':
          description: Match is not in `pending` state.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'

  /matches/{id}/dispute:
    post:
      tags: [Matches]
      summary: Dispute a match
      description: Any participant may move a `pending` match to `disputed`. Admin review is required to resolve.
      parameters:
        - $ref: '#/components/parameters/MatchId'
      requestBody:
        content:
          application/json:
            schema:
              type: object
              properties:
                reason:
                  type: string
                  maxLength: 500
      responses:
        '200':
          description: Match moved to `disputed`.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Match'
        '403':
          $ref: '#/components/responses/Forbidden'
        '409':
          description: Match is not in `pending` state.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'

  /matches/{id}/eliminations:
    post:
      tags: [Matches]
      summary: Report eliminations for a pod match
      description: |
        Player-reported kill data for kill-scored seasons. Each entry records who
        eliminated whom and is stored as one pairwise comparison. Idempotent on
        `(match_id, killer, victim)`.
      parameters:
        - $ref: '#/components/parameters/MatchId'
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [eliminations]
              properties:
                eliminations:
                  type: array
                  minItems: 1
                  items:
                    type: object
                    required: [killer_player_id, victim_player_id]
                    properties:
                      killer_player_id: { type: string, format: uuid }
                      victim_player_id: { type: string, format: uuid }
      responses:
        '201':
          description: Eliminations recorded.
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: array
                    items:
                      $ref: '#/components/schemas/MatchElimination'
        '400':
          description: Season is placement-scored, or killer/victim is not a participant.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
        '404':
          $ref: '#/components/responses/NotFound'

  # ----------------------------------------------------------- TOURNAMENTS
  /tournaments:
    get:
      tags: [Tournaments]
      summary: List tournaments
      parameters:
        - name: season_id
          in: query
          schema: { type: string, format: uuid }
        - name: format_id
          in: query
          schema: { type: string, format: uuid }
        - name: source
          in: query
          schema:
            type: string
            enum: [internal, challonge, melee]
        - name: status
          in: query
          schema:
            type: string
            enum: [draft, registration, active, completed, cancelled]
        - name: limit
          in: query
          schema: { type: integer, minimum: 1, maximum: 100, default: 25 }
        - name: offset
          in: query
          schema: { type: integer, minimum: 0, default: 0 }
      responses:
        '200':
          description: Page of tournaments.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/TournamentPage'

    post:
      tags: [Tournaments]
      summary: Create an in-tool tournament
      description: Admin only. Creates the tournament and its participant roster.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [name, format_id, season_id, structure, starts_at]
              properties:
                name: { type: string }
                format_id: { type: string, format: uuid }
                season_id: { type: string, format: uuid }
                structure:
                  type: string
                  enum: [swiss, swiss_top8, single_elim, double_elim, round_robin]
                starts_at: { type: string, format: date-time }
                ends_at: { type: string, format: date-time }
                participant_ids:
                  type: array
                  items: { type: string, format: uuid }
                  description: Optional pre-registration roster.
      responses:
        '201':
          description: Created.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Tournament'
        '400':
          $ref: '#/components/responses/BadRequest'
        '403':
          $ref: '#/components/responses/Forbidden'

  /tournaments/import:
    post:
      tags: [Tournaments]
      summary: Import a tournament from Challonge or Melee
      description: |
        Admin only. Pulls the tournament, its participants, and its matches from
        the configured provider using the deployment's BYO credentials.

        Participants are resolved to `Player` rows via `PlayerExternalAccount`.
        Unmatched participants are parked in `ImportQueueItem` with
        `status: waiting` and their matches do **not** apply to ratings until an
        admin maps them via `POST /admin/import-queue/{id}/map`.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [provider, source_ref, season_id]
              properties:
                provider:
                  type: string
                  enum: [challonge, melee]
                source_ref:
                  type: string
                  description: Tournament id or slug on the source platform.
                season_id:
                  type: string
                  format: uuid
                format_id:
                  type: string
                  format: uuid
                  description: Resolved from the source tournament when omitted.
                import_decklists:
                  type: boolean
                  default: true
                  description: Pull decklists when the provider exposes them (Melee does; Challonge does not).
      responses:
        '201':
          description: Import queued/processed.
          content:
            application/json:
              schema:
                type: object
                required: [tournament, import_queue_items]
                properties:
                  tournament:
                    $ref: '#/components/schemas/Tournament'
                  import_queue_items:
                    type: array
                    description: One per unresolved participant. Empty when everything matched.
                    items:
                      $ref: '#/components/schemas/ImportQueueItem'
        '400':
          $ref: '#/components/responses/BadRequest'
        '403':
          $ref: '#/components/responses/Forbidden'
        '409':
          description: Provider credentials not configured for this deployment.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
        '502':
          description: Upstream provider rejected the request or was unreachable.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'

  /tournaments/{id}:
    get:
      tags: [Tournaments]
      summary: Get a tournament
      description: Includes standings, structure, source metadata, and decklist availability.
      parameters:
        - $ref: '#/components/parameters/TournamentId'
      responses:
        '200':
          description: Tournament detail.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/TournamentDetail'
        '404':
          $ref: '#/components/responses/NotFound'

  /tournaments/{id}/rounds:
    post:
      tags: [Tournaments]
      summary: Generate the next round of pairings
      description: |
        Admin only. For `swiss` / `swiss_top8` structures the domain
        `SwissPairing` service computes pairings from current standings and
        tiebreakers. For elimination structures the next bracket round is
        generated from prior results.
      parameters:
        - $ref: '#/components/parameters/TournamentId'
      responses:
        '200':
          description: Generated round with pairings.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Round'
        '403':
          $ref: '#/components/responses/Forbidden'
        '409':
          description: Previous round has unreported matches.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'

  # ------------------------------------------------------------- DECKLISTS
  /tournaments/{id}/decklists:
    get:
      tags: [Decklists]
      summary: List decklists for a tournament
      parameters:
        - $ref: '#/components/parameters/TournamentId'
      responses:
        '200':
          description: Decklist summaries (without entries).
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: array
                    items:
                      $ref: '#/components/schemas/Decklist'

    post:
      tags: [Decklists]
      summary: Submit a decklist
      description: |
        A player submits (or replaces) their decklist for a tournament. Entries
        reference Scryfall card ids; the `CardProvider` adapter resolves names,
        sets, and image URLs for the Moxfield-style graphic view.
      parameters:
        - $ref: '#/components/parameters/TournamentId'
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [name, format_id, entries]
              properties:
                name:
                  type: string
                  example: 4c Control
                format_id: { type: string, format: uuid }
                entries:
                  type: array
                  minItems: 1
                  items:
                    type: object
                    required: [card_ref, quantity, section]
                    properties:
                      card_ref:
                        type: string
                        description: Scryfall card id (`scryfall_id`).
                      quantity:
                        type: integer
                        minimum: 1
                      section:
                        type: string
                        enum: [main, side, commander, companion, maybeboard]
      responses:
        '201':
          description: Decklist created.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Decklist'
        '400':
          $ref: '#/components/responses/BadRequest'
        '403':
          description: Caller is not a participant of this tournament.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
        '409':
          description: Tournament does not accept decklists (`cancelled`, or import source without decklist support).
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'

  /decklists/{id}:
    get:
      tags: [Decklists]
      summary: Get a decklist with entries
      description: |
        Returns entries grouped by section plus resolved card metadata and image
        URLs, sufficient to render both the graphic (card grid) and list views.
      parameters:
        - name: id
          in: path
          required: true
          schema: { type: string, format: uuid }
      responses:
        '200':
          description: Full decklist.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/DecklistDetail'
        '404':
          $ref: '#/components/responses/NotFound'

    patch:
      tags: [Decklists]
      summary: Update a decklist
      description: The owner may edit while the decklist is `draft` or `submitted`. Admins may edit in any state.
      parameters:
        - name: id
          in: path
          required: true
          schema: { type: string, format: uuid }
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              properties:
                name: { type: string }
                status:
                  type: string
                  enum: [draft, submitted, locked]
                entries:
                  type: array
                  items:
                    type: object
                    required: [card_ref, quantity, section]
                    properties:
                      card_ref: { type: string }
                      quantity: { type: integer, minimum: 1 }
                      section:
                        type: string
                        enum: [main, side, commander, companion, maybeboard]
      responses:
        '200':
          description: Updated.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Decklist'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/NotFound'
        '409':
          description: Decklist is `locked` and caller is not an admin.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'

  # -------------------------------------------------------------- RANKINGS
  /rankings:
    get:
      tags: [Rankings]
      summary: Get the leaderboard
      description: |
        Returns Elo standings for a season. Omit `format_id` for the general
        season ranking (all matches); set it for a format-specific ranking.

        Both boards are Elo and both reset when a new season opens. Ranks are
        dense-ordered by `value` descending.
      parameters:
        - name: season_id
          in: query
          required: true
          schema: { type: string, format: uuid }
        - name: format_id
          in: query
          schema: { type: string, format: uuid }
          description: Omit for the general season ranking.
        - name: limit
          in: query
          schema: { type: integer, minimum: 1, maximum: 200, default: 50 }
        - name: offset
          in: query
          schema: { type: integer, minimum: 0, default: 0 }
      responses:
        '200':
          description: Ranked standings.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/RankingPage'
        '404':
          description: Unknown season or format.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'

  # ---------------------------------------------------------- ACHIEVEMENTS
  /achievements:
    get:
      tags: [Achievements]
      summary: List achievement definitions
      parameters:
        - name: active_only
          in: query
          schema: { type: boolean, default: true }
        - name: award_method
          in: query
          schema:
            type: string
            enum: [automatic, manual]
      responses:
        '200':
          description: Achievement definitions.
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: array
                    items:
                      $ref: '#/components/schemas/Achievement'

    post:
      tags: [Achievements]
      summary: Create an achievement
      description: Admin only.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [name, award_method]
              properties:
                name: { type: string }
                description:
                  type: string
                  description: Human-readable criteria. Free text.
                reward_text:
                  type: string
                  description: |
                    Free-text reward description. Not enforced or delivered by
                    the system at this stage.
                award_method:
                  type: string
                  enum: [automatic, manual]
                criteria:
                  $ref: '#/components/schemas/AchievementCriteria'
                is_active:
                  type: boolean
                  default: true
      responses:
        '201':
          description: Created.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Achievement'
        '400':
          description: "`automatic` requires `criteria`; `manual` must not set it."
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
        '403':
          $ref: '#/components/responses/Forbidden'

  /achievements/{id}:
    patch:
      tags: [Achievements]
      summary: Update an achievement
      description: Admin only.
      parameters:
        - name: id
          in: path
          required: true
          schema: { type: string, format: uuid }
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              properties:
                name: { type: string }
                description: { type: string }
                reward_text: { type: string }
                criteria:
                  $ref: '#/components/schemas/AchievementCriteria'
                is_active: { type: boolean }
      responses:
        '200':
          description: Updated.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Achievement'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/NotFound'

  /achievements/{id}/request:
    post:
      tags: [Achievements]
      summary: Request a manual achievement
      description: |
        Player submits proof for a `manual` achievement. Creates a
        `PlayerAchievement` in `pending_review`. Admins resolve it via
        `POST /player-achievements/{id}/review`.
      parameters:
        - name: id
          in: path
          required: true
          schema: { type: string, format: uuid }
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [proof_text]
              properties:
                proof_text:
                  type: string
                  maxLength: 1000
                proof_url:
                  type: string
                  format: uri
      responses:
        '201':
          description: Request submitted.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/PlayerAchievement'
        '400':
          description: Achievement is not `manual`, or already requested.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
        '404':
          $ref: '#/components/responses/NotFound'

  /player-achievements/{id}/review:
    post:
      tags: [Achievements]
      summary: Grant or deny a manual achievement
      description: Admin only.
      parameters:
        - name: id
          in: path
          required: true
          schema: { type: string, format: uuid }
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [decision]
              properties:
                decision:
                  type: string
                  enum: [granted, denied]
                note:
                  type: string
                  maxLength: 500
      responses:
        '200':
          description: Player achievement updated.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/PlayerAchievement'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/NotFound'
        '409':
          description: Not in `pending_review`.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'

  # ----------------------------------------------------------------- ADMIN
  /admin/integrations:
    get:
      tags: [Admin]
      summary: List integration configurations
      description: |
        Admin only. Credential material is **never** included; the response
        reports only whether credentials are present and when they were last set.
      responses:
        '200':
          description: Integration status.
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: array
                    items:
                      $ref: '#/components/schemas/IntegrationStatus'
        '403':
          $ref: '#/components/responses/Forbidden'

  /admin/integrations/{provider}:
    put:
      tags: [Admin]
      summary: Configure BYO credentials for an integration
      description: |
        Admin only. Stores the provider credentials encrypted at rest. Replaces
        any existing credentials for the provider. Raw credential values are
        write-only and never echoed back.

        Access model differs by provider: Challonge issues standard API keys and
        OAuth clients to any account. Melee restricts API access to organizations
        and their designated staff and requires approval from
        `contact@melee.gg` before issuing a client id and secret.
      parameters:
        - name: provider
          in: path
          required: true
          schema:
            type: string
            enum: [challonge, melee]
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [enabled, credentials]
              properties:
                enabled: { type: boolean }
                credentials:
                  type: object
                  additionalProperties:
                    type: string
                  description: |
                    Provider-specific key/value material, e.g. Challonge
                    `api_key` or `client_id`/`client_secret`, or Melee
                    `client_id`/`client_secret`/`org_id`. Stored encrypted.
      responses:
        '200':
          description: Stored. Credentials are not echoed.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/IntegrationStatus'
        '400':
          $ref: '#/components/responses/BadRequest'
        '403':
          $ref: '#/components/responses/Forbidden'

  /admin/import-queue:
    get:
      tags: [Admin]
      summary: List orphaned import participants
      description: Admin only. Participants from imported tournaments that no `PlayerExternalAccount` matched.
      parameters:
        - name: status
          in: query
          schema:
            type: string
            enum: [waiting, mapped]
            default: waiting
        - name: tournament_id
          in: query
          schema: { type: string, format: uuid }
      responses:
        '200':
          description: Queue items.
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: array
                    items:
                      $ref: '#/components/schemas/ImportQueueItem'
        '403':
          $ref: '#/components/responses/Forbidden'

  /admin/import-queue/{id}/map:
    post:
      tags: [Admin]
      summary: Map an orphaned participant to a player
      description: |
        Admin only. Resolves the queue item to a `Player` and applies every
        parked match for that participant, producing `RatingChange` rows.

        Alternatively, a player linking the matching username in Account
        settings will resolve the item automatically; this endpoint is the
        manual override.
      parameters:
        - name: id
          in: path
          required: true
          schema: { type: string, format: uuid }
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [player_id]
              properties:
                player_id:
                  type: string
                  format: uuid
                create_link:
                  type: boolean
                  default: true
                  description: Also create the `PlayerExternalAccount` so future imports resolve automatically.
      responses:
        '200':
          description: Mapped. Includes the rating changes applied from previously parked matches.
          content:
            application/json:
              schema:
                type: object
                required: [import_queue_item, rating_changes]
                properties:
                  import_queue_item:
                    $ref: '#/components/schemas/ImportQueueItem'
                  rating_changes:
                    type: array
                    items:
                      $ref: '#/components/schemas/RatingChange'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/NotFound'
        '409':
          description: Already mapped.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'

  /admin/ratings/recompute:
    post:
      tags: [Admin]
      summary: Recompute ratings for a season
      description: |
        Admin only. Replays the match ledger in `played_at` / `created_at` order
        and rebuilds `Rating` and `RatingChange` for the season. Used after
        corrections to match data or a change in the domain `EloEngine` policy.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [season_id]
              properties:
                season_id: { type: string, format: uuid }
                format_id:
                  type: string
                  format: uuid
                  description: Limit the rebuild to one format. Omit for the whole season.
      responses:
        '202':
          description: Recompute accepted.
          content:
            application/json:
              schema:
                type: object
                properties:
                  job_id: { type: string }
                  status: { type: string, example: queued }
        '403':
          $ref: '#/components/responses/Forbidden'

components:

  securitySchemes:
    bearerAuth:
      type: http
      scheme: bearer
      bearerFormat: JWT
      description: JWT issued by `POST /auth/discord`.

  parameters:
    PlayerId:
      name: id
      in: path
      required: true
      description: Player id.
      schema: { type: string, format: uuid }
    FormatId:
      name: id
      in: path
      required: true
      description: Format id.
      schema: { type: string, format: uuid }
    MatchId:
      name: id
      in: path
      required: true
      description: Match id.
      schema: { type: string, format: uuid }
    TournamentId:
      name: id
      in: path
      required: true
      description: Tournament id.
      schema: { type: string, format: uuid }

  responses:
    BadRequest:
      description: Validation failed.
      content:
        application/json:
          schema:
            $ref: '#/components/schemas/Error'
    Unauthorized:
      description: Missing or invalid token.
      content:
        application/json:
          schema:
            $ref: '#/components/schemas/Error'
    Forbidden:
      description: Caller lacks admin privileges.
      content:
        application/json:
          schema:
            $ref: '#/components/schemas/Error'
    NotFound:
      description: Resource not found.
      content:
        application/json:
          schema:
            $ref: '#/components/schemas/Error'
    Conflict:
      description: Resource already exists or state conflict.
      content:
        application/json:
          schema:
            $ref: '#/components/schemas/Error'

  schemas:

    Error:
      type: object
      required: [code, message]
      properties:
        code:
          type: string
          example: validation_failed
        message:
          type: string
        details:
          type: array
          items:
            type: object
            properties:
              field: { type: string }
              issue: { type: string }

    PageMeta:
      type: object
      properties:
        total: { type: integer }
        limit: { type: integer }
        offset: { type: integer }

    # ------------------------------------------------------------- Player
    Player:
      type: object
      required: [id, display_name, is_admin, created_at]
      properties:
        id: { type: string, format: uuid }
        display_name: { type: string }
        avatar_url:
          type: string
          format: uri
        email:
          type: string
          format: email
          description: Only returned for the authenticated player.
        is_admin: { type: boolean }
        created_at: { type: string, format: date-time }

    PlayerPage:
      type: object
      properties:
        data:
          type: array
          items:
            $ref: '#/components/schemas/Player'
        meta:
          $ref: '#/components/schemas/PageMeta'

    PlayerProfile:
      type: object
      required: [player, ratings, achievements]
      properties:
        player:
          $ref: '#/components/schemas/Player'
        ratings:
          type: array
          items:
            $ref: '#/components/schemas/Rating'
        achievements:
          type: array
          items:
            $ref: '#/components/schemas/PlayerAchievement'
        recent_matches:
          type: array
          items:
            $ref: '#/components/schemas/Match'
        linked_accounts:
          type: array
          items:
            $ref: '#/components/schemas/PlayerExternalAccount'

    PlayerExternalAccount:
      type: object
      required: [id, provider, verified]
      properties:
        id: { type: string, format: uuid }
        provider:
          type: string
          enum: [discord, melee, challonge]
        external_user_id:
          type: string
          description: Platform-assigned id, when known or OAuth-verified.
        external_username:
          type: string
          description: Human handle. Used as the identity join key on import when no id is available.
        verified:
          type: boolean
          description: True when the link was established through the provider's OAuth flow.
        created_at: { type: string, format: date-time }

    # ----------------------------------------------------- Format / Season
    Format:
      type: object
      required: [id, name, slug, is_active]
      properties:
        id: { type: string, format: uuid }
        name: { type: string, example: Modern }
        slug: { type: string, example: modern }
        is_active: { type: boolean }

    Season:
      type: object
      required: [id, name, slug, status, multiplayer_scoring, starts_at]
      properties:
        id: { type: string, format: uuid }
        name: { type: string, example: Season 2027 }
        slug: { type: string }
        status:
          type: string
          enum: [draft, active, closed]
        multiplayer_scoring:
          type: string
          enum: [placement, kill]
          description: |
            How multiplayer pods are scored for Elo this season. Immutable once
            the season is `active`.
        starts_at: { type: string, format: date-time }
        ends_at:
          type: string
          format: date-time

    # -------------------------------------------------------------- Match
    MatchParticipant:
      type: object
      required: [player_id]
      properties:
        id: { type: string, format: uuid }
        player_id: { type: string, format: uuid }
        placement:
          type: integer
          minimum: 1
          description: Finishing position for pods. Null for duels.
        is_winner:
          type: boolean
          description: True for exactly one participant of a duel.
        games_won: { type: integer, minimum: 0 }
        games_lost: { type: integer, minimum: 0 }

    MatchElimination:
      type: object
      required: [id, killer_player_id, victim_player_id]
      properties:
        id: { type: string, format: uuid }
        match_id: { type: string, format: uuid }
        killer_player_id: { type: string, format: uuid }
        victim_player_id: { type: string, format: uuid }

    Match:
      type: object
      required: [id, season_id, format_id, source, kind, status, participants]
      properties:
        id: { type: string, format: uuid }
        season_id: { type: string, format: uuid }
        format_id: { type: string, format: uuid }
        tournament_id:
          type: string
          format: uuid
          description: Null for pickup matches.
        source:
          type: string
          enum: [direct, internal, challonge, melee]
        source_ref:
          type: string
          description: Match id on the source platform, when imported.
        kind:
          type: string
          enum: [duel, pod]
        status:
          type: string
          enum: [pending, confirmed, applied, disputed]
          description: |
            `pending` — submitted, awaiting opponent confirmation or import mapping.
            `confirmed` — accepted but not yet applied.
            `applied` — ratings updated.
            `disputed` — under review.
        played_at: { type: string, format: date-time }
        created_at: { type: string, format: date-time }
        participants:
          type: array
          items:
            $ref: '#/components/schemas/MatchParticipant'

    MatchDetail:
      allOf:
        - $ref: '#/components/schemas/Match'
        - type: object
          properties:
            eliminations:
              type: array
              items:
                $ref: '#/components/schemas/MatchElimination'
            rating_changes:
              type: array
              items:
                $ref: '#/components/schemas/RatingChange'

    MatchPage:
      type: object
      properties:
        data:
          type: array
          items:
            $ref: '#/components/schemas/Match'
        meta:
          $ref: '#/components/schemas/PageMeta'

    # -------------------------------------------------------------- Rating
    Rating:
      type: object
      required: [id, player_id, season_id, value]
      properties:
        id: { type: string, format: uuid }
        player_id: { type: string, format: uuid }
        season_id:
          type: string
          format: uuid
          description: Every rating is scoped to a season and resets when a new season opens.
        format_id:
          type: string
          format: uuid
          description: |
            Null for the general season rating (fed by every match). Set for the
            rating in that specific format.
        value:
          type: number
          description: Elo value. Baseline at season open is 1500.
        updated_at: { type: string, format: date-time }

    RatingChange:
      type: object
      required: [id, match_id, player_id, old_value, new_value, delta]
      properties:
        id: { type: string, format: uuid }
        match_id: { type: string, format: uuid }
        player_id: { type: string, format: uuid }
        rating_id: { type: string, format: uuid }
        scope:
          type: string
          enum: [general, format]
        format_id:
          type: string
          format: uuid
        old_value: { type: number }
        new_value: { type: number }
        delta: { type: number }
        created_at: { type: string, format: date-time }

    # --------------------------------------------------------- Tournament
    Tournament:
      type: object
      required: [id, name, format_id, season_id, source, structure, status]
      properties:
        id: { type: string, format: uuid }
        name: { type: string }
        format_id: { type: string, format: uuid }
        season_id: { type: string, format: uuid }
        source:
          type: string
          enum: [internal, challonge, melee]
        source_ref:
          type: string
          description: Tournament id on the source platform, when imported.
        structure:
          type: string
          enum: [swiss, swiss_top8, single_elim, double_elim, round_robin]
        status:
          type: string
          enum: [draft, registration, active, completed, cancelled]
        starts_at: { type: string, format: date-time }
        ends_at:
          type: string
          format: date-time
        external_url:
          type: string
          format: uri
          description: Link back to the source platform, when imported.
        decklists_available:
          type: boolean
          description: |
            True when decklists can be attached. Melee imports and internal
            tournaments are true; Challonge imports are false.

    TournamentDetail:
      allOf:
        - $ref: '#/components/schemas/Tournament'
        - type: object
          properties:
            participants:
              type: array
              items:
                $ref: '#/components/schemas/TournamentParticipant'
            rounds:
              type: array
              items:
                $ref: '#/components/schemas/Round'
            standings:
              type: array
              items:
                $ref: '#/components/schemas/Standing'

    TournamentParticipant:
      type: object
      properties:
        player_id:
          type: string
          format: uuid
          description: Null while the participant is orphaned in the import queue.
        display_name: { type: string }
        external_username: { type: string }
        seed: { type: integer }
        dropped: { type: boolean }

    TournamentPage:
      type: object
      properties:
        data:
          type: array
          items:
            $ref: '#/components/schemas/Tournament'
        meta:
          $ref: '#/components/schemas/PageMeta'

    Round:
      type: object
      properties:
        id: { type: string, format: uuid }
        number: { type: integer }
        status:
          type: string
          enum: [pending, active, complete]
        matches:
          type: array
          items:
            $ref: '#/components/schemas/Match'

    Standing:
      type: object
      properties:
        player_id: { type: string, format: uuid }
        rank: { type: integer }
        match_points: { type: integer }
        omw: { type: number, description: Opponent match win percentage. }
        pgw: { type: number, description: Game win percentage. }
        ogw: { type: number, description: Opponent game win percentage. }

    # ------------------------------------------------------------ Rankings
    RankingEntry:
      type: object
      required: [rank, player_id, value]
      properties:
        rank:
          type: integer
          description: Dense-ordered position by `value` descending.
        player_id: { type: string, format: uuid }
        display_name: { type: string }
        avatar_url: { type: string, format: uri }
        value:
          type: number
          description: Elo value in this season's scope.
        previous_rank:
          type: integer
          description: Rank at the end of the previous round or day, for movement indicators. Null when unavailable.
        played:
          type: integer
          description: Matches counted toward this rating in the current season.
        wins: { type: integer }
        losses: { type: integer }

    RankingPage:
      type: object
      required: [season_id, scope, data]
      properties:
        season_id: { type: string, format: uuid }
        scope:
          type: string
          enum: [general, format]
          description: "`general` when `format_id` was omitted, otherwise `format`."
        format_id:
          type: string
          format: uuid
          description: Present only when `scope` is `format`.
        format_name: { type: string }
        data:
          type: array
          items:
            $ref: '#/components/schemas/RankingEntry'
        meta:
          $ref: '#/components/schemas/PageMeta'


    # ------------------------------------------------------------ Decklist
    DecklistEntry:
      type: object
      required: [card_ref, quantity, section]
      properties:
        id: { type: string, format: uuid }
        card_ref:
          type: string
          description: Scryfall card id.
        quantity: { type: integer, minimum: 1 }
        section:
          type: string
          enum: [main, side, commander, companion, maybeboard]
        card:
          $ref: '#/components/schemas/Card'

    Card:
      type: object
      description: Resolved card metadata for Moxfield-style graphic and list views.
      properties:
        scryfall_id: { type: string }
        name: { type: string }
        set_code: { type: string }
        collector_number: { type: string }
        mana_cost: { type: string }
        type_line: { type: string }
        rarity: { type: string }
        image_url:
          type: string
          format: uri
          description: Normal card image.
        image_url_art_crop:
          type: string
          format: uri
        image_url_png:
          type: string
          format: uri
        colors:
          type: array
          items: { type: string }
        is_double_faced: { type: boolean }
        card_faces:
          type: array
          items:
            type: object
            properties:
              name: { type: string }
              image_url: { type: string, format: uri }

    Decklist:
      type: object
      required: [id, tournament_id, player_id, name, status]
      properties:
        id: { type: string, format: uuid }
        tournament_id: { type: string, format: uuid }
        player_id: { type: string, format: uuid }
        format_id: { type: string, format: uuid }
        name: { type: string }
        status:
          type: string
          enum: [draft, submitted, locked]
        submitted_at:
          type: string
          format: date-time
        entry_count: { type: integer }

    DecklistDetail:
      allOf:
        - $ref: '#/components/schemas/Decklist'
        - type: object
          properties:
            entries:
              type: array
              items:
                $ref: '#/components/schemas/DecklistEntry'
            sections:
              type: object
              description: Entry ids grouped by section, for the graphic view grouping.
              additionalProperties:
                type: array
                items: { type: string, format: uuid }

    # --------------------------------------------------------- Achievement
    AchievementCriteria:
      type: object
      description: |
        Fixed vocabulary of automatic criteria. `manual` achievements do not
        carry criteria; the admin judges submitted proof against the free-text
        `description` instead.
      required: [type]
      properties:
        type:
          type: string
          enum:
            - win_matches
            - win_tournament
            - play_matches
            - play_formats
            - reach_elo
            - submit_decklist
        match_count:
          type: integer
          minimum: 1
          description: Required by `win_matches` and `play_matches`.
        tournament_count:
          type: integer
          minimum: 1
          description: Required by `win_tournament`.
        format_count:
          type: integer
          minimum: 1
          description: Required by `play_formats`.
        format_id:
          type: string
          format: uuid
          description: Restrict counting to one format. Omit for any format.
        elo_value:
          type: number
          description: Required by `reach_elo`.
        elo_scope:
          type: string
          enum: [general, format]
          description: Required by `reach_elo`. `format` also requires `format_id`.
        decklist_count:
          type: integer
          minimum: 1
          description: Required by `submit_decklist`.

    Achievement:
      type: object
      required: [id, name, award_method, is_active]
      properties:
        id: { type: string, format: uuid }
        name: { type: string, example: Triple Crown }
        description:
          type: string
          description: Human-readable criteria text.
        reward_text:
          type: string
          description: Free-text reward. Not enforced or delivered by the system.
        award_method:
          type: string
          enum: [automatic, manual]
        criteria:
          $ref: '#/components/schemas/AchievementCriteria'
        is_active: { type: boolean }

    PlayerAchievement:
      type: object
      required: [id, player_id, achievement_id, status]
      properties:
        id: { type: string, format: uuid }
        player_id: { type: string, format: uuid }
        achievement_id: { type: string, format: uuid }
        status:
          type: string
          enum: [pending_review, granted, denied]
          description: Automatic awards are created directly as `granted`.
        proof_text:
          type: string
          description: Manual submissions only.
        proof_url:
          type: string
          format: uri
        awarded_at:
          type: string
          format: date-time
          description: Null while `pending_review`.
        awarded_by:
          type: string
          format: uuid
          description: Admin who granted a manual award. Null for automatic awards.

    # ---------------------------------------------------------------- Admin
    IntegrationStatus:
      type: object
      required: [provider, enabled, configured]
      properties:
        provider:
          type: string
          enum: [challonge, melee]
        enabled: { type: boolean }
        configured:
          type: boolean
          description: True when credential material is present. The material itself is never returned.
        configured_at:
          type: string
          format: date-time
        configured_by:
          type: string
          format: uuid

    ImportQueueItem:
      type: object
      required: [id, tournament_id, external_username, status]
      properties:
        id: { type: string, format: uuid }
        tournament_id: { type: string, format: uuid }
        external_username: { type: string }
        external_user_id: { type: string }
        resolved_player_id:
          type: string
          format: uuid
          description: Null while `waiting`.
        status:
          type: string
          enum: [waiting, mapped]
        parked_match_count:
          type: integer
          description: Matches held back from rating application while unresolved.
