openapi: 3.1.0

# =====================================================================
# WAS HIER DRINSTEHT - UND WAS NICHT
# =====================================================================
# NUR die Schnittstellen, die ein FREMDES System benutzen darf: mit
# einem Access Key oder einem Token, ohne AlarmPunkt-Anmeldung.
#
# Alles, was eine Sitzung oder ein App-Token braucht, gehoert NICHT
# hierher. Das ist keine Bequemlichkeit, sondern die Grenze: Was in
# dieser Datei steht, ist eine Zusage an Dritte und darf sich nicht
# beilaeufig aendern. Was nicht drinsteht, gehoert dem Portal und den
# Apps und darf sich jederzeit aendern.
#
# tests/ApiDokuTest.php haelt beide Seiten zusammen: Jeder Pfad hier
# muss es geben, und jeder Endpunkt mit Access Key oder Token muss
# entweder hier stehen oder dort ausdruecklich als intern benannt sein.
# Ohne das waere diese Datei nach dem dritten Umbau eine Erzaehlung.
# =====================================================================

info:
  title: AlarmPunkt - Schnittstellen für Fremdsysteme
  version: "1.0.0"
  description: |
    Die Schnittstellen, über die andere Systeme mit AlarmPunkt sprechen:
    Einsatzleitsysteme, Brandmeldeanlagen, Alarmmonitore und
    Auswertungswerkzeuge.

    ## Zwei Arten, sich auszuweisen

    | | |
    |---|---|
    | **Access Key** | Ein Schlüssel je Einheit. Er steht als Feld `access_key` in der Anfrage. Damit meldet ein Einsatzleitsystem Alarme und ein Alarmmonitor Fahrzeugstatus. |
    | **Bearer-Token** | Ein Token je Anbindung, im Kopf `Authorization: Bearer …`. Damit meldet eine Brandmeldeanlage, und damit liest ein Auswertungswerkzeug. |

    **Token gehören nie in die Adresse.** Dort landen sie im
    Zugriffsprotokoll des Servers und gelten damit als bekannt. Die
    Endpunkte weisen das ausdrücklich ab, statt es stillschweigend
    hinzunehmen.

    ## Alles über TLS

    Es gibt keinen Zugang über `http://`. Ein Alarm, der im Klartext
    über ein fremdes Netz geht, ist eine Einladung – sowohl zum Mitlesen
    als auch zum Einspeisen.

    ## Wo die Schlüssel herkommen

    | Schlüssel | Wird angelegt in |
    |---|---|
    | Access Key einer Einheit | Verwaltung → Einheiten |
    | Access Key einer Person | Verwaltung → Benutzer → Kennungen |
    | Token einer Brandmeldeanlage | Verwaltung → Brandfrüherkennung |
    | Token für die Auswertung | Verwaltung → Auswertungszugang |

    Ein Schlüssel lässt sich jederzeit widerrufen. Er wirkt sofort und
    ohne Neustart.

  contact:
    name: AlarmPunkt
    url: https://alarmpunkt.de

servers:
  - url: https://alarmpunkt.de
    description: Produktivsystem

tags:
  - name: Alarmierung
    description: |
      Alarme auslösen und fortschreiben. Das ist der Weg, über den ein
      Einsatzleitsystem mit AlarmPunkt spricht (Lastenheft 75).
  - name: Brandfrüherkennung
    description: |
      Meldungen aus Brandmeldeanlagen und Hausalarmanlagen (Lastenheft
      74). Für ESPA und potentialfreie Kontakte setzt ein kleiner
      Umsetzer davor auf; AlarmPunkt kennt nur diesen einen Eingang.
  - name: Status
    description: Fahrzeug- und Personenstatus melden.
  - name: Auswertung
    description: |
      Lesende Abfragen für PowerBI und andere Auswertungswerkzeuge
      (Lastenheft 60).

paths:

  # ==================================================================
  # Alarmierung
  # ==================================================================

  /api/ingest.php:
    post:
      tags: [Alarmierung]
      summary: Alarm, Mitteilung, Ticker oder Termin einliefern
      description: |
        Ein Eingang für vier Dinge, unterschieden durch `type`.

        ## Der Alarm

        `type=alarm` löst aus. Er klingelt auf jedem Gerät, umgeht den
        Ruhemodus und weckt Leute – auch nachts, auch am Wochenende.
        Wer hier einen Testlauf fährt, sagt vorher Bescheid.

        **Wen es trifft**, entscheidet AlarmPunkt anhand von `gssi`
        beziehungsweise `subgroup` und der hinterlegten Struktur. Die
        meldende Stelle bestimmt das Ziel nicht frei – sonst wäre der
        Access Key einer Einheit ein Schlüssel für die ganze Wehr.

        **Doppelte Alarme**: Wird derselbe Alarm zweimal geschickt (etwa
        weil die Antwort ausblieb), erkennt AlarmPunkt das an `number`
        und alarmiert nicht erneut.

        ## Die Fortschreibung

        Mit `update=1` und `changes` wird ein laufender Alarm
        **ergänzt**, ohne erneut zu klingeln – zum Beispiel, wenn die
        Leitstelle die Adresse nachreicht. `changes` nennt die Felder,
        die sich geändert haben, mit Semikolon getrennt.
      security:
        - AccessKeyForm: []
      requestBody:
        required: true
        content:
          application/x-www-form-urlencoded:
            schema:
              $ref: '#/components/schemas/Einlieferung'
            examples:
              alarm:
                summary: Ein Alarm aus dem Einsatzleitsystem
                value:
                  type: alarm
                  access_key: 2A7F…
                  number: "2026-004711"
                  title: "B2 - Zimmerbrand"
                  priority: "true"
                  gssi: "2621001"
                  address: "Musterweg 4, 35649 Bischoffen"
                  street: "Musterweg"
                  housenumber: "4"
                  district: "Niederweidbach"
                  lat: "50.6931"
                  lng: "8.4210"
                  text: "Rauch aus dem Dachstuhl, Personen im Haus"
                  alertedunits: "LF10, DLK23"
              fortschreibung:
                summary: Adresse nachgereicht - klingelt NICHT erneut
                value:
                  type: alarm
                  access_key: 2A7F…
                  number: "2026-004711"
                  update: "1"
                  changes: "address;lat;lng"
                  address: "Musterweg 4a, 35649 Bischoffen"
                  lat: "50.6932"
                  lng: "8.4211"
              ticker:
                summary: Eine Tickermeldung der Leitstelle
                value:
                  type: ticker
                  access_key: 2A7F…
                  sender: "Leitstelle"
                  text: "Sturmwarnung bis 22 Uhr"
      responses:
        '200':
          description: |
            Angenommen. **Auch ein Fehler kommt mit 200** und
            `success: false` – der ältere Teil der Schnittstelle
            antwortet so, und ein Einsatzleitsystem, das auf den
            HTTP-Code schaut, übersähe die Meldung.
          content:
            application/json:
              schema:
                oneOf:
                  - $ref: '#/components/schemas/Erfolg'
                  - $ref: '#/components/schemas/Fehler'
              examples:
                ok:
                  value: { success: true, alarm_id: 4711 }
                keinSchluessel:
                  value: { success: false, error: "Ungültiger Access-Key" }

  # ==================================================================
  # Brandfrüherkennung
  # ==================================================================

  /api/ueberwachung/meldung.php:
    post:
      tags: [Brandfrüherkennung]
      summary: Zustandsmeldung einer Brandmeldeanlage
      description: |
        ## Das Ziel steht am Melder, nie in der Meldung

        Die Anlage sagt, **was** los ist. **Wen** das angeht, steht in
        AlarmPunkt am Melder – benutzergruppendefiniert, wie es
        Lastenheft 74 verlangt.

        Dürfte die Anlage ihr Ziel mitschicken, wäre sie keine
        Brandmeldeanlage mehr, sondern eine Alarmierungsschnittstelle
        mit Vollzugriff: Wer die Leitung anzapft, alarmiert die ganze
        Wehr.

        ## Warum die Antwort immer 200 ist

        Auch dann, wenn nichts alarmiert wurde – etwa weil der Melder
        unbekannt ist oder der Zustand sich nicht geändert hat. Eine
        Brandmeldeanlage wiederholt bei einem Fehlercode; sie würde
        genau die Meldungen wiederholen, die AlarmPunkt bewusst nicht
        verarbeitet hat. Was passiert ist, steht in `ergebnis`.
      security:
        - BearerToken: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [kennung, zustand]
              properties:
                kennung:
                  type: string
                  description: |
                    Die Kennung des Melders, so wie sie in AlarmPunkt
                    hinterlegt ist. Eine unbekannte Kennung wird
                    protokolliert und nicht alarmiert.
                  example: "MG-07"
                zustand:
                  type: string
                  enum: [alarm, stoerung, revision, entwarnung]
                  description: |
                    * `alarm` – die Anlage hat ausgelöst
                    * `stoerung` – die Anlage meldet einen Fehler
                    * `revision` – die Anlage ist in Wartung
                    * `entwarnung` – zurückgesetzt
                  example: alarm
                text:
                  type: string
                  description: Freitext der Anlage, optional.
                  example: "Melder 12, Erdgeschoss Flur"
            examples:
              alarm:
                value: { kennung: "MG-07", zustand: "alarm", text: "Melder 12, EG Flur" }
              entwarnung:
                value: { kennung: "MG-07", zustand: "entwarnung" }
      responses:
        '200':
          description: Verarbeitet - was geschah, steht in `ergebnis`.
          content:
            application/json:
              schema:
                type: object
                properties:
                  success: { type: boolean }
                  ergebnis:
                    type: string
                    description: Was mit der Meldung geschah.
                  zustand:
                    type: string
        '400':
          description: Das Token stand in der Adresse statt im Kopf.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Fehler' }
        '401':
          description: |
            Token fehlt, ist unbekannt oder die Anbindung ist
            abgeschaltet. **Ein Text für alle drei Fälle** – wer Token
            durchprobiert, soll nicht erfahren, dass er ein echtes
            erwischt hat.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Fehler' }

  # ==================================================================
  # Status
  # ==================================================================

  /api/status.php:
    post:
      tags: [Status]
      summary: Fahrzeug- oder Personenstatus melden
      description: |
        `type=vehicle` meldet einen Fahrzeugstatus (Lastenheft 57),
        `type=user` einen Personenstatus.

        Ein Status, der in AlarmPunkt als „nur signalisieren" hinterlegt
        ist – Sprechwunsch etwa –, **ersetzt den bisherigen Zustand
        nicht**. Er meldet sich und ist vorbei. Die Position wird
        trotzdem übernommen: Wo das Fahrzeug steht, bleibt richtig, egal
        welchen Status es gerade meldet.
      security:
        - AccessKeyForm: []
      requestBody:
        required: true
        content:
          application/x-www-form-urlencoded:
            schema:
              type: object
              required: [type, access_key]
              properties:
                type:
                  type: string
                  enum: [vehicle, user]
                access_key:
                  type: string
                  description: |
                    Bei `vehicle` der Schlüssel der Einheit, bei `user`
                    der persönliche Schlüssel.
                issi:
                  type: string
                  description: Funkkennung des Fahrzeugs. Pflicht bei `type=vehicle`.
                  example: "2621101"
                status:
                  type: string
                  description: |
                    Der Statuscode. Beim Fahrzeug eine Zahl (1–9), bei
                    der Person **hexadezimal** (8002 bis 800B) – ein
                    Zahlen-Cast machte aus „800A" die 800.
                  example: "3"
                lat:
                  type: number
                  format: double
                lng:
                  type: number
                  format: double
            examples:
              fahrzeug:
                value: { type: vehicle, access_key: "2A7F…", issi: "2621101", status: "3", lat: 50.693, lng: 8.421 }
              person:
                value: { type: user, access_key: "PERSONKEY…", status: "8002" }
      responses:
        '200':
          description: "Angenommen - oder abgelehnt mit `success: false`."
          content:
            application/json:
              schema:
                oneOf:
                  - $ref: '#/components/schemas/Erfolg'
                  - $ref: '#/components/schemas/Fehler'

  # ==================================================================
  # Auswertung
  # ==================================================================

  /api/auswertung/:
    get:
      tags: [Auswertung]
      summary: Datensätze für PowerBI und andere Auswertungen
      description: |
        **Nur lesen.** Es gibt in diesem Zugang keine Aktion, keinen
        Schalter und keinen Zweig, der etwas schreibt – das ist keine
        Zusage, sondern eine Eigenschaft der Datei.

        Das Token nennt die Organisation; ab da ist jede Abfrage auf sie
        eingeschränkt.

        ## Ohne `datensatz` kommt das Verzeichnis

        Welche Datensätze es gibt, was sie enthalten und ob sie einen
        Zeitfilter kennen. Ein Bericht, der neu eingerichtet wird, fragt
        damit zuerst das System, statt eine Anleitung zu suchen.

        ## Seitenweise über `ab_id`, nicht über OFFSET

        `OFFSET` liest bei jeder Seite alles davor mit und verschiebt
        sich, sobald neue Zeilen dazukommen – bei einem Bericht, der
        stündlich läuft, fehlen dann Zeilen. `ab_id` nennt stattdessen
        den zuletzt gesehenen Schlüssel.

        Die Anleitung für PowerBI steht in `deploy/powerbi-zugang.md`.
      security:
        - BearerToken: []
      parameters:
        - name: datensatz
          in: query
          required: false
          schema:
            type: string
            enum: [alarme, zustellungen, rueckmeldungen, betroffene, personen, einheiten]
          description: |
            **Vier Datensätze mit Zahlen:**

            * `alarme` – ein Datensatz je Alarm
            * `zustellungen` – je Alarm und Gerät: eingereiht, gesendet, quittiert
            * `rueckmeldungen` – je Rückmeldung, mit Verlauf, nicht nur die letzte
            * `betroffene` – wen ein Alarm betraf und über welche Zugehörigkeit

            **Zwei zum Verbinden**, damit im Bericht Namen statt Nummern
            stehen:

            * `personen` – Vor- und Nachname zu den Personen-IDs, auch zu
              ausgeschiedenen. Ohne sie stünde in jeder Auswertung, wer
              gestern noch dabei war, als blosse Nummer.
            * `einheiten` – der Knotenbaum der Organisation, zum
              Verbinden mit `einheit_id`

            Fehlt der Parameter, kommt das Verzeichnis.
        - name: seit
          in: query
          required: false
          schema: { type: string, format: date }
          description: Nur Datensätze ab diesem Tag.
          example: "2026-01-01"
        - name: ab_id
          in: query
          required: false
          schema: { type: integer }
          description: |
            Weiterblättern: der zuletzt gesehene Schlüssel. Die Antwort
            nennt ihn als `naechste_ab_id`, solange es weitergeht.
        - name: anzahl
          in: query
          required: false
          schema: { type: integer, minimum: 1 }
          description: |
            Wie viele Zeilen höchstens. Der Server begrenzt das
            zusätzlich nach oben – eine Abfrage, die eine Million Zeilen
            in einem Zug will, würde den Alarmweg ausbremsen.
      responses:
        '200':
          description: Das Verzeichnis oder die Daten.
          content:
            application/json:
              schema:
                type: object
                properties:
                  success: { type: boolean }
                  datensatz: { type: string }
                  zeilen:
                    type: array
                    items: { type: object, additionalProperties: true }
                  naechste_ab_id:
                    type: [integer, "null"]
                    description: Weiterblättern, oder null am Ende.
        '400':
          description: Das Token stand in der Adresse statt im Kopf.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Fehler' }
        '401':
          description: Token fehlt, ist unbekannt oder widerrufen.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Fehler' }

components:

  securitySchemes:
    AccessKeyForm:
      type: apiKey
      in: query
      name: access_key
      description: |
        Der Schlüssel einer Einheit oder einer Person. Er darf als Feld
        im Formularkörper stehen – das ist der empfohlene Weg, weil er
        dann nicht im Zugriffsprotokoll landet.

        **Widerrufen**: In der Verwaltung einen neuen Schlüssel erzeugen.
        Der alte gilt ab dann nicht mehr, ohne Neustart.

    BearerToken:
      type: http
      scheme: bearer
      description: |
        `Authorization: Bearer <token>`.

        **Nicht in die Adresse schreiben.** Dort steht es danach im
        Zugriffsprotokoll des Servers; die Endpunkte weisen das mit 400
        ab und sagen, dass das Token als bekannt gilt.

  schemas:

    Erfolg:
      type: object
      properties:
        success: { type: boolean, const: true }
      additionalProperties: true

    Fehler:
      type: object
      properties:
        success: { type: boolean, const: false }
        error:
          type: string
          description: Klartext auf Deutsch (Lastenheft 69).

    Einlieferung:
      type: object
      required: [type, access_key]
      properties:
        type:
          type: string
          enum: [alarm, message, ticker, termin]
        access_key:
          type: string
          description: Der Schlüssel der Einheit.

        # --- type=alarm ---
        number:
          type: string
          description: |
            Die Einsatznummer der Leitstelle. Sie erkennt Wiederholungen
            und verbindet die Fortschreibung mit dem Alarm.
          example: "2026-004711"
        title:
          type: string
          description: Das Stichwort.
          example: "B2 - Zimmerbrand"
        priority:
          type: string
          enum: ["true", "false"]
          description: |
            `true` heißt: klingelt trotz Ruhemodus. Das ist die
            Voreinstellung für echte Einsätze.
        gssi:
          type: string
          description: Die Alarmgruppe, die alarmiert werden soll.
        subgroup:
          type: string
          description: |
            Untergruppe innerhalb der Alarmgruppe. Mehrere durch
            Semikolon getrennt.
        person_key:
          type: string
          description: |
            Persönlich gewidmeter Alarm. `access_key` bleibt dabei die
            Anmeldung der Organisation; der Personenschlüssel bestimmt
            nur den Empfänger. **Beide müssen zur selben Organisation
            gehören** – sonst ließe sich mit einem beliebigen
            Organisationsschlüssel jede fremde Person alarmieren.
        text:
          type: string
          description: Der Meldetext.
        address:
          type: string
        street:
          type: string
        housenumber:
          type: string
        district:
          type: string
        locationnote:
          type: string
          description: Hinweis zum Einsatzort ("Hinterhof", "3. OG").
        lat:
          type: number
          format: double
        lng:
          type: number
          format: double
        object:
          type: string
          description: Objektbezeichnung aus dem Objektverzeichnis.
        note:
          type: string
        alertedunits:
          type: string
          description: Die alarmierten Fahrzeuge, wie die Leitstelle sie nennt.
        update:
          type: string
          enum: ["1"]
          description: |
            Fortschreibung eines laufenden Alarms. Klingelt **nicht**
            erneut.
        changes:
          type: string
          description: |
            Bei einer Fortschreibung: welche Felder sich geändert haben,
            mit Semikolon getrennt.
          example: "address;lat;lng"

        # --- type=message / type=ticker ---
        sender:
          type: string
          description: Absender. Pflicht bei `message` und `ticker`.
        subject:
          type: string
        body:
          type: string

        # --- type=termin ---
        event_date:
          type: string
          description: |
            Beginn des Termins in **Ortszeit**. AlarmPunkt legt ihn in
            UTC ab.
          example: "2026-09-12 19:30:00"
        description:
          type: string
