openapi: 3.1.0
info:
  title: Frag Fitty Public API
  version: "1.1.0"
  summary: Public, AI-agent, and machine-readable endpoints for Frag Fitty.
  description: |
    OpenAPI 3.1 specification for Frag Fitty's public website, public JSON APIs,
    AI discovery documents, chatbot endpoints, ACP checkout-session endpoints,
    badges, and embeddable widgets.

    This document intentionally excludes authenticated admin and studio-owner HTML
    workflows. Those routes are documented in the in-repository Rails docs.

    Default content language is German (`de-DE`) unless an English route is used.
  contact:
    name: Frag Fitty
    email: hi@fragfitty.de
    url: https://fragfitty.de
  license:
    name: Proprietary
    url: https://fragfitty.de/impressum
servers:
  - url: https://fragfitty.de
    description: Production
security: []
tags:
  - name: Pages
    description: Public HTML pages and SEO landing pages.
  - name: Search
    description: Studio, city, article, and filter discovery endpoints.
  - name: Studio
    description: Public studio detail and auxiliary endpoints.
  - name: AI Discovery
    description: Machine-readable files for crawlers and AI agents.
  - name: Chat
    description: KI-Agent and KI-Suche streaming endpoints.
  - name: ACP Checkout
    description: ACP/UCP trial-booking checkout-session endpoints.
  - name: Widgets
    description: Public embed scripts and dynamic badges.
  - name: Health
    description: Health and uptime endpoints.

paths:
  /:
    get:
      tags: [Pages, Search]
      operationId: getHomepage
      summary: Startseite mit Studiosuche
      parameters:
        - $ref: "#/components/parameters/LocationQuery"
        - $ref: "#/components/parameters/RadiusQuery"
        - $ref: "#/components/parameters/MaxPriceQuery"
        - $ref: "#/components/parameters/SortQuery"
        - $ref: "#/components/parameters/NameQuery"
        - $ref: "#/components/parameters/FeatureIdsQuery"
        - $ref: "#/components/parameters/BrandIdsQuery"
      responses:
        "200":
          $ref: "#/components/responses/HtmlPage"

  /gym:
    get:
      tags: [Search, Studio]
      operationId: listStudios
      summary: Studios suchen und filtern
      parameters:
        - $ref: "#/components/parameters/LocationQuery"
        - $ref: "#/components/parameters/CityQuery"
        - $ref: "#/components/parameters/RadiusQuery"
        - $ref: "#/components/parameters/MaxPriceQuery"
        - $ref: "#/components/parameters/SortQuery"
        - $ref: "#/components/parameters/NameQuery"
        - $ref: "#/components/parameters/PageQuery"
        - $ref: "#/components/parameters/ViewQuery"
        - $ref: "#/components/parameters/FeatureIdsQuery"
        - $ref: "#/components/parameters/BrandIdsQuery"
      responses:
        "200":
          $ref: "#/components/responses/HtmlPage"

  /gym/count:
    get:
      tags: [Search]
      operationId: countStudios
      summary: Trefferanzahl für Studiofilter abrufen
      parameters:
        - $ref: "#/components/parameters/LocationQuery"
        - $ref: "#/components/parameters/CityQuery"
        - $ref: "#/components/parameters/RadiusQuery"
        - $ref: "#/components/parameters/MaxPriceQuery"
        - $ref: "#/components/parameters/SortQuery"
        - $ref: "#/components/parameters/NameQuery"
        - $ref: "#/components/parameters/FeatureIdsQuery"
        - $ref: "#/components/parameters/BrandIdsQuery"
        - name: top
          in: query
          required: false
          description: Optional number of top studio previews to include.
          schema:
            type: integer
            minimum: 0
            maximum: 100
      responses:
        "200":
          description: Trefferanzahl und optional Studio-Previews.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/StudioCountResponse"

  /gym/{id}:
    get:
      tags: [Studio]
      operationId: getStudio
      summary: Studio-Profil abrufen
      parameters:
        - $ref: "#/components/parameters/StudioSlugPath"
      responses:
        "200":
          $ref: "#/components/responses/HtmlPage"
        "404":
          $ref: "#/components/responses/NotFound"

  /gym/{id}/tariffs:
    get:
      tags: [Studio]
      operationId: getStudioTariffsFragment
      summary: Tariffragment für Studio abrufen
      parameters:
        - $ref: "#/components/parameters/StudioSlugPath"
      responses:
        "200":
          description: Turbo Stream fragment with tariffs.
          content:
            text/vnd.turbo-stream.html:
              schema:
                type: string
        "403":
          $ref: "#/components/responses/Forbidden"
        "404":
          $ref: "#/components/responses/NotFound"

  /gym/{id}/oeffnungszeiten:
    get:
      tags: [Studio]
      operationId: getStudioOpeningHoursPage
      summary: Öffnungszeiten-Seite eines Studios
      parameters:
        - $ref: "#/components/parameters/StudioSlugPath"
      responses:
        "200":
          $ref: "#/components/responses/HtmlPage"
        "404":
          $ref: "#/components/responses/NotFound"

  /gym/{id}/preise:
    get:
      tags: [Studio]
      operationId: getStudioPricesPage
      summary: Preis-Seite eines Studios
      parameters:
        - $ref: "#/components/parameters/StudioSlugPath"
      responses:
        "200":
          $ref: "#/components/responses/HtmlPage"
        "302":
          description: Redirect when price information is restricted.
        "404":
          $ref: "#/components/responses/NotFound"

  /gym/{id}/kuendigung:
    get:
      tags: [Studio, Pages]
      operationId: getStudioCancellationPage
      summary: Kündigungsschreiben-Tool für ein Studio
      parameters:
        - $ref: "#/components/parameters/StudioSlugPath"
      responses:
        "200":
          $ref: "#/components/responses/HtmlPage"
        "404":
          $ref: "#/components/responses/NotFound"

  /gym/{id}/kuendigung/download:
    post:
      tags: [Studio]
      operationId: downloadStudioCancellationPdf
      summary: Kündigungsschreiben als PDF erzeugen
      parameters:
        - $ref: "#/components/parameters/StudioSlugPath"
      requestBody:
        required: true
        content:
          application/x-www-form-urlencoded:
            schema:
              $ref: "#/components/schemas/CancellationLetterRequest"
      responses:
        "200":
          description: PDF download.
          content:
            application/pdf:
              schema:
                type: string
                contentEncoding: binary
        "302":
          description: Redirect back to form when required fields are missing.

  /in/{city}:
    get:
      tags: [Pages, Search]
      operationId: getCityLandingPage
      summary: Stadt-Landingpage
      parameters:
        - name: city
          in: path
          required: true
          description: City name, for example `Berlin` or `Köln`.
          schema:
            type: string
        - $ref: "#/components/parameters/FeatureIdsQuery"
        - $ref: "#/components/parameters/BrandIdsQuery"
        - $ref: "#/components/parameters/SortQuery"
        - $ref: "#/components/parameters/PageQuery"
      responses:
        "200":
          $ref: "#/components/responses/HtmlPage"

  /in/{city}/{radius}:
    get:
      tags: [Pages, Search]
      operationId: getCityRadiusLandingPage
      summary: Stadt-Landingpage mit Radius
      parameters:
        - name: city
          in: path
          required: true
          schema:
            type: string
        - name: radius
          in: path
          required: true
          description: Radius segment such as `2km`, `5km`, `10km`, or `20km`.
          schema:
            type: string
            pattern: "^\\d+km$"
        - $ref: "#/components/parameters/FeatureIdsQuery"
        - $ref: "#/components/parameters/BrandIdsQuery"
        - $ref: "#/components/parameters/SortQuery"
        - $ref: "#/components/parameters/PageQuery"
      responses:
        "200":
          $ref: "#/components/responses/HtmlPage"

  /marke/{slug}:
    get:
      tags: [Pages, Search]
      operationId: getBrandPage
      summary: Marken-Seite abrufen
      parameters:
        - $ref: "#/components/parameters/SlugPath"
      responses:
        "200":
          $ref: "#/components/responses/HtmlPage"
        "404":
          $ref: "#/components/responses/NotFound"

  /gruppe/{slug}:
    get:
      tags: [Pages]
      operationId: getGroupPage
      summary: Betreibergruppen-Seite abrufen
      parameters:
        - $ref: "#/components/parameters/SlugPath"
      responses:
        "200":
          $ref: "#/components/responses/HtmlPage"
        "404":
          $ref: "#/components/responses/NotFound"

  /vergleich/{slug}:
    get:
      tags: [Pages]
      operationId: getBrandComparisonPage
      summary: Markenvergleich abrufen
      parameters:
        - $ref: "#/components/parameters/SlugPath"
      responses:
        "200":
          $ref: "#/components/responses/HtmlPage"
        "404":
          $ref: "#/components/responses/NotFound"

  /artikel:
    get:
      tags: [Pages]
      operationId: listArticlesPage
      summary: Artikelübersicht abrufen
      parameters:
        - name: q
          in: query
          required: false
          schema:
            type: string
        - name: sort
          in: query
          required: false
          schema:
            type: string
            enum: [newest, relevance, title]
        - $ref: "#/components/parameters/PageQuery"
      responses:
        "200":
          $ref: "#/components/responses/HtmlPage"

  /artikel/{id}:
    get:
      tags: [Pages]
      operationId: getArticlePage
      summary: Deutschen Artikel abrufen
      parameters:
        - name: id
          in: path
          required: true
          schema:
            type: string
      responses:
        "200":
          $ref: "#/components/responses/HtmlPage"
        "404":
          $ref: "#/components/responses/NotFound"

  /en/articles/{id}:
    get:
      tags: [Pages]
      operationId: getEnglishArticlePage
      summary: English article page
      parameters:
        - name: id
          in: path
          required: true
          schema:
            type: string
      responses:
        "200":
          $ref: "#/components/responses/HtmlPage"
        "404":
          $ref: "#/components/responses/NotFound"

  /staedte:
    get:
      tags: [Pages, Search]
      operationId: getCitiesHubPage
      summary: Städte-Hub abrufen
      responses:
        "200":
          $ref: "#/components/responses/HtmlPage"

  /fuer-studios:
    get:
      tags: [Pages]
      operationId: getForStudiosPage
      summary: B2B-Seite für Studios abrufen
      responses:
        "200":
          $ref: "#/components/responses/HtmlPage"

  /fuer-brands:
    get:
      tags: [Pages]
      operationId: getForBrandsPage
      summary: Deutsche Brand-/Industrieseite abrufen
      responses:
        "200":
          $ref: "#/components/responses/HtmlPage"

  /en/for-brands:
    get:
      tags: [Pages]
      operationId: getEnglishForBrandsPage
      summary: English brand/industry page
      responses:
        "200":
          $ref: "#/components/responses/HtmlPage"

  /ki-suche:
    get:
      tags: [Pages, Chat]
      operationId: getGymFinderPage
      summary: KI-Suche UI abrufen
      parameters:
        - $ref: "#/components/parameters/CityQuery"
        - $ref: "#/components/parameters/RadiusQuery"
        - $ref: "#/components/parameters/MaxPriceQuery"
        - $ref: "#/components/parameters/FeatureIdsQuery"
      responses:
        "200":
          $ref: "#/components/responses/HtmlPage"

  /tools/kuendigungsschreiben:
    get:
      tags: [Pages]
      operationId: getCancellationLetterTool
      summary: Kündigungsschreiben-Tool abrufen
      parameters:
        - name: studio_id
          in: query
          required: false
          schema:
            type: integer
      responses:
        "200":
          $ref: "#/components/responses/HtmlPage"

  /tools/kuendigungsschreiben/download:
    post:
      tags: [Pages]
      operationId: downloadCancellationLetterPdf
      summary: Allgemeines Kündigungsschreiben als PDF erzeugen
      requestBody:
        required: true
        content:
          application/x-www-form-urlencoded:
            schema:
              $ref: "#/components/schemas/CancellationLetterRequest"
      responses:
        "200":
          description: PDF download.
          content:
            application/pdf:
              schema:
                type: string
                contentEncoding: binary
        "302":
          description: Redirect back to form when required fields are missing.

  /tools/preisrechner:
    get:
      tags: [Pages, Search]
      operationId: getPriceCalculatorPage
      summary: Preisrechner abrufen
      responses:
        "200":
          $ref: "#/components/responses/HtmlPage"

  /tools/preisrechner/results:
    get:
      tags: [Search]
      operationId: getPriceCalculatorResults
      summary: Preisrechner-Ergebnisse abrufen
      parameters:
        - name: limit
          in: query
          required: false
          schema:
            type: integer
            minimum: 1
            maximum: 100
        - $ref: "#/components/parameters/MaxPriceQuery"
        - name: contract_type
          in: query
          required: false
          schema:
            type: string
        - $ref: "#/components/parameters/FeatureIdsQuery"
      responses:
        "200":
          $ref: "#/components/responses/HtmlPage"

  /tools/tts:
    get:
      tags: [Pages]
      operationId: getTtsToolPage
      summary: Internes TTS-Tool abrufen
      responses:
        "200":
          $ref: "#/components/responses/HtmlPage"

  /api/cities:
    get:
      tags: [Search]
      operationId: searchCities
      summary: Städte suchen
      parameters:
        - name: q
          in: query
          required: false
          schema:
            type: string
        - name: limit
          in: query
          required: false
          schema:
            type: integer
            default: 10
            maximum: 50
      responses:
        "200":
          description: Matching cities.
          content:
            application/json:
              schema:
                type: object
                required: [cities]
                properties:
                  cities:
                    type: array
                    items:
                      $ref: "#/components/schemas/City"

  /api/articles:
    get:
      tags: [Search]
      operationId: searchArticles
      summary: Artikel suchen
      parameters:
        - name: q
          in: query
          required: false
          schema:
            type: string
        - name: limit
          in: query
          required: false
          schema:
            type: integer
            default: 10
            maximum: 50
      responses:
        "200":
          description: Matching articles.
          content:
            application/json:
              schema:
                type: object
                required: [articles]
                properties:
                  articles:
                    type: array
                    items:
                      $ref: "#/components/schemas/ArticleSummary"

  /api/studios/search:
    get:
      tags: [Search, Studio]
      operationId: searchStudios
      summary: Studios und Marken für Autocomplete suchen
      parameters:
        - name: q
          in: query
          required: true
          schema:
            type: string
            minLength: 2
        - name: limit
          in: query
          required: false
          schema:
            type: integer
            default: 8
            maximum: 50
      responses:
        "200":
          description: Search results.
          content:
            application/json:
              schema:
                type: object
                required: [results]
                properties:
                  results:
                    type: array
                    items:
                      $ref: "#/components/schemas/SearchResult"

  /api/studios/{id}/email_options:
    get:
      tags: [Studio]
      operationId: getStudioEmailOptions
      summary: Maskierte Login-E-Mail-Optionen abrufen
      parameters:
        - name: id
          in: path
          required: true
          description: Numeric studio id or `brand_{id}`.
          schema:
            type: string
      responses:
        "200":
          description: Email options for studio or brand login.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/EmailOptions"
        "404":
          $ref: "#/components/responses/JsonNotFound"

  /api/v1/studios/{studio_id}/votes:
    get:
      tags: [Widgets, Studio]
      operationId: getStudioVoteSummary
      summary: Vote summary for a studio
      parameters:
        - $ref: "#/components/parameters/StudioIdPath"
      responses:
        "200":
          description: Vote summary and current visitor state.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/VoteSummary"
        "404":
          $ref: "#/components/responses/JsonNotFound"
    post:
      tags: [Widgets, Studio]
      operationId: createStudioVote
      summary: Create or update a studio vote
      parameters:
        - $ref: "#/components/parameters/StudioIdPath"
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/VoteRequest"
          application/x-www-form-urlencoded:
            schema:
              $ref: "#/components/schemas/VoteRequest"
      responses:
        "200":
          description: Existing vote updated.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/VoteMutationResponse"
        "201":
          description: Vote created.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/VoteMutationResponse"
        "400":
          $ref: "#/components/responses/BadRequest"
        "404":
          $ref: "#/components/responses/JsonNotFound"
        "422":
          $ref: "#/components/responses/ValidationError"
    delete:
      tags: [Widgets, Studio]
      operationId: deleteStudioVote
      summary: Remove current visitor vote
      parameters:
        - $ref: "#/components/parameters/StudioIdPath"
      responses:
        "200":
          $ref: "#/components/responses/JsonSuccess"
        "404":
          $ref: "#/components/responses/JsonNotFound"

  /api/chat/config/{studio_slug}:
    get:
      tags: [Chat]
      operationId: getStudioChatConfig
      summary: KI-Agent-Konfiguration eines Studios abrufen
      parameters:
        - name: studio_slug
          in: path
          required: true
          schema:
            type: string
      responses:
        "200":
          description: Chatbot configuration or disabled payload.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ChatConfig"
        "403":
          $ref: "#/components/responses/Forbidden"
        "404":
          $ref: "#/components/responses/JsonNotFound"

  /api/chat:
    post:
      tags: [Chat]
      operationId: streamStudioChat
      summary: Mit einem Studio-KI-Agenten chatten
      description: Returns Server-Sent Events (`text/event-stream`).
      parameters:
        - name: X-Chatbot-Token
          in: header
          required: false
          description: Optional embed token for external widgets.
          schema:
            type: string
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/ChatRequest"
          application/x-www-form-urlencoded:
            schema:
              $ref: "#/components/schemas/ChatRequest"
      responses:
        "200":
          $ref: "#/components/responses/EventStream"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/Forbidden"
        "404":
          $ref: "#/components/responses/JsonNotFound"
        "422":
          $ref: "#/components/responses/ValidationError"

  /api/chat/voice:
    post:
      tags: [Chat]
      operationId: synthesizeStudioChatVoice
      summary: Studio-KI-Agent-Antwort als Audio erzeugen
      parameters:
        - name: X-Chatbot-Token
          in: header
          required: false
          schema:
            type: string
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [studio_slug, text]
              properties:
                studio_slug:
                  type: string
                text:
                  type: string
                  maxLength: 4000
      responses:
        "200":
          description: Generated audio.
          content:
            audio/wav:
              schema:
                type: string
                contentEncoding: binary
            audio/mpeg:
              schema:
                type: string
                contentEncoding: binary
        "404":
          $ref: "#/components/responses/JsonNotFound"
        "422":
          $ref: "#/components/responses/ValidationError"
        "502":
          $ref: "#/components/responses/BadGateway"

  /api/gym_finder:
    post:
      tags: [Chat, Search]
      operationId: streamGymFinderChat
      summary: KI-Suche Chat streamen
      description: Returns Server-Sent Events (`text/event-stream`).
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/GymFinderChatRequest"
      responses:
        "200":
          $ref: "#/components/responses/EventStream"
        "422":
          $ref: "#/components/responses/ValidationError"

  /api/gym_finder/locate:
    get:
      tags: [Search]
      operationId: locateNearestCity
      summary: Nächste suchbare Stadt aus Koordinaten ermitteln
      parameters:
        - name: lat
          in: query
          required: true
          schema:
            type: number
            minimum: -90
            maximum: 90
        - name: lng
          in: query
          required: true
          schema:
            type: number
            minimum: -180
            maximum: 180
      responses:
        "200":
          description: Nearest city.
          content:
            application/json:
              schema:
                type: object
                required: [city]
                properties:
                  city:
                    type: string
        "400":
          $ref: "#/components/responses/BadRequest"
        "404":
          $ref: "#/components/responses/JsonNotFound"

  /api/tts/voices:
    get:
      tags: [Chat]
      operationId: listTtsVoices
      summary: Verfügbare TTS-Stimmen abrufen
      parameters:
        - name: model
          in: query
          required: false
          schema:
            type: string
      responses:
        "200":
          description: Voice list. May be degraded if backend is unavailable.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/TtsVoicesResponse"
        "422":
          $ref: "#/components/responses/ValidationError"
    post:
      tags: [Chat]
      operationId: createTtsVoiceClone
      summary: Klonstimme aus Audio-Datei erstellen
      requestBody:
        required: true
        content:
          multipart/form-data:
            schema:
              type: object
              required: [file]
              properties:
                file:
                  type: string
                  contentEncoding: binary
                name:
                  type: string
                model:
                  type: string
      responses:
        "200":
          description: Backend voice-clone response.
          content:
            application/json:
              schema:
                type: object
        "422":
          $ref: "#/components/responses/ValidationError"
        "502":
          $ref: "#/components/responses/BadGateway"

  /api/tts:
    post:
      tags: [Chat]
      operationId: streamTtsAudioChunks
      summary: TTS-Audio-Chunks als SSE streamen
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/TtsRequest"
      responses:
        "200":
          $ref: "#/components/responses/EventStream"
        "422":
          $ref: "#/components/responses/ValidationError"
        "502":
          $ref: "#/components/responses/BadGateway"

  /api/tts/download:
    post:
      tags: [Chat]
      operationId: downloadTtsAudio
      summary: TTS-Chunks als Audiodatei herunterladen
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [chunks]
              properties:
                chunks:
                  type: array
                  items:
                    type: object
                filename:
                  type: string
      responses:
        "200":
          description: Exported audio file.
          content:
            audio/wav:
              schema:
                type: string
                contentEncoding: binary
            audio/mpeg:
              schema:
                type: string
                contentEncoding: binary
        "422":
          $ref: "#/components/responses/ValidationError"
        "502":
          $ref: "#/components/responses/BadGateway"

  /checkout_sessions:
    post:
      tags: [ACP Checkout]
      operationId: createCheckoutSession
      summary: ACP trial-booking checkout session erstellen
      parameters:
        - $ref: "#/components/parameters/AcpSignatureHeader"
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/CheckoutSessionCreateRequest"
      responses:
        "201":
          description: Checkout session created.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/CheckoutSession"
        "403":
          $ref: "#/components/responses/Forbidden"
        "404":
          $ref: "#/components/responses/JsonNotFound"
        "422":
          $ref: "#/components/responses/ValidationError"

  /checkout_sessions/{id}:
    get:
      tags: [ACP Checkout]
      operationId: getCheckoutSession
      summary: Checkout session abrufen
      parameters:
        - $ref: "#/components/parameters/CheckoutSessionIdPath"
        - $ref: "#/components/parameters/AcpSignatureHeader"
      responses:
        "200":
          description: Checkout session.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/CheckoutSession"
        "404":
          $ref: "#/components/responses/JsonNotFound"
    patch:
      tags: [ACP Checkout]
      operationId: updateCheckoutSession
      summary: Checkout session aktualisieren
      parameters:
        - $ref: "#/components/parameters/CheckoutSessionIdPath"
        - $ref: "#/components/parameters/AcpSignatureHeader"
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/CheckoutSessionUpdateRequest"
      responses:
        "200":
          description: Checkout session.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/CheckoutSession"
        "404":
          $ref: "#/components/responses/JsonNotFound"
        "422":
          $ref: "#/components/responses/ValidationError"
    put:
      tags: [ACP Checkout]
      operationId: replaceCheckoutSession
      summary: Checkout session aktualisieren
      parameters:
        - $ref: "#/components/parameters/CheckoutSessionIdPath"
        - $ref: "#/components/parameters/AcpSignatureHeader"
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/CheckoutSessionUpdateRequest"
      responses:
        "200":
          description: Checkout session.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/CheckoutSession"
        "404":
          $ref: "#/components/responses/JsonNotFound"
        "422":
          $ref: "#/components/responses/ValidationError"

  /checkout_sessions/{id}/complete:
    post:
      tags: [ACP Checkout]
      operationId: completeCheckoutSession
      summary: Checkout session abschließen
      parameters:
        - $ref: "#/components/parameters/CheckoutSessionIdPath"
        - $ref: "#/components/parameters/AcpSignatureHeader"
      responses:
        "200":
          description: Completed checkout session with order data.
          content:
            application/json:
              schema:
                allOf:
                  - $ref: "#/components/schemas/CheckoutSession"
                  - type: object
                    properties:
                      order:
                        type: object
        "404":
          $ref: "#/components/responses/JsonNotFound"
        "422":
          $ref: "#/components/responses/ValidationError"

  /checkout_sessions/{id}/cancel:
    post:
      tags: [ACP Checkout]
      operationId: cancelCheckoutSession
      summary: Checkout session abbrechen
      parameters:
        - $ref: "#/components/parameters/CheckoutSessionIdPath"
        - $ref: "#/components/parameters/AcpSignatureHeader"
      responses:
        "200":
          description: Canceled checkout session.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/CheckoutSession"
        "404":
          $ref: "#/components/responses/JsonNotFound"

  /embed/agent.js:
    get:
      tags: [Widgets, Chat]
      operationId: getChatbotEmbedScript
      summary: KI-Agent Embed-Script abrufen
      parameters:
        - name: token
          in: query
          required: false
          schema:
            type: string
      responses:
        "200":
          description: JavaScript widget runtime.
          content:
            application/javascript:
              schema:
                type: string

  /embed/studio-voting-widget.js:
    get:
      tags: [Widgets]
      operationId: getStudioVotingWidgetScript
      summary: Studio voting widget runtime abrufen
      responses:
        "200":
          description: JavaScript widget runtime.
          content:
            application/javascript:
              schema:
                type: string

  /embed/demo:
    get:
      tags: [Widgets]
      operationId: getEmbedDemoPage
      summary: Widget-Demo abrufen
      responses:
        "200":
          $ref: "#/components/responses/HtmlPage"

  /badges/{studio_slug}:
    get:
      tags: [Widgets, Studio]
      operationId: getStudioBadgeSvg
      summary: Dynamisches Studio-Badge als SVG abrufen
      parameters:
        - $ref: "#/components/parameters/StudioSlugPathNamed"
      responses:
        "200":
          $ref: "#/components/responses/SvgImage"
        "404":
          $ref: "#/components/responses/NotFound"

  /badges/{studio_slug}/preis:
    get:
      tags: [Widgets, Studio]
      operationId: getStudioPriceBadgeSvg
      summary: Preis-Badge als SVG abrufen
      parameters:
        - $ref: "#/components/parameters/StudioSlugPathNamed"
      responses:
        "200":
          $ref: "#/components/responses/SvgImage"
        "404":
          $ref: "#/components/responses/NotFound"

  /badges/{studio_slug}/rating:
    get:
      tags: [Widgets, Studio]
      operationId: getStudioRatingBadgeSvg
      summary: Bewertungs-Badge als SVG abrufen
      parameters:
        - $ref: "#/components/parameters/StudioSlugPathNamed"
      responses:
        "200":
          $ref: "#/components/responses/SvgImage"
        "404":
          $ref: "#/components/responses/NotFound"

  /badges/{studio_slug}/social:
    get:
      tags: [Widgets, Studio]
      operationId: getStudioSocialBadgePng
      summary: Social Share Badge als PNG abrufen
      parameters:
        - $ref: "#/components/parameters/StudioSlugPathNamed"
      responses:
        "200":
          $ref: "#/components/responses/PngImage"
        "404":
          $ref: "#/components/responses/NotFound"

  /badges/{studio_slug}/social-square:
    get:
      tags: [Widgets, Studio]
      operationId: getStudioSocialSquareBadgePng
      summary: Quadratisches Social Share Badge als PNG abrufen
      parameters:
        - $ref: "#/components/parameters/StudioSlugPathNamed"
      responses:
        "200":
          $ref: "#/components/responses/PngImage"
        "404":
          $ref: "#/components/responses/NotFound"

  /llms.txt:
    get:
      tags: [AI Discovery]
      operationId: getLlmsTxt
      summary: LLM-Kontextdatei abrufen
      responses:
        "200":
          $ref: "#/components/responses/TextFile"

  /llms-full.txt:
    get:
      tags: [AI Discovery]
      operationId: getLlmsFullTxt
      summary: Erweiterte LLM-Kontextdatei abrufen
      responses:
        "200":
          $ref: "#/components/responses/TextFile"

  /structured-data.json:
    get:
      tags: [AI Discovery]
      operationId: getStructuredDataManifest
      summary: Schema.org Manifest abrufen
      responses:
        "200":
          description: JSON manifest.
          content:
            application/json:
              schema:
                type: object

  /openapi.yaml:
    get:
      tags: [AI Discovery]
      operationId: getOpenApiYaml
      summary: Diese OpenAPI-Spezifikation abrufen
      responses:
        "200":
          description: OpenAPI YAML document.
          content:
            application/x-yaml:
              schema:
                type: string
            application/yaml:
              schema:
                type: string

  /.well-known/ai-metadata/model.jsonld:
    get:
      tags: [AI Discovery]
      operationId: getAiModelMetadata
      summary: AI model metadata abrufen
      responses:
        "200":
          $ref: "#/components/responses/JsonLd"

  /.well-known/ai-metadata/webpage.jsonld:
    get:
      tags: [AI Discovery]
      operationId: getAiWebpageMetadata
      summary: AI webpage metadata abrufen
      responses:
        "200":
          $ref: "#/components/responses/JsonLd"

  /.well-known/ai-metadata/dataset.croissant.json:
    get:
      tags: [AI Discovery]
      operationId: getCroissantDatasetMetadata
      summary: Croissant dataset metadata abrufen
      responses:
        "200":
          description: Croissant dataset metadata.
          content:
            application/json:
              schema:
                type: object

  /schemas/llm/1.0/schema.jsonld:
    get:
      tags: [AI Discovery]
      operationId: getLlmSchema
      summary: LLM schema definition abrufen
      responses:
        "200":
          $ref: "#/components/responses/JsonLd"

  /schemas/llm/versions.json:
    get:
      tags: [AI Discovery]
      operationId: getLlmSchemaVersions
      summary: LLM schema versions manifest abrufen
      responses:
        "200":
          description: Version manifest.
          content:
            application/json:
              schema:
                type: object

  /health:
    get:
      tags: [Health]
      operationId: getHealth
      summary: Application health status abrufen
      responses:
        "200":
          description: Health payload.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Health"

  /up:
    get:
      tags: [Health]
      operationId: getRailsHealth
      summary: Rails boot health check
      responses:
        "200":
          description: Application boots successfully.
        "500":
          description: Application failed to boot.

components:
  parameters:
    StudioSlugPath:
      name: id
      in: path
      required: true
      description: Friendly studio slug.
      schema:
        type: string
    StudioSlugPathNamed:
      name: studio_slug
      in: path
      required: true
      description: Friendly studio slug.
      schema:
        type: string
    StudioIdPath:
      name: studio_id
      in: path
      required: true
      schema:
        type: integer
    CheckoutSessionIdPath:
      name: id
      in: path
      required: true
      schema:
        type: integer
    SlugPath:
      name: slug
      in: path
      required: true
      schema:
        type: string
    LocationQuery:
      name: location
      in: query
      required: false
      description: City name or `lat,lng` coordinate pair.
      schema:
        type: string
    CityQuery:
      name: city
      in: query
      required: false
      schema:
        type: string
    RadiusQuery:
      name: radius
      in: query
      required: false
      description: Radius in kilometers.
      schema:
        oneOf:
          - type: integer
          - type: string
            pattern: "^\\d+km?$"
    MaxPriceQuery:
      name: max_price
      in: query
      required: false
      schema:
        type: number
        minimum: 0
    SortQuery:
      name: sort
      in: query
      required: false
      schema:
        type: string
    NameQuery:
      name: name
      in: query
      required: false
      schema:
        type: string
    PageQuery:
      name: page
      in: query
      required: false
      schema:
        type: integer
        minimum: 1
    ViewQuery:
      name: view
      in: query
      required: false
      schema:
        type: string
        enum: [list, grid]
    FeatureIdsQuery:
      name: feature_ids[]
      in: query
      required: false
      style: form
      explode: true
      schema:
        type: array
        items:
          type: integer
    BrandIdsQuery:
      name: brand_ids[]
      in: query
      required: false
      style: form
      explode: true
      schema:
        type: array
        items:
          type: integer
    AcpSignatureHeader:
      name: X-ACP-Signature
      in: header
      required: false
      description: Optional ACP signature header. Verification is implementation-specific.
      schema:
        type: string

  responses:
    HtmlPage:
      description: HTML page.
      content:
        text/html:
          schema:
            type: string
    TextFile:
      description: Plain text file.
      content:
        text/plain:
          schema:
            type: string
    JsonLd:
      description: JSON-LD document.
      content:
        application/ld+json:
          schema:
            type: object
    SvgImage:
      description: SVG image.
      content:
        image/svg+xml:
          schema:
            type: string
    PngImage:
      description: PNG image.
      content:
        image/png:
          schema:
            type: string
            contentEncoding: binary
    EventStream:
      description: Server-Sent Events stream.
      content:
        text/event-stream:
          schema:
            type: string
    JsonSuccess:
      description: Success payload.
      content:
        application/json:
          schema:
            $ref: "#/components/schemas/SuccessResponse"
    BadRequest:
      description: Bad request.
      content:
        application/json:
          schema:
            $ref: "#/components/schemas/ErrorResponse"
    Unauthorized:
      description: Unauthorized.
      content:
        application/json:
          schema:
            $ref: "#/components/schemas/ErrorResponse"
    Forbidden:
      description: Forbidden.
      content:
        application/json:
          schema:
            $ref: "#/components/schemas/ErrorResponse"
    NotFound:
      description: Resource not found.
      content:
        text/html:
          schema:
            type: string
    JsonNotFound:
      description: Resource not found.
      content:
        application/json:
          schema:
            $ref: "#/components/schemas/ErrorResponse"
    ValidationError:
      description: Validation error.
      content:
        application/json:
          schema:
            oneOf:
              - $ref: "#/components/schemas/ErrorResponse"
              - $ref: "#/components/schemas/ErrorsResponse"
    BadGateway:
      description: Upstream service error.
      content:
        application/json:
          schema:
            $ref: "#/components/schemas/ErrorResponse"

  schemas:
    ErrorResponse:
      type: object
      properties:
        error:
          type: string
        message:
          type: string
    ErrorsResponse:
      type: object
      properties:
        errors:
          type: array
          items:
            type: string
    SuccessResponse:
      type: object
      properties:
        success:
          type: boolean
        message:
          type: string
    Health:
      type: object
      required: [status, timestamp, rails_version, ruby_version, environment]
      properties:
        status:
          type: string
          enum: [ok]
        timestamp:
          type: string
          format: date-time
        rails_version:
          type: string
        ruby_version:
          type: string
        environment:
          type: string
    City:
      type: object
      required: [name, display_name]
      properties:
        name:
          type: string
        display_name:
          type: string
        state:
          type:
            - string
            - "null"
        postal_code:
          type:
            - string
            - "null"
    ArticleSummary:
      type: object
      required: [title, slug, url]
      properties:
        title:
          type: string
        slug:
          type: string
        description:
          type:
            - string
            - "null"
        category:
          type:
            - string
            - "null"
        category_label:
          type:
            - string
            - "null"
        url:
          type: string
    SearchResult:
      type: object
      required: [id, name, type]
      properties:
        id:
          oneOf:
            - type: integer
            - type: string
        name:
          type: string
        type:
          type: string
          enum: [brand, studio]
        city:
          type: string
        brand_name:
          type:
            - string
            - "null"
        studio_count:
          type: integer
    EmailOptions:
      type: object
      required: [type, entity, emails, can_add_email]
      properties:
        type:
          type: string
          enum: [studio, brand]
        entity:
          type: object
        emails:
          type: array
          items:
            type: string
        allowed_domain:
          type:
            - string
            - "null"
        can_add_email:
          type: boolean
    StudioPreview:
      type: object
      properties:
        id:
          type: integer
        name:
          type: string
        slug:
          type: string
        city:
          type: string
        url:
          type: string
        rating:
          type:
            - number
            - "null"
        price:
          type:
            - number
            - "null"
        features:
          type: array
          items:
            type: string
    StudioCountResponse:
      type: object
      required: [count]
      properties:
        count:
          type: integer
        studios:
          type: array
          items:
            $ref: "#/components/schemas/StudioPreview"
        compare_ids:
          type: array
          items:
            type: integer
        feature_names:
          type: object
          additionalProperties:
            type: string
    ChatMessage:
      type: object
      required: [role, content]
      properties:
        role:
          type: string
          enum: [user, assistant, system]
        content:
          type: string
    ChatRequest:
      type: object
      required: [studio_slug, message]
      properties:
        studio_slug:
          type: string
        message:
          type: string
          maxLength: 500
        conversation_id:
          type: string
        history:
          type: array
          items:
            $ref: "#/components/schemas/ChatMessage"
    GymFinderChatRequest:
      type: object
      required: [message]
      properties:
        message:
          type: string
          maxLength: 500
        conversation_id:
          type: string
        history:
          type: array
          items:
            $ref: "#/components/schemas/ChatMessage"
        active_filters:
          type: object
          properties:
            city:
              type: string
            radius:
              oneOf:
                - type: integer
                - type: string
            max_price:
              type: number
            sort:
              type: string
            name:
              type: string
            feature_ids:
              type: array
              items:
                type: integer
    ChatConfig:
      type: object
      required: [enabled]
      properties:
        enabled:
          type: boolean
        config_policy:
          type: object
        studio_name:
          type: string
        studio_city:
          type: string
        studio_slug:
          type: string
        chatbot_name:
          type: string
        chatbot_avatar_url:
          type:
            - string
            - "null"
        chatbot_avatar_mode:
          type: string
        chatbot_embed_theme:
          type: string
        welcome_message:
          type: string
        suggested_questions:
          type: array
          items:
            type: string
        booking_url:
          type:
            - string
            - "null"
        probetraining_prompt:
          type: string
        external_links:
          type: object
        voice_responses:
          type: object
    TtsRequest:
      type: object
      required: [text]
      properties:
        text:
          type: string
          maxLength: 4000
        model:
          type: string
        speaker:
          type: string
        voice_id:
          type: string
        chunked:
          type: boolean
    TtsVoicesResponse:
      type: object
      required: [voices, speakers]
      properties:
        voices:
          type: array
          items:
            type: object
        speakers:
          type: array
          items:
            type: string
        supports_voice_clone:
          type: boolean
        degraded:
          type: boolean
    VoteSummary:
      type: object
      required: [studio_id, vote_score, upvote_count, downvote_count, total_votes, vote_percentage, user_can_vote]
      properties:
        studio_id:
          type: integer
        vote_score:
          type: integer
        upvote_count:
          type: integer
        downvote_count:
          type: integer
        total_votes:
          type: integer
        vote_percentage:
          type: number
        user_can_vote:
          type: boolean
        user_vote:
          oneOf:
            - type: object
            - type: "null"
    VoteRequest:
      type: object
      required: [vote_type]
      properties:
        vote_type:
          type: integer
          enum: [1, -1]
        source:
          type: string
          default: web
    VoteMutationResponse:
      type: object
      required: [success, message]
      properties:
        success:
          type: boolean
        message:
          type: string
        vote:
          type: object
    CancellationLetterRequest:
      type: object
      required: [user_name, user_street, user_zip, user_city]
      properties:
        user_name:
          type: string
        user_street:
          type: string
        user_zip:
          type: string
        user_city:
          type: string
        membership_number:
          type: string
        date:
          type: string
          format: date
        reason:
          type: string
        studio_id:
          type: integer
    CheckoutSessionCreateRequest:
      type: object
      required: [studio_slug]
      properties:
        studio_slug:
          type: string
        source:
          type: string
          default: acp
        training_type:
          type: string
        goal:
          type: string
        appointment_starts_at:
          type: string
          format: date-time
        first_name:
          type: string
        last_name:
          type: string
        email:
          type: string
          format: email
        phone:
          type: string
    CheckoutSessionUpdateRequest:
      type: object
      properties:
        training_type:
          type: string
        goal:
          type: string
        appointment_starts_at:
          type: string
          format: date-time
        first_name:
          type: string
        last_name:
          type: string
        email:
          type: string
          format: email
        phone:
          type: string
    CheckoutSession:
      type: object
      required: [id, status, currency, line_items, totals, fulfillment, messages, links]
      properties:
        id:
          type: integer
        status:
          type: string
          enum: [not_ready_for_payment, ready_for_payment, completed, canceled, expired]
        currency:
          type: string
          enum: [eur]
        line_items:
          type: array
          items:
            type: object
            required: [name, quantity, unit_price]
            properties:
              name:
                type: string
              description:
                type: string
              quantity:
                type: integer
              unit_price:
                $ref: "#/components/schemas/Money"
        totals:
          type: array
          items:
            type: object
            properties:
              type:
                type: string
              amount:
                type: integer
              currency:
                type: string
        fulfillment:
          type: object
          properties:
            type:
              type: string
            scheduled_at:
              type:
                - string
                - "null"
              format: date-time
            location:
              type:
                - string
                - "null"
        messages:
          type: array
          items:
            type: object
        links:
          type: array
          items:
            type: object
    Money:
      type: object
      required: [amount, currency]
      properties:
        amount:
          type: integer
        currency:
          type: string
