Terminal im Geraetehaus mit Offline-Betrieb ergaenzen

Phase 4: eine eigene Oberflaeche unter /kiosk fuer das Wandtablet in der
Fahrzeughalle — mit Handschuhen bedienbar und arbeitsfaehig, wenn das Netz
weg ist.

Datenmodell: Fahrt, Anwesenheit, Waescheabgabe, Inventurgang,
Inventurposten, KioskVorgang und SyncKonflikt. Die Kennung eines Vorgangs
erzeugt der Browser beim Einreihen und ist damit der Idempotenzschluessel;
kommt derselbe Vorgang nach abgebrochener Uebertragung erneut an, erkennt
ihn der Server wieder. Die Datenbank setzt vier Regeln selbst durch und
verweigert UPDATE und DELETE auf kiosk_vorgang.

Offline: von Hand geschriebener Service Worker, Warteschlange in der
IndexedDB (Dexie), Fotos als Blob daneben, sichtbarer Zustand als Leiste
ueber allem. Was sich beim Nachtragen nicht anwenden laesst, landet als
Konflikt unter /terminal statt verworfen zu werden.

Dazu: Anmeldung per Ausweis, PIN oder signierter QR-Marke vom Handy,
Hallenmonitor mit optionalen DWD-Warnungen, Installation als App.

Behoben: die Warteschlange zog ihren Wiederholungstakt bei jedem Rendern
neu auf und haette einen liegen gebliebenen Vorgang nie ein zweites Mal
geschickt. Gefunden vom Offline-E2E-Test.

31 neue Tests (20 Unit, 11 gegen eine echte Datenbank, 9 E2E je Browser).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Florian Wilde
2026-09-03 18:35:48 +02:00
co-authored by Claude Opus 5
parent d8d692098d
commit 791523027f
126 changed files with 7787 additions and 40 deletions
+55 -1
View File
@@ -9,6 +9,7 @@ import { pruefen } from "@/lib/passwort";
import { rfidNormalisieren } from "@/lib/passwort";
import { protokolliereAnmeldung } from "@/lib/audit";
import { alsRolle, type Rolle, type Sitzung } from "@/lib/authz";
import { markePruefen } from "@/lib/kiosk/anmeldemarke";
/**
* Anmeldung — drei Wege, ein Sitzungsbegriff.
@@ -297,6 +298,59 @@ const terminalAnbieter = Credentials({
},
});
/**
* Anmeldemarke — der QR-Code vom Handy.
*
* Dritter Weg an das Terminal. Auch hier zwei Schranken: erst das Gerät mit
* KIOSK_DEVICE_SECRET, dann die Person über die signierte Marke. Die Marke gilt
* eine Minute; ein abfotografierter Code nützt danach nichts mehr.
*/
const markenAnbieter = Credentials({
id: "terminal-marke",
name: "Anmeldemarke",
credentials: {
geraeteSchluessel: { label: "Gerät", type: "text" },
marke: { label: "Anmeldemarke", type: "text" },
},
async authorize(eingabe) {
if (!env.KIOSK_ENABLED) return null;
const geprueft = z
.object({ geraeteSchluessel: z.string().min(1), marke: z.string().min(10).max(500) })
.safeParse(eingabe);
if (!geprueft.success) return null;
if (geprueft.data.geraeteSchluessel !== env.KIOSK_DEVICE_SECRET) return null;
const marke = markePruefen(geprueft.data.marke);
if ("fehler" in marke) return null;
const person = await personMitRollen(marke.personId);
if (!person || person.organisationId !== marke.organisationId) return null;
if (!person.organisation.aktiv) return null;
await protokolliereAnmeldung({
organisationId: person.organisationId,
personId: person.id,
name: `${person.vorname} ${person.nachname}`,
anmeldeArt: AnmeldeArt.KIOSK_RFID,
erfolgreich: true,
grund: "Anmeldemarke",
});
return {
id: person.id,
name: `${person.vorname} ${person.nachname}`,
email: person.email ?? undefined,
organisationId: person.organisationId,
rollen: rollenAus(person.rollen),
// Fachlich dasselbe wie ein Ausweis: die Person weist sich mit etwas
// aus, das sie bei sich trägt.
anmeldeArt: "KIOSK_RFID" as const,
};
},
});
/**
* OIDC gegen authentik. Wird nur eingehängt, wenn Issuer, Client-ID und Secret
* gesetzt sind — sonst bliebe ein Knopf stehen, der ins Leere führt.
@@ -497,7 +551,7 @@ function anbieterListe(): Provider[] {
const liste: Provider[] = [];
if (oidcVerfuegbar) liste.push(authentikAnbieter());
if (env.AUTH_LOCAL_ENABLED) liste.push(passwortAnbieter);
if (env.KIOSK_ENABLED) liste.push(terminalAnbieter);
if (env.KIOSK_ENABLED) liste.push(terminalAnbieter, markenAnbieter);
return liste;
}
+6
View File
@@ -89,6 +89,10 @@ export const RECHTE = {
// Fahrzeuge und Geräte
"fahrzeug.lesen": { bereich: "Fahrzeuge", bezeichnung: "Fahrzeuge ansehen" },
"fahrzeug.bearbeiten": { bereich: "Fahrzeuge", bezeichnung: "Fahrzeuge anlegen und bearbeiten" },
// Fahrtenbuch (Phase 4). Eintragen darf jedes Mitglied, das fährt — die
// Nachweispflicht trifft die Wehr, nicht den Gerätewart.
"fahrtenbuch.lesen": { bereich: "Fahrzeuge", bezeichnung: "Fahrtenbuch ansehen" },
"fahrtenbuch.fuehren": { bereich: "Fahrzeuge", bezeichnung: "Fahrten eintragen" },
"geraet.lesen": { bereich: "Geräte", bezeichnung: "Geräte ansehen" },
"geraet.bearbeiten": { bereich: "Geräte", bezeichnung: "Geräte anlegen und bearbeiten" },
"geraet.aussondern": { bereich: "Geräte", bezeichnung: "Geräte aussondern oder sperren" },
@@ -143,6 +147,8 @@ const GRUNDRECHTE: Recht[] = [
"organisation.lesen",
"person.lesen",
"fahrzeug.lesen",
"fahrtenbuch.lesen",
"fahrtenbuch.fuehren",
"geraet.lesen",
"pruefung.lesen",
"einsatz.lesen",
+14
View File
@@ -106,6 +106,20 @@ const schema = z.object({
LLM_API_KEY: z.string().optional(),
LLM_MODELL: z.string().optional(),
// Terminal im Gerätehaus (Phase 4)
/// Wetterwarnungen des DWD auf dem Hallenmonitor. Die einzige Verbindung,
/// die FLORIAN von sich aus nach außen aufbaut — standardmäßig aus.
WETTER_ENABLED: boolAusText(false),
/// Warnzellen-Nummer der eigenen Gemeinde aus der DWD-Warnzellenliste.
DWD_WARNCELL: z.string().optional(),
DWD_WARNUNGEN_URL: z
.string()
.url()
.default("https://www.dwd.de/DWD/warnungen/warnapp/json/warnings.json"),
WETTER_TIMEOUT: zahl(5),
/// Wie lange eine Antwort des DWD gilt, bevor neu gefragt wird.
WETTER_ZWISCHENSPEICHER: zahl(300),
// Einsätze und Übungen (Phase 3)
/// Adressen zu Koordinaten auflösen. Ohne eigenen Nominatim aus.
GEOCODING_ENABLED: boolAusText(false),
+16
View File
@@ -0,0 +1,16 @@
/**
* Klartext zu den Warnstufen des DWD.
*
* Steht bewusst getrennt von wetter.ts: das Modul dort ist "server-only", weil
* es Netzverbindungen aufbaut und die Konfiguration liest. Der Hallenmonitor
* läuft im Browser und braucht nur die Beschriftung — die darf deshalb nicht
* im selben Modul liegen.
*
* Dieselbe Trennung wie bei den QR-Codes (qr.ts / qr-parser.ts).
*/
export const WARNSTUFE_TEXT: Record<number, string> = {
1: "Wetterwarnung",
2: "markantes Wetter",
3: "Unwetterwarnung",
4: "extremes Unwetter",
};
+142
View File
@@ -0,0 +1,142 @@
import "server-only";
import { env } from "@/lib/env";
/**
* Wetterwarnungen des Deutschen Wetterdienstes.
*
* Das ist die **einzige** Verbindung, die FLORIAN von sich aus nach außen
* aufbaut — und sie ist standardmäßig aus. Wer sie einschaltet, sollte wissen,
* was dabei passiert:
*
* * Es geht eine Anfrage an opendata-Server des DWD, mehr nicht. Keine
* Kennung, keine Daten der Wehr, kein Standort außer der Warnzellen-Nummer,
* die in der Konfiguration steht.
* * Die Antwort wird für ein paar Minuten zwischengespeichert, damit ein
* Hallenmonitor, der jede Minute nachfragt, nicht jedes Mal hinausfunkt.
* * Fällt die Abfrage aus, bleibt die Kachel leer. Eine Wetterwarnung ist
* eine Zugabe; die Alarmierung läuft über die Leitstelle.
*
* Die Warnzellen-Nummer der eigenen Gemeinde steht in der Warnzellen-Liste des
* DWD (`DWD_WARNCELL`). Ohne sie werden alle Warnungen ignoriert.
*/
export interface Wetterwarnung {
/** 1 = Wetterwarnung, 2 = markantes Wetter, 3 = Unwetter, 4 = extremes Unwetter. */
stufe: number;
ereignis: string;
ueberschrift: string;
beschreibung: string;
von: Date | null;
bis: Date | null;
}
export interface WetterAnbieter {
warnungen(): Promise<Wetterwarnung[]>;
verfuegbar(): boolean;
}
/** Ohne Konfiguration: keine Warnungen, kein Fehler. */
class KeineWarnungen implements WetterAnbieter {
verfuegbar(): boolean {
return false;
}
async warnungen(): Promise<Wetterwarnung[]> {
return [];
}
}
interface DwdWarnung {
level?: number;
event?: string;
headline?: string;
description?: string;
start?: number;
end?: number | null;
}
/**
* Der DWD liefert seine Warnungen als JavaScript-Aufruf aus, nicht als reines
* JSON: `warnWetter.loadWarnings({...});`. Die Klammer außen muss weg, bevor
* sich der Inhalt lesen lässt.
*/
function jsonAusJsonp(text: string): unknown {
const anfang = text.indexOf("(");
const ende = text.lastIndexOf(")");
if (anfang < 0 || ende <= anfang) return JSON.parse(text);
return JSON.parse(text.slice(anfang + 1, ende));
}
class DwdWarnapp implements WetterAnbieter {
/** Antwort und Zeitpunkt — der Monitor fragt oft, der DWD soll das nicht merken. */
private zwischenspeicher: { zeit: number; warnungen: Wetterwarnung[] } | null = null;
verfuegbar(): boolean {
return Boolean(env.DWD_WARNCELL);
}
async warnungen(): Promise<Wetterwarnung[]> {
if (!this.verfuegbar()) return [];
const alter = this.zwischenspeicher ? Date.now() - this.zwischenspeicher.zeit : Infinity;
if (this.zwischenspeicher && alter < env.WETTER_ZWISCHENSPEICHER * 1000) {
return this.zwischenspeicher.warnungen;
}
try {
const antwort = await fetch(env.DWD_WARNUNGEN_URL, {
signal: AbortSignal.timeout(env.WETTER_TIMEOUT * 1000),
headers: { "User-Agent": "FLORIAN Feuerwehrverwaltung" },
cache: "no-store",
});
if (!antwort.ok) throw new Error(`DWD antwortete mit ${antwort.status}.`);
const daten = jsonAusJsonp(await antwort.text()) as {
warnings?: Record<string, DwdWarnung[]>;
vorabInformation?: Record<string, DwdWarnung[]>;
};
const zelle = env.DWD_WARNCELL!;
const roh = [...(daten.warnings?.[zelle] ?? []), ...(daten.vorabInformation?.[zelle] ?? [])];
const warnungen = roh.map((warnung) => ({
stufe: warnung.level ?? 1,
ereignis: warnung.event ?? "Wetterwarnung",
ueberschrift: warnung.headline ?? "",
beschreibung: warnung.description ?? "",
von: warnung.start ? new Date(warnung.start) : null,
bis: warnung.end ? new Date(warnung.end) : null,
}));
this.zwischenspeicher = { zeit: Date.now(), warnungen };
return warnungen;
} catch (fehler) {
console.error("[wetter] Warnungen nicht abrufbar:", fehler);
// Lieber den alten Stand als gar nichts — eine Unwetterwarnung von vor
// zehn Minuten ist immer noch die richtige Information.
return this.zwischenspeicher?.warnungen ?? [];
}
}
}
let anbieterCache: WetterAnbieter | null = null;
export function wetter(): WetterAnbieter {
if (!anbieterCache) {
anbieterCache = env.WETTER_ENABLED ? new DwdWarnapp() : new KeineWarnungen();
}
return anbieterCache;
}
/** Nur für Tests. */
export function _wetterErsetzen(ersatz: WetterAnbieter | null): void {
anbieterCache = ersatz;
}
/**
* Die Beschriftung der Warnstufen steht in wetter-texte.ts — der Hallenmonitor
* ist eine Client-Komponente und dürfte dieses "server-only"-Modul nicht
* anfassen.
*/
export { WARNSTUFE_TEXT } from "@/lib/integrations/wetter-texte";
+80
View File
@@ -0,0 +1,80 @@
import "server-only";
import { createHmac, timingSafeEqual } from "node:crypto";
import { env } from "@/lib/env";
/**
* Anmeldemarke — der QR-Code vom Handy.
*
* Der dritte Weg an das Terminal, neben PIN und RFID-Karte: wer sein Handy
* dabei hat, öffnet in FLORIAN die Seite „Anmeldemarke", hält den QR-Code vor
* die Kamera des Tablets und ist angemeldet.
*
* Die Marke ist **signiert, nicht gespeichert**. Sie enthält Person, Wehr und
* einen Zeitstempel, dazu einen HMAC über alles mit AUTH_SECRET. Damit braucht
* es keine Tabelle, keine Aufräumjobs und keinen zweiten Ort, an dem ein
* Geheimnis liegt — und eine abgelaufene Marke ist von selbst wertlos.
*
* Kurze Gültigkeit ist hier wichtiger als Bequemlichkeit: ein abfotografierter
* QR-Code aus der Umkleide soll nach einer Minute nichts mehr nützen. Die
* Seite auf dem Handy erneuert ihn deshalb laufend.
*/
/** Wie lange eine Marke gilt. Kurz genug, dass ein Foto davon nichts bringt. */
export const MARKE_GUELTIG_SEKUNDEN = 60;
const TRENNER = ".";
function signieren(nutzlast: string): string {
return createHmac("sha256", env.AUTH_SECRET).update(nutzlast).digest("base64url");
}
/**
* Erzeugt eine Marke für eine Person.
*
* Aufbau: `<personId>.<organisationId>.<zeitstempel>.<signatur>`
*/
export function markeErzeugen(personId: string, organisationId: string): string {
const nutzlast = [personId, organisationId, String(Date.now())].join(TRENNER);
return `${nutzlast}${TRENNER}${signieren(nutzlast)}`;
}
export type MarkeFehler = "ungueltig" | "abgelaufen" | "verfaelscht";
/**
* Prüft eine Marke.
*
* Der Vergleich der Signatur läuft über timingSafeEqual — sonst ließe sich aus
* der Antwortzeit ablesen, wie viele Zeichen einer geratenen Signatur stimmen.
*/
export function markePruefen(
marke: string,
): { personId: string; organisationId: string } | { fehler: MarkeFehler } {
const teile = marke.split(TRENNER);
if (teile.length !== 4) return { fehler: "ungueltig" };
const [personId, organisationId, zeitstempel, signatur] = teile as [
string,
string,
string,
string,
];
const erwartet = signieren([personId, organisationId, zeitstempel].join(TRENNER));
const a = Buffer.from(signatur);
const b = Buffer.from(erwartet);
if (a.length !== b.length || !timingSafeEqual(a, b)) return { fehler: "verfaelscht" };
const alter = Date.now() - Number(zeitstempel);
if (!Number.isFinite(alter) || alter < 0 || alter > MARKE_GUELTIG_SEKUNDEN * 1000) {
return { fehler: "abgelaufen" };
}
return { personId, organisationId };
}
export const MARKE_FEHLER_TEXT: Record<MarkeFehler, string> = {
ungueltig: "Dieser Code ist keine Anmeldemarke.",
verfaelscht: "Dieser Code stammt nicht von FLORIAN.",
abgelaufen: "Der Code ist abgelaufen. Bitte auf dem Handy einen neuen anzeigen lassen.",
};
+51
View File
@@ -0,0 +1,51 @@
"use client";
/**
* Was das Terminal über sich selbst weiß.
*
* Damit sich ein Tablet als Terminal ausweisen kann, braucht es zwei Angaben:
* das Gerätegeheimnis (KIOSK_DEVICE_SECRET) und den Kurznamen der Wehr. Beides
* wird einmal bei der Einrichtung hinterlegt und bleibt danach im Gerät.
*
* Warum localStorage und kein Cookie: das Geheimnis soll nicht bei jeder
* Anfrage mitgehen, sondern nur beim Anmelden verwendet werden. Und es soll
* einen Neustart des Tablets überleben, ohne dass jemand es erneut eintippt.
*
* Das ist bewusst **kein** Ersatz für die Anmeldung der Person. Es ist die
* erste von zwei Schranken: eine erratene PIN nützt ohne das Gerätegeheimnis
* nichts, und das Geheimnis allein meldet niemanden an.
*/
const SCHLUESSEL_GEHEIMNIS = "florian.kiosk.geraeteschluessel";
const SCHLUESSEL_WEHR = "florian.kiosk.wehr";
export interface Geraeteangaben {
geraeteSchluessel: string;
organisationSlug: string;
}
export function geraeteangaben(): Geraeteangaben | null {
if (typeof window === "undefined") return null;
try {
const geraeteSchluessel = window.localStorage.getItem(SCHLUESSEL_GEHEIMNIS);
const organisationSlug = window.localStorage.getItem(SCHLUESSEL_WEHR);
if (!geraeteSchluessel || !organisationSlug) return null;
return { geraeteSchluessel, organisationSlug };
} catch {
// Privater Modus oder gesperrter Speicher: dann ist das Gerät eben nicht
// eingerichtet.
return null;
}
}
export function geraetEinrichten(angaben: Geraeteangaben): void {
window.localStorage.setItem(SCHLUESSEL_GEHEIMNIS, angaben.geraeteSchluessel);
window.localStorage.setItem(SCHLUESSEL_WEHR, angaben.organisationSlug);
}
/** Für den Fall, dass ein Tablet die Wehr wechselt oder ausgemustert wird. */
export function geraetVergessen(): void {
window.localStorage.removeItem(SCHLUESSEL_GEHEIMNIS);
window.localStorage.removeItem(SCHLUESSEL_WEHR);
}
+233
View File
@@ -0,0 +1,233 @@
"use client";
import Dexie, { type Table } from "dexie";
import type { Vorgang, VorgangArt, VorgangErgebnis } from "@/lib/kiosk/vorgaenge";
/**
* Die Warteschlange des Terminals.
*
* Ein Wandtablet in einer Fahrzeughalle hat regelmäßig kein Netz — Stahlbeton,
* Tore aus Metall, ein Repeater, der irgendwann ausfällt. Deshalb schreibt das
* Terminal nichts direkt zum Server, sondern **immer zuerst hierher**: in eine
* IndexedDB im Browser. Von dort gehen die Vorgänge los, sobald wieder Netz da
* ist.
*
* Drei Eigenschaften machen das verlässlich:
*
* 1. **Immer über die Warteschlange.** Auch online. Sonst gäbe es zwei Wege
* durch den Code, und der seltenere wäre der ungetestete.
* 2. **Kennung vom Browser.** `crypto.randomUUID()` beim Einreihen, nicht
* beim Senden. Bricht die Übertragung ab und wird wiederholt, erkennt der
* Server den Vorgang an derselben Kennung wieder.
* 3. **Erst löschen, wenn der Server bestätigt hat.** Bis dahin bleibt der
* Vorgang liegen, auch über einen Neustart des Tablets hinweg.
*/
export interface OffenerVorgang {
/** Idempotenzschlüssel, im Browser erzeugt. */
id: string;
art: VorgangArt;
daten: unknown;
/**
* Ein Foto, das noch nicht hochgeladen ist.
*
* Fotos gehören zum Mangelmelden dazu — und ausgerechnet dabei ist das Netz
* oft weg. Deshalb liegt das Bild als Blob in derselben Warteschlange und
* wird beim Übertragen zuerst hochgeladen; erst danach geht der Vorgang mit
* der Dateikennung darin los. IndexedDB kann Blobs, das ist der Grund für
* Dexie und gegen localStorage.
*/
foto?: Blob;
fotoName?: string;
/** Wann es am Terminal passiert ist. ISO-Zeichenkette, damit es in IndexedDB passt. */
erfasstAm: string;
versuche: number;
letzterFehler?: string;
/** Beschriftung für die Anzeige der Warteschlange. */
beschriftung: string;
}
class KioskDatenbank extends Dexie {
vorgaenge!: Table<OffenerVorgang, string>;
constructor() {
super("florian-kiosk");
this.version(1).stores({
// Nur die Kennung als Primärschlüssel und ein Index auf die Zeit —
// gesucht wird hier nie, nur der Reihe nach abgearbeitet.
vorgaenge: "id, erfasstAm",
});
}
}
let datenbankCache: KioskDatenbank | null = null;
/**
* Die Datenbank wird erst beim ersten Zugriff geöffnet.
*
* Beim Rendern auf dem Server gibt es kein indexedDB; ein Modul, das sie im
* Rumpf öffnet, ließe sich dort nicht einmal importieren.
*/
export function datenbank(): KioskDatenbank {
if (!datenbankCache) datenbankCache = new KioskDatenbank();
return datenbankCache;
}
/** Ist überhaupt eine IndexedDB da? Im privaten Modus mancher Browser nicht. */
export function speicherVerfuegbar(): boolean {
return typeof window !== "undefined" && "indexedDB" in window;
}
/**
* Einen Vorgang einreihen.
*
* Gibt die Kennung zurück — das Terminal zeigt damit sofort „erfasst", ohne
* auf den Server zu warten. Genau darum geht es: der Gerätewart soll das
* Tablet weglegen können.
*/
export async function einreihen(
vorgang: Vorgang,
beschriftung: string,
foto?: { inhalt: Blob; name: string },
): Promise<OffenerVorgang> {
const eintrag: OffenerVorgang = {
id: crypto.randomUUID(),
art: vorgang.art,
daten: vorgang.daten,
erfasstAm: new Date().toISOString(),
versuche: 0,
beschriftung,
...(foto ? { foto: foto.inhalt, fotoName: foto.name } : {}),
};
await datenbank().vorgaenge.add(eintrag);
return eintrag;
}
/** Was noch aussteht, in der Reihenfolge der Erfassung. */
export async function warteschlange(): Promise<OffenerVorgang[]> {
return datenbank().vorgaenge.orderBy("erfasstAm").toArray();
}
export async function anzahlOffen(): Promise<number> {
return datenbank().vorgaenge.count();
}
/** Ein Vorgang, der nie durchgeht, lässt sich von Hand entfernen. */
export async function verwerfen(id: string): Promise<void> {
await datenbank().vorgaenge.delete(id);
}
/** Die Form, in der ein Vorgang zum Server geht. */
export interface SendeVorgang {
id: string;
art: VorgangArt;
daten: unknown;
erfasstAm: Date;
}
export type Sender = (vorgaenge: SendeVorgang[]) => Promise<{ ergebnisse: VorgangErgebnis[] }>;
/** Lädt ein wartendes Foto hoch und gibt die Dateikennung zurück. */
export type FotoSender = (foto: Blob, name: string) => Promise<string>;
export interface SyncErgebnis {
gesendet: number;
erledigt: number;
konflikte: number;
verblieben: number;
fehler?: string;
}
/**
* Die Warteschlange abarbeiten.
*
* Erledigt heißt: der Server hat den Vorgang angenommen **oder** ihn als
* bereits verarbeitet erkannt **oder** ihn in die Konfliktliste gelegt. In
* allen drei Fällen ist er beim Server angekommen und darf hier verschwinden —
* liegen bleibt nur, was technisch nicht durchging.
*/
export async function synchronisieren(
senden: Sender,
fotoSenden?: FotoSender,
hoechstzahl = 50,
): Promise<SyncErgebnis> {
if (!speicherVerfuegbar()) {
return { gesendet: 0, erledigt: 0, konflikte: 0, verblieben: 0 };
}
const offen = (await warteschlange()).slice(0, hoechstzahl);
if (offen.length === 0) {
return { gesendet: 0, erledigt: 0, konflikte: 0, verblieben: 0 };
}
try {
// Erst die Fotos: ohne Dateikennung wäre die Meldung nur halb so viel wert.
// Klappt ein Upload nicht, geht der Vorgang trotzdem los — eine Meldung
// ohne Bild ist besser als gar keine.
if (fotoSenden) {
for (const eintrag of offen) {
if (!eintrag.foto) continue;
try {
const dateiId = await fotoSenden(eintrag.foto, eintrag.fotoName ?? "foto.jpg");
eintrag.daten = { ...(eintrag.daten as Record<string, unknown>), fotoDateiId: dateiId };
await datenbank().vorgaenge.update(eintrag.id, {
daten: eintrag.daten,
foto: undefined,
fotoName: undefined,
});
} catch (fehler) {
console.warn(`[outbox] Foto zu ${eintrag.id} noch nicht übertragen:`, fehler);
}
}
}
const antwort = await senden(
offen.map((eintrag) => ({
id: eintrag.id,
art: eintrag.art,
daten: eintrag.daten,
erfasstAm: new Date(eintrag.erfasstAm),
})),
);
let erledigt = 0;
let konflikte = 0;
for (const ergebnis of antwort.ergebnisse) {
if (ergebnis.erfolgreich || ergebnis.bereitsVerarbeitet || ergebnis.konflikt) {
await datenbank().vorgaenge.delete(ergebnis.id);
erledigt += 1;
if (ergebnis.konflikt) konflikte += 1;
} else {
await datenbank().vorgaenge.update(ergebnis.id, {
versuche: (offen.find((eintrag) => eintrag.id === ergebnis.id)?.versuche ?? 0) + 1,
letzterFehler: ergebnis.meldung ?? "unbekannter Fehler",
});
}
}
return {
gesendet: offen.length,
erledigt,
konflikte,
verblieben: await anzahlOffen(),
};
} catch (fehler) {
// Kein Netz oder Server nicht erreichbar: alles bleibt liegen. Das ist der
// Normalfall und keine Ausnahme — deshalb wird nur gezählt, nicht gelöscht.
for (const eintrag of offen) {
await datenbank().vorgaenge.update(eintrag.id, { versuche: eintrag.versuche + 1 });
}
return {
gesendet: 0,
erledigt: 0,
konflikte: 0,
verblieben: await anzahlOffen(),
fehler: fehler instanceof Error ? fehler.message : String(fehler),
};
}
}
+133
View File
@@ -0,0 +1,133 @@
import { z } from "zod";
import { AnwesenheitAnlass, FahrtZweck, MangelPrioritaet } from "@prisma/client";
/**
* Der Vertrag zwischen Terminal und Server.
*
* Diese Datei kennen beide Seiten: der Browser legt danach seine Vorgänge in
* die Warteschlange, der Server nimmt sie danach an. Sie enthält deshalb
* bewusst nichts außer Typen und Prüfregeln — kein Datenbankzugriff, kein
* "use client", kein "server-only".
*
* Jeder Vorgang trägt drei Dinge:
*
* id eine im Browser erzeugte Kennung (crypto.randomUUID). Sie ist
* der Idempotenzschlüssel: kommt derselbe Vorgang nach einem
* abgebrochenen Sendeversuch noch einmal an, erkennt ihn der
* Server daran wieder.
* art was passieren soll
* erfasstAm wann es am Terminal passiert ist — nicht, wann es ankam. Bei
* einem Tablet, das eine Stunde offline war, ist das ein
* Unterschied, der im Fahrtenbuch zählt.
*/
/** Ein Mangel, direkt am Gerät gemeldet. */
export const mangelMelden = z.object({
/** Über den QR-Code am Regal gescannt. */
inventarnummer: z.string().trim().min(1).max(60),
beschreibung: z.string().trim().min(1, "Was ist kaputt?").max(2000),
prioritaet: z.nativeEnum(MangelPrioritaet).default(MangelPrioritaet.MITTEL),
fotoDateiId: z.string().uuid().nullish(),
});
/** Eine Fahrt beginnen. */
export const fahrtBeginnen = z.object({
fahrzeugId: z.string().uuid(),
kilometerAbfahrt: z.number().int().min(0).max(9_999_999),
zweck: z.nativeEnum(FahrtZweck).default(FahrtZweck.SONSTIGES),
ziel: z.string().trim().max(200).nullish(),
});
/** Eine Fahrt beenden. */
export const fahrtBeenden = z.object({
fahrtId: z.string().uuid(),
kilometerRueckkehr: z.number().int().min(0).max(9_999_999),
tankmenge: z.number().nonnegative().max(9999).nullish(),
bemerkung: z.string().trim().max(500).nullish(),
});
/** Kommen. */
export const anwesenheitKommen = z.object({
anlass: z.nativeEnum(AnwesenheitAnlass).default(AnwesenheitAnlass.DIENST),
bemerkung: z.string().trim().max(500).nullish(),
});
/** Gehen. Ohne Angabe wird die letzte offene Anwesenheit beendet. */
export const anwesenheitGehen = z.object({
anwesenheitId: z.string().uuid().nullish(),
});
/** Schutzkleidung abgeben. */
export const waescheAbgeben = z.object({
bezeichnung: z.string().trim().min(1).max(200),
menge: z.number().int().min(1).max(50).default(1),
grund: z.string().trim().max(200).nullish(),
inventarnummer: z.string().trim().max(60).nullish(),
});
/** Ein Gerät im Inventurgang scannen. */
export const inventurScannen = z.object({
inventurId: z.string().uuid(),
inventarnummer: z.string().trim().min(1).max(60),
gefunden: z.boolean().default(true),
vorgefundenAn: z.string().trim().max(200).nullish(),
});
/**
* Alle Vorgangsarten mit ihren Nutzlasten.
*
* PRUEFUNG_ERFASSEN fehlt hier bewusst: eine vollständige Prüfung mit
* Checkliste, Fotos und Unterschrift lässt sich nicht sinnvoll in eine
* Warteschlange legen. Das Terminal startet dafür den Wizard aus Phase 2 —
* und zeigt die Kachel nur, solange Netz da ist. Der Wert steht trotzdem im
* Datenmodell, weil die Atemschutzprüfung in Phase 5 genau das brauchen wird.
*/
export const vorgang = z.discriminatedUnion("art", [
z.object({ art: z.literal("MANGEL_MELDEN"), daten: mangelMelden }),
z.object({ art: z.literal("FAHRT_BEGINNEN"), daten: fahrtBeginnen }),
z.object({ art: z.literal("FAHRT_BEENDEN"), daten: fahrtBeenden }),
z.object({ art: z.literal("ANWESENHEIT_KOMMEN"), daten: anwesenheitKommen }),
z.object({ art: z.literal("ANWESENHEIT_GEHEN"), daten: anwesenheitGehen }),
z.object({ art: z.literal("WAESCHE_ABGEBEN"), daten: waescheAbgeben }),
z.object({ art: z.literal("INVENTUR_SCANNEN"), daten: inventurScannen }),
]);
/** Ein Vorgang, wie er über die Leitung geht. */
export const vorgangEingabe = z
.object({
id: z.string().uuid(),
erfasstAm: z.coerce.date(),
})
.and(vorgang);
export type Vorgang = z.infer<typeof vorgang>;
export type VorgangEingabe = z.infer<typeof vorgangEingabe>;
export type VorgangArt = Vorgang["art"];
/** Klartext für die Warteschlange auf dem Terminal. */
export const VORGANG_TEXT: Record<VorgangArt, string> = {
MANGEL_MELDEN: "Mangel gemeldet",
FAHRT_BEGINNEN: "Fahrt begonnen",
FAHRT_BEENDEN: "Fahrt beendet",
ANWESENHEIT_KOMMEN: "gekommen",
ANWESENHEIT_GEHEN: "gegangen",
WAESCHE_ABGEBEN: "Wäsche abgegeben",
INVENTUR_SCANNEN: "Gerät gescannt",
};
/**
* Antwort des Servers auf einen Vorgang.
*
* `bereitsVerarbeitet` ist der Normalfall bei einem zweiten Sendeversuch und
* ausdrücklich kein Fehler — das Terminal darf den Vorgang dann aus seiner
* Warteschlange nehmen.
*/
export interface VorgangErgebnis {
id: string;
erfolgreich: boolean;
bereitsVerarbeitet: boolean;
ergebnisId?: string | null;
meldung?: string | null;
/** Der Vorgang ließ sich nicht anwenden und liegt jetzt in der Konfliktliste. */
konflikt?: boolean;
}
+17
View File
@@ -57,6 +57,23 @@ export async function qrAlsDataUri(inventarnummer: string, groessePx = 256): Pro
});
}
/**
* QR-Code aus beliebigem Text.
*
* Die Funktionen darüber setzen alle eine Inventarnummer voraus und bauen
* daraus die Geräteadresse. Ab Phase 4 gibt es Codes, die keine Adresse
* enthalten, sondern eine signierte Anmeldemarke — die soll unverändert im
* Code stehen.
*/
export async function qrTextAlsDataUri(text: string, groessePx = 256): Promise<string> {
return QRCode.toDataURL(text, {
errorCorrectionLevel: FEHLERKORREKTUR,
margin: 1,
width: groessePx,
color: { dark: "#000000ff", light: "#ffffffff" },
});
}
/**
* Die Auswertung gescannter Codes steht in qr-parser.ts — sie läuft im
* Browser und darf deshalb nicht in einem "server-only"-Modul liegen.