{
  "openapi": "3.1.0",
  "info": {
    "title": "Ballbuddy Public-API v1",
    "version": "1.0.0",
    "description": "Öffentliche, lesende Schnittstelle von Ballbuddy zum Ballkalender: veröffentlichte Maturabälle in Österreich, ihre Schulen und Locations – redaktionell gepflegt.\n\n- Öffentlich und nur lesend: ausschließlich GET, kein Schlüssel, kein Konto, CORS offen (`Access-Control-Allow-Origin: *`).\n- Antworten sind eine Stunde cachebar (`Cache-Control: public, s-maxage=3600, stale-while-revalidate=86400`) und tragen einen ETag – beim Nachfragen `If-None-Match` mitschicken, dann kommt `304 Not Modified` ohne Body.\n- Zeiten (`starts_at`, `ends_at`, `doors_at`, `updated_at`) sind ISO-8601 in UTC; die Anzeige gehört nach `Europe/Vienna`.\n- Eine Saison heißt nach ihrem Endjahr: `season=2027` ist die Saison 2026/27 (September bis August).\n\nDer Bestand steht in `total` jeder Antwort und ändert sich laufend. Doku für Agents: https://ballbuddy.at/docs/agents. Angabe der Quelle mit Link auf die Ball-Seite (`url`) ist erwünscht.",
    "contact": {
      "name": "Ballbuddy",
      "email": "dein@ballbuddy.at",
      "url": "https://ballbuddy.at/docs/agents"
    },
    "license": {
      "name": "Daten © Ballbuddy/Framed – Nutzung in Agent-Antworten mit Quellenangabe (Link auf die Ball-Seite) erwünscht"
    }
  },
  "externalDocs": {
    "description": "Ballbuddy für Agents",
    "url": "https://ballbuddy.at/docs/agents"
  },
  "servers": [
    {
      "url": "https://ballbuddy.at",
      "description": "Ballbuddy"
    }
  ],
  "security": [],
  "tags": [
    {
      "name": "Bälle",
      "description": "Suche, Detail und Kalendereintrag."
    },
    {
      "name": "Verzeichnisse",
      "description": "Schulen, Locations und alle Ball-Seiten."
    },
    {
      "name": "System",
      "description": "Status."
    }
  ],
  "paths": {
    "/api/public/v1/balls": {
      "get": {
        "operationId": "searchBalls",
        "tags": [
          "Bälle"
        ],
        "summary": "Bälle suchen und filtern",
        "description": "Veröffentlichte Bälle nach Saison, Bundesland, Stadt, Schule, Location, Zeitraum oder Freitext – sortiert nach `starts_at` aufsteigend, paginiert. `filters` in der Antwort nennt die gültigen Werte für den nächsten Aufruf. Beispiel: `?season=2027&state=Steiermark&per=10`.",
        "parameters": [
          {
            "$ref": "#/components/parameters/season"
          },
          {
            "$ref": "#/components/parameters/state"
          },
          {
            "$ref": "#/components/parameters/city"
          },
          {
            "$ref": "#/components/parameters/school"
          },
          {
            "$ref": "#/components/parameters/venue"
          },
          {
            "$ref": "#/components/parameters/from"
          },
          {
            "$ref": "#/components/parameters/to"
          },
          {
            "$ref": "#/components/parameters/q"
          },
          {
            "$ref": "#/components/parameters/page"
          },
          {
            "$ref": "#/components/parameters/per"
          },
          {
            "$ref": "#/components/parameters/ifNoneMatch"
          }
        ],
        "responses": {
          "200": {
            "description": "Trefferliste.",
            "headers": {
              "ETag": {
                "$ref": "#/components/headers/ETag"
              },
              "Cache-Control": {
                "$ref": "#/components/headers/CacheControl"
              }
            },
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/BallsResponse"
                }
              }
            }
          },
          "304": {
            "$ref": "#/components/responses/NotModified"
          },
          "400": {
            "$ref": "#/components/responses/BadRequest"
          }
        }
      }
    },
    "/api/public/v1/balls/{slug}": {
      "get": {
        "operationId": "getBall",
        "tags": [
          "Bälle"
        ],
        "summary": "Ein Ball im Detail",
        "description": "Alles aus der Liste plus Beschreibung, Links, Tanzschule/Disco/Band und öffentliche Ticketkategorien. Alte Slugs antworten mit `301` auf die aktuelle Adresse.",
        "parameters": [
          {
            "$ref": "#/components/parameters/slug"
          },
          {
            "$ref": "#/components/parameters/ifNoneMatch"
          }
        ],
        "responses": {
          "200": {
            "description": "Der Ball.",
            "headers": {
              "ETag": {
                "$ref": "#/components/headers/ETag"
              },
              "Cache-Control": {
                "$ref": "#/components/headers/CacheControl"
              }
            },
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/BallDetail"
                }
              }
            }
          },
          "301": {
            "description": "Der Slug ist eine alte Adresse – `Location` zeigt auf `/api/public/v1/balls/<aktueller-slug>`.",
            "headers": {
              "Location": {
                "description": "Aktuelle Adresse des Balls.",
                "schema": {
                  "type": "string",
                  "format": "uri"
                }
              }
            }
          },
          "304": {
            "$ref": "#/components/responses/NotModified"
          },
          "404": {
            "$ref": "#/components/responses/NotFound"
          }
        }
      }
    },
    "/api/public/v1/schools": {
      "get": {
        "operationId": "listSchools",
        "tags": [
          "Verzeichnisse"
        ],
        "summary": "Alle Schulen",
        "description": "Schulen mit Anzahl veröffentlichter Bälle, sortiert nach Name. Die Slugs passen in den Filter `school`.",
        "parameters": [
          {
            "$ref": "#/components/parameters/ifNoneMatch"
          }
        ],
        "responses": {
          "200": {
            "description": "Schulen.",
            "headers": {
              "ETag": {
                "$ref": "#/components/headers/ETag"
              },
              "Cache-Control": {
                "$ref": "#/components/headers/CacheControl"
              }
            },
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/SchoolsResponse"
                }
              }
            }
          },
          "304": {
            "$ref": "#/components/responses/NotModified"
          }
        }
      }
    },
    "/api/public/v1/venues": {
      "get": {
        "operationId": "listVenues",
        "tags": [
          "Verzeichnisse"
        ],
        "summary": "Alle Locations",
        "description": "Locations mit Adresse, Koordinaten und Anzahl veröffentlichter Bälle, sortiert nach Name. Die Slugs passen in den Filter `venue`.",
        "parameters": [
          {
            "$ref": "#/components/parameters/ifNoneMatch"
          }
        ],
        "responses": {
          "200": {
            "description": "Locations.",
            "headers": {
              "ETag": {
                "$ref": "#/components/headers/ETag"
              },
              "Cache-Control": {
                "$ref": "#/components/headers/CacheControl"
              }
            },
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/VenuesResponse"
                }
              }
            }
          },
          "304": {
            "$ref": "#/components/responses/NotModified"
          }
        }
      }
    },
    "/api/public/v1/sitemap": {
      "get": {
        "operationId": "listBallUrls",
        "tags": [
          "Verzeichnisse"
        ],
        "summary": "Alle Ball-Seiten",
        "description": "Adressen aller veröffentlichten Ball-Seiten mit letzter Änderung – für Sitemaps und Crawler.",
        "parameters": [
          {
            "$ref": "#/components/parameters/ifNoneMatch"
          }
        ],
        "responses": {
          "200": {
            "description": "Ball-Seiten.",
            "headers": {
              "ETag": {
                "$ref": "#/components/headers/ETag"
              },
              "Cache-Control": {
                "$ref": "#/components/headers/CacheControl"
              }
            },
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/SitemapResponse"
                }
              }
            }
          },
          "304": {
            "$ref": "#/components/responses/NotModified"
          }
        }
      }
    },
    "/api/ics/{slug}": {
      "get": {
        "operationId": "getBallIcs",
        "tags": [
          "Bälle"
        ],
        "summary": "Kalendereintrag (ICS)",
        "description": "Ein VEVENT als `text/calendar` mit zwei Erinnerungen (7 Tage und 1 Tag vorher); eine Stunde cachebar. `has_motto` false ⇒ Titel „Maturaball <Schule>“, `time_known` false ⇒ ganztägig. Kennt nur den aktuellen Slug (keine Weiterleitung alter Adressen).",
        "parameters": [
          {
            "$ref": "#/components/parameters/slug"
          }
        ],
        "responses": {
          "200": {
            "description": "Kalenderdatei (`Content-Disposition: attachment`).",
            "headers": {
              "Cache-Control": {
                "description": "`public, max-age=3600, s-maxage=3600`.",
                "schema": {
                  "type": "string"
                }
              }
            },
            "content": {
              "text/calendar": {
                "schema": {
                  "type": "string",
                  "description": "iCalendar (RFC 5545)."
                }
              }
            }
          },
          "404": {
            "$ref": "#/components/responses/NotFound"
          }
        }
      }
    },
    "/api/health": {
      "get": {
        "operationId": "getHealth",
        "tags": [
          "System"
        ],
        "summary": "Läuft der Dienst?",
        "description": "Antwortet ohne Datenbank und ohne Cache (`Cache-Control: no-store`).",
        "responses": {
          "200": {
            "description": "Dienst läuft.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Health"
                }
              }
            }
          }
        }
      }
    }
  },
  "components": {
    "schemas": {
      "SchoolRef": {
        "type": "object",
        "description": "Schule (Kurzform).",
        "properties": {
          "name": {
            "type": "string",
            "description": "Name der Schule.",
            "examples": [
              "HTL Weiz"
            ]
          },
          "slug": {
            "type": "string",
            "description": "Slug – passt in den Filter `school`.",
            "pattern": "^[a-z0-9]+(?:-[a-z0-9]+)*$"
          }
        },
        "required": [
          "name",
          "slug"
        ],
        "additionalProperties": false
      },
      "BallVenue": {
        "type": "object",
        "description": "Location eines Balls mit Adresse.",
        "properties": {
          "name": {
            "type": "string",
            "description": "Name der Location."
          },
          "slug": {
            "type": "string",
            "description": "Slug – passt in den Filter `venue`.",
            "pattern": "^[a-z0-9]+(?:-[a-z0-9]+)*$"
          },
          "address": {
            "type": [
              "string",
              "null"
            ],
            "description": "Straße und Hausnummer."
          },
          "zip": {
            "type": [
              "string",
              "null"
            ],
            "description": "Postleitzahl."
          },
          "city": {
            "type": [
              "string",
              "null"
            ],
            "description": "Ort der Location."
          }
        },
        "required": [
          "name",
          "slug",
          "address",
          "zip",
          "city"
        ],
        "additionalProperties": false
      },
      "Tickets": {
        "type": "object",
        "description": "Ticket-Hinweis des Komitees.",
        "properties": {
          "mode": {
            "type": "string",
            "description": "`link`: Tickets unter `url`. `instagram`/`info`: Details stehen auf der Ball-Seite (`url` ist dann null).",
            "enum": [
              "link",
              "instagram",
              "info"
            ]
          },
          "url": {
            "type": [
              "string",
              "null"
            ],
            "description": "Ticket-Adresse – nur bei `mode` = link.",
            "format": "uri"
          }
        },
        "required": [
          "mode",
          "url"
        ],
        "additionalProperties": false
      },
      "Cover": {
        "type": "object",
        "description": "Titelbild in zwei Breiten (WebP) plus Platzhalter.",
        "properties": {
          "url640": {
            "type": "string",
            "description": "WebP, 640 px breit.",
            "format": "uri"
          },
          "url1280": {
            "type": "string",
            "description": "WebP, 1280 px breit.",
            "format": "uri"
          },
          "blur": {
            "type": [
              "string",
              "null"
            ],
            "description": "Unschärfe-Platzhalter als Data-URI (24 px) – null, wenn keiner vorliegt."
          }
        },
        "required": [
          "url640",
          "url1280",
          "blur"
        ],
        "additionalProperties": false
      },
      "BallLink": {
        "type": "object",
        "description": "Link des Komitees.",
        "properties": {
          "type": {
            "type": "string",
            "description": "Art des Links.",
            "enum": [
              "instagram",
              "youtube",
              "tiktok",
              "website",
              "other"
            ]
          },
          "label": {
            "type": "string",
            "description": "Beschriftung – kann fehlen."
          },
          "url": {
            "type": "string",
            "description": "Ziel.",
            "format": "uri"
          }
        },
        "required": [
          "type",
          "url"
        ],
        "additionalProperties": false
      },
      "TicketType": {
        "type": "object",
        "description": "Öffentliche Ticketkategorie.",
        "properties": {
          "id": {
            "type": "string",
            "description": "Id der Kategorie (UUID).",
            "format": "uuid"
          },
          "name": {
            "type": "string",
            "description": "Name der Kategorie.",
            "examples": [
              "Erwachsene"
            ]
          },
          "price_presale_cents": {
            "type": [
              "integer",
              "null"
            ],
            "description": "Vorverkaufspreis in Cent – null, wenn nicht angegeben."
          },
          "price_door_cents": {
            "type": [
              "integer",
              "null"
            ],
            "description": "Preis an der Abendkassa in Cent – null, wenn nicht angegeben."
          },
          "sort": {
            "type": "integer",
            "description": "Reihenfolge, aufsteigend."
          }
        },
        "required": [
          "id",
          "name",
          "price_presale_cents",
          "price_door_cents",
          "sort"
        ],
        "additionalProperties": false
      },
      "Ball": {
        "type": "object",
        "description": "Ein Ball, wie ihn `GET /api/public/v1/balls` liefert.",
        "properties": {
          "id": {
            "type": "string",
            "description": "Ball-Id (UUID)",
            "format": "uuid"
          },
          "slug": {
            "type": "string",
            "description": "Adresse des Balls – ein Vertrag: bleibt stabil, alte Slugs leiten mit 301 auf den aktuellen weiter.",
            "pattern": "^[a-z0-9]+(?:-[a-z0-9]+)*$",
            "examples": [
              "htl-weiz-2027"
            ]
          },
          "url": {
            "type": "string",
            "description": "Ball-Seite für Menschen (`/ball/<slug>`) – als Quelle in Antworten verlinken.",
            "format": "uri"
          },
          "title": {
            "type": "string",
            "description": "Motto des Balls – oder ein generischer Titel, wenn `has_motto` false ist (dann nicht als Motto ausgeben)."
          },
          "starts_at": {
            "type": "string",
            "format": "date-time",
            "description": "Beginn, ISO-8601 in UTC; Anzeige in Europe/Vienna. Bei `time_known` false nur das Datum verwenden."
          },
          "ends_at": {
            "type": [
              "string",
              "null"
            ],
            "format": "date-time",
            "description": "Ende, ISO-8601 in UTC – null, wenn nicht bekannt."
          },
          "doors_at": {
            "type": [
              "string",
              "null"
            ],
            "format": "date-time",
            "description": "Einlass, ISO-8601 in UTC – null, wenn nicht bekannt."
          },
          "season": {
            "type": "integer",
            "description": "Saison, benannt nach dem Endjahr: 2027 = Saison 2026/27 (September bis August)."
          },
          "status": {
            "type": "string",
            "description": "Status des Termins.",
            "enum": [
              "scheduled",
              "cancelled",
              "postponed"
            ]
          },
          "city": {
            "type": [
              "string",
              "null"
            ],
            "description": "Stadt des Balls."
          },
          "state": {
            "type": [
              "string",
              "null"
            ],
            "description": "Bundesland (die Werte des Filters `state`)."
          },
          "school": {
            "description": "Hauptschule – null, wenn keine Schule hinterlegt ist.",
            "oneOf": [
              {
                "$ref": "#/components/schemas/SchoolRef"
              },
              {
                "type": "null"
              }
            ]
          },
          "venue": {
            "description": "Location aus dem Katalog mit Adresse – null, wenn keine verknüpft ist (siehe `venue_text`).",
            "oneOf": [
              {
                "$ref": "#/components/schemas/BallVenue"
              },
              {
                "type": "null"
              }
            ]
          },
          "venue_text": {
            "type": [
              "string",
              "null"
            ],
            "description": "Freitext-Ort, wenn keine Location aus dem Katalog verknüpft ist."
          },
          "instagram": {
            "type": [
              "string",
              "null"
            ],
            "description": "Instagram-Handle des Balls ohne @ – null, wenn keiner hinterlegt ist."
          },
          "tickets": {
            "description": "Ticket-Hinweis des Komitees – null, wenn keiner gesetzt ist.",
            "oneOf": [
              {
                "$ref": "#/components/schemas/Tickets"
              },
              {
                "type": "null"
              }
            ]
          },
          "cover": {
            "description": "Titelbild – null, wenn keines hochgeladen wurde.",
            "oneOf": [
              {
                "$ref": "#/components/schemas/Cover"
              },
              {
                "type": "null"
              }
            ]
          },
          "is_claimed": {
            "type": "boolean",
            "description": "true ⇒ das Komitee hat den Ball übernommen und pflegt die Seite selbst."
          },
          "updated_at": {
            "type": "string",
            "format": "date-time",
            "description": "Letzte Änderung, ISO-8601 in UTC – auch `lastModified` in der Sitemap."
          },
          "has_motto": {
            "type": "boolean",
            "description": "false ⇒ `title` ist generisch („HLW Weiz Maturaball“, „Maturaball HLW Weiz 2027“) und kein Motto. Dann die Schule als Titel zeigen (`schools[0].name`, bei mehreren „A × B“) und „Motto folgt“ als Untertitel – so macht es ballbuddy.at selbst. `title` bleibt unverändert, `url` und `slug` gelten weiter."
          },
          "time_known": {
            "type": "boolean",
            "description": "false ⇒ die Uhrzeit ist unbekannt, `starts_at` trägt einen Platzhalter. Nur das Datum von `starts_at` in Europe/Vienna verwenden, keine Uhrzeit anzeigen, Kalendereinträge ganztägig anlegen; `ends_at` und `doors_at` dann ignorieren."
          },
          "schools": {
            "type": "array",
            "description": "Alle beteiligten Schulen, Hauptschule zuerst – nie leer, wenn `school` gesetzt ist. Gemeinsame Bälle (HTL × Modeschule) haben mehr als einen Eintrag; `school` bleibt die Hauptschule. Der Filter `school=<slug>` trifft jede beteiligte Schule.",
            "items": {
              "$ref": "#/components/schemas/SchoolRef"
            }
          }
        },
        "required": [
          "id",
          "slug",
          "url",
          "title",
          "starts_at",
          "ends_at",
          "doors_at",
          "season",
          "status",
          "city",
          "state",
          "school",
          "venue",
          "venue_text",
          "instagram",
          "tickets",
          "cover",
          "is_claimed",
          "updated_at",
          "has_motto",
          "time_known",
          "schools"
        ],
        "additionalProperties": false
      },
      "BallDetail": {
        "type": "object",
        "description": "Ein Ball mit allen öffentlichen Details (`GET /api/public/v1/balls/{slug}`).",
        "properties": {
          "id": {
            "type": "string",
            "description": "Ball-Id (UUID)",
            "format": "uuid"
          },
          "slug": {
            "type": "string",
            "description": "Adresse des Balls – ein Vertrag: bleibt stabil, alte Slugs leiten mit 301 auf den aktuellen weiter.",
            "pattern": "^[a-z0-9]+(?:-[a-z0-9]+)*$",
            "examples": [
              "htl-weiz-2027"
            ]
          },
          "url": {
            "type": "string",
            "description": "Ball-Seite für Menschen (`/ball/<slug>`) – als Quelle in Antworten verlinken.",
            "format": "uri"
          },
          "title": {
            "type": "string",
            "description": "Motto des Balls – oder ein generischer Titel, wenn `has_motto` false ist (dann nicht als Motto ausgeben)."
          },
          "starts_at": {
            "type": "string",
            "format": "date-time",
            "description": "Beginn, ISO-8601 in UTC; Anzeige in Europe/Vienna. Bei `time_known` false nur das Datum verwenden."
          },
          "ends_at": {
            "type": [
              "string",
              "null"
            ],
            "format": "date-time",
            "description": "Ende, ISO-8601 in UTC – null, wenn nicht bekannt."
          },
          "doors_at": {
            "type": [
              "string",
              "null"
            ],
            "format": "date-time",
            "description": "Einlass, ISO-8601 in UTC – null, wenn nicht bekannt."
          },
          "season": {
            "type": "integer",
            "description": "Saison, benannt nach dem Endjahr: 2027 = Saison 2026/27 (September bis August)."
          },
          "status": {
            "type": "string",
            "description": "Status des Termins.",
            "enum": [
              "scheduled",
              "cancelled",
              "postponed"
            ]
          },
          "city": {
            "type": [
              "string",
              "null"
            ],
            "description": "Stadt des Balls."
          },
          "state": {
            "type": [
              "string",
              "null"
            ],
            "description": "Bundesland (die Werte des Filters `state`)."
          },
          "school": {
            "description": "Hauptschule – null, wenn keine Schule hinterlegt ist.",
            "oneOf": [
              {
                "$ref": "#/components/schemas/SchoolRef"
              },
              {
                "type": "null"
              }
            ]
          },
          "venue": {
            "description": "Location aus dem Katalog mit Adresse – null, wenn keine verknüpft ist (siehe `venue_text`).",
            "oneOf": [
              {
                "$ref": "#/components/schemas/BallVenue"
              },
              {
                "type": "null"
              }
            ]
          },
          "venue_text": {
            "type": [
              "string",
              "null"
            ],
            "description": "Freitext-Ort, wenn keine Location aus dem Katalog verknüpft ist."
          },
          "instagram": {
            "type": [
              "string",
              "null"
            ],
            "description": "Instagram-Handle des Balls ohne @ – null, wenn keiner hinterlegt ist."
          },
          "tickets": {
            "description": "Ticket-Hinweis des Komitees – null, wenn keiner gesetzt ist.",
            "oneOf": [
              {
                "$ref": "#/components/schemas/Tickets"
              },
              {
                "type": "null"
              }
            ]
          },
          "cover": {
            "description": "Titelbild – null, wenn keines hochgeladen wurde.",
            "oneOf": [
              {
                "$ref": "#/components/schemas/Cover"
              },
              {
                "type": "null"
              }
            ]
          },
          "is_claimed": {
            "type": "boolean",
            "description": "true ⇒ das Komitee hat den Ball übernommen und pflegt die Seite selbst."
          },
          "updated_at": {
            "type": "string",
            "format": "date-time",
            "description": "Letzte Änderung, ISO-8601 in UTC – auch `lastModified` in der Sitemap."
          },
          "has_motto": {
            "type": "boolean",
            "description": "false ⇒ `title` ist generisch („HLW Weiz Maturaball“, „Maturaball HLW Weiz 2027“) und kein Motto. Dann die Schule als Titel zeigen (`schools[0].name`, bei mehreren „A × B“) und „Motto folgt“ als Untertitel – so macht es ballbuddy.at selbst. `title` bleibt unverändert, `url` und `slug` gelten weiter."
          },
          "time_known": {
            "type": "boolean",
            "description": "false ⇒ die Uhrzeit ist unbekannt, `starts_at` trägt einen Platzhalter. Nur das Datum von `starts_at` in Europe/Vienna verwenden, keine Uhrzeit anzeigen, Kalendereinträge ganztägig anlegen; `ends_at` und `doors_at` dann ignorieren."
          },
          "schools": {
            "type": "array",
            "description": "Alle beteiligten Schulen, Hauptschule zuerst – nie leer, wenn `school` gesetzt ist. Gemeinsame Bälle (HTL × Modeschule) haben mehr als einen Eintrag; `school` bleibt die Hauptschule. Der Filter `school=<slug>` trifft jede beteiligte Schule.",
            "items": {
              "$ref": "#/components/schemas/SchoolRef"
            }
          },
          "description": {
            "type": [
              "string",
              "null"
            ],
            "description": "Beschreibung des Komitees („Über den Ball“) – null, wenn keine hinterlegt ist."
          },
          "links": {
            "type": "array",
            "description": "Links des Komitees (Instagram, YouTube, TikTok, Website, Sonstiges).",
            "items": {
              "$ref": "#/components/schemas/BallLink"
            }
          },
          "tanzschule": {
            "type": [
              "string",
              "null"
            ],
            "description": "Tanzschule – null, wenn keine genannt ist."
          },
          "disco": {
            "type": [
              "string",
              "null"
            ],
            "description": "Disco/DJ – null, wenn keine genannt ist."
          },
          "band": {
            "type": [
              "string",
              "null"
            ],
            "description": "Band – null, wenn keine genannt ist."
          },
          "ticket_types": {
            "type": "array",
            "description": "Öffentliche Ticketkategorien mit Preisen in Cent (null = nicht angegeben), sortiert nach `sort`. Nur Information – gekauft wird nicht über die API.",
            "items": {
              "$ref": "#/components/schemas/TicketType"
            }
          }
        },
        "required": [
          "id",
          "slug",
          "url",
          "title",
          "starts_at",
          "ends_at",
          "doors_at",
          "season",
          "status",
          "city",
          "state",
          "school",
          "venue",
          "venue_text",
          "instagram",
          "tickets",
          "cover",
          "is_claimed",
          "updated_at",
          "has_motto",
          "time_known",
          "schools",
          "description",
          "links",
          "tanzschule",
          "disco",
          "band",
          "ticket_types"
        ],
        "additionalProperties": false
      },
      "FilterOption": {
        "type": "object",
        "description": "Gültiger Filterwert mit Trefferzahl in der angefragten Saison.",
        "properties": {
          "slug": {
            "type": "string",
            "description": "Wert für den Filter (`school` bzw. `venue`).",
            "pattern": "^[a-z0-9]+(?:-[a-z0-9]+)*$"
          },
          "name": {
            "type": "string",
            "description": "Anzeigename."
          },
          "count": {
            "type": "integer",
            "description": "Anzahl veröffentlichter Bälle in der Saison."
          }
        },
        "required": [
          "slug",
          "name",
          "count"
        ],
        "additionalProperties": false
      },
      "BallsResponse": {
        "type": "object",
        "description": "Trefferliste mit Paginierung und gültigen Filterwerten.",
        "properties": {
          "items": {
            "type": "array",
            "description": "Bälle, sortiert nach `starts_at` aufsteigend.",
            "items": {
              "$ref": "#/components/schemas/Ball"
            }
          },
          "total": {
            "type": "integer",
            "description": "Anzahl aller Treffer zur Anfrage (über alle Seiten) – der Bestand ändert sich laufend."
          },
          "page": {
            "type": "integer",
            "description": "Aktuelle Seite (ab 1)."
          },
          "per": {
            "type": "integer",
            "description": "Einträge je Seite."
          },
          "filters": {
            "type": "object",
            "description": "Gültige Werte für den nächsten Aufruf, bezogen auf die angefragte (sonst aktuelle) Saison.",
            "properties": {
              "seasons": {
                "type": "array",
                "description": "Saisonen mit Bällen (Endjahre).",
                "items": {
                  "type": "integer"
                }
              },
              "states": {
                "type": "array",
                "description": "Bundesländer mit Bällen.",
                "items": {
                  "type": "string"
                }
              },
              "cities": {
                "type": "array",
                "description": "Städte mit Bällen.",
                "items": {
                  "type": "string"
                }
              },
              "schools": {
                "type": "array",
                "description": "Schulen mit Bällen.",
                "items": {
                  "$ref": "#/components/schemas/FilterOption"
                }
              },
              "venues": {
                "type": "array",
                "description": "Locations mit Bällen.",
                "items": {
                  "$ref": "#/components/schemas/FilterOption"
                }
              }
            },
            "required": [
              "seasons",
              "states",
              "cities",
              "schools",
              "venues"
            ],
            "additionalProperties": false
          }
        },
        "required": [
          "items",
          "total",
          "page",
          "per",
          "filters"
        ],
        "additionalProperties": false
      },
      "School": {
        "type": "object",
        "description": "Schule aus dem Verzeichnis.",
        "properties": {
          "id": {
            "type": "string",
            "description": "Id (UUID).",
            "format": "uuid"
          },
          "name": {
            "type": "string",
            "description": "Name der Schule."
          },
          "slug": {
            "type": "string",
            "description": "Slug – passt in den Filter `school`.",
            "pattern": "^[a-z0-9]+(?:-[a-z0-9]+)*$"
          },
          "school_type": {
            "type": [
              "string",
              "null"
            ],
            "description": "Schultyp (z. B. HTL, HAK, HLW, Gymnasium) – null, wenn nicht bekannt."
          },
          "city": {
            "type": [
              "string",
              "null"
            ],
            "description": "Stadt der Schule."
          },
          "state": {
            "type": [
              "string",
              "null"
            ],
            "description": "Bundesland."
          },
          "website": {
            "type": [
              "string",
              "null"
            ],
            "description": "Website der Schule.",
            "format": "uri"
          },
          "instagram": {
            "type": [
              "string",
              "null"
            ],
            "description": "Instagram-Handle ohne @."
          },
          "ball_count": {
            "type": "integer",
            "description": "Anzahl veröffentlichter Bälle dieser Schule."
          }
        },
        "required": [
          "id",
          "name",
          "slug",
          "school_type",
          "city",
          "state",
          "website",
          "instagram",
          "ball_count"
        ],
        "additionalProperties": false
      },
      "SchoolsResponse": {
        "type": "object",
        "description": "Alle Schulen mit veröffentlichten Bällen.",
        "properties": {
          "items": {
            "type": "array",
            "description": "Schulen, sortiert nach Name.",
            "items": {
              "$ref": "#/components/schemas/School"
            }
          },
          "total": {
            "type": "integer",
            "description": "Anzahl der Einträge."
          }
        },
        "required": [
          "items",
          "total"
        ],
        "additionalProperties": false
      },
      "Venue": {
        "type": "object",
        "description": "Location aus dem Verzeichnis.",
        "properties": {
          "id": {
            "type": "string",
            "description": "Id (UUID).",
            "format": "uuid"
          },
          "name": {
            "type": "string",
            "description": "Name der Location."
          },
          "slug": {
            "type": "string",
            "description": "Slug – passt in den Filter `venue`.",
            "pattern": "^[a-z0-9]+(?:-[a-z0-9]+)*$"
          },
          "address": {
            "type": [
              "string",
              "null"
            ],
            "description": "Straße und Hausnummer."
          },
          "zip": {
            "type": [
              "string",
              "null"
            ],
            "description": "Postleitzahl."
          },
          "city": {
            "type": [
              "string",
              "null"
            ],
            "description": "Ort."
          },
          "state": {
            "type": [
              "string",
              "null"
            ],
            "description": "Bundesland."
          },
          "website": {
            "type": [
              "string",
              "null"
            ],
            "description": "Website der Location.",
            "format": "uri"
          },
          "lat": {
            "type": [
              "number",
              "null"
            ],
            "description": "Breitengrad (WGS84) – null, wenn nicht bekannt."
          },
          "lng": {
            "type": [
              "number",
              "null"
            ],
            "description": "Längengrad (WGS84) – null, wenn nicht bekannt."
          },
          "ball_count": {
            "type": "integer",
            "description": "Anzahl veröffentlichter Bälle an dieser Location."
          }
        },
        "required": [
          "id",
          "name",
          "slug",
          "address",
          "zip",
          "city",
          "state",
          "website",
          "lat",
          "lng",
          "ball_count"
        ],
        "additionalProperties": false
      },
      "VenuesResponse": {
        "type": "object",
        "description": "Alle Locations mit veröffentlichten Bällen.",
        "properties": {
          "items": {
            "type": "array",
            "description": "Locations, sortiert nach Name.",
            "items": {
              "$ref": "#/components/schemas/Venue"
            }
          },
          "total": {
            "type": "integer",
            "description": "Anzahl der Einträge."
          }
        },
        "required": [
          "items",
          "total"
        ],
        "additionalProperties": false
      },
      "SitemapItem": {
        "type": "object",
        "description": "Ball-Seite mit letzter Änderung.",
        "properties": {
          "url": {
            "type": "string",
            "description": "Adresse der Ball-Seite.",
            "format": "uri",
            "examples": [
              "https://ballbuddy.at/ball/htl-weiz-2027"
            ]
          },
          "lastModified": {
            "type": "string",
            "format": "date-time",
            "description": "Letzte Änderung des Balls, ISO-8601 in UTC."
          }
        },
        "required": [
          "url",
          "lastModified"
        ],
        "additionalProperties": false
      },
      "SitemapResponse": {
        "type": "object",
        "description": "Alle veröffentlichten Ball-Seiten.",
        "properties": {
          "items": {
            "type": "array",
            "description": "Ball-Seiten.",
            "items": {
              "$ref": "#/components/schemas/SitemapItem"
            }
          },
          "total": {
            "type": "integer",
            "description": "Anzahl der Einträge."
          }
        },
        "required": [
          "items",
          "total"
        ],
        "additionalProperties": false
      },
      "Error": {
        "type": "object",
        "description": "Fehlerantwort.",
        "properties": {
          "error": {
            "type": "string",
            "description": "Fehlercode.",
            "enum": [
              "invalid_params",
              "not_found"
            ]
          },
          "issues": {
            "type": "array",
            "description": "Nur bei `invalid_params`: welche Parameter nicht passen.",
            "items": {
              "type": "object",
              "description": "Einzelner Fehler.",
              "properties": {
                "path": {
                  "type": "string",
                  "description": "Parametername."
                },
                "message": {
                  "type": "string",
                  "description": "Meldung."
                }
              },
              "required": [
                "path",
                "message"
              ],
              "additionalProperties": false
            }
          }
        },
        "required": [
          "error"
        ],
        "additionalProperties": false
      },
      "Health": {
        "type": "object",
        "description": "Status des Dienstes.",
        "properties": {
          "ok": {
            "type": "boolean",
            "description": "true, wenn der Server antwortet."
          },
          "service": {
            "type": "string",
            "description": "Dienstname.",
            "examples": [
              "ballbuddy"
            ]
          },
          "ts": {
            "type": "string",
            "format": "date-time",
            "description": "Serverzeit, ISO-8601 in UTC."
          }
        },
        "required": [
          "ok",
          "service",
          "ts"
        ],
        "additionalProperties": false
      }
    },
    "parameters": {
      "season": {
        "name": "season",
        "in": "query",
        "description": "Saison als Endjahr (2027 = Saison 2026/27, September bis August). Ohne `season`, `from`, `to` und `q`: die aktuelle Saison.",
        "schema": {
          "type": "integer",
          "minimum": 2000,
          "maximum": 2100
        },
        "example": 2027
      },
      "state": {
        "name": "state",
        "in": "query",
        "description": "Bundesland, exakt einer der neun Werte.",
        "schema": {
          "type": "string",
          "enum": [
            "Burgenland",
            "Kärnten",
            "Niederösterreich",
            "Oberösterreich",
            "Salzburg",
            "Steiermark",
            "Tirol",
            "Vorarlberg",
            "Wien"
          ]
        },
        "example": "Steiermark"
      },
      "city": {
        "name": "city",
        "in": "query",
        "description": "Stadt, exakt wie in `filters.cities`.",
        "schema": {
          "type": "string",
          "minLength": 1,
          "maxLength": 60
        },
        "example": "Graz"
      },
      "school": {
        "name": "school",
        "in": "query",
        "description": "Slug der Schule (aus `filters.schools` oder `/api/public/v1/schools`); trifft bei gemeinsamen Bällen jede beteiligte Schule.",
        "schema": {
          "type": "string",
          "pattern": "^[a-z0-9]+(?:-[a-z0-9]+)*$",
          "maxLength": 80
        },
        "example": "htl-weiz"
      },
      "venue": {
        "name": "venue",
        "in": "query",
        "description": "Slug der Location (aus `filters.venues` oder `/api/public/v1/venues`).",
        "schema": {
          "type": "string",
          "pattern": "^[a-z0-9]+(?:-[a-z0-9]+)*$",
          "maxLength": 80
        },
        "example": "stadthalle-weiz"
      },
      "from": {
        "name": "from",
        "in": "query",
        "description": "Frühester Tag als JJJJ-MM-TT – Wiener Kalendertag, `from` inklusive, `to` exklusive. Mit `from` oder `to` gilt kein Saison-Default.",
        "schema": {
          "type": "string",
          "pattern": "^\\d{4}-\\d{2}-\\d{2}$"
        },
        "example": "2027-01-01"
      },
      "to": {
        "name": "to",
        "in": "query",
        "description": "Erster Tag, der nicht mehr dabei ist, als JJJJ-MM-TT – Wiener Kalendertag, `from` inklusive, `to` exklusive.",
        "schema": {
          "type": "string",
          "pattern": "^\\d{4}-\\d{2}-\\d{2}$"
        },
        "example": "2027-02-01"
      },
      "q": {
        "name": "q",
        "in": "query",
        "description": "Freitext über Titel/Motto, Schule(n), Location und Stadt – ohne Rücksicht auf Groß-/Kleinschreibung und Akzente; mehrere Wörter müssen alle treffen. Mit `q` gilt kein Saison-Default.",
        "schema": {
          "type": "string",
          "minLength": 2,
          "maxLength": 80
        },
        "example": "htl weiz"
      },
      "page": {
        "name": "page",
        "in": "query",
        "description": "Seite (ab 1). Nächste Seite, solange `page * per < total`.",
        "schema": {
          "type": "integer",
          "minimum": 1,
          "maximum": 1000,
          "default": 1
        }
      },
      "per": {
        "name": "per",
        "in": "query",
        "description": "Einträge je Seite.",
        "schema": {
          "type": "integer",
          "minimum": 1,
          "maximum": 100,
          "default": 48
        }
      },
      "slug": {
        "name": "slug",
        "in": "path",
        "required": true,
        "description": "Slug des Balls (`slug` aus der Liste oder aus `/ball/<slug>`).",
        "schema": {
          "type": "string",
          "pattern": "^[a-z0-9]+(?:-[a-z0-9]+)*$",
          "maxLength": 80
        },
        "example": "htl-weiz-2027"
      },
      "ifNoneMatch": {
        "name": "If-None-Match",
        "in": "header",
        "description": "ETag der letzten Antwort. Stimmt er noch, antwortet der Server mit `304 Not Modified` ohne Body.",
        "schema": {
          "type": "string"
        },
        "example": "W/\"0123456789abcdef0123456789\""
      }
    },
    "headers": {
      "ETag": {
        "description": "Schwacher ETag des Bodys – beim nächsten Aufruf als `If-None-Match` mitschicken.",
        "schema": {
          "type": "string"
        }
      },
      "CacheControl": {
        "description": "Immer `public, s-maxage=3600, stale-while-revalidate=86400`: eine Stunde frisch, danach bis zu einem Tag veraltet ausliefern, während neu geladen wird.",
        "schema": {
          "type": "string"
        }
      }
    },
    "responses": {
      "NotModified": {
        "description": "Unverändert seit dem ETag aus `If-None-Match` – kein Body, die gecachte Antwort gilt weiter.",
        "headers": {
          "ETag": {
            "$ref": "#/components/headers/ETag"
          },
          "Cache-Control": {
            "$ref": "#/components/headers/CacheControl"
          }
        }
      },
      "BadRequest": {
        "description": "Ungültige Parameter – `issues` nennt Parameter und Meldung.",
        "content": {
          "application/json": {
            "schema": {
              "$ref": "#/components/schemas/Error"
            }
          }
        }
      },
      "NotFound": {
        "description": "Unbekannter oder nicht veröffentlichter Slug.",
        "content": {
          "application/json": {
            "schema": {
              "$ref": "#/components/schemas/Error"
            }
          }
        }
      }
    }
  }
}
