openapi: "3.1.0"

info:
  title: HypeCard API
  version: "1.0.0"
  description: |
    Social share-card platform. Define HTML Handlebars templates,
    create shares (filled-in instances), and get back public OG-image
    URLs suitable for social previews and deep-link redirects.

    ## Authentication
    All `/v1/` endpoints require:
    - `x-organisation-id` header
    - `Authorization: Bearer <token>` header

    Public endpoints (`/s/:shareId`, `/m/:shareId/:kind`,
    `/health`, `/docs`) require no authentication.

  contact:
    name: Monterosa Engineering
    url: https://monterosa.co/

servers:
  - url: http://localhost:3001
    description: Local development
  - url: https://{ngrokHost}
    description: ngrok tunnel (dev)
    variables:
      ngrokHost:
        default: "example.ngrok-free.app"

# ---------------------------------------------------------------------------
# Security
# ---------------------------------------------------------------------------

components:
  securitySchemes:
    BearerAuth:
      type: http
      scheme: bearer
    OrgIdHeader:
      type: apiKey
      in: header
      name: x-organisation-id
    AdminBearerAuth:
      type: http
      scheme: bearer
      description: |
        Shared admin secret configured via the `HYPECARD_ADMIN_TOKEN`
        env var on the API. Used only for the cross-tenant `/admin/*`
        routes — *never* for tenant-scoped endpoints.

  # ── Schemas ───────────────────────────────────────────────────────────────

  schemas:

    Template:
      type: object
      required: [id, name, organisationId, createdAt]
      properties:
        id:
          type: string
          format: uuid
        name:
          type: string
        organisationId:
          type: string
        spaceId:
          type: string
        projectId:
          type: string
        createdAt:
          type: string
          format: date-time

    TemplateVariableSpec:
      type: object
      required: [name, required]
      properties:
        name:
          type: string
        required:
          type: boolean
        defaultValue:
          type: string

    TemplateFormat:
      type: string
      enum: [og, story]
      description: |
        Output format determining render dimensions.
        `og` = 1200×630 (Open Graph), `story` = 1080×1920 (9:16).

    DeliveryMode:
      type: string
      enum: [bitmap, interactive, both]
      description: |
        Share representation mode.
        - `bitmap`: image/video delivery via `/m/:shareId/:kind` (default)
        - `interactive`: interactive endpoint (future)
        - `both`: expose both delivery forms

    TemplateVersion:
      type: object
      required: [id, templateId, version, format, html, styles,
                 previewType, variables, createdAt]
      properties:
        id:
          type: string
          format: uuid
        templateId:
          type: string
          format: uuid
        version:
          type: integer
          minimum: 1
        format:
          $ref: '#/components/schemas/TemplateFormat'
        html:
          type: string
          description: Full HTML with Handlebars {{variable}} tokens
        styles:
          type: string
          description: Optional CSS injected into <head>
        previewType:
          type: string
          enum: [image, video, both]
        variables:
          type: array
          items:
            $ref: '#/components/schemas/TemplateVariableSpec'
        createdAt:
          type: string
          format: date-time

    DdcOrgConfig:
      type: object
      required: [teamName, primaryColour, secondaryColour]
      properties:
        teamName:
          type: string
          description: Display name for the team or organisation
        studioProjectIds:
          type: array
          items:
            type: string
          description: Studio project UUIDs bound to this org
        primaryColour:
          type: string
          description: Primary brand colour (hex, e.g. "#C8102E")
        secondaryColour:
          type: string
          description: Secondary brand colour (hex)
        crestUrl:
          type: string
          description: URL to team crest image (SVG or PNG)
        dataSource:
          type: string
          enum: [sportmonks, opta]
          description: Preferred data source
        sportmonksTeamId:
          type: integer
          description: SportMonks v3 team ID
        optaContestantId:
          type: string
          description: Opta internal contestant ID
        optaTournamentCalendarId:
          type: string
          deprecated: true
          description: Use optaTournamentCalendarIds instead
        optaTournamentCalendarIds:
          type: array
          items:
            type: object
            required: [id, name]
            properties:
              id:
                type: string
              name:
                type: string
        competitionName:
          type: string
          description: Human-readable competition name (e.g. "La Liga")
        playerNames:
          type: object
          additionalProperties:
            type: string
          description: Short display name overrides keyed by Opta matchName

    Organisation:
      type: object
      required: [id, name, createdAt]
      properties:
        id:
          type: string
        name:
          type: string
        createdAt:
          type: string
          format: date-time

    RenderArtifact:
      type: object
      required: [id, shareId, status]
      properties:
        id:
          type: string
          format: uuid
        shareId:
          type: string
          format: uuid
        hash:
          type: string
        status:
          type: string
          enum: [pending, rendering, complete, failed]
        imageUrl:
          type: string
          format: uri
        videoUrl:
          type: string
          format: uri
        thumbnailUrl:
          type: string
          format: uri
        retryCount:
          type: integer
        errorMessage:
          type: string
        renderStartedAt:
          type: string
          format: date-time
        renderCompletedAt:
          type: string
          format: date-time
        createdAt:
          type: string
          format: date-time

    ShareStatus:
      type: string
      enum: [pending, rendering, ready, failed]

    Share:
      type: object
      required: [id, templateId, templateVersionId, organisationId,
                 format, status, destinationUrl, payload, createdAt]
      properties:
        id:
          type: string
          format: uuid
        templateId:
          type: string
          format: uuid
        templateVersionId:
          type: string
          format: uuid
        organisationId:
          type: string
        format:
          $ref: '#/components/schemas/TemplateFormat'
        deliveryMode:
          $ref: '#/components/schemas/DeliveryMode'
        status:
          $ref: '#/components/schemas/ShareStatus'
        destinationUrl:
          type: string
          format: uri
        payload:
          type: object
          additionalProperties: true
        attributionData:
          type: object
          additionalProperties: true
        expiresAt:
          type: string
          format: date-time
        createdAt:
          type: string
          format: date-time

    CreateShareResponse:
      type: object
      required: [shareId, shareUrl, previewUrl, status, format]
      properties:
        shareId:
          type: string
          format: uuid
        shareUrl:
          type: string
          format: uri
          description: Public share page URL (/r/:shareId)
        previewUrl:
          type: string
          format: uri
          description: Direct bitmap media URL (/m/:shareId/image). Backward-compatible alias of bitmapUrl.
        bitmapUrl:
          type: string
          format: uri
          description: Direct bitmap media URL (/m/:shareId/image)
        interactiveUrl:
          type: string
          format: uri
          description: Interactive experience URL (/x/:shareId). Returned when deliveryMode is interactive or both.
        status:
          $ref: '#/components/schemas/ShareStatus'
        format:
          $ref: '#/components/schemas/TemplateFormat'
        deliveryMode:
          $ref: '#/components/schemas/DeliveryMode'
        expiresAt:
          type: string
          format: date-time

    Asset:
      type: object
      required: [id, url, mimeType, filename, size, createdAt]
      properties:
        id:
          type: string
          format: uuid
        url:
          type: string
          format: uri
        mimeType:
          type: string
        filename:
          type: string
        size:
          type: integer
          description: File size in bytes
        createdAt:
          type: string
          format: date-time

    HealthResponse:
      type: object
      required: [status]
      properties:
        status:
          type: string
          enum: [OK, WARNING, ERROR]

    ErrorResponse:
      type: object
      required: [error]
      properties:
        error:
          type: string

    DataSourceItem:
      type: object
      required:
        [label, sublabel, value, imageUrl, fixtureId,
         startingAt, cached, hasLineup]
      properties:
        label:
          type: string
          description: |
            Human-readable fixture label.
            Format: "{fixtureName} - {leagueName}" or
            "{fixtureName}" when leagueName is absent.
          example: Real Madrid vs Atletico - La Liga
        sublabel:
          type: string
          description: Formatted kickoff date (en-GB locale)
          example: Sat, 15 Mar 2026, 20:00 GMT
        value:
          type: string
          format: uri
          description: |
            Card image URL. Stored in Studio element when
            the editor selects this item.
        imageUrl:
          type: string
          format: uri
          description: Same as value - enables image preview.
        fixtureId:
          type: string
          description: Source-specific fixture identifier.
        startingAt:
          type: string
          format: date-time
          description: Kickoff time (ISO 8601 UTC).
        cached:
          type: boolean
          description: |
            Whether a rendered card is stored in S3.
            Uncached fixtures render on first request.
        hasLineup:
          type: boolean
          description: |
            Whether the cached card shows a confirmed
            lineup (true) or a pre-match placeholder
            (false).

    DataSourceResponse:
      type: object
      required: [items]
      properties:
        items:
          type: array
          items:
            $ref: '#/components/schemas/DataSourceItem'
          description: |
            Fixtures sorted upcoming-first (asc by kickoff),
            then past (desc by kickoff).

# ---------------------------------------------------------------------------
# Paths
# ---------------------------------------------------------------------------

paths:

  # ── Health ──────────────────────────────────────────────────────────────

  /health:
    get:
      summary: Health check
      operationId: getHealth
      tags: [Health]
      security: []
      responses:
        "200":
          description: Service healthy
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/HealthResponse'
              example:
                status: OK

  # ── Demo config ──────────────────────────────────────────────────────────

  /config/demo:
    get:
      summary: Demo organisation credentials
      operationId: getDemoConfig
      tags: [Public]
      security: []
      description: |
        Returns the `orgId` and `apiKey` for the public demo organisation
        used by the marketing-page playground. No authentication required.

        The demo org is subject to strict rate and credit limits so
        exposure of these credentials does not create an abuse vector.
        **Never** use this endpoint pattern for production credentials.
      responses:
        "200":
          description: Demo credentials
          content:
            application/json:
              schema:
                type: object
                required: [orgId, apiKey]
                properties:
                  orgId:
                    type: string
                    example: demo-org
                  apiKey:
                    type: string
                    example: dev-token

  # ── Docs ────────────────────────────────────────────────────────────────

  /docs:
    get:
      summary: API reference (rendered HTML)
      operationId: getDocs
      tags: [Documentation]
      security: []
      responses:
        "200":
          description: HTML page
          content:
            text/html:
              schema:
                type: string

  # ── Templates ───────────────────────────────────────────────────────────

  /v1/templates:
    get:
      summary: List templates
      operationId: listTemplates
      tags: [Templates]
      security:
        - BearerAuth: []
          OrgIdHeader: []
      description: |
        Returns all templates for the organisation, including system
        templates (organisationId `_system`) which are visible to all tenants.
      responses:
        "200":
          description: Template list
          content:
            application/json:
              schema:
                type: object
                required: [items]
                properties:
                  items:
                    type: array
                    items:
                      $ref: '#/components/schemas/Template'
        "401":
          description: Missing or invalid auth
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'

    post:
      summary: Create template
      operationId: createTemplate
      tags: [Templates]
      security:
        - BearerAuth: []
          OrgIdHeader: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [name]
              properties:
                name:
                  type: string
                spaceId:
                  type: string
                projectId:
                  type: string
            example:
              name: Score Card
      responses:
        "201":
          description: Template created
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Template'
        "400":
          description: Validation error
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'

  /v1/templates/{templateId}/versions:
    post:
      summary: Add template version
      operationId: createTemplateVersion
      tags: [Templates]
      security:
        - BearerAuth: []
          OrgIdHeader: []
      description: |
        Add an HTML version to a template. Version numbers increment
        automatically (v1, v2, …). Use Handlebars `{{variableName}}`
        tokens for dynamic content.

        The `format` field determines render dimensions:
        - `og` (default) → 1200×630 px (Open Graph)
        - `story` → 1080×1920 px (9:16 portrait for Instagram/TikTok)

        A single template can hold versions of any format.
      parameters:
        - name: templateId
          in: path
          required: true
          schema:
            type: string
            format: uuid
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [html]
              properties:
                format:
                  $ref: '#/components/schemas/TemplateFormat'
                html:
                  type: string
                  description: |
                    Full HTML with Handlebars {{variable}} tokens.
                    Dimensions depend on `format`.
                styles:
                  type: string
                  description: Optional CSS injected into <head>
                previewType:
                  type: string
                  enum: [image, video, both]
                  default: image
                variables:
                  type: array
                  items:
                    $ref: '#/components/schemas/TemplateVariableSpec'
            example:
              format: og
              html: |
                <!DOCTYPE html>
                <html><head><style>
                  body { width: 1200px; height: 630px; display: flex;
                         align-items: center; justify-content: center;
                         background: #1a1a2e; color: #fff;
                         font-family: sans-serif; }
                  h1 { font-size: 80px; font-weight: 900; }
                </style></head>
                <body><h1>{{playerName}} scored {{score}}</h1></body></html>
              previewType: image
              variables:
                - name: playerName
                  required: true
                - name: score
                  required: false
                  defaultValue: "0"
      responses:
        "201":
          description: Version created
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/TemplateVersion'
        "400":
          description: Validation error
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        "404":
          description: Template not found
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'

  /v1/templates/{templateId}/versions/{versionId}:
    get:
      summary: Get template version
      operationId: getTemplateVersion
      tags: [Templates]
      security:
        - BearerAuth: []
          OrgIdHeader: []
      description: |
        Returns the full template version including `html` and `styles`.

        Pass `?format=html` to receive raw HTML (Content-Type: text/html)
        suitable for piping directly to a local file:
        ```
        curl "...versions/latest?format=html" > template.html
        ```
      parameters:
        - name: templateId
          in: path
          required: true
          schema:
            type: string
            format: uuid
        - name: versionId
          in: path
          required: true
          schema:
            type: string
          description: |
            A version UUID, or the string `"latest"` to get the
            most recent version.
        - name: format
          in: query
          required: false
          schema:
            type: string
            enum: [html]
          description: |
            Pass `html` to return raw HTML instead of JSON.
      responses:
        "200":
          description: Template version
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/TemplateVersion'
            text/html:
              schema:
                type: string
        "404":
          description: Version not found
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'

  # ── Shares ───────────────────────────────────────────────────────────────

  /v1/shares:
    post:
      summary: Create share
      operationId: createShare
      tags: [Shares]
      security:
        - BearerAuth: []
          OrgIdHeader: []
      description: |
        Creates a share from a template. Rendering is async — poll
        `GET /v1/shares/{shareId}` until `status` is `"ready"`.

        The `format` field selects which template format to render:
        - `og` (default) → uses the latest OG (1200×630) version
        - `story` → uses the latest story (1080×1920) version

        If no version of the requested format exists, returns 400.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [templateId, payload, destinationUrl]
              properties:
                templateId:
                  type: string
                  format: uuid
                templateVersionId:
                  type: string
                  format: uuid
                  description: |
                    Pin to a specific version; omit to use latest
                    matching the requested format
                format:
                  $ref: '#/components/schemas/TemplateFormat'
                deliveryMode:
                  $ref: '#/components/schemas/DeliveryMode'
                payload:
                  type: object
                  additionalProperties: true
                  description: Key/value data for Handlebars tokens
                destinationUrl:
                  type: string
                  format: uri
                  description: |
                    Where humans are redirected when they click
                    the share
                attributionData:
                  type: object
                  additionalProperties: true
                  description: Merged into redirect URL query params
                expirySeconds:
                  type: integer
                  minimum: 60
                  description: |
                    Time-to-live in seconds (default 30 days)
            example:
              templateId: "00000000-0000-0000-0000-000000000001"
              format: og
              deliveryMode: bitmap
              payload:
                playerName: Alice
                score: "4200"
              destinationUrl: "https://example.com/results"
              attributionData:
                campaign: twitter
      responses:
        "201":
          description: Share created
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/CreateShareResponse'
        "400":
          description: Validation error
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        "404":
          description: Template or version not found
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'

  /v1/shares/{shareId}:
    get:
      summary: Get share status
      operationId: getShare
      tags: [Shares]
      security:
        - BearerAuth: []
          OrgIdHeader: []
      parameters:
        - name: shareId
          in: path
          required: true
          schema:
            type: string
            format: uuid
      responses:
        "200":
          description: Share details
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Share'
        "404":
          description: Share not found
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'

    delete:
      summary: Delete share
      operationId: deleteShare
      tags: [Shares]
      security:
        - BearerAuth: []
          OrgIdHeader: []
      parameters:
        - name: shareId
          in: path
          required: true
          schema:
            type: string
            format: uuid
      responses:
        "204":
          description: Deleted
        "404":
          description: Share not found
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'

  /v1/shares/{shareId}/render:
    post:
      summary: Force re-render
      operationId: renderShare
      tags: [Shares]
      security:
        - BearerAuth: []
          OrgIdHeader: []
      description: |
        Triggers a new Puppeteer render for an existing share.
        Useful after editing the underlying template version.
      parameters:
        - name: shareId
          in: path
          required: true
          schema:
            type: string
            format: uuid
      responses:
        "202":
          description: Render accepted
        "404":
          description: Share not found
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'

  # ── Public share endpoints ───────────────────────────────────────────────

  /r/{shareId}:
    get:
      summary: Public share page
      operationId: serveRedirectPage
      tags: [Public]
      security: []
      description: |
        Behaviour depends on the User-Agent:
        - **Crawlers** (Twitterbot, facebookexternalhit, LinkedInBot, etc.) →
          Returns HTML with Open Graph meta tags (`og:image` points to the
          rendered 1200×630 PNG once ready).
        - **Humans** (browser UA) →
          Returns an HTML share page containing the rendered card preview,
          sharing action buttons (Web Share API on mobile, platform links
          on desktop), a story image download button, and a
          "Continue to destination" link. The page also includes OG meta
          tags so forwarded links unfurl correctly on social platforms.
      parameters:
        - name: shareId
          in: path
          required: true
          schema:
            type: string
            format: uuid
      responses:
        "200":
          description: OG meta HTML (crawler)
          content:
            text/html:
              schema:
                type: string
        "302":
          description: Redirect to destination (human)
          headers:
            Location:
              schema:
                type: string

  /m/{shareId}/{kind}:
    get:
      summary: Serve media asset
      operationId: serveMedia
      security: []
      tags: [Public]
      description: |
        Returns the rendered media asset for a share.
        - Returns 404 if the share does not exist.
        - Returns 503 if the share is still rendering.
        - For `"image"`, returns PNG.
      parameters:
        - name: shareId
          in: path
          required: true
          schema:
            type: string
            format: uuid
        - name: kind
          in: path
          required: true
          schema:
            type: string
            enum: [image, video, thumbnail]
          description: |
            - `image` — rendered PNG (dimensions depend on
              share format: 1200×630 for OG, 1080×1920 for story)
            - `video` — MP4 preview
            - `thumbnail` — JPEG thumbnail (640×320)

            Story-format shares include
            `Content-Disposition: attachment` on the image
            endpoint for convenient downloading.
      responses:
        "200":
          description: Media file
          content:
            image/png:
              schema:
                type: string
                format: binary
            video/mp4:
              schema:
                type: string
                format: binary
        "404":
          description: Share or artifact not found
        "503":
          description: Render not yet complete

  # ── Assets ───────────────────────────────────────────────────────────────

  /v1/assets:
    get:
      summary: List assets
      operationId: listAssets
      tags: [Assets]
      security:
        - BearerAuth: []
          OrgIdHeader: []
      responses:
        "200":
          description: Asset list
          content:
            application/json:
              schema:
                type: object
                required: [items]
                properties:
                  items:
                    type: array
                    items:
                      $ref: '#/components/schemas/Asset'

    post:
      summary: Upload asset
      operationId: uploadAsset
      tags: [Assets]
      security:
        - BearerAuth: []
          OrgIdHeader: []
      description: |
        Upload an image or other binary asset. Use the returned `url`
        directly in template HTML (e.g. `<img src="{{logoUrl}}">`).
      requestBody:
        required: true
        content:
          multipart/form-data:
            schema:
              type: object
              required: [file]
              properties:
                file:
                  type: string
                  format: binary
      responses:
        "201":
          description: Asset uploaded
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Asset'
        "400":
          description: No file provided
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'

  # ── DDC - Data-Driven Content ────────────────────────────────────────────

  /ddc/prefill:
    post:
      summary: Unified Studio prefill endpoint
      operationId: postDdcPrefill
      tags: [DDC]
      security: []
      description: |
        All Studio elements point their `prefill.url` at this single endpoint.
        DDC routes on `element.content_type` in the request body to the
        appropriate card-type handler and returns field values for the
        Studio element picker.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              properties:
                project_id:
                  type: string
                project:
                  type: object
                  properties:
                    custom_fields:
                      type: object
                      additionalProperties: true
                element:
                  type: object
                  properties:
                    content_type:
                      type: string
                      description: |
                        Controls which card type is returned.
                        e.g. `hypecard-lineup`, `hypecard-result`, `hypecard-form`
                    custom_fields:
                      type: object
                      additionalProperties: true
      responses:
        "200":
          description: Prefill field values for the Studio element picker
          content:
            application/json:
              schema:
                type: object
                description: Studio prefill response shape (varies by content_type)
                additionalProperties: true
        "400":
          description: Cannot resolve org or missing required fields
        "500":
          description: Prefill handler error

  /ddc/{orgSlug}/tournaments:
    get:
      summary: List available and active tournament calendars
      operationId: getDdcTournaments
      tags: [DDC]
      security: []
      description: |
        Fetches all available Opta tournament calendars for the org's contestant
        and marks which are currently active. Requires Opta credentials to be
        configured server-side.
      parameters:
        - name: orgSlug
          in: path
          required: true
          schema:
            type: string
      responses:
        "200":
          description: Tournament calendars
          content:
            application/json:
              schema:
                type: object
                required: [available, active]
                properties:
                  available:
                    type: array
                    items:
                      type: object
                      required: [id, name, active]
                      properties:
                        id:
                          type: string
                        name:
                          type: string
                        active:
                          type: boolean
                  active:
                    type: array
                    items:
                      type: object
                      required: [id, name]
                      properties:
                        id:
                          type: string
                        name:
                          type: string
        "404":
          description: Unknown org slug
        "500":
          description: Opta credentials not configured or upstream error

    put:
      summary: Set active tournament calendars for an org
      operationId: putDdcTournaments
      tags: [DDC]
      security:
        - BearerAuth: []
      description: |
        Replaces the org's active tournament calendar list. Pass the `id` + `name`
        pairs from `GET /ddc/{orgSlug}/tournaments` for the competitions you want
        to activate.
      parameters:
        - name: orgSlug
          in: path
          required: true
          schema:
            type: string
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [tournaments]
              properties:
                tournaments:
                  type: array
                  items:
                    type: object
                    required: [id, name]
                    properties:
                      id:
                        type: string
                      name:
                        type: string
      responses:
        "200":
          description: Tournaments updated
          content:
            application/json:
              schema:
                type: object
                required: [ok, tournaments]
                properties:
                  ok:
                    type: boolean
                  tournaments:
                    type: array
                    items:
                      type: object
                      required: [id, name]
                      properties:
                        id:
                          type: string
                        name:
                          type: string
        "400":
          description: Body must include tournaments array
        "401":
          description: Missing or invalid API key
        "404":
          description: Unknown org slug

  /ddc/{orgSlug}/players/names:
    get:
      summary: Get player name overrides for an org
      operationId: getDdcPlayerNames
      tags: [DDC]
      security:
        - BearerAuth: []
      description: |
        Returns the current `playerNames` mapping (Opta matchName → display name)
        and, for Opta orgs, the raw matchName strings from the most recent
        confirmed lineup. Use these to populate the mapping.
      parameters:
        - name: orgSlug
          in: path
          required: true
          schema:
            type: string
      responses:
        "200":
          description: Player name data
          content:
            application/json:
              schema:
                type: object
                required: [playerNames]
                properties:
                  playerNames:
                    type: object
                    additionalProperties:
                      type: string
                    description: Current name override map
                  optaNames:
                    type: array
                    items:
                      type: string
                    description: |
                      Raw Opta matchName strings (Opta orgs only).
                      Use these as keys when adding overrides.
        "401":
          description: Missing or invalid API key
        "404":
          description: Unknown org slug

  /ddc/{orgSlug}/config:
    patch:
      summary: Update player name overrides for an org
      operationId: patchDdcOrgConfig
      tags: [DDC]
      security:
        - BearerAuth: []
      description: |
        Merges `playerNames` additions into the org's config and/or removes
        specific keys. Changes take effect immediately without a server restart.
      parameters:
        - name: orgSlug
          in: path
          required: true
          schema:
            type: string
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              properties:
                playerNames:
                  type: object
                  additionalProperties:
                    type: string
                  description: |
                    Key-value pairs to merge in. Key = Opta matchName,
                    value = short display name shown on cards.
                  example:
                    "Vinícius Júnior": "Vini Jr"
                    "Kylian Mbappé": "Mbappé"
                remove:
                  type: array
                  items:
                    type: string
                  description: Opta matchName keys to remove from the mapping.
      responses:
        "200":
          description: Config updated
          content:
            application/json:
              schema:
                type: object
                required: [ok]
                properties:
                  ok:
                    type: boolean
                  playerNames:
                    type: object
                    additionalProperties:
                      type: string
        "400":
          description: Invalid request body
        "401":
          description: Missing or invalid API key
        "404":
          description: Unknown org slug

  /ddc/{orgSlug}/{skill}/{filename}:
    get:
      summary: Render a DDC skill card
      operationId: getDdcRender
      tags: [DDC]
      security: []
      description: |
        Renders a skill card for a specific fixture and returns the image.
        The `filename` encodes both the fixture ID and the desired format:

        - `{fixtureId}.png` — PNG image
        - `{fixtureId}.jpg` — JPEG image
        - `{fixtureId}.html` — raw HTML (for debugging)

        Supported skills: `lineup`, `lineup-dual`, `preview`, `result`,
        `matchstats`, `form`, `h2h`, `scorers`.

        Rendered images are cached and served with `Cache-Control: public, max-age=300`.
      parameters:
        - name: orgSlug
          in: path
          required: true
          schema:
            type: string
        - name: skill
          in: path
          required: true
          schema:
            type: string
            enum: [lineup, lineup-dual, preview, result, matchstats, form, h2h, scorers]
        - name: filename
          in: path
          required: true
          schema:
            type: string
          description: |
            `{fixtureId}.png`, `{fixtureId}.jpg`, or `{fixtureId}.html`
      responses:
        "200":
          description: Rendered card image or HTML
          content:
            image/png:
              schema:
                type: string
                format: binary
            image/jpeg:
              schema:
                type: string
                format: binary
            text/html:
              schema:
                type: string
        "404":
          description: Unknown org slug or skill
        "500":
          description: Render failed

  /ddc/{orgSlug}/players/{playerId}/image:
    post:
      summary: Upload a player headshot
      operationId: postDdcPlayerImage
      tags: [DDC]
      security:
        - BearerAuth: []
      description: |
        Accepts a raw JPEG or PNG image (max 5 MB), resizes it to a 176×176 JPEG
        thumbnail, and stores it for use in rendered lineup cards.
        The `playerId` should match the jersey number or stable player identifier
        used in your roster config.
      parameters:
        - name: orgSlug
          in: path
          required: true
          schema:
            type: string
        - name: playerId
          in: path
          required: true
          schema:
            type: string
      requestBody:
        required: true
        content:
          image/jpeg:
            schema:
              type: string
              format: binary
          image/png:
            schema:
              type: string
              format: binary
      responses:
        "200":
          description: Image uploaded and resized
          content:
            application/json:
              schema:
                type: object
                required: [playerId, orgSlug, bytes]
                properties:
                  playerId:
                    type: string
                  orgSlug:
                    type: string
                  bytes:
                    type: integer
                    description: Size of the stored thumbnail in bytes
        "400":
          description: Invalid org slug, player ID, or image body
        "401":
          description: Missing or invalid API key
        "404":
          description: Unknown org slug

  /ddc/{skill}/source:
    post:
      summary: External Data Source - Studio integration (POST)
      operationId: postDdcDataSource
      tags: [DDC]
      security: []
      description: |
        Studio integration endpoint. The `source.url` in an element
        spec is a fixed URL - the org cannot be in the path. Instead,
        the platform POSTs the project context and DDC reads
        `project.custom_fields.hypecard_orgSlug` from the body.

        **Studio element spec:**
        ```json
        "source": {
          "url": "https://api-dev.hypecard.io/ddc/lineup/source",
          "method": "post"
        }
        ```

        **Project settings:** add `hypecard_orgSlug` as a custom
        field on the project (e.g. `"real-madrid-mens"`). The
        platform includes it in every POST body automatically.

        Returns the same `DataSourceResponse` as the GET variant.
      parameters:
        - name: skill
          in: path
          required: true
          schema:
            type: string
            enum: [lineup]
          description: DDC skill name. Only "lineup" is supported.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              properties:
                project_id:
                  type: string
                project:
                  type: object
                  properties:
                    custom_fields:
                      type: object
                      required: [hypecard_orgSlug]
                      properties:
                        hypecard_orgSlug:
                          type: string
                          description: |
                            Org slug configured in project
                            settings (e.g. "real-madrid-mens")
                      additionalProperties: true
                element:
                  type: object
                  properties:
                    content_type:
                      type: string
                    custom_fields:
                      type: object
                      additionalProperties: true
            example:
              project_id: proj-001
              project:
                custom_fields:
                  hypecard_orgSlug: real-madrid-mens
              element:
                content_type: hypecard-element
                custom_fields: {}
      responses:
        "200":
          description: Data source items
          headers:
            Cache-Control:
              schema:
                type: string
                example: no-store
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/DataSourceResponse'
        "400":
          description: Missing or invalid hypecard_orgSlug in body
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        "404":
          description: Unknown org slug
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'

  /ddc/{orgSlug}/{skill}/source:
    get:
      summary: External Data Source - direct access (GET)
      operationId: getDdcDataSource
      tags: [DDC]
      description: |
        Direct/curl access variant. Returns the same fixture card
        items as the POST endpoint but reads orgSlug from the URL
        path. Use this for development, testing, and the test
        harness. Studio deployments should use the POST variant.

        **Sort order:** upcoming fixtures first (asc by kickoff),
        then past fixtures (desc by kickoff). Uncached fixtures
        still appear - they render on first request.
      security: []
      parameters:
        - name: orgSlug
          in: path
          required: true
          schema:
            type: string
          description: Organisation slug (e.g. "real-madrid-mens")
        - name: skill
          in: path
          required: true
          schema:
            type: string
            enum: [lineup]
          description: DDC skill name. Only "lineup" is supported.
      responses:
        "200":
          description: Data source items
          headers:
            Cache-Control:
              schema:
                type: string
                example: no-store
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/DataSourceResponse'
              example:
                items:
                  - label: Real Madrid vs Atletico - La Liga
                    sublabel: Sat, 15 Mar 2026, 20:00 GMT
                    value: "https://api-dev.hypecard.io/ddc/real-madrid-mens/lineup/abc123.jpg"
                    imageUrl: "https://api-dev.hypecard.io/ddc/real-madrid-mens/lineup/abc123.jpg"
                    fixtureId: abc123
                    startingAt: "2026-03-15T20:00:00.000Z"
                    cached: true
                    hasLineup: true
        "400":
          description: Unsupported skill
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        "404":
          description: Unknown org slug
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'

  # ── Settings ─────────────────────────────────────────────────────────────

  /v1/settings/effective:
    get:
      summary: Get effective settings
      operationId: getEffectiveSettings
      tags: [Settings]
      security:
        - BearerAuth: []
          OrgIdHeader: []
      description: |
        Returns the merged settings for the given scope hierarchy
        (organisation → space → project). Query params override the
        header values for the scope lookup.
      parameters:
        - name: orgId
          in: query
          schema:
            type: string
        - name: spaceId
          in: query
          schema:
            type: string
        - name: projectId
          in: query
          schema:
            type: string
      responses:
        "200":
          description: Effective settings object
          content:
            application/json:
              schema:
                type: object
                additionalProperties: true

  # ── Admin (cross-tenant; requires HYPECARD_ADMIN_TOKEN) ─────────────────

  /admin/orgs:
    post:
      summary: Provision a new organisation
      operationId: adminCreateOrg
      tags: [Admin]
      security:
        - AdminBearerAuth: []
      description: |
        Creates a new organisation and issues its first Bearer token.
        Both the `id` (slug + random suffix) and the `apiKey` are
        generated server-side. The returned `apiKey` is shown once —
        callers must persist it immediately.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [name]
              properties:
                name:
                  type: string
                  description: Display name for the organisation
                id:
                  type: string
                  description: |
                    Optional slug prefix. A random suffix is always
                    appended server-side to guarantee uniqueness.
      responses:
        "201":
          description: Organisation created
          content:
            application/json:
              schema:
                type: object
                required: [organisation, apiKey]
                properties:
                  organisation:
                    $ref: '#/components/schemas/Organisation'
                  apiKey:
                    type: string
                    description: |
                      Opaque Bearer token. Shown once; cannot be
                      retrieved later.
        "400":
          description: Missing name
        "401":
          description: Missing or invalid admin token
        "409":
          description: Organisation id already exists
        "503":
          description: HYPECARD_ADMIN_TOKEN not configured

    get:
      summary: List all organisations
      operationId: adminListOrgs
      tags: [Admin]
      security:
        - AdminBearerAuth: []
      responses:
        "200":
          description: All organisations
          content:
            application/json:
              schema:
                type: object
                required: [organisations]
                properties:
                  organisations:
                    type: array
                    items:
                      $ref: '#/components/schemas/Organisation'
        "401":
          description: Missing or invalid admin token
        "503":
          description: HYPECARD_ADMIN_TOKEN not configured

  /admin/orgs/{id}:
    get:
      summary: Get a single organisation
      operationId: adminGetOrg
      tags: [Admin]
      security:
        - AdminBearerAuth: []
      parameters:
        - name: id
          in: path
          required: true
          schema:
            type: string
      responses:
        "200":
          description: Organisation found
          content:
            application/json:
              schema:
                type: object
                required: [organisation]
                properties:
                  organisation:
                    $ref: '#/components/schemas/Organisation'
        "401":
          description: Missing or invalid admin token
        "404":
          description: Organisation not found

  /admin/ddc/orgs:
    get:
      summary: List all DDC org configs
      operationId: adminListDdcOrgs
      tags: [Admin]
      security:
        - AdminBearerAuth: []
      description: Returns all configured DDC org slugs and their full configs.
      responses:
        "200":
          description: All DDC orgs
          content:
            application/json:
              schema:
                type: object
                required: [orgs]
                properties:
                  orgs:
                    type: array
                    items:
                      type: object
                      required: [slug, config]
                      properties:
                        slug:
                          type: string
                        config:
                          $ref: '#/components/schemas/DdcOrgConfig'
        "401":
          description: Missing or invalid admin token
        "503":
          description: HYPECARD_ADMIN_TOKEN not configured

    post:
      summary: Create a DDC org config
      operationId: adminCreateDdcOrg
      tags: [Admin]
      security:
        - AdminBearerAuth: []
      description: |
        Creates a new DDC org. The `slug` becomes the URL segment used
        in `/ddc/{orgSlug}/*` endpoints. Fails with 409 if the slug
        already exists — use PUT to replace.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              allOf:
                - type: object
                  required: [slug]
                  properties:
                    slug:
                      type: string
                      pattern: '^[a-zA-Z0-9_-]{1,64}$'
                      description: URL-safe identifier for this org
                - $ref: '#/components/schemas/DdcOrgConfig'
      responses:
        "201":
          description: Org created
          content:
            application/json:
              schema:
                type: object
                required: [slug, config]
                properties:
                  slug:
                    type: string
                  config:
                    $ref: '#/components/schemas/DdcOrgConfig'
        "400":
          description: Missing or invalid fields
        "401":
          description: Missing or invalid admin token
        "409":
          description: Org slug already exists
        "503":
          description: HYPECARD_ADMIN_TOKEN not configured

  /admin/ddc/orgs/{slug}:
    get:
      summary: Get a single DDC org config
      operationId: adminGetDdcOrg
      tags: [Admin]
      security:
        - AdminBearerAuth: []
      parameters:
        - name: slug
          in: path
          required: true
          schema:
            type: string
      responses:
        "200":
          description: Org config
          content:
            application/json:
              schema:
                type: object
                required: [slug, config]
                properties:
                  slug:
                    type: string
                  config:
                    $ref: '#/components/schemas/DdcOrgConfig'
        "401":
          description: Missing or invalid admin token
        "404":
          description: Unknown org slug
        "503":
          description: HYPECARD_ADMIN_TOKEN not configured

    put:
      summary: Replace a DDC org config
      operationId: adminReplaceDdcOrg
      tags: [Admin]
      security:
        - AdminBearerAuth: []
      description: |
        Full replacement of an org's config. Creates the org if it does
        not exist yet (upsert semantics). Use PATCH for partial updates.
      parameters:
        - name: slug
          in: path
          required: true
          schema:
            type: string
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/DdcOrgConfig'
      responses:
        "200":
          description: Org replaced
          content:
            application/json:
              schema:
                type: object
                required: [slug, config]
                properties:
                  slug:
                    type: string
                  config:
                    $ref: '#/components/schemas/DdcOrgConfig'
        "400":
          description: Missing required fields
        "401":
          description: Missing or invalid admin token
        "503":
          description: HYPECARD_ADMIN_TOKEN not configured

    patch:
      summary: Partially update a DDC org config
      operationId: adminPatchDdcOrg
      tags: [Admin]
      security:
        - AdminBearerAuth: []
      description: |
        Shallow-merges the request body into the existing org config.
        Only the fields included in the body are updated; omitted fields
        are left unchanged. The org must already exist — use PUT to create.
      parameters:
        - name: slug
          in: path
          required: true
          schema:
            type: string
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/DdcOrgConfig'
      responses:
        "200":
          description: Org updated
          content:
            application/json:
              schema:
                type: object
                required: [slug, config]
                properties:
                  slug:
                    type: string
                  config:
                    $ref: '#/components/schemas/DdcOrgConfig'
        "401":
          description: Missing or invalid admin token
        "404":
          description: Unknown org slug
        "503":
          description: HYPECARD_ADMIN_TOKEN not configured

    delete:
      summary: Remove a DDC org config
      operationId: adminDeleteDdcOrg
      tags: [Admin]
      security:
        - AdminBearerAuth: []
      parameters:
        - name: slug
          in: path
          required: true
          schema:
            type: string
      responses:
        "204":
          description: Org removed
        "401":
          description: Missing or invalid admin token
        "404":
          description: Unknown org slug
        "503":
          description: HYPECARD_ADMIN_TOKEN not configured

  /admin/shares/{id}:
    get:
      summary: Inspect a share cross-tenant
      operationId: adminGetShare
      tags: [Admin]
      security:
        - AdminBearerAuth: []
      description: |
        Returns a share and its render artefact regardless of owning
        organisation — distinct from `/v1/shares/{shareId}` which is
        tenant-scoped. Intended for operator debugging only.
      parameters:
        - name: id
          in: path
          required: true
          schema:
            type: string
            format: uuid
      responses:
        "200":
          description: Share found
          content:
            application/json:
              schema:
                type: object
                required: [share, artifact]
                properties:
                  share:
                    $ref: '#/components/schemas/Share'
                  artifact:
                    oneOf:
                      - $ref: '#/components/schemas/RenderArtifact'
                      - type: 'null'
        "401":
          description: Missing or invalid admin token
        "404":
          description: Share not found

# ---------------------------------------------------------------------------
# Tags (display order in docs)
# ---------------------------------------------------------------------------

tags:
  - name: Health
    description: Service health
  - name: Documentation
    description: API reference
  - name: Templates
    description: HTML template management
  - name: Shares
    description: Share creation and lifecycle
  - name: Public
    description: Public-facing share and media endpoints
  - name: Assets
    description: Binary asset upload
  - name: Settings
    description: Hierarchical settings
  - name: DDC
    description: Data-Driven Content - server-side rendered cards
  - name: Admin
    description: |
      Cross-tenant operator endpoints. Require a separate admin
      token (`HYPECARD_ADMIN_TOKEN`) distinct from tenant API keys.
