Adressdaten-Bereich, Mock hinter einem Profil, Webhook

Drei Stränge, die sich über dieselben Dateien ziehen (HomePage, index.css,
README, application.properties) und deshalb nicht getrennt committet werden
können, ohne einen nicht übersetzbaren Zwischenstand zu hinterlassen:

Adressdaten: Der Bereich zeigt den gesamten Bestand des globalen Telefon-
buchs - zuerst aus dem Adress-Cache der App (Protokoll 8), sonst über das
Kommando "addresses" und ersatzweise aus rund 110 Einzelabfragen hinter
einem Wartedialog. Der Mock gibt denselben Bestand heraus und lässt sich
über /api/mock/address-cache leeren, um die Rückfallebene zu prüfen.

Mock hinter dem Profil "mock": Seine Bohnen (/ws und /api/mock/**) hängen
jetzt an @Profile, sind ohne das Profil also nicht vorhanden. Damit kann
der Mock nicht versehentlich in einer Produktivumgebung mitlaufen; das
Container-Image setzt das Profil nicht. Je ein Test hält beide Richtungen
fest.

Webhook: POST /api/webhook nimmt beliebiges JSON eines fremden Systems an
und reicht es über Server-Sent Events an die offenen Browser weiter, wo es
der neue Bereich "Webhook" unverändert anzeigt. Der WebSocket kam dafür
nicht in Frage - er gehört der SwyxTray-App. Das Backend hält die letzten
50 Nachrichten vor und liefert sie beim Wiederverbinden anhand der
Last-Event-ID nach; ein Heartbeat und X-Accel-Buffering: no halten die
Verbindung durch Reverse Proxys hindurch offen. Ein Token (app.webhook.token)
ist vorgesehen, aber nicht voreingestellt.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-08-24 15:11:55 +02:00
co-authored by Claude Opus 5
parent b2f608055a
commit 01594a1f3d
31 changed files with 2687 additions and 32 deletions
+120
View File
@@ -0,0 +1,120 @@
/**
* 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
/** 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})`)
}
}