Stempeluhr-API

Alle Statistiken dieser Instanz sind über eine schlanke HTTP-API abrufbar – ideal für Dashboards, Tabellen, Automationen und eigene Auswertungen. Diagramme werden serverseitig als PNG gerendert und lassen sich direkt einbetten.

Basis-URL: https://stempeluhr.npdev.eu/api/v1

Bot zum eigenen Server hinzufügen: https://stempeluhr.npdev.eu/invite

Authentifizierung

Jeder Request (außer /health, /docs und /invite) benötigt einen instanz-spezifischen API-Schlüssel als Bearer-Token:

Authorization: Bearer stmp_dein-schluessel

Einen Schlüssel erstellst du im Discord mit /apikey create – er wird nur einmal angezeigt. Weitere Schlüssel verwaltest du mit /apikey list und /apikey revoke. Jeder Schlüssel ist an genau einen Server (Guild) gebunden; die API liefert ausschließlich Daten dieses Servers.

Fehlt der Header oder ist der Schlüssel ungültig, antwortet die API mit 401 und einem JSON-Body { "error": "..." }.

Endpunkte

Methode & PfadBeschreibungFormat
GET /healthStatusprüfung (ohne Auth)JSON
GET /docsDiese Dokumentation im Browser (ohne Auth)HTML
GET /dispatchLeitstellenblatt im Browser (ohne Auth)HTML
GET /stats/leaderboardRangliste des ServersJSON
GET /stats/user/:userIdStatistik eines einzelnen NutzersJSON
GET /stats/leaderboard/chart/:type.pngRanglisten-DiagrammPNG
GET /stats/user/:userId/chart/:type.pngNutzer-DiagrammPNG
POST /admin/clock-inPerson per Dienstmarke einstempelnJSON
POST /admin/clock-outPerson per Dienstmarke ausstempelnJSON
GET /admin/clock-status/:badgeIst die Dienstmarke im Dienst?JSON
GET /admin/on-dutyAlle offenen Schichten (im Dienst)JSON
GET /admin/membersAlle Mitarbeiter mit DienstmarkeJSON
PUT /admin/dispatchersLeitstelle / Sek. Leitstelle / Höchstrangiger setzenJSON
POST /admin/unitsEinheit anlegenJSON
GET /admin/unitsEinheiten auflistenJSON
PUT /admin/units/:callsignEinheit aktualisieren (leere Besatzung löscht sie)JSON
DELETE /admin/units/:callsignEinheit löschenJSON
GET /personalPersonalblatt lesen (Mitarbeiter + Spalten)JSON
PUT /personal/columnsSpalten des Personalblatts ersetzenJSON
POST /personal/employeesMitarbeiter manuell anlegenJSON
PATCH /personal/employees/:idZellen eines Mitarbeiters aktualisierenJSON
POST /personal/employees/:id/archiveMitarbeiter archivierenJSON
POST /personal/employees/:id/restoreArchivierten Mitarbeiter reaktivierenJSON
GET /personal/syncVorschläge aus dem Discord-Abgleich lesenJSON
POST /personal/sync/applyVorschläge annehmen/verwerfenJSON
GET /personal/employees/:id/notesNotizen zur Personalakte lesenJSON
POST /personal/employees/:id/notesNotiz anlegenJSON
PATCH /personal/employees/:id/notes/:noteIdNotiz ändernJSON
DELETE /personal/employees/:id/notes/:noteIdNotiz löschenJSON
GET /firstaidErste-Hilfe-Kurs-Anmeldungen lesenJSON
POST /firstaidAnmeldung anlegenJSON
PATCH /firstaid/:idFelder einer Anmeldung bearbeitenJSON
POST /firstaid/:id/passAnmeldung als bestanden markierenJSON
POST /firstaid/:id/unpassBestehen einer Anmeldung zurücknehmenJSON
DELETE /firstaid/:idAnmeldung löschenJSON
GET /board-noticesAushänge des Schwarzen Bretts lesenJSON
POST /board-noticesAushang veröffentlichenJSON
DELETE /board-notices/:idAushang entfernenJSON
GET /einsatz?archived=1Einsatzblätter lesen (ohne archived nur laufende)JSON
POST /einsatzEinsatzblatt anlegenJSON
PATCH /einsatz/:idTitel, Ort, Kartenmarkierung oder Einsatzleiter setzenJSON
POST /einsatz/:id/closeEinsatz abschließen (ins Archiv)JSON
POST /einsatz/:id/reopenAbgeschlossenen Einsatz wieder öffnenJSON
DELETE /einsatz/:idAbgeschlossenes Einsatzblatt löschen (nur Archiv; ein offener Einsatz wird mit 409 abgelehnt)JSON
POST /einsatz/:id/patientsPatient erfassenJSON
PATCH /einsatz/:id/patients/:pidName, Zugehörigkeit, Triage, Ort oder Schritte ändernJSON
DELETE /einsatz/:id/patients/:pidPatient entfernenJSON
GET /einsatz/:id/entriesVerlauf des Einsatzes lesen (Einsatz- und Patienteneinträge)JSON
POST /einsatz/:id/entriesNotiz eintragen (patientId: null = Lagemeldung)JSON
DELETE /einsatz/:id/entries/:eidEigene Notiz zurücknehmenJSON
POST /einsatz/:id/patients/:pid/transferPatient ins Krankenhaus verlegen — optionales Feld buildingId wählt das Zielkrankenhaus; ohne Angabe das ersteJSON
GET /hospital/buildingsKrankenhäuser auflistenJSON
POST /hospital/buildingsKrankenhaus anlegenJSON
PATCH /hospital/buildings/:idKrankenhaus umbenennen oder Position ändernJSON
DELETE /hospital/buildings/:idKrankenhaus löschen — 409, solange Patienten darin liegen oder es das letzte istJSON
`GET /hospital[?view=active\pending\discharged]`Krankenhaus-Board lesen — ohne Parameter die aktiven Patienten, pending die Entlassenen ohne geschriebene Akte (mit involved: wer beteiligt war), discharged das Archiv. ?discharged=1 bleibt als Alias für view=discharged, liefert seitdem aber nur noch Patienten mit geschriebener Akte — vor pending waren das alle Entlassenen, jetzt strikt weniger. Antwort enthält buildings (alle Krankenhäuser der Guild); jeder Raum und jeder Patient trägt buildingId. Gefiltert wird im Client.JSON
POST /hospital/patientsPatient direkt im Krankenhaus anlegen (Direktaufnahme, landet im Warteraum). Optionales Feld buildingId wählt das Krankenhaus; ohne Angabe das erste.JSON
PATCH /hospital/patients/:idOrt (buildingId + Raum/Bett — alle drei Felder gemeinsam), Triage, Zugehörigkeit oder Schritte eines Patienten ändernJSON
GET /hospital/patients/:id/entriesVerlauf eines Krankenhaus-Patienten lesenJSON
POST /hospital/patients/:id/entriesNotiz zum Krankenhaus-Patienten eintragenJSON
DELETE /hospital/patients/:id/entries/:eidEigene Notiz eines Krankenhaus-Patienten zurücknehmen (Leitstelle: jede)JSON
GET /hospital/roomsKrankenhäuser mit ihren Räumen und Betten auflisten (Antwort: { buildings: [{ id, name, position, rooms: [...] }] })JSON
POST /hospital/roomsRaum anlegen (Felder buildingId, name)JSON
PATCH /hospital/rooms/:idRaum umbenennen oder Position ändernJSON
DELETE /hospital/rooms/:idRaum löschen (Patienten fallen in den Warteraum zurück)JSON
POST /hospital/rooms/:id/bedsBett in einem Raum anlegenJSON
PATCH /hospital/beds/:idBett umbenennen oder Position ändernJSON
DELETE /hospital/beds/:idBett löschen (Patient fällt in den Streifen „ohne Bett" zurück)JSON

:userId ist die numerische Discord-User-ID. :type ist einer der unten gelisteten Diagramm-Typen.

Das Leitstellenblatt unter 0 nutzt ausschließlich die hier dokumentierten Endpunkte. Die Seite selbst ist offen; den API-Schlüssel hinterlegst du einmalig unter /dispatch/settings, er wird nur im Browser gespeichert.

Query-Parameter

ParameterWerteStandardGilt für
perioddaily, weekly, monthly, alltimeweeklyalle Stats-Endpunkte
limit110010Rangliste (JSON)
chartstrue, falsefalseStats-JSON-Endpunkte
maxPixelsGanzzahl ≥ 50000990000alle Diagramme
includeArchivedtrue, falsefalseRangliste, Heatmap, Ranglisten-Diagramme

period

includeArchived

Im Personalblatt archivierte Personen („Ehemalige") bleiben mit ihren Schichten in der Historie, zählen aber standardmäßig in keiner serverweiten Statistik mehr mit — weder in den Summen noch im Verlauf, im Anteil oder in der Heatmap. Mit includeArchived=true werden ihre Schichten wieder mitgerechnet. Die Einzelstatistik /stats/user/:userId ist davon nicht betroffen: Sie liefert immer die Schichten der angefragten Person.

charts und maxPixels

Mit charts=true werden die Diagramme als Base64-PNG direkt in die JSON-Antwort eingebettet (Objekt charts). Ohne den Parameter bleibt die Antwort schlank.

maxPixels ist das Pixel-Budget pro Diagramm. Viele Einbettungs-Ziele begrenzen Bilder auf 1 Mio. Pixel bzw. 2 MB. Die Diagramme werden normalerweise mit 2×-Skalierung für scharfe Kanten gerendert; überschreitet das Ergebnis das Budget, wird die Skalierung so weit reduziert, dass es gerade darunter bleibt. Setze maxPixels kleiner, um für ein Ziel mit strengerem Limit zu rendern:

https://stempeluhr.npdev.eu/api/v1/stats/leaderboard?charts=true&maxPixels=1000000

Diagramm-Typen

Rangliste (/stats/leaderboard/chart/:type.png):

Nutzer (/stats/user/:userId/chart/:type.png):

In der JSON-Antwort (mit charts=true) erscheinen dieselben Schlüssel im Objekt charts, jeweils als Base64-kodiertes PNG.

Antwort-Schemas

Alle Zeiten sind Millisekunden (*Ms), Zeitpunkte ISO-8601-Strings in UTC.

GET /stats/leaderboard

{
  "period": "weekly",
  "generatedAt": "2026-07-25T02:15:10.000Z",
  "totals": { "totalMs": 518400000, "totalShifts": 42 },
  "rows": [
    {
      "userId": "123456789012345678",
      "badgeNumber": "01",
      "nickname": "[MD-01] Mustermann",
      "totalMs": 144000000,
      "shiftCount": 12,
      "avgMs": 12000000
    }
  ],
  "trend": [{ "label": "Mo", "totalMs": 72000000 }],
  "charts": { "bars": "<base64>", "share": "<base64>", "trend": "<base64>" }
}

Das Feld charts erscheint nur bei charts=true und wenn Daten vorhanden sind.

GET /stats/user/:userId

{
  "period": "weekly",
  "generatedAt": "2026-07-25T02:15:10.000Z",
  "userId": "123456789012345678",
  "row": {
    "userId": "123456789012345678",
    "badgeNumber": "01",
    "nickname": "[MD-01] Mustermann",
    "totalMs": 144000000,
    "shiftCount": 12,
    "avgMs": 12000000
  },
  "trend": [{ "label": "Mo", "totalMs": 72000000 }],
  "charts": { "shifts": "<base64>", "trend": "<base64>" }
}

row ist null, wenn der Nutzer im Zeitraum keine Schichten hatte.

Beispiele

Rangliste der Woche inklusive eingebetteter Diagramme:

curl -H "Authorization: Bearer $KEY" \
  "https://stempeluhr.npdev.eu/api/v1/stats/leaderboard?period=weekly&charts=true"

Balkendiagramm als PNG herunterladen:

curl -H "Authorization: Bearer $KEY" \
  "https://stempeluhr.npdev.eu/api/v1/stats/leaderboard/chart/bars.png?period=weekly" -o bars.png

Einzelnen Nutzer über den gesamten Zeitraum abfragen:

curl -H "Authorization: Bearer $KEY" \
  "https://stempeluhr.npdev.eu/api/v1/stats/user/123456789012345678?period=alltime"

Ein eingebettetes Diagramm aus der JSON-Antwort dekodieren (Shell):

curl -s -H "Authorization: Bearer $KEY" \
  "https://stempeluhr.npdev.eu/api/v1/stats/leaderboard?charts=true" \
  | jq -r '.charts.trend' | base64 -d > trend.png

Admin-Endpunkte

Diese Endpunkte schreiben Daten und aktualisieren das Panel sofort. Alle Aktionen werden über die Dienstmarke adressiert – im Request als Zahl oder numerischer String (z. B. 99 oder "01"), 01 und 1 sind dieselbe Marke. In allen JSON-Antworten erscheint die Dienstmarke dagegen immer als zweistelliger, mit führender Null aufgefüllter String (1"01", 99"99").

Die Auflösung Dienstmarke → Person läuft dreistufig, von billig nach teuer:

  1. Offene Schicht – ist die Marke eingestempelt, steht alles schon in der DB.
  2. Mitarbeiter-Cache – alle Mitglieder mit [MD-<Nummer>] im Nickname werden

in der Datenbank gehalten und über Gateway-Events aktuell gehalten.

  1. Live-Abruf der Server-Mitglieder – langsam und rate-limitiert, daher nur

als letzte Instanz (z. B. brandneues Mitglied, das der Cache noch nicht kennt). Nur dieser Schritt braucht den Server-Members-Intent.

Der Cache wird beim Start des Bots einmal vollständig befüllt, danach über GuildMemberAdd/Update/Remove gepflegt und alle 6 Stunden sicherheitshalber neu synchronisiert. Im Normalbetrieb erreicht damit keine Anfrage mehr Stufe 3.

POST /admin/clock-in

Body { "badge": 99 }. Stempelt die Person mit Dienstmarke MD-99 ein. 404 wenn keine Person diese Marke im Nickname trägt, 409 wenn sie bereits eingestempelt ist. Antwort 201:

{ "badgeNumber": "99", "userId": "…", "nickname": "[MD-99] Dr. Pearce",
  "clockInAt": "2026-07-25T10:00:00.000Z", "status": "OPEN" }

POST /admin/clock-out

Body { "badge": 99 }. Beendet die offene Schicht der Person mit Dienstmarke MD-99 (wie der „Ausstempeln"-Knopf im Panel). Räumt zusätzlich auf: hält die Person einen Dispatcher-Slot, werden alle drei geleert; außerdem wird sie aus allen Einheiten entfernt (leere Einheiten werden gelöscht). 404 wenn die Marke keine offene Schicht hat. Diese Route löst keine Dienstmarke über Server-Mitglieder auf, benötigt also den Server-Members-Intent nicht. Antwort 200:

{ "badgeNumber": "99", "userId": "…", "clockOutAt": "2026-07-25T18:00:00.000Z",
  "workedMs": 27000000, "breakMs": 1800000, "status": "CLOSED" }

GET /admin/clock-status/:badge

{ "badgeNumber": "99", "clockedIn": true, "status": "OPEN", "since": "2026-07-25T10:00:00.000Z" }

clockedIn ist false (mit status/since = null), wenn die Marke keine offene Schicht hat.

GET /admin/on-duty

Alle aktuell offenen Schichten (OPEN oder PAUSED), früheste Einstempelung zuerst. since ist immer die Einstempelzeit (clockInAt), unabhängig vom Status. onDuty ist [], wenn niemand im Dienst ist.

{
  "generatedAt": "2026-07-25T19:20:00.000Z",
  "onDuty": [
    { "badgeNumber": "99", "userId": "…", "nickname": "[MD-99] Aiden Pearce",
      "status": "OPEN", "since": "2026-07-25T10:00:00.000Z" },
    { "badgeNumber": "01", "userId": "…", "nickname": "[MD-01] Jermaine Prince",
      "status": "PAUSED", "since": "2026-07-25T18:45:00.000Z" }
  ]
}

GET /admin/members

Alle Mitarbeiter, deren Nickname eine Dienstmarke [MD-<Nummer>] trägt, nach Dienstmarke sortiert. Quelle ist der Cache – die Antwort kostet keinen Discord-Abruf. members ist [], solange der erste Sync nach dem Bot-Start noch nicht durchgelaufen ist.

{
  "members": [
    { "badgeNumber": "01", "userId": "…", "name": "Jermaine Prince" },
    { "badgeNumber": "99", "userId": "…", "name": "Aiden Pearce" }
  ]
}

name ist der Nickname ohne Marken-Präfix.

PUT /admin/dispatchers

Body mit optionalen Feldern leitstelle, sekLeitstelle, hoechstrangiger (jeweils Dienstmarke). Nur mitgesendete Felder werden gesetzt; weggelassene bleiben unverändert. Die Slots werden geleert, sobald die zugewiesene Person sich ausstempelt. Antwort 200 mit dem aktuellen Stand aller drei Slots.

Einheiten (/admin/units)

Eine Einheit hat ein eindeutiges Rufzeichen (callsign), einen freien status, eine freie info und bis zu 3 Mitglieder (Dienstmarken).

Beim Ausstempeln wird die Person automatisch aus allen Einheiten entfernt; eine Einheit ohne Mitglieder wird gelöscht.

Statusmeldungen (/admin/statuses)

Das Vokabular, das das Board für den status einer Einheit anbietet. Pro Server konfigurierbar; wurde nie etwas gespeichert, gelten die Standardmeldungen (Status 1Status 6, Status 9).

Ein Eintrag hat ein label, eine color aus der festen Palette (green, orange, blue, red, grey) und alarm – eine so markierte Meldung bekommt im Board und im Discord-Panel das 🚨.

Einträge werden getrimmt, leere und zu lange (> 40 Zeichen) verworfen, Labels case-insensitiv dedupliziert, die Liste bei 20 Einträgen gekappt; eine unbekannte Farbe wird zu grey.

PUT /admin/units/:callsign bleibt davon unberührt und akzeptiert weiterhin beliebigen status-Freitext. Nur die Selbstbedienungsroute unten ist auf die konfigurierten Meldungen beschränkt.

PUT /me/unit-status

Body { "callsign": "Adam-01", "status": "Status 3" }. Setzt den Status einer Einheit, die die aufrufende Person selbst besetzt – dafür genügt Lesezugriff auf den Server (das Funkgerät im Leitstellenblatt). Nur mit Session, nicht mit API-Key.

404 wenn es die Einheit nicht gibt, 403 wenn die aufrufende Person nicht zu ihrer Besatzung gehört, 400 wenn status keine konfigurierte Meldung ist. Antwort 200 mit der aktualisierten Einheit.

GET /me/alarm

Was das Leitstellenblatt braucht, um einen Notruf hörbar zu machen. Bewusst klein: die Route wird von jedem offenen Tab alle 5 Sekunden abgefragt.

{ "onDuty": true, "alarms": ["Adam-01"] }

GET /admin/statuses alarm: true trägt. Reihenfolge wie GET /admin/units. Ein Freitext-Status, den die Statusliste nicht kennt, zählt nicht.

Browser den Alarmton.

Lesezugriff genügt. Ohne verknüpftes Discord-Konto ist onDuty immer false.

Personalblatt

Das Personalblatt verwaltet Stammdaten und Ausbildungsstand aller Mitarbeitenden in admin-konfigurierbaren Spalten. GET /personal darf lesen, wer das Leitstellenblatt lesen darf (Berechtigung view); jede andere Route unter /personal – einschließlich GET /personal/sync – braucht Admin-Rechte (Server-Administrator oder unter Einstellungen eingetragener zusätzlicher Admin). Das gilt auch für Dispatcher, die andernorts in dieser API Schreibrechte haben: hier haben sie keine. Ein API-Schlüssel hat wie überall vollen Zugriff.

GET /personal

{
  "employees": [
    {
      "id": "clx…",
      "userId": "123456789012345678",
      "badgeNumber": "01",
      "name": "Jermaine Prince",
      "active": true,
      "values": {
        "clm…": { "v": true, "at": "2026-07-26T10:00:00.000Z", "by": "987654321012345678" }
      }
    }
  ],
  "columns": [
    {
      "id": "clm…",
      "label": "Flug Ausbildung",
      "kind": "checkbox",
      "options": [],
      "group": "Ausbildung",
      "color": "orange",
      "adminOnly": false,
      "order": 0
    }
  ]
}

userId ist null bei manuell angelegten Mitarbeitern ohne Discord-Konto. values ist nach Spalten-id indiziert; fehlt eine Spalte in einer Zeile, wurde für sie noch nie ein Wert gesetzt. kind ist eine von text, number, date, promotion, select, checkbox, rank; color eine von red, green, orange, blue, magenta oder null. Eine Spalte mit "adminOnly": true wird Nicht-Admins überhaupt nicht ausgeliefert – weder die Spaltendefinition noch die Werte der Mitarbeitenden dazu. Ein kind: "promotion" verhält sich wie date, wird vom Server aber zusätzlich selbst gesetzt: wechselt der Rang einer Person von einem Rang auf einen anderen, trägt der Server das heutige Datum (Zeitzone Europe/Berlin) ein. Die erste Rangvergabe und das Leeren des Rangs lösen das nicht aus, ein mitgeschickter eigener Wert hat Vorrang. Aktive, mit einem Discord-Konto verknüpfte Zeilen werden bei jedem Lesen automatisch mit Dienstmarke/Name aus dem Mitarbeiter-Cache abgeglichen; archivierte Zeilen behalten ihren letzten Stand.

PUT /personal/columns

Body { "columns": [ … ] } – die vollständige Spaltenliste. Ein Eintrag mit id behält sie, ein Eintrag ohne id bekommt eine neue; eine gespeicherte Spalte, die im Body fehlt, wird gelöscht – ihr Wert ist danach bei keinem Mitarbeiter mehr sichtbar. Die Position im Array bestimmt order; ein mitgeschicktes order-Feld wird ignoriert. options gilt nur für select (max. 50 Einträge à 48 Zeichen), label max. 64 Zeichen, group max. 32 Zeichen, insgesamt max. 100 Spalten. adminOnly ist optional und gilt nur bei exakt true als gesetzt.

400 mit "Keine Spaltenliste übergeben", wenn der Body kein columns-Array enthält. 400 mit "Mindestens eine Spalte ist ungültig: Bezeichnung fehlt oder ist länger als 64 Zeichen." hat drei mögliche Ursachen: ein unbekanntes kind, eine leere oder zu lange Bezeichnung – oder schlicht mehr als 100 Einträge im Body (die Normalisierung kappt bei genau 100 Spalten und verwirft den Rest stillschweigend, was denselben Längenvergleich auslöst wie eine ungültige Spalte). In allen drei Fällen schlägt die ganze Anfrage fehl, statt eine Spalte still zu löschen; die Meldung nennt dabei immer den Bezeichnungs-Grund, auch wenn die tatsächliche Ursache die 100er-Grenze war. Antwort { "columns": [ … ] }.

POST /personal/employees

Legt einen Mitarbeiter ohne Discord-Konto an. Body { "name": "…", "badgeNumber": 1 } (badgeNumber optional/null, als Zahl oder numerischer String, z. B. "01"). 400 mit "Name fehlt oder Dienstnummer ist ungültig", wenn name leer oder länger als 64 Zeichen ist, oder badgeNumber keine positive Ganzzahl ist. Antwort: der neue Mitarbeiter (Schema wie in GET /personal, also badgeNumber als zweistelliger, nullpadded String).

PATCH /personal/employees/:id

Body { "values": { "<spaltenId>": <wert> } } – setzt nur die mitgeschickten Zellen, alle anderen bleiben unverändert. at und by kommen ausschließlich vom Server (aktueller Zeitpunkt, aufrufender Discord-User bzw. "api-key" bei Zugriff per API-Schlüssel) und werden aus dem Body ignoriert. Der Wert wird je nach Spalten-kind geprüft: checkbox akzeptiert nur true/false (ein gesendetes null wird als false gespeichert), select nur einen der hinterlegten Optionswerte, date nur das Format YYYY-MM-DD, number nur eine gültige Zahl. Passt der gesendete Wert bei einer dieser vier Arten nicht zum kind, wird die betroffene Zelle stillschweigend übersprungen und behält ihren alten Wert; der Rest des Patches wird trotzdem angewendet. Ausnahme text: ein String über 512 Zeichen wird nicht übersprungen, sondern auf 512 Zeichen gekürzt und in dieser gekürzten Form gespeichert – die vorherige Zelle wird also überschrieben, nicht erhalten. 400 mit "Keine Werte übergeben", wenn values fehlt oder kein Objekt ist. 404 mit "Mitarbeiter nicht gefunden". Antwort: der aktualisierte Mitarbeiter.

Archivieren (/personal/employees/:id/archive, /restore)

Ein Mitarbeiter wird nie gelöscht, nur archiviert.

"Mitarbeiter nicht gefunden". Antwort: der aktualisierte Mitarbeiter.

zurück; die Zeile und ihre bisherigen Spaltenwerte (Ausbildungsstand) bleiben dabei erhalten. 404 mit "Mitarbeiter nicht gefunden". Antwort: der aktualisierte Mitarbeiter.

Discord-Abgleich (/personal/sync)

Der Abgleich mit Discord schlägt nur vor, er ändert nie selbständig etwas.

– Kandidaten mit userId, badgeNumber, name und kind ("add" oder "remove"). Ein Vorschlag entsteht für jedes Guild-Mitglied mit Dienstmarke (und, falls konfiguriert, Mitarbeiter-Rolle), das noch keine aktive Zeile hat (add), bzw. für jede aktive Zeile, deren Person die Bedingung nicht mehr erfüllt (remove). „Noch keine aktive Zeile“ zählt dabei die Dienstnummer mit, nicht nur die verknüpfte userId: trägt bereits eine aktive Zeile die DN des Mitglieds – etwa eine von Hand angelegte, die nie eine userId bekommt –, entsteht kein add. Archivierte Zeilen zählen nicht mit, damit eine zurückkehrende Person weiterhin vorgeschlagen wird.

"kind": "add" | "remove", "accept": true | false } ] }. accept: true bei add reaktiviert eine archivierte Zeile dieser Person (falls vorhanden), statt eine neue anzulegen, und erhält so die Ausbildungshistorie; hat die Person keine eigene Zeile, trägt aber eine aktive Zeile ohne userId ihre DN, wird diese übernommen und verknüpft, statt eine zweite daneben zu schreiben; accept: true bei remove archiviert die Zeile. accept: false blendet den Vorschlag aus, bis sich die zugrunde liegende Bedingung wieder ändert. 400 mit "Keine Entscheidungen übergeben", wenn decisions fehlt oder kein Array ist. Antwort: wie GET /personal/sync`, mit dem Stand nach der Anwendung.

Personalakte-Notizen (/personal/employees/:id/notes)

Freitext-Notizen zu einer Person, sichtbar ausschließlich für Admins – auch lesend. Jeder Admin darf jede Notiz ändern und löschen; authorUserId hält fest, wer sie ursprünglich verfasst hat, und wird dabei nicht überschrieben.

{
  "notes": [
    {
      "id": "cln…",
      "body": "Zweite Verspätung diesen Monat, Gespräch geführt.",
      "authorUserId": "987654321012345678",
      "createdAt": "2026-07-26T10:00:00.000Z",
      "updatedAt": "2026-07-26T10:00:00.000Z"
    }
  ]
}

400 mit "Notiz fehlt oder ist länger als 2000 Zeichen", wenn body fehlt, leer ist oder 2000 Zeichen überschreitet – zu lange Notizen werden nicht gekürzt, sondern abgelehnt. 404 mit "Mitarbeiter nicht gefunden" bzw. "Notiz nicht gefunden", auch dann, wenn die Id zu einem anderen Server oder zu einer anderen Person gehört. authorUserId ist "api-key" bei Zugriff per API-Schlüssel.

Erste-Hilfe-Kurs

Verwaltet die Anmeldungen zum Erste-Hilfe-Kurs. GET und POST /firstaid dürfen alle nutzen, die das Leitstellenblatt lesen dürfen (Berechtigung view); alle anderen Routen unter /firstaid – Bearbeiten, Bestehen setzen/zurücknehmen, Löschen – brauchen Admin-Rechte (Server-Administrator oder unter Einstellungen eingetragener zusätzlicher Admin). Ein API-Schlüssel hat wie überall vollen Zugriff.

GET /firstaid

{
  "registrations": [
    {
      "id": "clx…",
      "name": "Jermaine Prince",
      "phone": "0176 12345678",
      "affiliation": "Feuerwehr Musterstadt",
      "passedAt": null,
      "createdAt": "2026-07-26T10:00:00.000Z"
    }
  ]
}

passedAt ist null, solange der Kurs nicht als bestanden markiert wurde, sonst der Zeitpunkt der Markierung (ISO-8601, UTC).

POST /firstaid

Body { "name": "…", "phone": "…", "affiliation": "…" }phone und affiliation sind optional, name ist Pflicht (max. 64 Zeichen, phone max. 32, affiliation max. 64). 400 mit "Name fehlt oder ein Feld ist zu lang", wenn eine dieser Bedingungen verletzt ist. Antwort: die neue Anmeldung (Schema wie in GET /firstaid).

PATCH /firstaid/:id

Body { "values": { "name"?: "…", "phone"?: "…", "affiliation"?: "…" } } – setzt nur die mitgeschickten Felder, alle anderen bleiben unverändert. 400 mit "Keine gültigen Werte übergeben", wenn values fehlt, kein Objekt ist oder kein gültiges Feld enthält. 404 mit "Anmeldung nicht gefunden". Antwort: die aktualisierte Anmeldung.

Bestehen (/firstaid/:id/pass, /unpass)

404 mit "Anmeldung nicht gefunden". Antwort: die aktualisierte Anmeldung.

mit "Anmeldung nicht gefunden". Antwort: die aktualisierte Anmeldung.

DELETE /firstaid/:id

Löscht die Anmeldung endgültig. 404 mit "Anmeldung nicht gefunden". Antwort { "ok": true }.

Schwarzes Brett

Die Aushänge, die im Leitstellenblatt zwischen Kopfzeile und der Zeile „Leitung / Übersicht" von rechts nach links durchlaufen. Lesen darf jede und jeder mit Zugriff auf das Blatt (Berechtigung view); Veröffentlichen und Entfernen brauchen Admin-Rechte (Server-Administrator oder unter Einstellungen eingetragener zusätzlicher Admin). Ein API-Schlüssel hat wie überall vollen Zugriff.

GET /board-notices

{
  "notices": [
    {
      "id": "clx…",
      "body": "Schulung am Samstag um 20 Uhr",
      "expiresOn": "2026-07-30",
      "expired": false,
      "createdAt": "2026-07-26T10:00:00.000Z"
    }
  ]
}

Neueste zuerst. expiresOn ist der letzte Tag, an dem der Aushang läuft (YYYY-MM-DD), oder null für „läuft bis auf Widerruf". expired wird vom Server gegen den Berliner Kalendertag berechnet: Das Laufband zeigt die Aushänge mit expired: false, abgelaufene bleiben in der Antwort, damit ein Admin sie sehen und entfernen kann.

POST /board-notices

Body { "body": "…", "expiresOn": "2026-07-30" | null }body ist Pflicht (max. 280 Zeichen, wird getrimmt), expiresOn ist optional. 400, wenn der Text fehlt, zu lang ist oder das Datum kein gültiger Kalendertag im Format YYYY-MM-DD ist. 409, wenn der Server bereits 20 Aushänge hält – dann muss zuerst einer entfernt werden. Antwort: der neue Aushang (Schema wie oben).

DELETE /board-notices/:id

Entfernt den Aushang endgültig. 404 mit "Aushang nicht gefunden". Antwort { "ok": true }.

Statuscodes

CodeBedeutung
200OK
400Ungültiger Parameter (z. B. unbekannte period)
401Fehlender oder ungültiger API-Schlüssel
404Route oder Diagramm-Typ unbekannt / keine Daten
409Konflikt (bereits eingestempelt / Rufzeichen existiert)
500Interner Fehler