From 9e71fe3a11776f34af5a5c8f317941143bb941b8 Mon Sep 17 00:00:00 2001 From: Sven Carstensen Date: Mon, 2 Nov 2026 15:58:53 +0100 Subject: [PATCH] Anrufer- und Webhook-Verarbeitung verbessern --- .env.example | 7 + AGENTS.md | 305 ++++++++++ README.md | 24 +- .../swyxweb/storage/ArchiveService.java | 41 +- .../appcreation/swyxweb/storage/JobEntry.java | 4 + .../swyxweb/web/AddressController.java | 5 +- .../swyxweb/web/WebhookController.java | 8 +- .../swyxweb/webhook/WebhookService.java | 84 ++- .../swyxweb/AddressControllerTests.java | 2 +- .../swyxweb/WebhookControllerTests.java | 33 ++ .../swyxweb/storage/ArchiveServiceTests.java | 60 ++ .../swyxweb/webhook/WebhookStreamTests.java | 103 ++++ compose.yaml | 4 + frontend/package-lock.json | 527 ++++++++++++++++++ frontend/package.json | 1 + frontend/src/hooks/useCallerLookup.test.tsx | 120 ++++ frontend/src/hooks/useCallerLookup.ts | 30 + frontend/src/pages/HomePage.tsx | 36 +- 18 files changed, 1283 insertions(+), 111 deletions(-) create mode 100644 AGENTS.md create mode 100644 backend/src/test/java/de/appcreation/swyxweb/storage/ArchiveServiceTests.java create mode 100644 backend/src/test/java/de/appcreation/swyxweb/webhook/WebhookStreamTests.java create mode 100644 frontend/src/hooks/useCallerLookup.test.tsx create mode 100644 frontend/src/hooks/useCallerLookup.ts diff --git a/.env.example b/.env.example index 670407b..200f2d8 100644 --- a/.env.example +++ b/.env.example @@ -13,6 +13,13 @@ WHISPER_THREADS=4 # SwyxTray-Adresse aus Sicht des Browsers am Arbeitsplatz APP_WEBSOCKET_HOST=127.0.0.1 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. # URL und Modell gemeinsam setzen; beide leer lassen für reine Transkription. diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..3cf17f4 --- /dev/null +++ b/AGENTS.md @@ -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:`. 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. diff --git a/README.md b/README.md index 303504e..e4e00b9 100644 --- a/README.md +++ b/README.md @@ -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 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/ ├── 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" } ``` -**Angenommen wird beliebiges JSON** – Objekt wie Liste. Das aufrufende System muss sich an kein -Schema halten; die Startseite zeigt die Nutzlast unverändert. Nur die Kopfzeile eines Eintrags +Der generische Webhook nimmt **beliebiges JSON** an – Objekt wie Liste. Ausnahme: +`"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 ([summarize](frontend/src/webhook.ts)). | 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/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/history` | Verlauf, damit ein später geöffneter Bereich die vorigen Nachrichten sieht | | `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)). 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 ruhende Verbindung nicht kappt. @@ -570,7 +584,9 @@ docker compose up -d --build ``` 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- Backend-Startkonfigurationen laden diese Datei. `.env` ist aus Git und Docker-Build-Kontext ausgeschlossen; [.env.example](.env.example) enthält diff --git a/backend/src/main/java/de/appcreation/swyxweb/storage/ArchiveService.java b/backend/src/main/java/de/appcreation/swyxweb/storage/ArchiveService.java index ddf756c..4d23ffc 100644 --- a/backend/src/main/java/de/appcreation/swyxweb/storage/ArchiveService.java +++ b/backend/src/main/java/de/appcreation/swyxweb/storage/ArchiveService.java @@ -50,31 +50,36 @@ public class ArchiveService { } /** - * Legt Adressdaten-Einträge in der Sammlung {@code addresses} ab – alle - * Quellen (Webhooks Kunden/Kuriere, SwyxTray-Telefonbuch) in derselben - * 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. + * Übernimmt vollständige Adressdaten aus den Webhooks. Ein erneuter + * Eintrag zur selben Rufnummer ersetzt auch die Kunden-/Job-Zuordnung. */ public void saveAddresses(List entries) { + saveAddresses(entries, true); + } + + /** Der Telefonbuch-Cache kennt keine Kundenzuordnungen und darf sie nicht löschen. */ + public void savePhonebookAddresses(List entries) { + saveAddresses(entries, false); + } + + private void saveAddresses(List entries, boolean replaceAssignments) { if (entries.isEmpty()) return; // Upsert über die Rufnummer: aktualisiert den bekannten Eintrag oder // legt ihn an; die Dokument-Kennung vergibt dabei die MongoDB. - submit("Adressdaten", () -> entries.forEach(entry -> mongo.upsert( - new Query(Criteria.where("number").is(entry.number())), - new Update() - .set("name", entry.name()) - .set("description", entry.description()) - .set("role", entry.role()) + submit("Adressdaten", () -> entries.forEach(entry -> { + Update update = new Update() + .set("name", entry.name()) + .set("description", entry.description()) + .unset("receivedAt") + .unset("source"); + if (replaceAssignments) { + update.set("role", entry.role()) .set("cscId", entry.cscId()) .set("jobIds", entry.jobIds()) - .set("url", entry.url()) - // Altbestand angleichen: Diese Felder wurden früher - // mitgeschrieben und sollen aus der Sammlung verschwinden. - .unset("receivedAt") - .unset("source"), - AddressEntry.class))); + .set("url", entry.url()); + } + mongo.upsert(new Query(Criteria.where("number").is(entry.number())), update, AddressEntry.class); + })); } /** diff --git a/backend/src/main/java/de/appcreation/swyxweb/storage/JobEntry.java b/backend/src/main/java/de/appcreation/swyxweb/storage/JobEntry.java index 2385534..99de23b 100644 --- a/backend/src/main/java/de/appcreation/swyxweb/storage/JobEntry.java +++ b/backend/src/main/java/de/appcreation/swyxweb/storage/JobEntry.java @@ -143,6 +143,10 @@ public record JobEntry( /** Füllt die Klasse aus der Webhook-Nutzlast und stempelt die Eingangszeit. */ 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); return new JobEntry(job.id(), receivedAt, job.url(), job.mapUrl(), job.state(), job.ordertime(), job.orderdate(), job.modified(), job.finished(), job.vehicle(), job.service(), diff --git a/backend/src/main/java/de/appcreation/swyxweb/web/AddressController.java b/backend/src/main/java/de/appcreation/swyxweb/web/AddressController.java index 080775b..ddc665c 100644 --- a/backend/src/main/java/de/appcreation/swyxweb/web/AddressController.java +++ b/backend/src/main/java/de/appcreation/swyxweb/web/AddressController.java @@ -24,7 +24,8 @@ import de.appcreation.swyxweb.storage.ArchiveService; *

{@code POST} nimmt die Adress-Nachricht der SwyxTray-App entgegen – die * WebSocket-Verbindung zur App hält nur der Browser, deshalb meldet 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. * *

{@code GET} liefert die gespeicherten Einträge aus der MongoDB zurück, @@ -53,7 +54,7 @@ public class AddressController { if (entries.isEmpty()) { throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "Keine Adressdaten in der Nutzlast."); } - archive.saveAddresses(entries); + archive.savePhonebookAddresses(entries); return entries.size(); } diff --git a/backend/src/main/java/de/appcreation/swyxweb/web/WebhookController.java b/backend/src/main/java/de/appcreation/swyxweb/web/WebhookController.java index bd428c5..bdfea66 100644 --- a/backend/src/main/java/de/appcreation/swyxweb/web/WebhookController.java +++ b/backend/src/main/java/de/appcreation/swyxweb/web/WebhookController.java @@ -47,10 +47,10 @@ import de.appcreation.swyxweb.webhook.WebhookService; * -d '{"id":4711,"state":1,"customer":{"csc_id":100164,"name":"SYSGEN GmbH"},"tours":[…]}' * *

Daneben bleibt der generische {@code POST /api/webhook}: Er nimmt - * beliebiges JSON an – {@code "type":"job"} wird als Job abgelegt, alles - * andere als Adressdaten. Die Startseite zeigt jede Nachricht im Bereich - * „Webhook" unverändert an; das aufrufende System muss sich an kein festes - * Schema halten. + * beliebiges JSON an. Nachrichten mit {@code "type":"job"} müssen wie am + * Job-Endpunkt ein gültiger Job mit positiver ganzzahliger {@code id} sein; + * ungültige Jobdaten ergeben HTTP 400. Andere Nachrichten brauchen kein + * festes Schema. Die Startseite zeigt angenommene Nutzlasten unverändert an. * *

Der Weg zum Browser läuft über {@code GET /api/webhook/events} * (Server-Sent Events), siehe {@link WebhookService}. diff --git a/backend/src/main/java/de/appcreation/swyxweb/webhook/WebhookService.java b/backend/src/main/java/de/appcreation/swyxweb/webhook/WebhookService.java index 0d1ea04..7fb14e5 100644 --- a/backend/src/main/java/de/appcreation/swyxweb/webhook/WebhookService.java +++ b/backend/src/main/java/de/appcreation/swyxweb/webhook/WebhookService.java @@ -3,7 +3,6 @@ package de.appcreation.swyxweb.webhook; import java.io.IOException; import java.time.Instant; import java.util.ArrayDeque; -import java.util.ArrayList; import java.util.Deque; import java.util.List; import java.util.concurrent.CopyOnWriteArrayList; @@ -16,10 +15,13 @@ import jakarta.annotation.PreDestroy; import tools.jackson.databind.JsonNode; import tools.jackson.databind.ObjectMapper; +import tools.jackson.core.JacksonException; import org.slf4j.Logger; import org.slf4j.LoggerFactory; 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 de.appcreation.swyxweb.config.WebhookProperties; @@ -110,46 +112,34 @@ public class WebhookService { * @return das angelegte Ereignis, damit der Aufrufer die Id quittieren kann */ 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 addresses = isJob ? List.of() : AddressEntry.manyFrom(payload); 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); while (events.size() > Math.max(1, properties.history())) { 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; } - /** - * Die URL bestimmt die Ablage: Kunden und Kuriere sind Adressdaten, Jobs - * sind Jobs. Beim generischen Webhook ({@code channel == null}) entscheidet - * die Nutzlast selbst: {@code "type":"job"} ist ein Job, alles andere sind - * Adressdaten. Alle Adressdaten landen in derselben Sammlung. - */ - 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)); - } - } + private JobEntry parseJob(JsonNode payload, Instant receivedAt) { + try { + return JobEntry.from(payload, mapper, receivedAt); + } catch (JacksonException | IllegalArgumentException e) { + throw new ResponseStatusException(HttpStatus.BAD_REQUEST, + "Ungültige Jobdaten: Ein Job benötigt eine positive ganzzahlige id und passende Feldtypen.", e); } } @@ -186,25 +176,21 @@ public class WebhookService { }); emitter.onError(e -> subscribers.remove(emitter)); - List missed = new ArrayList<>(); - if (lastEventId != null) { - for (WebhookEvent event : history()) { - if (event.id() > lastEventId) missed.add(event); + synchronized (events) { + try { + // Nachlieferung und Anmeldung sind atomar gegenüber record(): + // 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; } diff --git a/backend/src/test/java/de/appcreation/swyxweb/AddressControllerTests.java b/backend/src/test/java/de/appcreation/swyxweb/AddressControllerTests.java index e18f32c..2748da7 100644 --- a/backend/src/test/java/de/appcreation/swyxweb/AddressControllerTests.java +++ b/backend/src/test/java/de/appcreation/swyxweb/AddressControllerTests.java @@ -47,7 +47,7 @@ class AddressControllerTests { .andExpect(status().isAccepted()) .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. */ diff --git a/backend/src/test/java/de/appcreation/swyxweb/WebhookControllerTests.java b/backend/src/test/java/de/appcreation/swyxweb/WebhookControllerTests.java index 0a912c2..8c037e3 100644 --- a/backend/src/test/java/de/appcreation/swyxweb/WebhookControllerTests.java +++ b/backend/src/test/java/de/appcreation/swyxweb/WebhookControllerTests.java @@ -3,6 +3,7 @@ package de.appcreation.swyxweb; import static org.assertj.core.api.Assertions.assertThat; import static org.mockito.ArgumentMatchers.argThat; 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.get; 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.bean.override.mockito.MockitoBean; 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 @@ -38,6 +41,9 @@ class WebhookControllerTests { @Autowired WebhookService service; + @Autowired + ObjectMapper mapper; + /** Ersetzt: Die Tests laufen ohne MongoDB und hinterlassen dort nichts. */ @MockitoBean ArchiveService archive; @@ -151,6 +157,33 @@ class WebhookControllerTests { && "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. */ @Test void tokenIsNotRequiredWhenUnset() throws Exception { diff --git a/backend/src/test/java/de/appcreation/swyxweb/storage/ArchiveServiceTests.java b/backend/src/test/java/de/appcreation/swyxweb/storage/ArchiveServiceTests.java new file mode 100644 index 0000000..6cd2979 --- /dev/null +++ b/backend/src/test/java/de/appcreation/swyxweb/storage/ArchiveServiceTests.java @@ -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); + } +} diff --git a/backend/src/test/java/de/appcreation/swyxweb/webhook/WebhookStreamTests.java b/backend/src/test/java/de/appcreation/swyxweb/webhook/WebhookStreamTests.java new file mode 100644 index 0000000..0c0b753 --- /dev/null +++ b/backend/src/test/java/de/appcreation/swyxweb/webhook/WebhookStreamTests.java @@ -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 subscription = threads.submit(() -> subscribe("0")); + try { + assertThat(replayStarted.await(5, TimeUnit.SECONDS)).isTrue(); + var postStarted = new CountDownLatch(1); + Future 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> posts = new ArrayList<>(); + for (int i = 0; i < 20; i++) { + posts.add(threads.submit(() -> service.record(mapper.readTree("{}")))); + } + for (Future post : posts) post.get(5, TimeUnit.SECONDS); + List 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 eventIds(MockHttpServletResponse response) throws Exception { + return response.getContentAsString().lines().filter(line -> line.startsWith("id:")) + .map(line -> Long.parseLong(line.substring(3).trim())).toList(); + } +} diff --git a/compose.yaml b/compose.yaml index fa7a4cb..ed310b7 100644 --- a/compose.yaml +++ b/compose.yaml @@ -14,6 +14,10 @@ services: LLM_CHUNK_CHARS: ${LLM_CHUNK_CHARS:-12000} APP_WEBSOCKET_HOST: ${APP_WEBSOCKET_HOST:-127.0.0.1} 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: - "host.docker.internal:host-gateway" volumes: diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 191666b..f34f3ed 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -16,11 +16,65 @@ "@types/react": "^19.1.0", "@types/react-dom": "^19.1.0", "@vitejs/plugin-react": "^4.3.4", + "jsdom": "^30.1.3", "typescript": "^5.7.3", "vite": "^6.0.11", "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": { "version": "7.29.7", "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.7.tgz", @@ -303,6 +357,159 @@ "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": { "version": "0.25.12", "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.12.tgz", @@ -745,6 +952,24 @@ "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": { "version": "0.3.13", "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", @@ -1475,6 +1700,16 @@ "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": { "version": "4.28.8", "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.8.tgz", @@ -1547,6 +1782,20 @@ "dev": true, "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": { "version": "3.2.3", "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", @@ -1554,6 +1803,20 @@ "dev": true, "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": { "version": "4.4.3", "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": { "version": "1.5.404", "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.404.tgz", @@ -1579,6 +1849,19 @@ "dev": true, "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": { "version": "2.3.1", "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-2.3.1.tgz", @@ -1701,6 +1984,26 @@ "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": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", @@ -1708,6 +2011,56 @@ "dev": true, "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": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz", @@ -1754,6 +2107,13 @@ "@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": { "version": "2.1.3", "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", @@ -1804,6 +2164,19 @@ "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": { "version": "2.0.3", "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz", @@ -1860,6 +2233,16 @@ "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": { "version": "19.2.8", "resolved": "https://registry.npmjs.org/react/-/react-19.2.8.tgz", @@ -1891,6 +2274,16 @@ "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": { "version": "4.62.4", "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.62.4.tgz", @@ -1937,6 +2330,19 @@ "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": { "version": "0.27.0", "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.27.0.tgz", @@ -2028,6 +2434,52 @@ "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": { "version": "5.9.3", "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", @@ -2042,6 +2494,16 @@ "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": { "version": "8.3.0", "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": { "version": "2.3.0", "resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz", @@ -2262,6 +2772,23 @@ "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": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz", diff --git a/frontend/package.json b/frontend/package.json index 8c85a74..49886bf 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -19,6 +19,7 @@ "@types/react": "^19.1.0", "@types/react-dom": "^19.1.0", "@vitejs/plugin-react": "^4.3.4", + "jsdom": "^30.1.3", "typescript": "^5.7.3", "vite": "^6.0.11", "vitest": "^4.1.10" diff --git a/frontend/src/hooks/useCallerLookup.test.tsx b/frontend/src/hooks/useCallerLookup.test.tsx new file mode 100644 index 0000000..2c18b23 --- /dev/null +++ b/frontend/src/hooks/useCallerLookup.test.tsx @@ -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((yes, no) => { resolve = yes; reject = no }) + return { promise, resolve, reject } +} + +describe('useCallerLookup', () => { + let root: Root + let element: HTMLDivElement + let current: ReturnType + + function Probe({ call }: { call: CallEvent | null }) { + current = useCallerLookup(call) + return

{current.caller?.name ?? current.caller?.number ?? ''}
+ } + + async function render(call: CallEvent | null, strict = false) { + await act(async () => { + root.render(strict ? : ) + }) + } + + 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') + }) +}) diff --git a/frontend/src/hooks/useCallerLookup.ts b/frontend/src/hooks/useCallerLookup.ts new file mode 100644 index 0000000..b8d30e7 --- /dev/null +++ b/frontend/src/hooks/useCallerLookup.ts @@ -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(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 } +} diff --git a/frontend/src/pages/HomePage.tsx b/frontend/src/pages/HomePage.tsx index a848ef7..52527ee 100644 --- a/frontend/src/pages/HomePage.tsx +++ b/frontend/src/pages/HomePage.tsx @@ -5,7 +5,7 @@ import { useCallNotifications } from '../hooks/useCallNotifications' import { useWebhook } from '../hooks/useWebhook' import { countUnseen } from '../webhook' import { applyBrand, loadBrand, type Brand } from '../brand' -import { findCallerByNumber, type StoredAddress } from '../addresses' +import { useCallerLookup } from '../hooks/useCallerLookup' import StatusBadge from '../components/StatusBadge' import CustomerCallPopup from '../components/CustomerCallPopup' import StadtboteSignet from '../components/StadtboteSignet' @@ -55,37 +55,7 @@ export default function HomePage() { const { permission, requestPermission } = useCallNotifications(ringingCall) - // Meldet SwyxIt! einen eingehenden Anruf, geht das Popup auf – mit den - // 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(null) - const lookedUpRef = useRef(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]) + const { caller, dismissCaller } = useCallerLookup(ringingCall) // 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 @@ -382,7 +352,7 @@ export default function HomePage() { onAnswer={ ringingCall && !busy ? () => void tray.answer(ringingCall.line) : undefined } - onClose={() => setCaller(null)} + onClose={dismissCaller} /> )}