/** * Webhook: Adressdaten, die ein fremdes System per POST an das Backend schickt. * * Das Backend nimmt unter `POST /api/webhook` **beliebiges** JSON an und reicht * es über Server-Sent Events (`/api/webhook/events`) an die offenen Browser * weiter. Dieses Modul enthält nur die reine Logik – das Holen und Anzeigen * stehen in `hooks/useWebhook.ts` und `components/WebhookPanel.tsx`. */ /** Eine über den Webhook eingegangene Nachricht, so wie das Backend sie liefert. */ export interface WebhookEvent { /** Fortlaufend ab 1. Eindeutig bis zum Neustart des Backends. */ id: number /** Eingangszeit im Backend, ISO-8601. */ receivedAt: string /** * Über welche URL die Nachricht kam: `kunden`, `kuriere` oder `jobs`; * fehlt (bzw. `null`) beim generischen `POST /api/webhook`. */ channel?: string | null /** Das empfangene JSON, unverändert. Objekt, Liste oder ein einfacher Wert. */ payload: unknown } /** So viele Nachrichten hält die Anzeige vor; das Backend deckelt zusätzlich. */ export const MAX_EVENTS = 50 /** * Prüft grob, ob eine Nachricht die Form des Backends hat. Die Nutzlast bleibt * bewusst ungeprüft – sie darf alles sein. */ export function isWebhookEvent(value: unknown): value is WebhookEvent { if (typeof value !== 'object' || value === null) return false const candidate = value as Partial return typeof candidate.id === 'number' && typeof candidate.receivedAt === 'string' } /** * Nimmt eine Nachricht in die Liste auf: jüngste zuerst, ohne Doppel und * gedeckelt. * * Doppel entstehen im Normalbetrieb: Der Bereich holt beim Öffnen den Verlauf * und hört gleichzeitig auf den Ereignisstrom, und nach einem Abbruch liefert * das Backend anhand der `Last-Event-ID` nach. Entschieden wird über die `id`. */ export function mergeEvent( events: WebhookEvent[], incoming: WebhookEvent, max = MAX_EVENTS, ): WebhookEvent[] { const without = events.filter((event) => event.id !== incoming.id) return [incoming, ...without].sort((a, b) => b.id - a.id).slice(0, max) } /** Wie {@link mergeEvent}, aber für den Verlauf am Stück. */ export function mergeEvents( events: WebhookEvent[], incoming: WebhookEvent[], max = MAX_EVENTS, ): WebhookEvent[] { return incoming.reduce((all, event) => mergeEvent(all, event, max), events) } /** * Wie viele Nachrichten jünger sind als die zuletzt gesehene. * * Der Bereich „Webhook" ist nicht immer offen, die Nachrichten kommen aber * trotzdem an. Daraus wird der Zähler am Tab. */ export function countUnseen(events: WebhookEvent[], seenId: number): number { return events.filter((event) => event.id > seenId).length } /** Die Nutzlast lesbar eingerückt. */ export function formatPayload(payload: unknown): string { try { return JSON.stringify(payload, null, 2) ?? String(payload) } catch { // Zirkuläre Strukturen kann es über JSON nicht geben; bleibt die Notbremse. return String(payload) } } /** * Kurzfassung für die Kopfzeile eines Eintrags. Adressdaten tragen üblicherweise * `name` und `number`; alles andere wird der Form nach beschrieben, statt zu * raten. */ export function summarize(payload: unknown): string { if (Array.isArray(payload)) { return payload.length === 1 ? '1 Eintrag' : `${payload.length} Einträge` } if (typeof payload === 'object' && payload !== null) { const record = payload as Record const name = typeof record.name === 'string' ? record.name.trim() : '' const number = typeof record.number === 'string' ? record.number.trim() : '' if (name && number) return `${name} · ${number}` if (name) return name if (number) return number const keys = Object.keys(record) if (keys.length === 0) return 'leeres Objekt' return keys.length === 1 ? `1 Feld: ${keys[0]}` : `${keys.length} Felder: ${keys.join(', ')}` } if (payload === null) return 'null' return String(payload) } /** Holt den Verlauf des Backends – die Nachrichten vor dem Öffnen des Bereichs. */ export async function fetchHistory(signal?: AbortSignal): Promise { const response = await fetch('/api/webhook/history', { signal }) if (!response.ok) { throw new Error(`/api/webhook/history antwortete mit HTTP ${response.status}`) } const data: unknown = await response.json() if (!Array.isArray(data)) return [] return data.filter(isWebhookEvent) } /** Leert den Verlauf im Backend. */ export async function clearHistory(): Promise { const response = await fetch('/api/webhook/history', { method: 'DELETE' }) if (!response.ok) { throw new Error(`Verlauf konnte nicht geleert werden (HTTP ${response.status})`) } }