Anrufer- und Webhook-Verarbeitung verbessern

This commit is contained in:
Sven committed 2026-11-02 15:58:53 +01:00
1 parent 7b3c187985
commit 9e71fe3a11
18 files changed
+1283 -111

No files matched your search

+7
View File
@@ -13,6 +13,13 @@ WHISPER_THREADS=4
# SwyxTray-Adresse aus Sicht des Browsers am Arbeitsplatz # SwyxTray-Adresse aus Sicht des Browsers am Arbeitsplatz
APP_WEBSOCKET_HOST=127.0.0.1 APP_WEBSOCKET_HOST=127.0.0.1
APP_WEBSOCKET_PORT=17654 APP_WEBSOCKET_PORT=17654
APP_WEBSOCKET_PATH=/ws
APP_WEBSOCKET_SECURE=false
# Webhook-Token: bei gesetztem Wert muss der Absender X-Webhook-Token mitsenden.
APP_WEBHOOK_TOKEN=
# MongoDB-Adresse aus Sicht des Backends bzw. Containers.
SPRING_MONGODB_URI=mongodb://192.168.180.25:27017/swyxweb?serverSelectionTimeoutMS=2000
# Zusammenfassung über eine OpenAI-kompatible Chat-Completions-API. # Zusammenfassung über eine OpenAI-kompatible Chat-Completions-API.
# URL und Modell gemeinsam setzen; beide leer lassen für reine Transkription. # URL und Modell gemeinsam setzen; beide leer lassen für reine Transkription.
+305
View File
@@ -0,0 +1,305 @@
# SwyxWeb – Leitfaden für Coding-Agents
## Projektüberblick
SwyxWeb ist eine deutschsprachige Telefonie- und Kundenoberfläche für SwyxTray/SwyxIt!. Das System besteht aus drei zusammenarbeitenden Teilen:
- **Frontend:** React 19, TypeScript und Vite; stellt Dashboard, Entwickleransicht, Anruf-Popups und Aufnahmeverwaltung bereit.
- **Backend:** Spring Boot 4.1 mit Java 21 und Maven Wrapper; stellt REST-API und Server-Sent Events bereit und archiviert Daten in MongoDB.
- **Transkriptionsworker:** FastAPI mit `faster-whisper`; verarbeitet WAV-Dateien lokal, speichert Aufträge in SQLite und erstellt optional Zusammenfassungen über eine OpenAI-kompatible LLM-API.
Die Standardansicht ist ein Dashboard der gespeicherten eingehenden Anrufe. Der Schalter **Dev** blendet die weiteren Bereiche für Telefonie, Adressdaten, Webhooks, Firefox-Tabs und Verbindungsdiagnose ein. Eingehende Anrufe können Kunden- und Kurierdaten sowie zugehörige Jobs anzeigen. Gesprächsaufnahmen werden transkribiert und optional zusammengefasst; eine Sprecherzuordnung ist nicht implementiert.
Die ausführliche fachliche und protokollbezogene Dokumentation steht in `README.md`. Bei Änderungen an Verhalten, Protokoll, Konfiguration oder Deployment müssen README und gegebenenfalls diese Datei mitaktualisiert werden.
## Wichtige Laufzeitgrenzen
Die Verbindungen dürfen nicht verwechselt werden:
1. **Browser ↔ SwyxTray:** Das Frontend öffnet selbst einen WebSocket zur SwyxTray-App, standardmäßig `ws://127.0.0.1:17654/ws`. Das Java-Backend hält keine Verbindung zur echten Telefonanlage.
2. **Browser ↔ Spring Boot:** REST-Aufrufe und der Webhook-Ereignisstrom laufen über `/api/**`, in der Entwicklung zunächst über den Vite-Proxy. Webhook-Ereignisse verwenden SSE unter `/api/webhook/events`, nicht den SwyxTray-WebSocket.
3. **Spring Boot ↔ Whisper-Worker:** Das Backend reicht WAV-Uploads an den FastAPI-Worker weiter. Im Container ist das ausschließlich `http://127.0.0.1:8765`; der Worker-Port wird nicht nach außen veröffentlicht.
4. **Backend ↔ MongoDB:** MongoDB ist ein externer Dienst. `compose.yaml` enthält keinen MongoDB-Container.
Standardports:
| Port | Zweck |
|---|---|
| `5173` | Vite-Entwicklungsserver |
| `8080` | Spring Boot, REST-API und im Produktiv-Image das statische Frontend |
| `8765` | FastAPI-Whisper-Worker, nur containerintern auf Loopback |
| `17654` | SwyxTray-App auf dem Arbeitsplatz, vom Browser aus erreicht |
### Zentrale Datenflüsse
- SwyxTray sendet vollständige Snapshots und bei neueren Protokollen zusätzlich Anrufereignisse. `frontend/src/swyx/protocol.ts` und `useSwyxTray.ts` gleichen Zustände ab, verhindern doppelte Ende-Ereignisse und melden Anrufdaten an `POST /api/calls`.
- Leitungen sind 1-basiert. Bei Protokoll 10 und neuer identifiziert `callId` zusammen mit `line` einen Anruf. Aufnahme- und Auflegebefehle müssen diese Identität beachten, damit verspätete Befehle keinen Folgeanruf auf derselben Leitung treffen.
- Der Adress-Cache der Tray-App wird vom Browser an `POST /api/addresses` gemeldet. Dieser Import aktualisiert nur Name und Beschreibung und erhält Rollen, Kundenkennungen, Job-Zuordnungen und URLs aus vollständigen Webhook-Daten.
- Fremdsysteme liefern Adressen und Jobs über die Webhook-Endpunkte. Das Backend verteilt angenommene Nutzlasten sofort per SSE und schreibt MongoDB-Daten asynchron.
- Bei Protokoll 11 überträgt SwyxTray eine fertige WAV nach bestätigtem Gesprächsende in Blöcken über den bestehenden WebSocket. Der Browser lädt sie an `/api/transcriptions` hoch und bestätigt SwyxTray erst nach Erhalt der dauerhaften Worker-Auftragskennung.
- Der Worker verarbeitet jeweils einen Auftrag, speichert das Transkript vor einem optionalen LLM-Aufruf und setzt unterbrochene Aufträge nach einem Neustart fort. Ein Retry nach einem Zusammenfassungsfehler wiederholt nicht die Whisper-Transkription.
## Repository-Struktur und wichtige Dateien
```text
SwyxWeb/
├── backend/
│ ├── pom.xml Spring Boot 4.1, Java 21, Abhängigkeiten
│ ├── mvnw Maven Wrapper verwenden
│ └── src/
│ ├── main/java/de/appcreation/swyxweb/
│ │ ├── config/ Properties, CORS und Profil-geschützter Mock
│ │ ├── web/ REST-Controller für Config, Calls, Adressen, Jobs, Webhooks
│ │ ├── webhook/ SSE-Verlauf und Verteilung
│ │ ├── storage/ MongoDB-Dokumente, Archiv und Job-Bereinigung
│ │ ├── transcription/ Validierung und Weiterleitung an FastAPI
│ │ └── websocket/ SwyxTray-Mock und dessen Leitungsmodell
│ ├── main/resources/application.properties
│ └── test/java/ JUnit-/Spring-/Mockito-Tests
├── frontend/
│ ├── package.json npm-Skripte und Abhängigkeiten
│ ├── package-lock.json Für reproduzierbare Installation mit npm ci
│ ├── vite.config.ts Dev-Proxy für /api
│ ├── tsconfig*.json Strikter TypeScript-Build
│ └── src/
│ ├── pages/HomePage.tsx Dashboard/Dev-Umschaltung und Gesamtseite
│ ├── components/ React-Panels, Dashboard, Popup, Aufnahmeanzeige
│ ├── hooks/ Tray-, Webhook-, Caller-Lookup- und Upload-Zustand
│ ├── swyx/ WebSocket-Client, Protokoll und Adressabgleich
│ ├── calls.ts Anruf-API und Suche
│ ├── addresses.ts Adress-API und Rufnummernabgleich
│ ├── jobs.ts Typisierte Job-Nutzlasten
│ ├── recordings.ts WAV-Validierung und Transkriptions-API
│ └── *.test.ts(x) Vitest-Tests neben dem getesteten Code
├── transcription/
│ ├── service.py FastAPI, SQLite-Aufträge und Whisper-Verarbeitung
│ ├── summary.py OpenAI-kompatible Zusammenfassungen
│ ├── requirements.txt Fixierte Laufzeitabhängigkeiten
│ ├── requirements-test.txt Laufzeit- plus Testabhängigkeiten
│ └── tests/ pytest-Tests
├── Dockerfile Vierstufiger Gesamt-Build
├── compose.yaml Ein Container plus persistentem Aufnahme-Volume
├── docker/entrypoint.sh Start und gemeinsamer Abbruch von Java und Python
├── docker_push.sh Buildx-Build/Push für linux/amd64
├── .env.example Öffentliche Compose-Konfigurationsvorlage
└── .vscode/launch.json Compound-Starts für Backend und Frontend
```
## Entwicklung und lokale Starts
Voraussetzungen sind Java 21, Node.js/npm und für den lokalen Worker eine Python-Umgebung mit den Projektabhängigkeiten. Der Docker-Build verwendet Node 22 und Python 3.12. Für produktive Datenbankfunktionen muss die konfigurierte MongoDB erreichbar sein.
### Backend
```bash
cd backend
./mvnw spring-boot:run
```
Das Backend lauscht auf `0.0.0.0:8080`. Alternativer Port:
```bash
./mvnw spring-boot:run -Dspring-boot.run.arguments="--server.port=8081"
```
`application.properties` verweist standardmäßig auf eine MongoDB im internen Netz. Schreibvorgänge des Archivs sind asynchron und best-effort; eine nicht erreichbare Datenbank verhindert daher nicht jede Anzeige, wohl aber Datenbankabfragen und dauerhafte Speicherung.
### Frontend
```bash
cd frontend
npm ci
npm run dev
```
Vite läuft auf `http://localhost:5173` und leitet `/api` standardmäßig an `http://localhost:8080` weiter. `VITE_BACKEND_URL` ändert das Proxy-Ziel. Der SwyxTray-WebSocket läuft nicht über diesen Proxy.
In VS Code startet die Compound-Konfiguration **„SwyxWeb starten (Backend + Frontend)"** beide Seiten mit Debugging. Die zweite Compound-Konfiguration verwendet den Mock. Diese Startkonfigurationen lesen die projektweite `.env`; ein reiner Terminalstart aus `backend` tut das nicht automatisch.
### SwyxTray-Mock
Der Mock existiert nur mit Spring-Profil `mock`:
```bash
cd backend
./mvnw spring-boot:run -Dspring-boot.run.profiles=mock
```
Dann stehen `/ws` und `/api/mock/**` zur Verfügung. Für die vorbereitete VS-Code-Konfiguration zeigt `/api/config` automatisch auf `ws://localhost:8080/ws`. Einen Testanruf auslösen:
```bash
curl -X POST "http://localhost:8080/api/mock/incoming-call?number=%2B493012345&name=Muster%20GmbH"
```
Der Mock bildet Telefonie-, Adress-, Tab- und Aufnahmeprotokolle ab, erzeugt aber keine Audiodateien. Ohne das Profil sollen `/ws` und `/api/mock/**` mit 404 antworten; dieses Opt-in nicht durch allgemeine Konfigurationsänderungen aushebeln.
### Lokaler Transkriptionsworker
Ein vollständiges CTranslate2-Whisper-Modell muss bereits lokal vorliegen; der Worker lädt es nicht selbst aus dem Internet:
```bash
python3 -m venv transcription/.venv
transcription/.venv/bin/pip install -r transcription/requirements.txt
WHISPER_MODEL_PATH=/pfad/zum/whisper-modell WHISPER_DATA_DIR=/pfad/zu/auftraegen \
transcription/.venv/bin/python -m uvicorn service:app --app-dir transcription \
--host 127.0.0.1 --port 8765
```
Für den Terminalstart des Backends zusätzlich:
```bash
cd backend
APP_TRANSCRIPTION_BASE_URL=http://127.0.0.1:8765 ./mvnw spring-boot:run
```
Ohne `APP_TRANSCRIPTION_BASE_URL` bleibt die Transkriptionsübergabe außerhalb von Docker deaktiviert.
## Build-Befehle
Es gibt kein übergreifendes Root-Buildskript; die Teilprojekte werden einzeln oder durch das Dockerfile gebaut.
```bash
# Backend: Tests ausführen und target/backend-0.0.1-SNAPSHOT.jar erzeugen
cd backend
./mvnw clean package
# Frontend: Typprüfung und Produktionsbundle in frontend/dist
cd frontend
npm ci
npm run typecheck
npm run build
# Produktionsbundle lokal über Vite ansehen
npm run preview
```
`npm run build` führt `tsc -b && vite build` aus. Um ein lokal gebautes Frontend in das Spring-Jar aufzunehmen, muss `frontend/dist/*` vor dem Maven-Packen nach `backend/src/main/resources/static/` kopiert werden. Der Docker-Build übernimmt diesen Schritt selbst.
## Tests und Prüfstrategie
### Backend
```bash
cd backend
./mvnw test
# Einzelne Testklasse:
./mvnw -Dtest=WebhookControllerTests test
```
- JUnit 5, AssertJ, Mockito, Spring Boot Test und MockMvc.
- Controller- und Profil-Tests ersetzen die Archivschicht mit `@MockitoBean`; MongoDB ist für die Tests nicht erforderlich.
- `ArchiveService` wird mit einem gemockten `MongoTemplate` geprüft.
- Der Transkriptionsclient wird gegen einen lokalen JDK-`HttpServer` getestet, nicht gegen einen echten Whisper-Dienst.
- Der Mock wird einschließlich Profilaktivierung, SSE-Verhalten und Aufnahmeprotokoll getestet.
### Frontend
```bash
cd frontend
npm test
# Einzelne Datei:
npm test -- src/swyx/protocol.test.ts
```
- Vitest-Tests liegen als `*.test.ts` beziehungsweise `*.test.tsx` neben dem Code.
- Die meisten Tests laufen in der Standardumgebung mit gefakten WebSockets, Timern und HTTP-Funktionen. Hook-Tests, die DOM benötigen, kennzeichnen `// @vitest-environment jsdom` direkt in der Datei.
- Schwerpunkte sind Protokoll-Parsing, Snapshot-/Ende-Reihenfolgen, Leitungswiederverwendung, Aufnahme-Timeouts, WAV-Blöcke, Adressabgleich, Job-Parsing und Webhook-Zusammenführung.
- React-Hooks werden mit `react-dom/client` und `act` getestet; es ist keine separate React-Testing-Library konfiguriert.
### Transkriptionsworker
Vom Projektroot:
```bash
python3 -m venv transcription/.venv
transcription/.venv/bin/pip install -r transcription/requirements-test.txt
transcription/.venv/bin/pytest -q transcription/tests
```
- Transkription und Zusammenfassung werden überwiegend durch Funktionen ersetzt; kein Modell-Download und kein externes LLM sind für die Tests nötig.
- Ein Test prüft zusätzlich die echte WAV-Dekodierung mit den installierten `faster-whisper`/PyAV-Abhängigkeiten.
- LLM-Tests verwenden einen lokalen HTTP-Testserver und prüfen auch Timeouts, Redirect-Verbot, Antwortgrenzen und das Vermeiden von Geheimnissen in Fehlermeldungen.
Es ist keine separate Browser-E2E- oder CI-Pipeline im Repository konfiguriert. Reale Aufnahme- und Telefonieänderungen müssen zusätzlich auf einem Windows-Arbeitsplatz mit SwyxTray/SwyxIt! geprüft werden; der Mock und synthetische WAVs ersetzen diesen Praxistest nicht.
## Code- und Projektkonventionen
- Dokumentation, Kommentare, UI-Texte und fachliche Fehlermeldungen sind überwiegend **Deutsch**. Bezeichner und API-Strukturen sind überwiegend Englisch. Diese Sprachtrennung beibehalten.
- TypeScript verwendet zwei Leerzeichen, einfache Anführungszeichen und normalerweise keine Semikolons. `strict`, `noUnusedLocals`, `noUnusedParameters` und `noFallthroughCasesInSwitch` sind aktiv.
- Java-Produktivcode verwendet überwiegend vier Leerzeichen, Records für Datenmodelle und Constructor Injection. Bei Testdateien und älteren Abschnitten die vorhandene Einrückung der jeweiligen Datei beibehalten.
- Python verwendet vier Leerzeichen und für HTTP zum LLM bewusst die Standardbibliothek. Keine neue Bibliothek allein aufgrund ihrer Üblichkeit einführen; vorhandene Manifeste und Idiome sind maßgeblich.
- Es sind keine eigenen Lint-, Formatierungs-, Checkstyle- oder Python-Formatter-Befehle konfiguriert. Typecheck, Compiler und Tests sind die vorhandenen automatischen Prüfungen.
- Spring Boot 4 verwendet im Code `tools.jackson.databind` für JSON-Verarbeitung; einzelne Mapping-Annotationen stammen weiterhin aus `com.fasterxml.jackson.annotation`. Diese Imports nicht vorschnell vereinheitlichen.
- JSON-Feldnamen des Fremdsystems wie `csc_id`, `job_ids` und `map_url` müssen an den API-/MongoDB-Grenzen korrekt auf Java-/TypeScript-Felder abgebildet werden. Unbekannte Jobfelder werden ignoriert; Job-IDs müssen positive Long-Werte sein.
- HTTP 202 bei Archiv- und Upload-Endpunkten bedeutet **angenommen**, nicht bereits vollständig verarbeitet. Die Asynchronität und die best-effort-Semantik des MongoDB-Archivs nicht unbeabsichtigt in synchrone Erfolgsgarantien umwandeln.
- Änderungen am SwyxTray-Protokoll gehören in Parser, Client, Mock und Tests gemeinsam. Ältere Protokollversionen bleiben relevant; insbesondere Snapshot-basierte Abläufe und Deduplizierung nicht entfernen.
- Abhängigkeiten sind in Maven, `package-lock.json` und den Python-Requirements fixiert beziehungsweise versioniert. Die PyAV-Version ist wegen Kompatibilität mit `faster-whisper` ausdrücklich angepinnt.
## Konfiguration und Persistenz
Zentrale Konfigurationsdateien:
- `backend/src/main/resources/application.properties`: Server, MongoDB, WebSocket-Ziel, Webhook und Transkription.
- `frontend/.env.example`: `VITE_WS_URL` und `VITE_BACKEND_URL` für Frontend-/Dev-Builds.
- `.env.example`: Vorlage für Compose und VS-Code-Starts; nach `.env` kopieren und lokale Werte eintragen.
- `compose.yaml`: Übergibt Laufzeitwerte an den Container und legt das Transkriptionsvolume fest.
Die SwyxTray-Adresse ergibt sich aus zuletzt erfolgreich verwendeter Browser-Eingabe beziehungsweise `localStorage`, danach aus `GET /api/config`; ohne Backend-Antwort dient `VITE_WS_URL` und schließlich der Code-Default als Fallback. Laufzeitwerte für den Container sind `APP_WEBSOCKET_HOST`, `APP_WEBSOCKET_PORT`, `APP_WEBSOCKET_PATH` und `APP_WEBSOCKET_SECURE`. Ein festes `VITE_WS_URL` ist normalerweise nicht erforderlich.
Weitere wichtige Werte:
- `SPRING_MONGODB_URI`: externe MongoDB.
- `APP_WEBHOOK_TOKEN`, `app.webhook.history` und `app.webhook.max-size`: Webhook-Schutz und Grenzen.
- `APP_TRANSCRIPTION_BASE_URL`, `WHISPER_MODEL_PATH`, `WHISPER_DATA_DIR`, `WHISPER_THREADS`: Worker-Integration und lokale Verarbeitung.
- `LLM_BASE_URL`, `LLM_MODEL`, `LLM_API_KEY`, `LLM_TIMEOUT_SECONDS`, `LLM_CHUNK_CHARS`: optionale Zusammenfassung. URL und Modell müssen gemeinsam konfiguriert sein; ohne beide bleibt die Zusammenfassung deaktiviert.
Persistenzregeln:
- MongoDB-Sammlungen: `addresses`, `jobs` und `calls`.
- Adressen werden per Rufnummer upserted. Vollständige Webhook-Adressen ersetzen auch Zuordnungen; der Telefonbuchimport erhält sie.
- Jobs werden anhand ihrer externen `id` ersetzt. `JobCleanupService` löscht jede Nacht um 0 Uhr deutscher Zeit Jobs, deren `ordertime` mehr als vier Wochen zurückliegt; Jobs ohne `ordertime` bleiben erhalten.
- Der Webhook-Verlauf umfasst standardmäßig die letzten 50 Nachrichten **nur im Java-Speicher** und ist nach einem Neustart leer. SSE nutzt `Last-Event-ID` zur Nachlieferung und sendet alle 25 Sekunden einen Heartbeat.
- WAVs, `jobs.sqlite3`, Transkripte und Zusammenfassungen liegen im Volume `/data/transcriptions`. Dafür gibt es keine automatische Löschfrist. `docker compose down` erhält das Volume; `docker compose down -v` löscht es.
## Docker und Deployment
```bash
# Frischen Checkout einmalig vorbereiten; vorhandene .env nicht überschreiben
test -f .env || cp .env.example .env
docker compose up -d --build
```
Der mehrstufige `Dockerfile`:
1. baut das Frontend mit Node 22,
2. kopiert `dist` in die statischen Spring-Ressourcen und baut das Java-21-Jar,
3. lädt ein fixiertes `faster-whisper-large-v3-turbo`-Modell in eine eigene Build-Stufe,
4. erzeugt ein Laufzeit-Image mit Java, Python, Whisper und Modell.
Der Container läuft als Benutzer `swyx` mit UID/GID 10001 und veröffentlicht Port 8080. Das Modell ist im Image enthalten; Hugging-Face-Offline-Modus und `local_files_only=True` verhindern Modell-Downloads zur Laufzeit. `WHISPER_MODEL_REPO` und `WHISPER_MODEL_REVISION` sind Build-Argumente. Für den Container sind zunächst mindestens 4 GB RAM einzuplanen; der Java-Heap ist standardmäßig auf 25 % des verfügbaren Speichers begrenzt und über `JAVA_OPTS` änderbar.
`docker/entrypoint.sh` startet Java und Python gemeinsam. Endet einer der beiden Prozesse, wird auch der andere beendet, damit Compose den Container neu starten kann. Der Healthcheck prüft sowohl `/api/config` als auch `/health` des Workers. Der Maven-Schritt im Dockerfile verwendet `-DskipTests`; Tests deshalb vor dem Image-Build separat ausführen.
Registry-Veröffentlichung:
```bash
docker login gitea.appcreation.de
./docker_push.sh 0.9.13
./docker_push.sh 0.9.13 --dry-run
```
Das Skript baut für `linux/amd64` und pusht nach `gitea.appcreation.de/sven/swyxweb:<x.y.z>`. Die Semver-artige Versionsangabe ist Pflicht und wird nicht aus `pom.xml` abgeleitet. `--dry-run` baut ohne Push. Lokale Maven-/npm-Builds sind vorher nicht nötig.
Der Produktiv-Container setzt das Profil `mock` nicht. HTTPS-Seiten benötigen für nicht-lokale SwyxTray-Ziele `wss://`; der WebSocket wird weiterhin vom Arbeitsplatzbrowser aus aufgebaut, nicht vom Docker-Host.
## Sicherheits- und Datenschutzregeln
- `.env`, lokale Frontend-Umgebungen, Build-Ergebnisse und virtuelle Umgebungen sind von Git und Docker-Build-Kontext ausgeschlossen. Keine echten Tokens, API-Schlüssel oder Zugangsdaten in versionierte Dateien oder Beispiele übernehmen.
- Für öffentlich erreichbare Instanzen muss `APP_WEBHOOK_TOKEN` gesetzt sein. Ein leerer Wert lässt alle Webhook-POSTs ohne Token zu und erzeugt eine Start-Warnung. Das Token schützt nur die Webhook-POST-Endpunkte; Verlauf, SSE und die übrigen APIs besitzen keine allgemeine Benutzerauthentifizierung.
- CORS für `/api/**` und Origins des Mocks sind derzeit mit `*` geöffnet. Das ist keine Zugriffskontrolle und muss für eine produktive Exposition gegebenenfalls eingeschränkt werden. Den Mock nicht unbeabsichtigt produktiv aktivieren.
- Die Standard-MongoDB-Konfiguration enthält keine Zugangsdaten und zeigt auf ein internes Netz. Netzwerkzugriff und Datenbankabsicherung müssen zur tatsächlichen Umgebung passen.
- WAV-Uploads werden im Browser, Java-Backend und Worker auf WAV-Dateikopf und höchstens 500 MB geprüft. Diese Grenzen und die Validierung von UUIDs beziehungsweise 32-stelligen Hex-Job-IDs beibehalten. Externe Karten-/Download-URLs dürfen nur vorgesehene HTTP(S)-Formen verwenden.
- Der FastAPI-Worker deaktiviert Dokumentationsendpunkte, beschränkt den `Host`-Header auf Loopback-Namen und soll nicht öffentlich exponiert werden.
- Whisper verarbeitet Audio lokal. An das optionale LLM wird ausschließlich Transkripttext gesendet, nicht die WAV. API-Schlüssel gehören nur in die Worker-Umgebung, nicht ins Frontend, Image oder in Auftragsdaten. LLM-Redirects werden nicht verfolgt; Provider-Antworten und Zugangsdaten dürfen nicht in Logs oder Fehlermeldungen durchsickern.
- Aufnahmen, Rufnummern, Kunden-/Jobdaten, Transkripte und Zusammenfassungen sind sensible Daten. Das persistente Transkriptionsvolume hat keine automatische Aufbewahrungsgrenze; Löschung und Zugriffsschutz müssen betrieblich berücksichtigt werden.
+20 -4
View File
@@ -32,6 +32,11 @@ Kartenadresse. Bestehende Jobs ohne Kartenadresse bleiben lesbar; zum Ergänzen
die vollständigen Jobdaten mit `map_url` erneut senden. Eine Schema-Migration ist die vollständigen Jobdaten mit `map_url` erneut senden. Eine Schema-Migration ist
nicht erforderlich. nicht erforderlich.
Der Telefonbuchimport über `POST /api/addresses` aktualisiert Name und Beschreibung,
erhält aber vorhandene Rollen, Kundenkennungen, Job-Zuordnungen und Sprungadressen.
Diese Felder werden nur durch vollständige Adressdaten über die Webhooks ersetzt;
so können dort beispielsweise mit `job_ids: []` alte Job-Zuordnungen entfernt werden.
``` ```
SwyxWeb/ SwyxWeb/
├── backend/ Spring Boot 4.1 (Java 21, Maven Wrapper) ├── backend/ Spring Boot 4.1 (Java 21, Maven Wrapper)
@@ -339,14 +344,21 @@ Die Quittung nennt die vergebene Nummer und die Eingangszeit:
{ "id": 1, "receivedAt": "2026-08-24T12:38:01.563742294Z" } { "id": 1, "receivedAt": "2026-08-24T12:38:01.563742294Z" }
``` ```
**Angenommen wird beliebiges JSON** – Objekt wie Liste. Das aufrufende System muss sich an kein Der generische Webhook nimmt **beliebiges JSON** an – Objekt wie Liste. Ausnahme:
Schema halten; die Startseite zeigt die Nutzlast unverändert. Nur die Kopfzeile eines Eintrags `"type":"job"` und `POST /api/webhook/jobs` verlangen ein einzelnes Job-Objekt mit
positiver ganzzahliger `id` im Java-Long-Wertebereich und passenden Feldtypen.
Ungültige Jobs ergeben **HTTP 400**, werden nicht archiviert und erscheinen nicht im
Verlauf oder Ereignisstrom. Die Quittung gültiger Daten bestätigt die Annahme;
die MongoDB-Speicherung läuft weiterhin asynchron.
Die Startseite zeigt angenommene Nutzlasten unverändert. Nur die Kopfzeile eines Eintrags
fasst `name` und `number` zusammen, wenn es sie gibt, und zählt sonst Felder bzw. Einträge fasst `name` und `number` zusammen, wenn es sie gibt, und zählt sonst Felder bzw. Einträge
([summarize](frontend/src/webhook.ts)). ([summarize](frontend/src/webhook.ts)).
| Endpunkt | Zweck | | Endpunkt | Zweck |
|---|---| |---|---|
| `POST /api/webhook` | Nachricht abliefern. `415` bei falschem Content-Type, `400` bei kaputtem JSON, `413` über der Größengrenze, `401` bei falschem Token | | `POST /api/webhook` | Nachricht abliefern. `415` bei falschem Content-Type, `400` bei kaputtem JSON, `413` über der Größengrenze, `401` bei falschem Token |
| `POST /api/webhook/jobs` | Einen Job abliefern. Zusätzlich `400` bei fehlender/ungültiger `id` oder unpassenden Feldtypen; gilt auch für `"type":"job"` am generischen Webhook |
| `GET /api/webhook/events` | Strom für die Startseite (Server-Sent Events) | | `GET /api/webhook/events` | Strom für die Startseite (Server-Sent Events) |
| `GET /api/webhook/history` | Verlauf, damit ein später geöffneter Bereich die vorigen Nachrichten sieht | | `GET /api/webhook/history` | Verlauf, damit ein später geöffneter Bereich die vorigen Nachrichten sieht |
| `DELETE /api/webhook/history` | Verlauf leeren – das macht der Knopf „Leeren" | | `DELETE /api/webhook/history` | Verlauf leeren – das macht der Knopf „Leeren" |
@@ -362,7 +374,9 @@ Zwei Vorkehrungen, damit dabei nichts verloren geht:
- Das Backend hält die letzten **50 Nachrichten** im Speicher ([WebhookService](backend/src/main/java/de/appcreation/swyxweb/webhook/WebhookService.java)). - Das Backend hält die letzten **50 Nachrichten** im Speicher ([WebhookService](backend/src/main/java/de/appcreation/swyxweb/webhook/WebhookService.java)).
Beim Wiederverbinden schickt der Browser die `Last-Event-ID` mit, und alles Jüngere wird Beim Wiederverbinden schickt der Browser die `Last-Event-ID` mit, und alles Jüngere wird
nachgeliefert. Über einen **Neustart hinaus wird nichts aufgehoben**. nachgeliefert. Nachlieferung und Anmeldung am laufenden Strom sind gegen gleichzeitige
POSTs abgesichert; Ereignisse bleiben dabei in Id-Reihenfolge.
Über einen **Neustart hinaus wird nichts aufgehoben**.
- Alle 25 Sekunden geht ein Kommentar (`:ping`) durch die Leitung, damit ein Reverse Proxy die - Alle 25 Sekunden geht ein Kommentar (`:ping`) durch die Leitung, damit ein Reverse Proxy die
ruhende Verbindung nicht kappt. ruhende Verbindung nicht kappt.
@@ -570,7 +584,9 @@ docker compose up -d --build
``` ```
Die lokale `.env` enthält die interne Worker-Adresse, `WHISPER_THREADS` Die lokale `.env` enthält die interne Worker-Adresse, `WHISPER_THREADS`
(Standard: 4), die LLM-Einstellungen, den SwyxWeb-Port und die SwyxTray-Adresse. Compose liest sie (Standard: 4), die LLM-Einstellungen, den SwyxWeb-Port und die SwyxTray-Adresse
einschließlich `APP_WEBSOCKET_PATH` und `APP_WEBSOCKET_SECURE`. Auch `APP_WEBHOOK_TOKEN`
und `SPRING_MONGODB_URI` werden an den Container weitergereicht. Compose liest sie
automatisch; auch die beiden VS-Code- automatisch; auch die beiden VS-Code-
Backend-Startkonfigurationen laden diese Datei. `.env` ist aus Git und Backend-Startkonfigurationen laden diese Datei. `.env` ist aus Git und
Docker-Build-Kontext ausgeschlossen; [.env.example](.env.example) enthält Docker-Build-Kontext ausgeschlossen; [.env.example](.env.example) enthält
@@ -50,31 +50,36 @@ public class ArchiveService {
} }
/** /**
* Legt Adressdaten-Einträge in der Sammlung {@code addresses} ab – alle * Übernimmt vollständige Adressdaten aus den Webhooks. Ein erneuter
* Quellen (Webhooks Kunden/Kuriere, SwyxTray-Telefonbuch) in derselben * Eintrag zur selben Rufnummer ersetzt auch die Kunden-/Job-Zuordnung.
* Sammlung, je Eintrag nur Rufnummer, Name und Beschreibung. Die Sammlung
* ist ein Verzeichnis, kein Protokoll: Ein Eintrag zu einer bekannten
* Rufnummer wird aktualisiert statt verdoppelt – die SwyxTray-App schickt
* bei jeder Cache-Änderung das ganze Telefonbuch.
*/ */
public void saveAddresses(List<AddressEntry> entries) { public void saveAddresses(List<AddressEntry> entries) {
saveAddresses(entries, true);
}
/** Der Telefonbuch-Cache kennt keine Kundenzuordnungen und darf sie nicht löschen. */
public void savePhonebookAddresses(List<AddressEntry> entries) {
saveAddresses(entries, false);
}
private void saveAddresses(List<AddressEntry> entries, boolean replaceAssignments) {
if (entries.isEmpty()) return; if (entries.isEmpty()) return;
// Upsert über die Rufnummer: aktualisiert den bekannten Eintrag oder // Upsert über die Rufnummer: aktualisiert den bekannten Eintrag oder
// legt ihn an; die Dokument-Kennung vergibt dabei die MongoDB. // legt ihn an; die Dokument-Kennung vergibt dabei die MongoDB.
submit("Adressdaten", () -> entries.forEach(entry -> mongo.upsert( submit("Adressdaten", () -> entries.forEach(entry -> {
new Query(Criteria.where("number").is(entry.number())), Update update = new Update()
new Update() .set("name", entry.name())
.set("name", entry.name()) .set("description", entry.description())
.set("description", entry.description()) .unset("receivedAt")
.set("role", entry.role()) .unset("source");
if (replaceAssignments) {
update.set("role", entry.role())
.set("cscId", entry.cscId()) .set("cscId", entry.cscId())
.set("jobIds", entry.jobIds()) .set("jobIds", entry.jobIds())
.set("url", entry.url()) .set("url", entry.url());
// Altbestand angleichen: Diese Felder wurden früher }
// mitgeschrieben und sollen aus der Sammlung verschwinden. mongo.upsert(new Query(Criteria.where("number").is(entry.number())), update, AddressEntry.class);
.unset("receivedAt") }));
.unset("source"),
AddressEntry.class)));
} }
/** /**
@@ -143,6 +143,10 @@ public record JobEntry(
/** Füllt die Klasse aus der Webhook-Nutzlast und stempelt die Eingangszeit. */ /** Füllt die Klasse aus der Webhook-Nutzlast und stempelt die Eingangszeit. */
public static JobEntry from(JsonNode json, ObjectMapper mapper, Instant receivedAt) { public static JobEntry from(JsonNode json, ObjectMapper mapper, Instant receivedAt) {
if (json == null || !json.isObject() || !json.path("id").isIntegralNumber()
|| !json.path("id").canConvertToLong() || json.path("id").longValue() <= 0) {
throw new IllegalArgumentException("Ein Job benötigt eine positive ganzzahlige id im Long-Wertebereich.");
}
JobEntry job = mapper.convertValue(json, JobEntry.class); JobEntry job = mapper.convertValue(json, JobEntry.class);
return new JobEntry(job.id(), receivedAt, job.url(), job.mapUrl(), job.state(), job.ordertime(), return new JobEntry(job.id(), receivedAt, job.url(), job.mapUrl(), job.state(), job.ordertime(),
job.orderdate(), job.modified(), job.finished(), job.vehicle(), job.service(), job.orderdate(), job.modified(), job.finished(), job.vehicle(), job.service(),
@@ -24,7 +24,8 @@ import de.appcreation.swyxweb.storage.ArchiveService;
* <p>{@code POST} nimmt die Adress-Nachricht der SwyxTray-App entgegen – die * <p>{@code POST} nimmt die Adress-Nachricht der SwyxTray-App entgegen – die
* WebSocket-Verbindung zur App hält nur der Browser, deshalb meldet die * WebSocket-Verbindung zur App hält nur der Browser, deshalb meldet die
* Startseite den Adress-Cache hierher. Angenommen wird jede Form, die * Startseite den Adress-Cache hierher. Angenommen wird jede Form, die
* {@link AddressEntry#manyFrom} versteht; die Adressdaten fremder Systeme * {@link AddressEntry#manyFrom} versteht. Aktualisiert werden nur Name und
* Beschreibung, bestehende Kundenzuordnungen bleiben erhalten. Die Adressdaten fremder Systeme
* kommen dagegen normalerweise über {@code POST /api/webhook} herein. * kommen dagegen normalerweise über {@code POST /api/webhook} herein.
* *
* <p>{@code GET} liefert die gespeicherten Einträge aus der MongoDB zurück, * <p>{@code GET} liefert die gespeicherten Einträge aus der MongoDB zurück,
@@ -53,7 +54,7 @@ public class AddressController {
if (entries.isEmpty()) { if (entries.isEmpty()) {
throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "Keine Adressdaten in der Nutzlast."); throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "Keine Adressdaten in der Nutzlast.");
} }
archive.saveAddresses(entries); archive.savePhonebookAddresses(entries);
return entries.size(); return entries.size();
} }
@@ -47,10 +47,10 @@ import de.appcreation.swyxweb.webhook.WebhookService;
* -d '{"id":4711,"state":1,"customer":{"csc_id":100164,"name":"SYSGEN GmbH"},"tours":[…]}'</pre> * -d '{"id":4711,"state":1,"customer":{"csc_id":100164,"name":"SYSGEN GmbH"},"tours":[…]}'</pre>
* *
* <p>Daneben bleibt der generische {@code POST /api/webhook}: Er nimmt * <p>Daneben bleibt der generische {@code POST /api/webhook}: Er nimmt
* <b>beliebiges</b> JSON an – {@code "type":"job"} wird als Job abgelegt, alles * beliebiges JSON an. Nachrichten mit {@code "type":"job"} müssen wie am
* andere als Adressdaten. Die Startseite zeigt jede Nachricht im Bereich * Job-Endpunkt ein gültiger Job mit positiver ganzzahliger {@code id} sein;
* „Webhook" unverändert an; das aufrufende System muss sich an kein festes * ungültige Jobdaten ergeben HTTP 400. Andere Nachrichten brauchen kein
* Schema halten. * festes Schema. Die Startseite zeigt angenommene Nutzlasten unverändert an.
* *
* <p>Der Weg zum Browser läuft über {@code GET /api/webhook/events} * <p>Der Weg zum Browser läuft über {@code GET /api/webhook/events}
* (Server-Sent Events), siehe {@link WebhookService}. * (Server-Sent Events), siehe {@link WebhookService}.
@@ -3,7 +3,6 @@ package de.appcreation.swyxweb.webhook;
import java.io.IOException; import java.io.IOException;
import java.time.Instant; import java.time.Instant;
import java.util.ArrayDeque; import java.util.ArrayDeque;
import java.util.ArrayList;
import java.util.Deque; import java.util.Deque;
import java.util.List; import java.util.List;
import java.util.concurrent.CopyOnWriteArrayList; import java.util.concurrent.CopyOnWriteArrayList;
@@ -16,10 +15,13 @@ import jakarta.annotation.PreDestroy;
import tools.jackson.databind.JsonNode; import tools.jackson.databind.JsonNode;
import tools.jackson.databind.ObjectMapper; import tools.jackson.databind.ObjectMapper;
import tools.jackson.core.JacksonException;
import org.slf4j.Logger; import org.slf4j.Logger;
import org.slf4j.LoggerFactory; import org.slf4j.LoggerFactory;
import org.springframework.stereotype.Service; import org.springframework.stereotype.Service;
import org.springframework.http.HttpStatus;
import org.springframework.web.server.ResponseStatusException;
import org.springframework.web.servlet.mvc.method.annotation.SseEmitter; import org.springframework.web.servlet.mvc.method.annotation.SseEmitter;
import de.appcreation.swyxweb.config.WebhookProperties; import de.appcreation.swyxweb.config.WebhookProperties;
@@ -110,46 +112,34 @@ public class WebhookService {
* @return das angelegte Ereignis, damit der Aufrufer die Id quittieren kann * @return das angelegte Ereignis, damit der Aufrufer die Id quittieren kann
*/ */
public WebhookEvent record(JsonNode payload, String channel) { public WebhookEvent record(JsonNode payload, String channel) {
WebhookEvent event = new WebhookEvent(nextId.getAndIncrement(), Instant.now(), channel, payload); Instant receivedAt = Instant.now();
boolean isJob = "jobs".equals(channel) || (channel == null && JobEntry.isJob(payload));
// Ungültige Jobs dürfen weder quittiert noch an Browser verteilt werden.
JobEntry job = isJob ? parseJob(payload, receivedAt) : null;
List<AddressEntry> addresses = isJob ? List.of() : AddressEntry.manyFrom(payload);
synchronized (events) { synchronized (events) {
// Auch Id-Vergabe und Versand gehören zum Monitor: parallele POSTs
// und eine SSE-Nachlieferung müssen dieselbe Reihenfolge sehen.
WebhookEvent event = new WebhookEvent(nextId.getAndIncrement(), receivedAt, channel, payload);
if (job != null) archive.saveJob(job);
else archive.saveAddresses(addresses);
events.addLast(event); events.addLast(event);
while (events.size() > Math.max(1, properties.history())) { while (events.size() > Math.max(1, properties.history())) {
events.removeFirst(); events.removeFirst();
} }
log.debug("Webhook: Nachricht {} angenommen ({} Empfänger).", event.id(), subscribers.size());
broadcast(event);
return event;
} }
log.debug("Webhook: Nachricht {} angenommen ({} Empfänger).", event.id(), subscribers.size());
// Den Inhalt dauerhaft in die MongoDB – nebenbei, die Quittung wartet
// nicht darauf.
try {
archiveByChannel(event, channel);
} catch (RuntimeException e) {
// Eine unerwartet geformte Nutzlast darf die Quittung nicht kippen.
log.warn("Webhook: Nachricht {} nicht in die MongoDB übernommen ({}).", event.id(), e.getMessage());
}
broadcast(event);
return event;
} }
/** private JobEntry parseJob(JsonNode payload, Instant receivedAt) {
* Die URL bestimmt die Ablage: Kunden und Kuriere sind Adressdaten, Jobs try {
* sind Jobs. Beim generischen Webhook ({@code channel == null}) entscheidet return JobEntry.from(payload, mapper, receivedAt);
* die Nutzlast selbst: {@code "type":"job"} ist ein Job, alles andere sind } catch (JacksonException | IllegalArgumentException e) {
* Adressdaten. Alle Adressdaten landen in derselben Sammlung. throw new ResponseStatusException(HttpStatus.BAD_REQUEST,
*/ "Ungültige Jobdaten: Ein Job benötigt eine positive ganzzahlige id und passende Feldtypen.", e);
private void archiveByChannel(WebhookEvent event, String channel) {
JsonNode payload = event.payload();
switch (channel == null ? "" : channel) {
case "kunden", "kuriere" -> archive.saveAddresses(AddressEntry.manyFrom(payload));
case "jobs" -> archive.saveJob(JobEntry.from(payload, mapper, event.receivedAt()));
default -> {
if (JobEntry.isJob(payload)) {
archive.saveJob(JobEntry.from(payload, mapper, event.receivedAt()));
} else {
archive.saveAddresses(AddressEntry.manyFrom(payload));
}
}
} }
} }
@@ -186,25 +176,21 @@ public class WebhookService {
}); });
emitter.onError(e -> subscribers.remove(emitter)); emitter.onError(e -> subscribers.remove(emitter));
List<WebhookEvent> missed = new ArrayList<>(); synchronized (events) {
if (lastEventId != null) { try {
for (WebhookEvent event : history()) { // Nachlieferung und Anmeldung sind atomar gegenüber record():
if (event.id() > lastEventId) missed.add(event); // Ein neuer Eintrag kommt entweder im Replay oder live an.
emitter.send(SseEmitter.event().comment("verbunden"));
if (lastEventId != null) {
for (WebhookEvent event : events) {
if (event.id() > lastEventId) emitter.send(toSse(event));
}
}
subscribers.add(emitter);
} catch (IOException | IllegalStateException e) {
emitter.completeWithError(e);
} }
} }
try {
// Ohne erste Nachricht bliebe die Antwort ohne Kopfzeilen hängen.
emitter.send(SseEmitter.event().comment("verbunden"));
for (WebhookEvent event : missed) {
emitter.send(toSse(event));
}
} catch (IOException | IllegalStateException e) {
emitter.completeWithError(e);
return emitter;
}
subscribers.add(emitter);
return emitter; return emitter;
} }
@@ -47,7 +47,7 @@ class AddressControllerTests {
.andExpect(status().isAccepted()) .andExpect(status().isAccepted())
.andExpect(jsonPath("$").value(2)); .andExpect(jsonPath("$").value(2));
verify(archive).saveAddresses(argThat(entries -> entries.size() == 2)); verify(archive).savePhonebookAddresses(argThat(entries -> entries.size() == 2));
} }
/** Die Suche läuft in der Datenbank – der Begriff wird durchgereicht. */ /** Die Suche läuft in der Datenbank – der Begriff wird durchgereicht. */
@@ -3,6 +3,7 @@ package de.appcreation.swyxweb;
import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.ArgumentMatchers.argThat; import static org.mockito.ArgumentMatchers.argThat;
import static org.mockito.Mockito.verify; import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.verifyNoInteractions;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.delete; import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.delete;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
@@ -23,6 +24,8 @@ import org.springframework.http.MediaType;
import org.springframework.test.context.TestPropertySource; import org.springframework.test.context.TestPropertySource;
import org.springframework.test.context.bean.override.mockito.MockitoBean; import org.springframework.test.context.bean.override.mockito.MockitoBean;
import org.springframework.test.web.servlet.MockMvc; import org.springframework.test.web.servlet.MockMvc;
import tools.jackson.databind.ObjectMapper;
import tools.jackson.databind.node.ObjectNode;
/** /**
* Der Webhook nimmt beliebiges JSON an und hält es im Verlauf vor. Ohne * Der Webhook nimmt beliebiges JSON an und hält es im Verlauf vor. Ohne
@@ -38,6 +41,9 @@ class WebhookControllerTests {
@Autowired @Autowired
WebhookService service; WebhookService service;
@Autowired
ObjectMapper mapper;
/** Ersetzt: Die Tests laufen ohne MongoDB und hinterlassen dort nichts. */ /** Ersetzt: Die Tests laufen ohne MongoDB und hinterlassen dort nichts. */
@MockitoBean @MockitoBean
ArchiveService archive; ArchiveService archive;
@@ -151,6 +157,33 @@ class WebhookControllerTests {
&& "https://vhs.assecutor.org/?jb=21891233&cr=13385".equals(job.mapUrl()))); && "https://vhs.assecutor.org/?jb=21891233&cr=13385".equals(job.mapUrl())));
} }
@ParameterizedTest
@ValueSource(strings = {"{}", "{\"id\":null}", "{\"id\":0}", "{\"id\":-1}",
"{\"id\":\"4711\"}", "{\"id\":1.5}", "{\"id\":9223372036854775808}",
"{\"id\":4711,\"customer\":[]}", "{\"id\":4711,\"tours\":\"ungueltig\"}"})
void rejectsInvalidJobsBeforeRecordingOrArchiving(String payload) throws Exception {
mvc.perform(post("/api/webhook/jobs").contentType(MediaType.APPLICATION_JSON).content(payload))
.andExpect(status().isBadRequest());
ObjectNode genericJob = (ObjectNode) mapper.readTree(payload);
genericJob.put("type", "job");
mvc.perform(post("/api/webhook").contentType(MediaType.APPLICATION_JSON)
.content(mapper.writeValueAsString(genericJob)))
.andExpect(status().isBadRequest());
assertThat(service.history()).isEmpty();
verifyNoInteractions(archive);
}
@ParameterizedTest
@ValueSource(strings = {"[]", "[ {\"id\":4711} ]", "42", "\"job\""})
void jobEndpointRequiresOneObject(String payload) throws Exception {
mvc.perform(post("/api/webhook/jobs").contentType(MediaType.APPLICATION_JSON).content(payload))
.andExpect(status().isBadRequest());
assertThat(service.history()).isEmpty();
verifyNoInteractions(archive);
}
/** Ohne Token in der Konfiguration darf der Kopf fehlen. */ /** Ohne Token in der Konfiguration darf der Kopf fehlen. */
@Test @Test
void tokenIsNotRequiredWhenUnset() throws Exception { void tokenIsNotRequiredWhenUnset() throws Exception {
@@ -0,0 +1,60 @@
package de.appcreation.swyxweb.storage;
import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.Mockito.*;
import java.util.List;
import org.bson.Document;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.Test;
import org.mockito.ArgumentCaptor;
import org.springframework.data.mongodb.core.MongoTemplate;
import org.springframework.data.mongodb.core.query.Query;
import org.springframework.data.mongodb.core.query.UpdateDefinition;
class ArchiveServiceTests {
private final MongoTemplate mongo = mock(MongoTemplate.class);
private final ArchiveService archive = new ArchiveService(mongo);
@AfterEach
void shutdown() {
archive.shutdown();
}
@Test
void phonebookRefreshPreservesPreviouslyImportedCustomerAssignments() {
archive.saveAddresses(List.of(new AddressEntry(null, "+493012345", "Kunde", "CRM",
"customer", 42, List.of(4711L), "https://example.test/customer/42")));
archive.savePhonebookAddresses(List.of(new AddressEntry(null, "+493012345", "Telefonbuch", "Swyx",
null, null, null, null)));
var queries = ArgumentCaptor.forClass(Query.class);
var updates = ArgumentCaptor.forClass(UpdateDefinition.class);
verify(mongo, timeout(2000).times(2)).upsert(queries.capture(), updates.capture(), eq(AddressEntry.class));
assertThat(queries.getAllValues()).allSatisfy(query ->
assertThat(query.getQueryObject()).containsEntry("number", "+493012345"));
Document customer = updates.getAllValues().getFirst().getUpdateObject().get("$set", Document.class);
assertThat(customer).containsEntry("role", "customer").containsEntry("cscId", 42)
.containsEntry("jobIds", List.of(4711L)).containsEntry("url", "https://example.test/customer/42");
Document phonebookUpdate = updates.getAllValues().getLast().getUpdateObject();
assertThat(phonebookUpdate.get("$set", Document.class))
.containsEntry("name", "Telefonbuch").containsEntry("description", "Swyx");
// Kein Mongo-Operator darf die bestehenden Kundenzuordnungen verändern.
phonebookUpdate.values().forEach(fields -> assertThat((Document) fields)
.doesNotContainKeys("role", "cscId", "jobIds", "url"));
}
@Test
void completeWebhookUpdateCanStillRemoveAssignments() {
archive.saveAddresses(List.of(new AddressEntry(null, "+493012345", "Kunde", null,
"customer", 42, List.of(), null)));
var update = ArgumentCaptor.forClass(UpdateDefinition.class);
verify(mongo, timeout(2000)).upsert(any(Query.class), update.capture(), eq(AddressEntry.class));
assertThat(update.getValue().getUpdateObject().get("$set", Document.class))
.containsEntry("jobIds", List.of()).containsEntry("url", null);
}
}
@@ -0,0 +1,103 @@
package de.appcreation.swyxweb.webhook;
import static org.assertj.core.api.Assertions.*;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.*;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
import java.util.ArrayList;
import java.util.List;
import java.util.concurrent.*;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.Test;
import org.springframework.mock.web.MockHttpServletResponse;
import org.springframework.test.web.servlet.MockMvc;
import org.springframework.test.web.servlet.setup.MockMvcBuilders;
import de.appcreation.swyxweb.config.WebhookProperties;
import de.appcreation.swyxweb.storage.ArchiveService;
import de.appcreation.swyxweb.web.WebhookController;
import tools.jackson.databind.ObjectMapper;
class WebhookStreamTests {
private final ObjectMapper mapper = spy(new ObjectMapper());
private final WebhookProperties properties = new WebhookProperties("", 50, 262144);
private final WebhookService service = new WebhookService(properties, mapper, mock(ArchiveService.class));
private final MockMvc mvc = MockMvcBuilders.standaloneSetup(new WebhookController(service, properties, mapper)).build();
private final ExecutorService threads = Executors.newFixedThreadPool(4);
@AfterEach
void shutdown() {
threads.shutdownNow();
service.shutdown();
}
@Test
void concurrentPostDuringReplayIsDeliveredExactlyOnceAfterReplay() throws Exception {
WebhookEvent first = service.record(mapper.readTree("{\"n\":1}"));
var replayStarted = new CountDownLatch(1);
var releaseReplay = new CountDownLatch(1);
doAnswer(invocation -> {
WebhookEvent event = invocation.getArgument(0);
if (event.id() == first.id()) {
replayStarted.countDown();
if (!releaseReplay.await(5, TimeUnit.SECONDS)) throw new AssertionError("Replay was not released");
}
return invocation.callRealMethod();
}).when(mapper).writeValueAsString(any(WebhookEvent.class));
Future<MockHttpServletResponse> subscription = threads.submit(() -> subscribe("0"));
try {
assertThat(replayStarted.await(5, TimeUnit.SECONDS)).isTrue();
var postStarted = new CountDownLatch(1);
Future<WebhookEvent> post = threads.submit(() -> {
postStarted.countDown();
return service.record(mapper.readTree("{\"n\":2}"));
});
assertThat(postStarted.await(5, TimeUnit.SECONDS)).isTrue();
// Der POST darf die Lücke zwischen Replay und Anmeldung nicht passieren.
assertThatThrownBy(() -> post.get(150, TimeUnit.MILLISECONDS)).isInstanceOf(TimeoutException.class);
releaseReplay.countDown();
MockHttpServletResponse response = subscription.get(5, TimeUnit.SECONDS);
WebhookEvent second = post.get(5, TimeUnit.SECONDS);
WebhookEvent third = service.record(mapper.readTree("{\"n\":3}"));
assertThat(eventIds(response)).containsExactly(first.id(), second.id(), third.id());
} finally {
releaseReplay.countDown();
}
}
@Test
void reconnectReplaysOnlyMissedEventsAndThenStreamsNewOnes() throws Exception {
WebhookEvent first = service.record(mapper.readTree("{\"n\":1}"));
WebhookEvent second = service.record(mapper.readTree("{\"n\":2}"));
MockHttpServletResponse response = subscribe(Long.toString(first.id()));
WebhookEvent third = service.record(mapper.readTree("{\"n\":3}"));
assertThat(eventIds(response)).containsExactly(second.id(), third.id());
}
@Test
void parallelPostsKeepTheSameOrderInHistoryAndStream() throws Exception {
MockHttpServletResponse response = subscribe("0");
List<Future<WebhookEvent>> posts = new ArrayList<>();
for (int i = 0; i < 20; i++) {
posts.add(threads.submit(() -> service.record(mapper.readTree("{}"))));
}
for (Future<WebhookEvent> post : posts) post.get(5, TimeUnit.SECONDS);
List<Long> ids = eventIds(response);
assertThat(ids).hasSize(20).doesNotHaveDuplicates().isSorted();
assertThat(service.history()).extracting(WebhookEvent::id).containsExactlyElementsOf(ids);
}
private MockHttpServletResponse subscribe(String lastId) throws Exception {
return mvc.perform(get("/api/webhook/events").header("Last-Event-ID", lastId))
.andReturn().getResponse();
}
private List<Long> eventIds(MockHttpServletResponse response) throws Exception {
return response.getContentAsString().lines().filter(line -> line.startsWith("id:"))
.map(line -> Long.parseLong(line.substring(3).trim())).toList();
}
}
+4
View File
@@ -14,6 +14,10 @@ services:
LLM_CHUNK_CHARS: ${LLM_CHUNK_CHARS:-12000} LLM_CHUNK_CHARS: ${LLM_CHUNK_CHARS:-12000}
APP_WEBSOCKET_HOST: ${APP_WEBSOCKET_HOST:-127.0.0.1} APP_WEBSOCKET_HOST: ${APP_WEBSOCKET_HOST:-127.0.0.1}
APP_WEBSOCKET_PORT: ${APP_WEBSOCKET_PORT:-17654} APP_WEBSOCKET_PORT: ${APP_WEBSOCKET_PORT:-17654}
APP_WEBSOCKET_PATH: ${APP_WEBSOCKET_PATH:-/ws}
APP_WEBSOCKET_SECURE: ${APP_WEBSOCKET_SECURE:-false}
APP_WEBHOOK_TOKEN: ${APP_WEBHOOK_TOKEN:-}
SPRING_MONGODB_URI: ${SPRING_MONGODB_URI:-mongodb://192.168.180.25:27017/swyxweb?serverSelectionTimeoutMS=2000}
extra_hosts: extra_hosts:
- "host.docker.internal:host-gateway" - "host.docker.internal:host-gateway"
volumes: volumes:
+527
View File
@@ -16,11 +16,65 @@
"@types/react": "^19.1.0", "@types/react": "^19.1.0",
"@types/react-dom": "^19.1.0", "@types/react-dom": "^19.1.0",
"@vitejs/plugin-react": "^4.3.4", "@vitejs/plugin-react": "^4.3.4",
"jsdom": "^30.1.3",
"typescript": "^5.7.3", "typescript": "^5.7.3",
"vite": "^6.0.11", "vite": "^6.0.11",
"vitest": "^4.1.10" "vitest": "^4.1.10"
} }
}, },
"node_modules/@asamuzakjp/css-color": {
"version": "7.1.9",
"resolved": "https://registry.npmjs.org/@asamuzakjp/css-color/-/css-color-7.1.9.tgz",
"integrity": "sha512-vZ6JQVq8Ivgz6b/IBlAf90wZviXdluY13cQOPO+nJHbmXwGrPl2GcpqUY7a55/8ZVdfONXpCFMpRijOBCHNlcQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"@csstools/css-calc": "^3.4.3",
"@csstools/css-color-parser": "^4.2.6",
"@csstools/css-parser-algorithms": "^4.0.2",
"@csstools/css-tokenizer": "^4.0.2",
"lru-cache": "^11.5.3"
},
"engines": {
"node": "^22.22.2 || ^24.15.0 || >=26.0.0"
}
},
"node_modules/@asamuzakjp/css-color/node_modules/lru-cache": {
"version": "11.5.3",
"resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.5.3.tgz",
"integrity": "sha512-U4N8FgzmWxc8k1VH8Kr6lQg18U7Fjvby6wXHVRX/ZZ7IwWbRMgrRbP0Wrb5q5NVinryp4SQampHKdvtecItxUg==",
"dev": true,
"license": "BlueOak-1.0.0",
"engines": {
"node": "20 || >=22"
}
},
"node_modules/@asamuzakjp/dom-selector": {
"version": "10.0.5",
"resolved": "https://registry.npmjs.org/@asamuzakjp/dom-selector/-/dom-selector-10.0.5.tgz",
"integrity": "sha512-EgmJ7oEaGQECTn3H3wmnQpEy4hKY66VvM7VQndwPIQob3p877BgbyOUE3EX0LgXQCciF2H8KMdR+RznqjMNOLw==",
"dev": true,
"license": "MIT",
"dependencies": {
"bidi-js": "^1.1.0",
"css-tree": "^3.2.1",
"is-potential-custom-element-name": "^1.0.1",
"lru-cache": "^11.5.3"
},
"engines": {
"node": "^22.22.2 || ^24.15.0 || >=26.0.0"
}
},
"node_modules/@asamuzakjp/dom-selector/node_modules/lru-cache": {
"version": "11.5.3",
"resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.5.3.tgz",
"integrity": "sha512-U4N8FgzmWxc8k1VH8Kr6lQg18U7Fjvby6wXHVRX/ZZ7IwWbRMgrRbP0Wrb5q5NVinryp4SQampHKdvtecItxUg==",
"dev": true,
"license": "BlueOak-1.0.0",
"engines": {
"node": "20 || >=22"
}
},
"node_modules/@babel/code-frame": { "node_modules/@babel/code-frame": {
"version": "7.29.7", "version": "7.29.7",
"resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.7.tgz", "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.7.tgz",
@@ -303,6 +357,159 @@
"node": ">=6.9.0" "node": ">=6.9.0"
} }
}, },
"node_modules/@bramus/specificity": {
"version": "2.4.3",
"resolved": "https://registry.npmjs.org/@bramus/specificity/-/specificity-2.4.3.tgz",
"integrity": "sha512-0GEz8qJWokkUVYQYRukmkSHIFNoBH8QTreeHrAFbvz1XFYc3Y+puhMs8PQ5x8GNugiC2l5jvDBXKLbBIByeSBw==",
"dev": true,
"license": "MIT",
"dependencies": {
"css-tree": "^3.2.1"
},
"bin": {
"specificity": "bin/cli.js"
}
},
"node_modules/@csstools/color-helpers": {
"version": "6.1.2",
"resolved": "https://registry.npmjs.org/@csstools/color-helpers/-/color-helpers-6.1.2.tgz",
"integrity": "sha512-grhRy3OKmniaAEKXMjua5z/EODX0MSqBGjunw8+j/3HQjOnahs2AGhvEOIYVUWcU6ScApbhLhVrQTX8XqrMrow==",
"dev": true,
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/csstools"
},
{
"type": "opencollective",
"url": "https://opencollective.com/csstools"
}
],
"license": "MIT-0",
"engines": {
"node": ">=20.19.0"
}
},
"node_modules/@csstools/css-calc": {
"version": "3.4.3",
"resolved": "https://registry.npmjs.org/@csstools/css-calc/-/css-calc-3.4.3.tgz",
"integrity": "sha512-iex20d8CHVkyvg6B7UKV7uHnI2Bqo9g+EFfT9E0y+GvTvhZ/DwONJ+9aKb1dlqm0ZiGsL5RXjp0fCoJYnkeDjA==",
"dev": true,
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/csstools"
},
{
"type": "opencollective",
"url": "https://opencollective.com/csstools"
}
],
"license": "MIT",
"engines": {
"node": ">=20.19.0"
},
"peerDependencies": {
"@csstools/css-parser-algorithms": "^4.0.2",
"@csstools/css-tokenizer": "^4.0.2"
}
},
"node_modules/@csstools/css-color-parser": {
"version": "4.2.7",
"resolved": "https://registry.npmjs.org/@csstools/css-color-parser/-/css-color-parser-4.2.7.tgz",
"integrity": "sha512-zgm9yjQz5iJB4VL8RyVwd71TWB8Q89FSxt/NzXoWqPSkEBOVtn9L6vAPtSc9hpKfB0Awo/AL2VKFHjYetBLFHQ==",
"dev": true,
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/csstools"
},
{
"type": "opencollective",
"url": "https://opencollective.com/csstools"
}
],
"license": "MIT",
"dependencies": {
"@csstools/color-helpers": "^6.1.2",
"@csstools/css-calc": "^3.4.3"
},
"engines": {
"node": ">=20.19.0"
},
"peerDependencies": {
"@csstools/css-parser-algorithms": "^4.0.2",
"@csstools/css-tokenizer": "^4.0.2"
}
},
"node_modules/@csstools/css-parser-algorithms": {
"version": "4.0.2",
"resolved": "https://registry.npmjs.org/@csstools/css-parser-algorithms/-/css-parser-algorithms-4.0.2.tgz",
"integrity": "sha512-40cSKyMvK+tq4qz6Awrlye2WGuOKt3FwPgtGg6KTfbHOWNw+Rk1rzbAtZnZ6IBhsY491HLRnDXwoyBAijmmILA==",
"dev": true,
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/csstools"
},
{
"type": "opencollective",
"url": "https://opencollective.com/csstools"
}
],
"license": "MIT",
"engines": {
"node": ">=20.19.0"
},
"peerDependencies": {
"@csstools/css-tokenizer": "^4.0.2"
}
},
"node_modules/@csstools/css-syntax-patches-for-csstree": {
"version": "1.1.16",
"resolved": "https://registry.npmjs.org/@csstools/css-syntax-patches-for-csstree/-/css-syntax-patches-for-csstree-1.1.16.tgz",
"integrity": "sha512-LsDbJPP/B8cPasUJyTA5S99F7C4G93ngW8hUluXz8CFeOaaRnBPtBiIlvgcPYU0HmHNz8CXg+1vwFrIB4F6Dgw==",
"dev": true,
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/csstools"
},
{
"type": "opencollective",
"url": "https://opencollective.com/csstools"
}
],
"license": "MIT-0",
"peerDependencies": {
"css-tree": "^3.2.1"
},
"peerDependenciesMeta": {
"css-tree": {
"optional": true
}
}
},
"node_modules/@csstools/css-tokenizer": {
"version": "4.0.2",
"resolved": "https://registry.npmjs.org/@csstools/css-tokenizer/-/css-tokenizer-4.0.2.tgz",
"integrity": "sha512-OoKoR0f76dCY666JlcbhmVTs2drYj1GUXZTYTcbUgJjh9Nv41aFfZ21bPQTERm5+L5cBDo466NltB2lplS5GBw==",
"dev": true,
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/csstools"
},
{
"type": "opencollective",
"url": "https://opencollective.com/csstools"
}
],
"license": "MIT",
"engines": {
"node": ">=20.19.0"
}
},
"node_modules/@esbuild/aix-ppc64": { "node_modules/@esbuild/aix-ppc64": {
"version": "0.25.12", "version": "0.25.12",
"resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.12.tgz", "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.12.tgz",
@@ -745,6 +952,24 @@
"node": ">=18" "node": ">=18"
} }
}, },
"node_modules/@exodus/bytes": {
"version": "1.16.0",
"resolved": "https://registry.npmjs.org/@exodus/bytes/-/bytes-1.16.0.tgz",
"integrity": "sha512-IcpW84uEn3N7ETtNZMlxKhfl6Pec8rUNGOTBtWbK1FKhJxIFAptZyVrvVRVBimAJxJCgc3PxepxkdWWG4DVzfA==",
"dev": true,
"license": "MIT",
"engines": {
"node": "^20.19.0 || ^22.12.0 || >=24.0.0"
},
"peerDependencies": {
"@noble/hashes": "^1.8.0 || ^2.0.0"
},
"peerDependenciesMeta": {
"@noble/hashes": {
"optional": true
}
}
},
"node_modules/@jridgewell/gen-mapping": { "node_modules/@jridgewell/gen-mapping": {
"version": "0.3.13", "version": "0.3.13",
"resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz",
@@ -1475,6 +1700,16 @@
"node": ">=6.0.0" "node": ">=6.0.0"
} }
}, },
"node_modules/bidi-js": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/bidi-js/-/bidi-js-1.1.0.tgz",
"integrity": "sha512-fX1Onk0tdVPC7obPWB5EbJ1z7NVhLq4m2xZLq2YXBkxzMXIGRpNMU88n0EPgWseKl12J7zXs7qrDxPK4sRs2fg==",
"dev": true,
"license": "MIT",
"dependencies": {
"require-from-string": "^2.0.2"
}
},
"node_modules/browserslist": { "node_modules/browserslist": {
"version": "4.28.8", "version": "4.28.8",
"resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.8.tgz", "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.8.tgz",
@@ -1547,6 +1782,20 @@
"dev": true, "dev": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/css-tree": {
"version": "3.2.1",
"resolved": "https://registry.npmjs.org/css-tree/-/css-tree-3.2.1.tgz",
"integrity": "sha512-X7sjQzceUhu1u7Y/ylrRZFU2FS6LRiFVp6rKLPg23y3x3c3DOKAwuXGDp+PAGjh6CSnCjYeAul8pcT8bAl+lSA==",
"dev": true,
"license": "MIT",
"dependencies": {
"mdn-data": "2.27.1",
"source-map-js": "^1.2.1"
},
"engines": {
"node": "^10 || ^12.20.0 || ^14.13.0 || >=15.0.0"
}
},
"node_modules/csstype": { "node_modules/csstype": {
"version": "3.2.3", "version": "3.2.3",
"resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz",
@@ -1554,6 +1803,20 @@
"dev": true, "dev": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/data-urls": {
"version": "8.0.0",
"resolved": "https://registry.npmjs.org/data-urls/-/data-urls-8.0.0.tgz",
"integrity": "sha512-JQUCCK+/CAGbGGx618PlFZMXH81Jtj7t73CHv9dd/zVWvxgGT2zLFRjnKb+Ng46GIwcy+f7f88qd4duqllK+WQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"whatwg-mimetype": "^5.0.0",
"whatwg-url": "^17.1.2"
},
"engines": {
"node": "^22.14.0 || >=24.0.0"
}
},
"node_modules/debug": { "node_modules/debug": {
"version": "4.4.3", "version": "4.4.3",
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
@@ -1572,6 +1835,13 @@
} }
} }
}, },
"node_modules/decimal.js": {
"version": "10.6.0",
"resolved": "https://registry.npmjs.org/decimal.js/-/decimal.js-10.6.0.tgz",
"integrity": "sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg==",
"dev": true,
"license": "MIT"
},
"node_modules/electron-to-chromium": { "node_modules/electron-to-chromium": {
"version": "1.5.404", "version": "1.5.404",
"resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.404.tgz", "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.404.tgz",
@@ -1579,6 +1849,19 @@
"dev": true, "dev": true,
"license": "ISC" "license": "ISC"
}, },
"node_modules/entities": {
"version": "9.0.0",
"resolved": "https://registry.npmjs.org/entities/-/entities-9.0.0.tgz",
"integrity": "sha512-oJFNDJg3kTPwAD1cNn2+WmUq4wFoo09npc3Ydf1D7ED4smRiWrsKnaLxykrTWgyGEcusc87P5dxlAleol9kpkg==",
"dev": true,
"license": "BSD-2-Clause",
"engines": {
"node": ">=20.19.0"
},
"funding": {
"url": "https://github.com/fb55/entities?sponsor=1"
}
},
"node_modules/es-module-lexer": { "node_modules/es-module-lexer": {
"version": "2.3.1", "version": "2.3.1",
"resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-2.3.1.tgz", "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-2.3.1.tgz",
@@ -1701,6 +1984,26 @@
"node": ">=6.9.0" "node": ">=6.9.0"
} }
}, },
"node_modules/html-encoding-sniffer": {
"version": "7.0.0",
"resolved": "https://registry.npmjs.org/html-encoding-sniffer/-/html-encoding-sniffer-7.0.0.tgz",
"integrity": "sha512-UikN5yr7xsCDAq87Or5or0PAlD3HJJOKVzM05az588WnpDJ4Ux7a2A53Qi6gofGg2/EtvF/H4hCi/TXfCW4Y6w==",
"dev": true,
"license": "MIT",
"dependencies": {
"@exodus/bytes": "^1.15.1"
},
"engines": {
"node": "^22.13.0 || >=24.0.0"
}
},
"node_modules/is-potential-custom-element-name": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/is-potential-custom-element-name/-/is-potential-custom-element-name-1.0.1.tgz",
"integrity": "sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ==",
"dev": true,
"license": "MIT"
},
"node_modules/js-tokens": { "node_modules/js-tokens": {
"version": "4.0.0", "version": "4.0.0",
"resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz",
@@ -1708,6 +2011,56 @@
"dev": true, "dev": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/jsdom": {
"version": "30.1.3",
"resolved": "https://registry.npmjs.org/jsdom/-/jsdom-30.1.3.tgz",
"integrity": "sha512-hQMUPvRgN7KxsfIytDQji7bimuJeRQa6Qo5+iuiZQk5CafeRxLIA10/cG5YCBFNpD9RO3oqIiN4CO1Y44kxqXg==",
"dev": true,
"license": "MIT",
"dependencies": {
"@asamuzakjp/css-color": "^7.1.3",
"@asamuzakjp/dom-selector": "^10.0.3",
"@bramus/specificity": "^2.4.2",
"@csstools/css-syntax-patches-for-csstree": "^1.1.15",
"@exodus/bytes": "^1.16.0",
"css-tree": "^3.2.1",
"data-urls": "^8.0.0",
"decimal.js": "^10.6.0",
"html-encoding-sniffer": "^7.0.0",
"is-potential-custom-element-name": "^1.0.1",
"lru-cache": "^11.5.3",
"parse5": "^8.0.1",
"saxes": "^6.0.0",
"tough-cookie": "^6.0.2",
"undici": "^8.12.1",
"w3c-xmlserializer": "^6.0.0",
"webidl-conversions": "^8.0.1",
"whatwg-mimetype": "^5.0.0",
"whatwg-url": "^17.2.0",
"xml-name-validator": "^5.0.0"
},
"engines": {
"node": "^22.22.2 || ^24.15.0 || >=26.0.0"
},
"peerDependencies": {
"canvas": "^3.2.3"
},
"peerDependenciesMeta": {
"canvas": {
"optional": true
}
}
},
"node_modules/jsdom/node_modules/lru-cache": {
"version": "11.5.3",
"resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.5.3.tgz",
"integrity": "sha512-U4N8FgzmWxc8k1VH8Kr6lQg18U7Fjvby6wXHVRX/ZZ7IwWbRMgrRbP0Wrb5q5NVinryp4SQampHKdvtecItxUg==",
"dev": true,
"license": "BlueOak-1.0.0",
"engines": {
"node": "20 || >=22"
}
},
"node_modules/jsesc": { "node_modules/jsesc": {
"version": "3.1.0", "version": "3.1.0",
"resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz", "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz",
@@ -1754,6 +2107,13 @@
"@jridgewell/sourcemap-codec": "^1.5.5" "@jridgewell/sourcemap-codec": "^1.5.5"
} }
}, },
"node_modules/mdn-data": {
"version": "2.27.1",
"resolved": "https://registry.npmjs.org/mdn-data/-/mdn-data-2.27.1.tgz",
"integrity": "sha512-9Yubnt3e8A0OKwxYSXyhLymGW4sCufcLG6VdiDdUGVkPhpqLxlvP5vl1983gQjJl3tqbrM731mjaZaP68AgosQ==",
"dev": true,
"license": "CC0-1.0"
},
"node_modules/ms": { "node_modules/ms": {
"version": "2.1.3", "version": "2.1.3",
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
@@ -1804,6 +2164,19 @@
"node": ">=12.20.0" "node": ">=12.20.0"
} }
}, },
"node_modules/parse5": {
"version": "8.1.0",
"resolved": "https://registry.npmjs.org/parse5/-/parse5-8.1.0.tgz",
"integrity": "sha512-sq3qBFpee5jeocbFbO48VHlGr8sT7mbXS0vxlKN6sottBtJzrOG3998sr0llSZcQAGgfpdmEYAIZE0P9z0dE8w==",
"dev": true,
"license": "MIT",
"dependencies": {
"entities": "^9.0.0"
},
"funding": {
"url": "https://github.com/inikulin/parse5?sponsor=1"
}
},
"node_modules/pathe": { "node_modules/pathe": {
"version": "2.0.3", "version": "2.0.3",
"resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz", "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz",
@@ -1860,6 +2233,16 @@
"node": "^10 || ^12 || >=14" "node": "^10 || ^12 || >=14"
} }
}, },
"node_modules/punycode": {
"version": "2.3.1",
"resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz",
"integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=6"
}
},
"node_modules/react": { "node_modules/react": {
"version": "19.2.8", "version": "19.2.8",
"resolved": "https://registry.npmjs.org/react/-/react-19.2.8.tgz", "resolved": "https://registry.npmjs.org/react/-/react-19.2.8.tgz",
@@ -1891,6 +2274,16 @@
"node": ">=0.10.0" "node": ">=0.10.0"
} }
}, },
"node_modules/require-from-string": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz",
"integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/rollup": { "node_modules/rollup": {
"version": "4.62.4", "version": "4.62.4",
"resolved": "https://registry.npmjs.org/rollup/-/rollup-4.62.4.tgz", "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.62.4.tgz",
@@ -1937,6 +2330,19 @@
"fsevents": "~2.3.2" "fsevents": "~2.3.2"
} }
}, },
"node_modules/saxes": {
"version": "6.0.0",
"resolved": "https://registry.npmjs.org/saxes/-/saxes-6.0.0.tgz",
"integrity": "sha512-xAg7SOnEhrm5zI3puOOKyy1OMcMlIJZYNJY7xLBwSze0UjhPLnWfj2GF2EpT0jmzaJKIWKHLsaSSajf35bcYnA==",
"dev": true,
"license": "ISC",
"dependencies": {
"xmlchars": "^2.2.0"
},
"engines": {
"node": ">=v12.22.7"
}
},
"node_modules/scheduler": { "node_modules/scheduler": {
"version": "0.27.0", "version": "0.27.0",
"resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.27.0.tgz", "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.27.0.tgz",
@@ -2028,6 +2434,52 @@
"node": ">=14.0.0" "node": ">=14.0.0"
} }
}, },
"node_modules/tldts": {
"version": "7.4.19",
"resolved": "https://registry.npmjs.org/tldts/-/tldts-7.4.19.tgz",
"integrity": "sha512-48dH6aSpQnpbzDUc4vIQZ1VjB8tkK+e8c1yx05/SZK+rkssgjEOlxDd/yU8/a4vGYQ9vRgJr9dwMq9TJ0b3X2Q==",
"dev": true,
"license": "MIT",
"dependencies": {
"tldts-core": "^7.4.19"
},
"bin": {
"tldts": "bin/cli.js"
}
},
"node_modules/tldts-core": {
"version": "7.4.20",
"resolved": "https://registry.npmjs.org/tldts-core/-/tldts-core-7.4.20.tgz",
"integrity": "sha512-C630TWXZCZmLq0F2bW4QqwnaaxCQ/svpAqvJwnC4JvgEM5ohhBnGsEzTLSOXcN+xjz+kEaHS5uoWIw2klnqd0A==",
"dev": true,
"license": "MIT"
},
"node_modules/tough-cookie": {
"version": "6.0.2",
"resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-6.0.2.tgz",
"integrity": "sha512-exgYmnmL/sJpR3upZfXG5PoatXQii55xAiXGXzY+sROLZ/Y+SLcp9PgJNI9Vz37HpQ74WvDcLT8eqm+kV3FzrA==",
"dev": true,
"license": "BSD-3-Clause",
"dependencies": {
"tldts": "^7.0.5"
},
"engines": {
"node": ">=16"
}
},
"node_modules/tr46": {
"version": "7.0.0",
"resolved": "https://registry.npmjs.org/tr46/-/tr46-7.0.0.tgz",
"integrity": "sha512-BcDSDW+SIGWVOns1Zm6Yg91q9AEj7eaID0QDY9oIqJD1A8dscNwMcl9Sms2DeuWCNov1X0/oXanHGYQBSLwZsg==",
"dev": true,
"license": "MIT",
"dependencies": {
"punycode": "^2.3.1"
},
"engines": {
"node": "^22.14.0 || >=24.0.0"
}
},
"node_modules/typescript": { "node_modules/typescript": {
"version": "5.9.3", "version": "5.9.3",
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz",
@@ -2042,6 +2494,16 @@
"node": ">=14.17" "node": ">=14.17"
} }
}, },
"node_modules/undici": {
"version": "8.12.3",
"resolved": "https://registry.npmjs.org/undici/-/undici-8.12.3.tgz",
"integrity": "sha512-+fdndUTwBP86cVjihTp21hZHo7d6oj1c/47XPA3lL8mM0XHs+C4EkcjmgRsFnFk2f/UONGDy9ItBItmXsLOQHw==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=22.19.0"
}
},
"node_modules/undici-types": { "node_modules/undici-types": {
"version": "8.3.0", "version": "8.3.0",
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-8.3.0.tgz", "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-8.3.0.tgz",
@@ -2245,6 +2707,54 @@
} }
} }
}, },
"node_modules/w3c-xmlserializer": {
"version": "6.0.0",
"resolved": "https://registry.npmjs.org/w3c-xmlserializer/-/w3c-xmlserializer-6.0.0.tgz",
"integrity": "sha512-4Nsy8K5Tr6SPDH9jhKJOHf7ChDrc1zufZTVSF7x72hwuEXBqxqk9G6cK+K2NRUtB3iELRJqjXb4JPDMBjMTl2Q==",
"dev": true,
"license": "MIT",
"dependencies": {
"xml-name-validator": "^5.0.0"
},
"engines": {
"node": "^22.22.2 || ^24.15.0 || >=26.0.0"
}
},
"node_modules/webidl-conversions": {
"version": "8.0.1",
"resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-8.0.1.tgz",
"integrity": "sha512-BMhLD/Sw+GbJC21C/UgyaZX41nPt8bUTg+jWyDeg7e7YN4xOM05YPSIXceACnXVtqyEw/LMClUQMtMZ+PGGpqQ==",
"dev": true,
"license": "BSD-2-Clause",
"engines": {
"node": ">=20"
}
},
"node_modules/whatwg-mimetype": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/whatwg-mimetype/-/whatwg-mimetype-5.0.0.tgz",
"integrity": "sha512-sXcNcHOC51uPGF0P/D4NVtrkjSU2fNsm9iog4ZvZJsL3rjoDAzXZhkm2MWt1y+PUdggKAYVoMAIYcs78wJ51Cw==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=20"
}
},
"node_modules/whatwg-url": {
"version": "17.3.0",
"resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-17.3.0.tgz",
"integrity": "sha512-KwOAJrpf+GTgBvcBQJcP38sMF0DsQKdwtG+TxM2pW44P6E5k0hDbWrDmyuJDIF0Uef0aQq0ZoJs+5oI+k8ddhg==",
"dev": true,
"license": "MIT",
"dependencies": {
"@exodus/bytes": "^1.16.0",
"tr46": "^7.0.0",
"webidl-conversions": "^8.0.1"
},
"engines": {
"node": "^22.14.0 || >=24.0.0"
}
},
"node_modules/why-is-node-running": { "node_modules/why-is-node-running": {
"version": "2.3.0", "version": "2.3.0",
"resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz", "resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz",
@@ -2262,6 +2772,23 @@
"node": ">=8" "node": ">=8"
} }
}, },
"node_modules/xml-name-validator": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/xml-name-validator/-/xml-name-validator-5.0.0.tgz",
"integrity": "sha512-EvGK8EJ3DhaHfbRlETOWAS5pO9MZITeauHKJyb8wyajUfQUenkIg2MvLDTZ4T/TgIcm3HU0TFBgWWboAZ30UHg==",
"dev": true,
"license": "Apache-2.0",
"engines": {
"node": ">=18"
}
},
"node_modules/xmlchars": {
"version": "2.2.0",
"resolved": "https://registry.npmjs.org/xmlchars/-/xmlchars-2.2.0.tgz",
"integrity": "sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==",
"dev": true,
"license": "MIT"
},
"node_modules/yallist": { "node_modules/yallist": {
"version": "3.1.1", "version": "3.1.1",
"resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz", "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz",
+1
View File
@@ -19,6 +19,7 @@
"@types/react": "^19.1.0", "@types/react": "^19.1.0",
"@types/react-dom": "^19.1.0", "@types/react-dom": "^19.1.0",
"@vitejs/plugin-react": "^4.3.4", "@vitejs/plugin-react": "^4.3.4",
"jsdom": "^30.1.3",
"typescript": "^5.7.3", "typescript": "^5.7.3",
"vite": "^6.0.11", "vite": "^6.0.11",
"vitest": "^4.1.10" "vitest": "^4.1.10"
+120
View File
@@ -0,0 +1,120 @@
// @vitest-environment jsdom
import { act, StrictMode } from 'react'
import { createRoot, type Root } from 'react-dom/client'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { findCallerByNumber, type StoredAddress } from '../addresses'
import type { CallEvent } from '../swyx/protocol'
import { useCallerLookup } from './useCallerLookup'
vi.mock('../addresses', () => ({ findCallerByNumber: vi.fn() }))
const ringing: CallEvent = { line: 1, callId: 42, event: 'incoming', peerNumber: '+493012345' }
const customer: StoredAddress = { number: ringing.peerNumber, name: 'Kunde', role: 'customer', jobIds: [4711] }
function deferred() {
let resolve!: (value: StoredAddress | null) => void
let reject!: (reason: Error) => void
const promise = new Promise<StoredAddress | null>((yes, no) => { resolve = yes; reject = no })
return { promise, resolve, reject }
}
describe('useCallerLookup', () => {
let root: Root
let element: HTMLDivElement
let current: ReturnType<typeof useCallerLookup>
function Probe({ call }: { call: CallEvent | null }) {
current = useCallerLookup(call)
return <div>{current.caller?.name ?? current.caller?.number ?? ''}</div>
}
async function render(call: CallEvent | null, strict = false) {
await act(async () => {
root.render(strict ? <StrictMode><Probe call={call} /></StrictMode> : <Probe call={call} />)
})
}
beforeEach(() => {
vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true)
vi.mocked(findCallerByNumber).mockReset()
element = document.createElement('div')
document.body.append(element)
root = createRoot(element)
})
afterEach(async () => {
await act(async () => root.unmount())
element.remove()
vi.unstubAllGlobals()
})
it('shows the lookup result even when another snapshot arrives before it', async () => {
const request = deferred()
vi.mocked(findCallerByNumber).mockReturnValue(request.promise)
await render(ringing)
await render({ ...ringing, stateText: 'klingelt' })
await render({ ...ringing })
expect(findCallerByNumber).toHaveBeenCalledTimes(1)
await act(async () => request.resolve(customer))
expect(element.textContent).toBe('Kunde')
expect(current.caller?.jobIds).toEqual([4711])
})
it('ignores the previous lookup when the line is reused, even for the same number', async () => {
const previous = deferred(), next = deferred()
vi.mocked(findCallerByNumber).mockReturnValueOnce(previous.promise).mockReturnValueOnce(next.promise)
await render(ringing)
await render({ ...ringing, callId: 43 })
await act(async () => previous.resolve(customer))
expect(current.caller).toBeNull()
await act(async () => next.resolve({ ...customer, name: 'Aktueller Kunde' }))
expect(element.textContent).toBe('Aktueller Kunde')
expect(findCallerByNumber).toHaveBeenCalledTimes(2)
})
it('does not open a late popup after ringing ends', async () => {
const request = deferred()
vi.mocked(findCallerByNumber).mockReturnValue(request.promise)
await render(ringing)
await render(null)
await act(async () => request.resolve(customer))
expect(current.caller).toBeNull()
})
it('keeps loaded customer details after ringing ends', async () => {
vi.mocked(findCallerByNumber).mockResolvedValue(customer)
await render(ringing)
await render(null)
expect(current.caller).toEqual(customer)
})
it('keeps a dismissed popup closed until the next call', async () => {
const legacyCall = { ...ringing, callId: undefined }
vi.mocked(findCallerByNumber).mockResolvedValue(customer)
await render(legacyCall)
await act(async () => current.dismissCaller())
await render({ ...legacyCall })
expect(current.caller).toBeNull()
expect(findCallerByNumber).toHaveBeenCalledTimes(1)
// Auch ältere Tray-Versionen ohne callId dürfen denselben Anschluss erneut melden.
await render(null)
await render(legacyCall)
expect(current.caller).toEqual(customer)
expect(findCallerByNumber).toHaveBeenCalledTimes(2)
})
it('falls back to the phone number on a lookup failure', async () => {
vi.mocked(findCallerByNumber).mockRejectedValue(new Error('Backend unavailable'))
await render(ringing)
expect(current.caller).toEqual({ number: ringing.peerNumber })
})
it('still loads the caller when React StrictMode restarts the effect', async () => {
vi.mocked(findCallerByNumber).mockResolvedValue(customer)
await render(ringing, true)
expect(element.textContent).toBe('Kunde')
})
})
+30
View File
@@ -0,0 +1,30 @@
import { useCallback, useEffect, useState } from 'react'
import { findCallerByNumber, type StoredAddress } from '../addresses'
import type { CallEvent } from '../swyx/protocol'
/** Ein Lookup je klingelndem Anruf; weitere Snapshots lassen die Abfrage weiterlaufen. */
export function useCallerLookup(ringingCall: CallEvent | null) {
const [caller, setCaller] = useState<StoredAddress | null>(null)
const line = ringingCall?.line
const callId = ringingCall?.callId
const number = ringingCall?.peerNumber?.trim()
useEffect(() => {
if (line === undefined || !number) return
let active = true
// Kundendaten des vorigen Gesprächs gehören nicht zum neuen Annehmen-Knopf.
setCaller(null)
findCallerByNumber(number)
.then((match) => {
if (active) setCaller(match ?? { number })
})
.catch(() => {
if (active) setCaller({ number })
})
return () => { active = false }
}, [line, callId, number])
// Nach Gesprächsende bleiben bereits geladene Kundendaten wie bisher sichtbar.
const dismissCaller = useCallback(() => setCaller(null), [])
return { caller, dismissCaller }
}
+3 -33
View File
@@ -5,7 +5,7 @@ import { useCallNotifications } from '../hooks/useCallNotifications'
import { useWebhook } from '../hooks/useWebhook' import { useWebhook } from '../hooks/useWebhook'
import { countUnseen } from '../webhook' import { countUnseen } from '../webhook'
import { applyBrand, loadBrand, type Brand } from '../brand' import { applyBrand, loadBrand, type Brand } from '../brand'
import { findCallerByNumber, type StoredAddress } from '../addresses' import { useCallerLookup } from '../hooks/useCallerLookup'
import StatusBadge from '../components/StatusBadge' import StatusBadge from '../components/StatusBadge'
import CustomerCallPopup from '../components/CustomerCallPopup' import CustomerCallPopup from '../components/CustomerCallPopup'
import StadtboteSignet from '../components/StadtboteSignet' import StadtboteSignet from '../components/StadtboteSignet'
@@ -55,37 +55,7 @@ export default function HomePage() {
const { permission, requestPermission } = useCallNotifications(ringingCall) const { permission, requestPermission } = useCallNotifications(ringingCall)
// Meldet SwyxIt! einen eingehenden Anruf, geht das Popup auf – mit den const { caller, dismissCaller } = useCallerLookup(ringingCall)
// Daten aus der Adress-Ablage, wenn die Rufnummer einem Kunden oder Kurier
// gehört, sonst nur mit der Rufnummer. Je Anruf nur eine Abfrage – der
// Snapshot kommt mehrfach.
const [caller, setCaller] = useState<StoredAddress | null>(null)
const lookedUpRef = useRef<string | null>(null)
useEffect(() => {
if (!ringingCall) {
// Anruf vorbei: Der nächste – auch von derselben Nummer – zählt neu.
lookedUpRef.current = null
return
}
const number = ringingCall.peerNumber?.trim()
if (!number) return
const key = `${ringingCall.line}:${number}`
if (lookedUpRef.current === key) return
lookedUpRef.current = key
let stale = false
findCallerByNumber(number)
.then((match) => {
if (!stale) setCaller(match ?? { number })
})
.catch(() => {
// Ablage nicht erreichbar – das Popup zeigt dann eben nur die
// Rufnummer; annehmen lässt sich der Anruf trotzdem.
if (!stale) setCaller({ number })
})
return () => {
stale = true
}
}, [ringingCall])
// Läuft unabhängig vom geöffneten Bereich: Der Webhook hängt am Backend, nicht // Läuft unabhängig vom geöffneten Bereich: Der Webhook hängt am Backend, nicht
// an der SwyxTray-Verbindung, und soll auch dann mitschreiben, wenn gerade ein // an der SwyxTray-Verbindung, und soll auch dann mitschreiben, wenn gerade ein
@@ -382,7 +352,7 @@ export default function HomePage() {
onAnswer={ onAnswer={
ringingCall && !busy ? () => void tray.answer(ringingCall.line) : undefined ringingCall && !busy ? () => void tray.answer(ringingCall.line) : undefined
} }
onClose={() => setCaller(null)} onClose={dismissCaller}
/> />
)} )}
</div> </div>