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>
126 lines
4.6 KiB
TypeScript
126 lines
4.6 KiB
TypeScript
/**
|
||
* 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})`)
|
||
}
|
||
}
|