Anrufer- und Webhook-Verarbeitung verbessern
This commit is contained in:
1 parent
7b3c187985
commit
9e71fe3a11
18 files changed
+1283
-111
No files matched your search
@@ -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.
|
||||||
|
|||||||
@@ -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.
|
||||||
@@ -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();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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:
|
||||||
|
|||||||
Generated
+527
@@ -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",
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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')
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -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 }
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
|||||||
Reference in new issue
Block a user