Files
swyxweb/frontend/src/webhook.ts
T
SvenandClaude Fable 5 97c3778180 MongoDB-Ablage für Adressen, Anrufe und Jobs; Webhook-URLs je Datenart
Adress-, Anruf- und Jobdaten landen dauerhaft in der MongoDB
(ArchiveService, best-effort auf eigenem Thread): Die Startseite meldet
Adress-Cache und Anruf-Ereignisse an POST /api/addresses bzw. /api/calls,
der Webhook legt seine Nutzlasten selbst ab. Adressen tragen eine
generierte Kennung, die Rufnummer erkennt per Upsert die Dublette.

Der Webhook bekommt je Datenart eine eigene URL – /api/webhook/kunden,
/api/webhook/kuriere, /api/webhook/jobs –, die URL bestimmt die Ablage,
ein Kennzeichen in der Nutzlast ist nicht mehr nötig. Die Herkunft steht
als channel am Ereignis. Der generische POST /api/webhook bleibt.

Die Controller-Tests ersetzen den ArchiveService (MockitoBean) und
laufen damit ohne MongoDB. Markenrot laut Styleguide in beiden Farbmodi.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-27 10:24:53 +02:00

126 lines
4.6 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 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<WebhookEvent>
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<string, unknown>
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<WebhookEvent[]> {
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<void> {
const response = await fetch('/api/webhook/history', { method: 'DELETE' })
if (!response.ok) {
throw new Error(`Verlauf konnte nicht geleert werden (HTTP ${response.status})`)
}
}