Compare commits

...
14 Commits
Author SHA1 Message Date
SvenandClaude Fable 5.1 afd6d03a71 Anruf-Popup auch für Kuriere: Rufnummernsuche findet Kunden und Kuriere
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-03 15:21:31 +02:00
SvenandClaude Fable 5 ab5e402ee9 Anruf-Popup mit Job-Knöpfen und Annehmen; Anrufkarte entfernt
Adressen tragen die Kennungen ihrer Jobs (job_ids) – das Anruf-Popup zeigt
je Kennung einen Knopf, der den Job über den neuen Endpunkt
GET /api/jobs/{id} aus der Sammlung holt und seine Sprungadresse (url) in
einem neuen Tab öffnet; Fehler (unbekannte Kennung, Ablage nicht
erreichbar) erscheinen im Popup. Der Knopf "Auftrag öffnen" (customer.url)
ist entfallen.

Das Popup öffnet jetzt bei jedem eingehenden Anruf – mit Kundendaten, wenn
die Rufnummer einem Kunden gehört, sonst nur mit der Rufnummer – und trägt
links unten einen grünen Knopf "Anruf annehmen", solange es klingelt. Die
Anrufkarte über den Tabs (IncomingCallCard) ist damit überflüssig und
samt Styles ausgebaut; ein Ablehnen-Knopf fehlt dem Popup noch.

Die Debug-Starts beenden vorab einen Altlauf des Backends: eine Task macht
Port 8080 frei (nur der Lauscher – lsof ohne -sTCP:LISTEN träfe auch den
Vite-Proxy) und hängt als preLaunchTask an beiden Backend-Konfigurationen;
tasks.json wandert dafür mit ins Repo.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-31 11:07:14 +02:00
SvenandClaude Fable 5 3a86bc1eee Jobs typisiert und erweitert, nächtliche Bereinigung, Kunden-Popup bei Anruf
Job-Klassen in Frontend (Job/JobCustomer/JobCourier/JobTour) und Backend
auf die neue Webhook-Form erweitert (url, finished, service, canceled,
global, phone statt number – alte Schreibweise bleibt verstanden, remark
je Station); Stationen legen ihre Kennung wieder als "id" ab. Adressen
tragen zusätzlich role, csc_id, job_ids und url. Ein Zeitplan löscht
jede Nacht um 0 Uhr Jobs, deren ordertime über vier Wochen zurückliegt.
Meldet SwyxIt! einen Anruf, sucht die Startseite die Rufnummer in der
Ablage (Schreibweisen-Abgleich über die Endziffern) und zeigt bei einem
Kunden ein Popup mit dessen Daten; die Auftrags-URL öffnet als Knopf
einen neuen Tab und schließt das Popup.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-28 15:40:20 +02:00
SvenandClaude Fable 5 ee81f25942 Umschaltbares Erscheinungsbild: STADTBOTE oder HANSETRANS
Nach dem HANSETRANS Brandbook (2025/10): Grün #68B022 als Akzent,
HANSETRANS Grau als Wortmarken-Farbe, Überschriften gemischt geschrieben
mit negativer Laufweite, Bildmarke (Achteck mit Fahrbahn-Schwung) als
SVG angenähert. Umschalter in der Kopfzeile neben dem Verbindungsstatus,
per data-brand am Wurzelelement; Wahl samt Favicon und Titel bleibt über
Neustarts erhalten.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-28 14:32:58 +02:00
SvenandClaude Fable 5 0fa690a67d Oberfläche entschlackt: Adressdaten ohne Neu-laden und Erklärtexte, Diagnose ohne Rohnachricht
Der Adressdaten-Bereich verliert den Neu-laden-Knopf samt
Telefonbuch-Abgleich und Wartedialog – die Ablage hält sich über
Cache-Push und die Abfragen der Suche aktuell. Anrufen erscheint nur
noch bei bestehender Verbindung zur Tray-App, eine Suche ohne Treffer
zeigt nichts weiter an. Erklärtexte in Adressdaten und Tabs entfernt,
ebenso das Rohnachricht-Formular im Diagnose-Bereich.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-27 17:31:13 +02:00
SvenandClaude Fable 5 607cbaecbc Null-Safety-Warnungen der IDE behoben: Methodenreferenzen durch Lambdas ersetzt
Bei einer Methodenreferenz wird der Empfänger zum Parameter des
funktionalen Interfaces, dessen @NonNull-Annotation die Eclipse-Analyse
nicht zusichern kann; als Lambda übernimmt der Parameter die Annotation.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-27 17:15:54 +02:00
SvenandClaude Fable 5 a9b40c7e6a Adressdaten-Suche direkt in der MongoDB; Sammlung auf Nummer, Name, Beschreibung verschlankt
Die Sammlung addresses führt für alle Quellen (Webhooks Kunden/Kuriere,
SwyxTray-Telefonbuch) nur noch Rufnummer, Name und Beschreibung; alte
Felder receivedAt/source werden beim Upsert entfernt. GET /api/addresses
sucht mit ?q= als Teilzeichenkette in allen drei Feldern. Der Bereich
"Adressdaten" hält keinen Bestand mehr im Browser: Jede Eingabe fragt
entprellt die Datenbank ab, das Suchfeld startet leer.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-27 17:07:48 +02:00
SvenandClaude Fable 5 715720fbb3 Erfolgreiche Verbindung merken und automatisch wieder aufbauen; Hinweistexte gekürzt
Die zuletzt erfolgreich verbundene SwyxTray-Adresse landet im localStorage;
beim Laden der Seite wird damit sofort wieder verbunden, manuelles Trennen
vergisst sie. Untertitel, Backend-Hinweis und Webhook-Erklärtexte entfernt.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-27 10:36:24 +02:00
SvenandClaude Fable 5 97c3778180 MongoDB-Ablage für Adressen, Anrufe und Jobs; Webhook-URLs je Datenart
Adress-, Anruf- und Jobdaten landen dauerhaft in der MongoDB
(ArchiveService, best-effort auf eigenem Thread): Die Startseite meldet
Adress-Cache und Anruf-Ereignisse an POST /api/addresses bzw. /api/calls,
der Webhook legt seine Nutzlasten selbst ab. Adressen tragen eine
generierte Kennung, die Rufnummer erkennt per Upsert die Dublette.

Der Webhook bekommt je Datenart eine eigene URL – /api/webhook/kunden,
/api/webhook/kuriere, /api/webhook/jobs –, die URL bestimmt die Ablage,
ein Kennzeichen in der Nutzlast ist nicht mehr nötig. Die Herkunft steht
als channel am Ereignis. Der generische POST /api/webhook bleibt.

Die Controller-Tests ersetzen den ArchiveService (MockitoBean) und
laufen damit ohne MongoDB. Markenrot laut Styleguide in beiden Farbmodi.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-27 10:24:53 +02:00
SvenandClaude Fable 5 1598852785 STADTBOTE-Erscheinungsbild: Farben, Schriften, Signet
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-25 17:15:28 +02:00
SvenandClaude Opus 5 521ceb044b Loopback als Vorgabe, verständliche Meldung bei Mixed Content
Die feste Adresse 192.168.180.135 der SwyxTray-App wird überall durch
127.0.0.1 ersetzt: Die App läuft auf dem Arbeitsplatz des Anwenders, die
Verbindung bleibt damit auf dem Rechner und braucht keine Firewall-Freigabe.

Scheitert der Verbindungsaufbau, weil eine über HTTPS ausgelieferte Seite
kein unverschlüsseltes ws:// öffnen darf, meldet der Browser nur "The
operation is insecure." – describeConnectFailure() benennt stattdessen den
Grund und den Ausweg (wss:// oder Loopback).

node_modules/ wird nun auf jeder Ebene ignoriert, nicht nur unter frontend/.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-24 17:16:41 +02:00
SvenandClaude Opus 5 01594a1f3d Adressdaten-Bereich, Mock hinter einem Profil, Webhook
Drei Stränge, die sich über dieselben Dateien ziehen (HomePage, index.css,
README, application.properties) und deshalb nicht getrennt committet werden
können, ohne einen nicht übersetzbaren Zwischenstand zu hinterlassen:

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

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

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

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-24 15:11:55 +02:00
SvenandClaude Opus 5 b2f608055a Versionsnummer beim Push verpflichtend
Der Tag des Images soll bewusst gesetzt werden, nicht aus der pom.xml
hergeleitet. Ohne Argument bricht das Skript mit der Kurzhilfe ab.

Damit entfällt die Auswertung der pom.xml über den Maven-Wrapper - das
Skript braucht backend/mvnw nicht mehr und startet ohne Maven-Lauf. Das
Format ist wieder strikt x.y.z; die Ausnahme fuer -SNAPSHOT gab es nur,
weil die pom.xml als Vorgabe diente.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-24 15:11:39 +02:00
SvenandClaude Opus 5 2a2bce860e Deployment als Docker-Container
Ein Container liefert Startseite und REST-API über Port 8080 aus: Das
Dockerfile baut das Frontend mit Node, legt das Ergebnis in die statischen
Ressourcen des Backends und packt beides mit Maven zum Jar; die Laufzeit-
stufe enthält nur ein JRE und das Jar, ausgeführt als Benutzer swyx.

Die beiden Build-Stufen laufen auf $BUILDPLATFORM, also nativ auf dem
bauenden Rechner - JS-Bundle und Jar sind architekturunabhängig. Nur das
Laufzeit-Image wird für die Zielarchitektur gezogen, was beim Bauen für
linux/amd64 auf einem ARM-Mac die Emulation spart.

docker_push.sh liest die Version aus backend/pom.xml, baut für linux/amd64
und pusht in die Registry.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-24 14:17:44 +02:00
67 changed files with 5412 additions and 275 deletions
+21
View File
@@ -0,0 +1,21 @@
# Build-Ergebnisse und Abhängigkeiten kommen aus dem Image-Build, nicht vom Host.
backend/target/
frontend/node_modules/
frontend/dist/
*.tsbuildinfo
# Lokale Konfiguration
frontend/.env
frontend/.env.local
# Tooling / OS / VCS
.git/
.gitignore
.vscode/
.idea/
*.iml
.DS_Store
*.log
README.md
Dockerfile
.dockerignore
+2 -1
View File
@@ -3,7 +3,7 @@ backend/target/
!backend/.mvn/wrapper/maven-wrapper.properties !backend/.mvn/wrapper/maven-wrapper.properties
# Frontend # Frontend
frontend/node_modules/ node_modules/
frontend/dist/ frontend/dist/
frontend/.env frontend/.env
frontend/.env.local frontend/.env.local
@@ -16,6 +16,7 @@ frontend/.env.local
# sonst greift die Ausnahme für die Datei darin nicht. # sonst greift die Ausnahme für die Datei darin nicht.
.vscode/* .vscode/*
!.vscode/launch.json !.vscode/launch.json
!.vscode/tasks.json
*.iml *.iml
.DS_Store .DS_Store
*.log *.log
+15 -5
View File
@@ -9,12 +9,17 @@
"mainClass": "de.appcreation.swyxweb.BackendApplication", "mainClass": "de.appcreation.swyxweb.BackendApplication",
"projectName": "backend", "projectName": "backend",
"cwd": "${workspaceFolder}/backend", "cwd": "${workspaceFolder}/backend",
"console": "internalConsole" "console": "internalConsole",
// Ein noch laufendes Backend würde Port 8080 belegen und weiter den
// alten Stand ausliefern vor dem Start wird es deshalb beendet.
"preLaunchTask": "Backend-Port 8080 freimachen"
}, },
{ {
// Wie oben, aber /api/config zeigt auf den eingebauten SwyxTray-Mock statt // Wie oben, aber der eingebaute SwyxTray-Mock wird über das Profil "mock"
// auf 192.168.180.135:17654 so lässt sich die Startseite ohne echte // eingeschaltet (ohne das Profil gibt es ihn nicht) und /api/config zeigt
// Telefonanlage ausprobieren (eingehenden Anruf auslösen: siehe README). // auf ihn statt auf 127.0.0.1:17654 so lässt sich die Startseite
// ohne echte Telefonanlage ausprobieren (eingehenden Anruf auslösen:
// siehe README).
"type": "java", "type": "java",
"name": "Backend (lokaler Test gegen SwyxTray-Mock)", "name": "Backend (lokaler Test gegen SwyxTray-Mock)",
"request": "launch", "request": "launch",
@@ -22,7 +27,12 @@
"projectName": "backend", "projectName": "backend",
"cwd": "${workspaceFolder}/backend", "cwd": "${workspaceFolder}/backend",
"console": "internalConsole", "console": "internalConsole",
"args": ["--app.websocket.host=localhost", "--app.websocket.port=8080"] "args": [
"--spring.profiles.active=mock",
"--app.websocket.host=localhost",
"--app.websocket.port=8080"
],
"preLaunchTask": "Backend-Port 8080 freimachen"
}, },
{ {
"type": "node-terminal", "type": "node-terminal",
+24
View File
@@ -0,0 +1,24 @@
{
"version": "2.0.0",
"tasks": [
{
// Beendet ein noch laufendes Backend (den Lauscher auf Port 8080), damit
// der Debug-Start immer den frischen Stand startet statt an der
// Portbelegung zu scheitern egal ob der Altlauf aus VS Code,
// "mvnw spring-boot:run" oder einem Terminal stammt. Nur -sTCP:LISTEN:
// ohne die Einschränkung träfe lsof auch Prozesse mit offener Verbindung
// zum Backend, etwa den Vite-Dev-Server (Proxy). Wartet kurz, bis der
// Port wirklich frei ist; "exit 0" auch ohne Treffer, sonst bräche der
// Launch ab.
"label": "Backend-Port 8080 freimachen",
"type": "shell",
"command": "pids=$(lsof -ti tcp:8080 -sTCP:LISTEN); if [ -n \"$pids\" ]; then echo \"Beende laufendes Backend (PID $pids) …\"; kill $pids; for i in $(seq 1 25); do lsof -ti tcp:8080 -sTCP:LISTEN >/dev/null || break; sleep 0.2; done; fi; exit 0",
"presentation": {
"reveal": "silent",
"panel": "shared",
"close": true
},
"problemMatcher": []
}
]
}
+76
View File
@@ -0,0 +1,76 @@
# syntax=docker/dockerfile:1
# SwyxWeb als ein Container: Das React-Frontend wird gebaut, in die statischen
# Ressourcen des Backends gelegt und zusammen mit ihm in ein Jar gepackt.
# Ausgeliefert wird am Ende alles über den einen Port des Backends (8080).
#
# Der eingebaute SwyxTray-Mock (/ws und /api/mock/**) läuft NICHT mit: seine
# Bohnen hängen am Spring-Profil "mock", und das setzt dieses Image nicht.
#
# Die beiden Build-Stufen laufen bewusst auf $BUILDPLATFORM, also nativ auf dem
# bauenden Rechner: JS-Bundle und Jar sind architekturunabhängig. Nur die
# Laufzeitstufe wird für die Zielarchitektur gezogen. Ein Build mit
# --platform linux/amd64 auf einem ARM-Mac läuft dadurch ohne Emulation.
# ---------------------------------------------------------------------------
# 1. Frontend bauen (Vite -> frontend/dist)
# ---------------------------------------------------------------------------
FROM --platform=$BUILDPLATFORM node:22-alpine AS frontend
WORKDIR /build
# Erst die Abhängigkeiten, damit diese Schicht im Cache bleibt, solange sich
# package.json/package-lock.json nicht ändern.
COPY frontend/package.json frontend/package-lock.json ./
RUN npm ci
COPY frontend/ ./
# VITE_WS_URL nur setzen, wenn die Adresse fest ins Bundle soll; im Normalfall
# kommt sie zur Laufzeit aus /api/config (siehe README).
ARG VITE_WS_URL
RUN npm run build
# ---------------------------------------------------------------------------
# 2. Backend bauen (Spring Boot 4.1, Java 21) inkl. Frontend als static/
# ---------------------------------------------------------------------------
FROM --platform=$BUILDPLATFORM maven:3.9-eclipse-temurin-21 AS backend
WORKDIR /build
# Abhängigkeiten vorab auflösen eigene Schicht, unabhängig vom Quelltext.
COPY backend/pom.xml ./
RUN --mount=type=cache,target=/root/.m2 \
mvn -B -q dependency:go-offline
COPY backend/src ./src
COPY --from=frontend /build/dist/ ./src/main/resources/static/
RUN --mount=type=cache,target=/root/.m2 \
mvn -B -DskipTests clean package \
&& cp target/*.jar /build/app.jar
# ---------------------------------------------------------------------------
# 3. Laufzeit-Image
# ---------------------------------------------------------------------------
FROM eclipse-temurin:21-jre-alpine AS runtime
# Nicht als root laufen.
RUN addgroup -S swyx && adduser -S -G swyx swyx
WORKDIR /app
COPY --from=backend --chown=swyx:swyx /build/app.jar ./app.jar
USER swyx
EXPOSE 8080
# Adresse der SwyxTray-App zur Laufzeit überschreibbar (Spring Relaxed Binding):
# -e APP_WEBSOCKET_HOST=127.0.0.1 -e APP_WEBSOCKET_PORT=17654
# -e APP_WEBSOCKET_PATH=/ws -e APP_WEBSOCKET_SECURE=false
#
# SPRING_PROFILES_ACTIVE bleibt bewusst leer der SwyxTray-Mock bliebe sonst
# beim Ausrollen erreichbar. Für eine Vorführung ohne Telefonanlage lässt er
# sich einzeln einschalten: -e SPRING_PROFILES_ACTIVE=mock
ENV JAVA_OPTS="-XX:MaxRAMPercentage=75.0"
HEALTHCHECK --interval=30s --timeout=3s --start-period=40s --retries=3 \
CMD wget -q -O /dev/null http://127.0.0.1:8080/api/config || exit 1
ENTRYPOINT ["sh", "-c", "exec java $JAVA_OPTS -jar /app/app.jar"]
+304 -19
View File
@@ -1,7 +1,7 @@
# SwyxWeb # SwyxWeb
React-Frontend mit Spring-Boot-Backend. Die Startseite verbindet sich **aus dem Browser heraus** React-Frontend mit Spring-Boot-Backend. Die Startseite verbindet sich **aus dem Browser heraus**
per WebSocket mit der **SwyxTray-App** (`ws://192.168.180.135:17654/ws`), meldet eingehende per WebSocket mit der **SwyxTray-App** (`ws://127.0.0.1:17654/ws`), meldet eingehende
Anrufe und startet ausgehende Anrufe. Anrufe und startet ausgehende Anrufe.
``` ```
@@ -18,17 +18,21 @@ Das Backend baut **keine** eigene WebSocket-Verbindung auf; es hat zwei Aufgaben
1. `GET /api/config` liefert dem Frontend die Zieladresse, damit sie nicht im JS-Bundle 1. `GET /api/config` liefert dem Frontend die Zieladresse, damit sie nicht im JS-Bundle
fest verdrahtet ist (konfigurierbar in [application.properties](backend/src/main/resources/application.properties)). fest verdrahtet ist (konfigurierbar in [application.properties](backend/src/main/resources/application.properties)).
2. Es stellt unter seinem eigenen Port einen **SwyxTray-Mock** bereit, um die Startseite ohne 2. Es nimmt unter `POST /api/webhook` **Adressdaten fremder Systeme** entgegen und reicht sie
echte Telefonanlage testen zu können (siehe unten). Im Normalbetrieb wird er nicht benutzt. an die offenen Browser weiter (siehe [Webhook](#webhook-für-adressdaten-fremder-systeme)).
3. Es stellt unter seinem eigenen Port einen **SwyxTray-Mock** bereit, um die Startseite ohne
echte Telefonanlage testen zu können (siehe unten). Der läuft **nur mit dem Spring-Profil
`mock`**; im Normalbetrieb und damit auch im Container gibt es ihn nicht.
**Zwei getrennte Ports:** `8080` ist der Port dieser Anwendung (REST-API und Mock), **Zwei getrennte Ports:** `8080` ist der Port dieser Anwendung (REST-API und, mit Profil `mock`, der Mock),
`17654` der Port der SwyxTray-App auf 192.168.180.135. Sie sind unabhängig voneinander. `17654` der Port der SwyxTray-App auf dem Arbeitsplatz des Anwenders. Sie sind unabhängig voneinander.
## Protokoll der SwyxTray-App ## Protokoll der SwyxTray-App
Quelle: Mitschnitt gegen die laufende App (Version 1.0.0.0, `protocol: 1`) am 13.08.2026. Quelle: Mitschnitt gegen die laufende App (Version 1.0.0.0, `protocol: 1`) am 13.08.2026.
Seit der Tab-Verwaltung meldet die App im `hello` **`protocol: 6`**; die älteren Nachrichten Mit der Tab-Verwaltung meldete die App im `hello` `protocol: 6`, mit der Adresssuche
sind unverändert geblieben. Implementiert in [protocol.ts](frontend/src/swyx/protocol.ts) und `protocol: 7`, mit dem Adress-Cache **`protocol: 8`**; die älteren Nachrichten sind
unverändert geblieben. Implementiert in [protocol.ts](frontend/src/swyx/protocol.ts) und
[SwyxTrayClient.ts](frontend/src/swyx/SwyxTrayClient.ts). [SwyxTrayClient.ts](frontend/src/swyx/SwyxTrayClient.ts).
Ein Anruf wird über die **1-basierte Leitungsnummer `line`** identifiziert so, wie sie auch Ein Anruf wird über die **1-basierte Leitungsnummer `line`** identifiziert so, wie sie auch
@@ -95,6 +99,8 @@ Wählbereich darauf hin.
| `tabs` | | Offene Tabs des Firefox-Plugins auflisten | `tabs` | | `tabs` | | Offene Tabs des Firefox-Plugins auflisten | `tabs` |
| `opentab` | `url` | Neuen Tab öffnen | `tabId` | | `opentab` | `url` | Neuen Tab öffnen | `tabId` |
| `closetab` | `tabId` | Tab schließen | – | | `closetab` | `tabId` | Tab schließen | – |
| `contacts` | `query` | Adressdaten des Swyx-Clients durchsuchen | `contacts` |
| `addresses`| | Adress-Cache der App am Stück abrufen | `addresses` |
Alles andere quittiert die App mit `"Unbekanntes Kommando '…'."`, ein fehlendes `cmd` mit Alles andere quittiert die App mit `"Unbekanntes Kommando '…'."`, ein fehlendes `cmd` mit
`"Feld 'cmd' fehlt."`. `"Feld 'cmd' fehlt."`.
@@ -154,16 +160,122 @@ wartet die Startseite auf diese drei Kommandos 15 statt 10 Sekunden
Auch hier gilt: **die App meldet Tab-Änderungen nicht von selbst.** Die Liste wird nach jedem Auch hier gilt: **die App meldet Tab-Änderungen nicht von selbst.** Die Liste wird nach jedem
Öffnen und Schließen neu über `tabs` geholt. Öffnen und Schließen neu über `tabs` geholt.
Alle Regeln sind in [protocol.test.ts](frontend/src/swyx/protocol.test.ts) festgehalten ### Adressdaten des Swyx-Clients
Seit Protokoll 7 gibt die App das globale Telefonbuch der Anlage heraus dieselben
Einträge, die SwyxIt! im Adressbuch zeigt.
```jsonc
// ->
{ "id": 20, "cmd": "contacts", "query": "abt" }
// <-
{ "type": "result", "id": 20, "ok": true, "contacts": [
{ "name": "Abt, Bettina", "number": "7587", "description": "S-SB" },
{ "name": "Abdul, Rokhsareh", "number": "5215", "description": "" } ] }
```
Wichtig für die Anzeige alles gegen die laufende App sondiert:
- **`query` ist Pflicht.** Ein fehlender, leerer oder nur aus Leerzeichen bestehender
Begriff und einer über 128 Zeichen ergeben dieselbe Meldung:
`"Feld 'query' fehlt oder ist zu lang (max. 128 Zeichen)."` Ein **vollständiges Auflisten
gibt es nicht**; der Bereich der Startseite ist deshalb eine Suche.
- Gesucht wird als **Teilzeichenkette in Name und Rufnummer**, Groß-/Kleinschreibung egal.
Die `description` (Standortkürzel wie „HH-MT") wird **nicht** durchsucht.
- Sortiert nach Namen, **höchstens 100 Treffer** gekürzt wird stillschweigend, es gibt
kein Feld dafür. Ein `limit` im Kommando ignoriert die App.
- `description` ist bei vielen Einträgen `""`. Namen sind **nicht eindeutig**: dieselbe
Person kommt mit mehreren Durchwahlen vor.
- `query` muss eine **Zeichenkette** sein; eine Zahl quittiert die App mit
`"Ungueltiges JSON."` und zwar **ohne `id`**, die Antwort lässt sich also keinem
Kommando zuordnen. `SwyxTrayClient.searchContacts()` schickt deshalb immer Text.
Die `number` ist die Durchwahl in der Form, die `call` direkt annimmt die Einträge im
Bereich „Adressdaten" haben darum einen „Anrufen"-Knopf.
### Adress-Cache (Protokoll 8)
Seit Protokoll 8 hält die App die Adressdaten selbst vor und gibt sie **am Stück** heraus
ohne Suchbegriff und ohne Deckel. Das ersetzt im Normalfall die 110 Einzelabfragen des
nächsten Abschnitts.
```jsonc
// ->
{ "id": 5, "cmd": "addresses" }
// <-
{ "type": "result", "id": 5, "ok": true,
"addresses": [ { "name": "Muster GmbH", "number": "+493012345",
"description": "Globales Telefonbuch" } ] }
```
Dieselben Daten schickt die App außerdem **unaufgefordert**, direkt nach dem ersten Snapshot
und wenn sich ihr Cache ändert:
```json
{ "type": "addresses", "addresses": [ ] }
```
Diese Push-Nachricht ist die **einzige Nachricht der App ohne `id` und ohne `ok`** sie ist
damit keine Quittung und wird von [parseAddresses](frontend/src/swyx/protocol.ts) gelesen,
nicht von `parseResult`. Die Einträge tragen dieselben Felder wie bei `contacts`.
Ein **leeres** `addresses` ist ein gültiger Zustand und heißt „Cache ist leer" nicht
„keine Antwort". Stand 21.08.2026 meldet die Anlage genau das: das Kommando wird mit
`ok: true` beantwortet, der Cache ist aber leer, und der Push beim Verbinden trägt `[]`.
Deshalb bleiben die Einzelabfragen als Rückfallebene bestehen.
### Rückfallebene: den Gesamtbestand aus Einzelabfragen holen
Kennt die App das Kommando `addresses` nicht oder ist ihr Cache leer, setzt
[directory.ts](frontend/src/swyx/directory.ts) den Bestand aus vielen `contacts`-Abfragen
zusammen. Der Trick steckt in der Wahl der Suchbegriffe:
> Jeder Eintrag hat eine Rufnummer, und die ist eine **reine Ziffernfolge**. Eine Nummer mit
> mindestens zwei Ziffern enthält deshalb mindestens eines der hundert Paare `"00"`…`"99"`.
> Die Vereinigung dieser hundert Abfragen ist damit **nachweislich vollständig** solange
> keine davon am Deckel hängt.
Dazu kommen die zehn einstelligen Abfragen `"0"``"9"`: eine einstellige Rufnummer (etwa `0`
für eine Zentrale) käme in keinem Paar vor. Macht **110 Abfragen**.
Gegen die Anlage gemessen (21.08.2026):
| | |
|---|---|
| Einträge insgesamt | 404 |
| Abfragen | 110 (keine Verfeinerung nötig) |
| größte Trefferzahl eines Ziffernpaars | 59 also weit unter dem Deckel von 100 |
| einstellige Abfragen am Deckel | 7 von 10 (deshalb genügen sie allein nicht) |
| Dauer | rund 6,7 s |
| Rufnummern | durchgehend 4- bis 6-stellig, rein numerisch |
Die App beantwortet die Abfragen **nacheinander**; mehrere gleichzeitig zu schicken bringt
kaum etwas (110 Abfragen: 7,2 s einzeln, 6,3 s zu viert). Deshalb der Wartedialog.
**Was passiert, wenn doch eine Abfrage gekürzt wird?** Dann verfeinert der Durchlauf sie
vorn *und* hinten je eine Ziffer angehängt, denn eine Teilzeichenkette kann an beiden Enden
weitergehen. Das erfasst jeden Eintrag, dessen Rufnummer **länger** ist als die gekappte
Abfrage. Bleibt eine Abfrage auch dreistellig noch gekürzt, meldet der Bereich den Bestand
ausdrücklich als unvollständig, statt eine gekürzte Liste als vollständig auszugeben.
Die eine verbleibende Lücke, offen benannt: Ein Eintrag, dessen Rufnummer **genau** einer
gekappten Abfrage entspricht, ließe sich nicht nachladen. Bei vier- bis sechsstelligen
Rufnummern betrifft das nur den Fall einer einstelligen Rufnummer bei gleichzeitig gekappter
einstelliger Abfrage.
Die Regeln des Protokolls stehen in [protocol.test.ts](frontend/src/swyx/protocol.test.ts),
die des Gesamtabrufs in [directory.test.ts](frontend/src/swyx/directory.test.ts)
darunter die wörtlich mitgeschnittene Nachricht eines echten eingehenden Anrufs. darunter die wörtlich mitgeschnittene Nachricht eines echten eingehenden Anrufs.
## Funktionen der Startseite ## Funktionen der Startseite
Die Seite ist in drei Bereiche aufgeteilt ([Tabs.tsx](frontend/src/components/Tabs.tsx)): Die Seite ist in fünf Bereiche aufgeteilt ([Tabs.tsx](frontend/src/components/Tabs.tsx)):
| Tab | Inhalt | | Tab | Inhalt |
|---|---| |---|---|
| **Anrufe** | Rufnummer wählen (`call`), belegte Leitungen mit Aktionen und dem Zustand der App (`statusText`, angemeldeter Benutzer, Warnung bei `connected: false`), Verlauf der Anrufereignisse | | **Anrufe** | Rufnummer wählen (`call`), belegte Leitungen mit Aktionen und dem Zustand der App (`statusText`, angemeldeter Benutzer, Warnung bei `connected: false`), Verlauf der Anrufereignisse |
| **Adressdaten** | Der **gesamte** Bestand des Telefonbuchs. Zuerst aus dem Adress-Cache, den die App beim Verbinden von selbst schickt dann steht er sofort und ohne Wartedialog. Sonst über `addresses`, und erst wenn auch das nichts liefert, über rund 110 Einzelabfragen hinter einem Wartedialog mit Fortschritt und „Abbrechen". Die Anzeige nennt die Herkunft. Danach liegt alles im Browser: das Filtern über Name, Durchwahl **und** Standortkürzel läuft ohne weitere Abfrage. Ein Klick auf „Anrufen" wählt die Durchwahl (`call`) |
| **Webhook** | Was ein fremdes System an `POST /api/webhook` geschickt hat jüngste Nachricht zuerst, jede mit ihrem JSON im Original. Die Seite hört über Server-Sent Events mit, **unabhängig vom geöffneten Bereich**: Eine Nachricht erscheint sofort, und ist gerade ein anderer Bereich offen, zeigt der Tab die Zahl der ungesehenen Nachrichten |
| **Tabs** | Offene Tabs des Firefox-Plugins anzeigen (`tabs`), einzeln schließen (`closetab`) und eine URL als neuen Tab öffnen (`opentab`); die Liste wird beim ersten Öffnen des Bereichs und nach jeder Änderung geholt, die eingetippte URL übersteht einen Reload (`localStorage`) | | **Tabs** | Offene Tabs des Firefox-Plugins anzeigen (`tabs`), einzeln schließen (`closetab`) und eine URL als neuen Tab öffnen (`opentab`); die Liste wird beim ersten Öffnen des Bereichs und nach jeder Änderung geholt, die eingetippte URL übersteht einen Reload (`localStorage`) |
| **Verbindung** | Adresse, Verbinden/Trennen, Benachrichtigungen erlauben, Diagnose: Rohnachrichten-Log, Status abfragen, Freitext senden | | **Verbindung** | Adresse, Verbinden/Trennen, Benachrichtigungen erlauben, Diagnose: Rohnachrichten-Log, Status abfragen, Freitext senden |
@@ -178,6 +290,83 @@ Weiteres:
„nicht erkannt" vermerkt statt still verworfen. „nicht erkannt" vermerkt statt still verworfen.
- Automatischer Reconnect mit exponentiellem Backoff, nur nach unerwartetem Abbruch. - Automatischer Reconnect mit exponentiellem Backoff, nur nach unerwartetem Abbruch.
## Webhook für Adressdaten fremder Systeme
Ein anderes System kann Adressdaten per POST an die Anwendung schicken; die Startseite zeigt
sie im Bereich **Webhook** an. Anders als alles Übrige läuft das **nicht** über die SwyxTray-App,
sondern allein über das Backend.
```bash
curl -X POST https://swyxweb.appcreation.de/api/webhook \
-H "Content-Type: application/json" \
-H "X-Webhook-Token: …" \
-d '{"name":"Muster GmbH","number":"+493012345","description":"CRM-Import"}'
```
Die Quittung nennt die vergebene Nummer und die Eingangszeit:
```json
{ "id": 1, "receivedAt": "2026-08-24T12:38:01.563742294Z" }
```
**Angenommen wird beliebiges JSON** Objekt wie Liste. Das aufrufende System muss sich an kein
Schema halten; die Startseite zeigt die Nutzlast unverändert. Nur die Kopfzeile eines Eintrags
fasst `name` und `number` zusammen, wenn es sie gibt, und zählt sonst Felder bzw. Einträge
([summarize](frontend/src/webhook.ts)).
| Endpunkt | Zweck |
|---|---|
| `POST /api/webhook` | Nachricht abliefern. `415` bei falschem Content-Type, `400` bei kaputtem JSON, `413` über der Größengrenze, `401` bei falschem Token |
| `GET /api/webhook/events` | Strom für die Startseite (Server-Sent Events) |
| `GET /api/webhook/history` | Verlauf, damit ein später geöffneter Bereich die vorigen Nachrichten sieht |
| `DELETE /api/webhook/history` | Verlauf leeren das macht der Knopf „Leeren" |
### Wie die Nachricht in den Browser kommt
Über **Server-Sent Events**, nicht über den WebSocket: Der gehört der SwyxTray-App. Die
Startseite hält eine `EventSource` auf `/api/webhook/events` offen
([useWebhook.ts](frontend/src/hooks/useWebhook.ts)) einseitig, über dieselbe HTTPS-Verbindung
wie die Seite, und der Browser baut sie nach einem Abbruch von selbst wieder auf.
Zwei Vorkehrungen, damit dabei nichts verloren geht:
- Das Backend hält die letzten **50 Nachrichten** im Speicher ([WebhookService](backend/src/main/java/de/appcreation/swyxweb/webhook/WebhookService.java)).
Beim Wiederverbinden schickt der Browser die `Last-Event-ID` mit, und alles Jüngere wird
nachgeliefert. Über einen **Neustart hinaus wird nichts aufgehoben**.
- Alle 25 Sekunden geht ein Kommentar (`:ping`) durch die Leitung, damit ein Reverse Proxy die
ruhende Verbindung nicht kappt.
Die Antwort trägt außerdem `X-Accel-Buffering: no` und `Cache-Control: no-cache`. **Ohne den
ersten Kopf sammelt nginx die Ereignisse in seinem Puffer** und gibt sie erst später aus die
Nachricht erschiene dann verspätet oder scheinbar gar nicht. nginx wertet ihn aus, andere Proxys
ignorieren ihn folgenlos. Steht vor der Anwendung etwas anderes als nginx, muss dort die
Pufferung für `/api/webhook/events` abgeschaltet sein (bei Apache `mod_proxy`: `flushpackets=on`).
Doppelte Nachrichten sind dadurch normal der Bereich holt beim Start den Verlauf *und* hört
auf den Strom. Entschieden wird über die `id` ([mergeEvent](frontend/src/webhook.ts)).
### Token
```properties
app.webhook.token= # leer ⇒ jeder Aufruf wird angenommen
app.webhook.history=50 # so viele Nachrichten bleiben im Speicher
app.webhook.max-size=262144 # größte Nutzlast in Byte
```
> **Für die öffentlich erreichbare Instanz gehört hier ein Geheimnis hinein.** `/api/webhook`
> liegt unter derselben Adresse wie die Startseite und ist damit aus dem Internet erreichbar;
> ohne Token kann jeder beliebiges JSON in die Anzeige schreiben. Ist der Wert leer, weist das
> Backend beim Start ausdrücklich darauf hin (`WARN`).
Im Container:
```bash
docker run -d -p 8080:8080 -e APP_WEBHOOK_TOKEN=… … gitea.appcreation.de/sven/swyxweb:0.9.13
```
Das Token gilt nur für `POST`. Verlauf und Ereignisstrom bleiben offen sie liefern dasselbe,
was die Seite ohnehin jedem Betrachter zeigt.
## Starten ## Starten
### Aus VS Code ### Aus VS Code
@@ -187,8 +376,9 @@ Im Debug-Panel die Compound-Konfiguration **„SwyxWeb starten (Backend + Fronte
Vite-Dev-Server und öffnet Chrome, sobald dieser bereit ist Breakpoints funktionieren auf Vite-Dev-Server und öffnet Chrome, sobald dieser bereit ist Breakpoints funktionieren auf
beiden Seiten. „Stop" beendet beides zusammen. beiden Seiten. „Stop" beendet beides zusammen.
Alternative **„SwyxWeb starten (lokaler Test gegen SwyxTray-Mock)"**: `/api/config` liefert dann Alternative **„SwyxWeb starten (lokaler Test gegen SwyxTray-Mock)"**: startet das Backend mit dem
`ws://localhost:8080/ws`, die Startseite spricht also mit dem SwyxTray-Mock. Profil `mock`, `/api/config` liefert dann `ws://localhost:8080/ws`, die Startseite spricht also
mit dem SwyxTray-Mock.
Benötigte Extensions: *Extension Pack for Java* (Backend) und *JavaScript Debugger* (im Benötigte Extensions: *Extension Pack for Java* (Backend) und *JavaScript Debugger* (im
VS Code enthalten, für Vite und Chrome). VS Code enthalten, für Vite und Chrome).
@@ -223,10 +413,29 @@ diesen Proxy der Browser verbindet sich direkt mit der SwyxTray-App.
## Ohne echte Telefonanlage testen ## Ohne echte Telefonanlage testen
> **Der Mock ist Opt-in.** Sein WebSocket-Endpunkt `/ws` und seine Steuer-Endpunkte
> `/api/mock/**` hängen am Spring-Profil **`mock`**
> ([MockProfile](backend/src/main/java/de/appcreation/swyxweb/config/MockProfile.java))
> ohne das Profil existieren die Bohnen nicht und beide Pfade antworten mit 404. So kann der
> Mock nicht versehentlich in einer Produktivumgebung mitlaufen; das Container-Image setzt das
> Profil bewusst nicht. Einschalten:
>
> ```bash
> cd backend && ./mvnw spring-boot:run -Dspring-boot.run.profiles=mock
> java -jar app.jar --spring.profiles.active=mock # oder SPRING_PROFILES_ACTIVE=mock
> ```
>
> Abgesichert ist beides durch je einen Test in
> [BackendApplicationTests](backend/src/test/java/de/appcreation/swyxweb/BackendApplicationTests.java)
> (ohne Profil keine Mock-Bohne) und
> [MockProfileTests](backend/src/test/java/de/appcreation/swyxweb/MockProfileTests.java)
> (mit Profil alle da).
Der [SwyxTrayMockHandler](backend/src/main/java/de/appcreation/swyxweb/websocket/SwyxTrayMockHandler.java) Der [SwyxTrayMockHandler](backend/src/main/java/de/appcreation/swyxweb/websocket/SwyxTrayMockHandler.java)
spricht dasselbe Protokoll: `hello` (mit `protocol: 6`) und Snapshot beim Verbinden, Quittungen spricht dasselbe Protokoll: `hello` (mit `protocol: 8`), Snapshot und Adress-Cache beim
auf `call`/`answer`/`hangup`/`ping`/`status`/`focus`/`tabs`/`opentab`/`closetab` und nach jeder Verbinden, Quittungen auf
Änderung einen vollständigen Snapshot über alle vier Leitungen. Einen eingehenden Anruf auslösen: `call`/`answer`/`hangup`/`ping`/`status`/`focus`/`tabs`/`opentab`/`closetab`/`contacts`/`addresses`
und nach jeder Änderung einen vollständigen Snapshot über alle vier Leitungen. Einen eingehenden Anruf auslösen:
```bash ```bash
curl -X POST "http://localhost:8080/api/mock/incoming-call?number=%2B493012345&name=Muster%20GmbH" curl -X POST "http://localhost:8080/api/mock/incoming-call?number=%2B493012345&name=Muster%20GmbH"
@@ -244,8 +453,30 @@ liefert dessen `tabId`, `closetab` entfernt ihn; eine unbekannte Kennung ergibt
Geöffnet wird auch hier nichts. Der Mock bildet den Fall „kein Plugin verbunden" **nicht** nach; Geöffnet wird auch hier nichts. Der Mock bildet den Fall „kein Plugin verbunden" **nicht** nach;
er antwortet immer sofort. er antwortet immer sofort.
Für `contacts` führt der Mock ein Telefonbuch mit **154 Einträgen** groß genug, dass der
Bereich „Adressdaten" dieselbe Arbeit leisten muss wie gegen die Anlage. Drei Sonderfälle
sind absichtlich dabei: zweimal **„Muster, Max"** mit verschiedenen Durchwahlen (Namen sind
nicht eindeutig), Einträge **ohne Beschreibung**, und **„Zentrale" mit der Rufnummer `0`**
die steckt in keinem Ziffernpaar und wird nur über die einstelligen Abfragen gefunden.
Er hält sich an dieselben Regeln wie die echte App: Teilzeichenkette in Name und Rufnummer,
Schreibweise egal, nach Namen sortiert, gekappt bei 100 Treffern, und derselbe Fehlertext
für einen fehlenden, leeren oder zu langen Suchbegriff.
Denselben Bestand gibt der Mock als Adress-Cache heraus auf `addresses` und beim Verbinden
als Push. Der Cache lässt sich leeren, um die Rückfallebene zu prüfen; dann verhält er sich
wie die Anlage zurzeit:
```bash
curl -X POST "http://localhost:8080/api/mock/address-cache?filled=false" # -> Einzelabfragen
curl -X POST "http://localhost:8080/api/mock/address-cache?filled=true" # -> Cache
```
Jedes Umschalten schickt allen Verbundenen sofort einen neuen Push.
Die Startseite muss dazu auf `ws://localhost:8080/ws` zeigen entweder im Adressfeld Die Startseite muss dazu auf `ws://localhost:8080/ws` zeigen entweder im Adressfeld
eintragen oder das Backend mit `--app.websocket.host=localhost --app.websocket.port=8080` eintragen oder das Backend mit
`--spring.profiles.active=mock --app.websocket.host=localhost --app.websocket.port=8080`
starten (macht die VS-Code-Konfiguration „lokaler Test" automatisch). starten (macht die VS-Code-Konfiguration „lokaler Test" automatisch).
## Konfiguration der WebSocket-Adresse ## Konfiguration der WebSocket-Adresse
@@ -257,14 +488,14 @@ Es gilt die erste Quelle, die etwas liefert:
| 1 | Eingabefeld auf der Startseite | zur Laufzeit änderbar | | 1 | Eingabefeld auf der Startseite | zur Laufzeit änderbar |
| 2 | `GET /api/config` vom Backend | `app.websocket.host` in `application.properties` | | 2 | `GET /api/config` vom Backend | `app.websocket.host` in `application.properties` |
| 3 | `VITE_WS_URL` aus `frontend/.env` | siehe [.env.example](frontend/.env.example) | | 3 | `VITE_WS_URL` aus `frontend/.env` | siehe [.env.example](frontend/.env.example) |
| 4 | Default im Code | `ws://192.168.180.135:17654/ws` | | 4 | Default im Code | `ws://127.0.0.1:17654/ws` |
Backend-seitig: Backend-seitig:
```properties ```properties
server.port=8080 # Port dieser Anwendung server.port=8080 # Port dieser Anwendung
app.websocket.host=192.168.180.135 # SwyxTray-App app.websocket.host=127.0.0.1 # SwyxTray-App
app.websocket.port=17654 app.websocket.port=17654
app.websocket.path=/ws app.websocket.path=/ws
app.websocket.secure=false # true ⇒ wss:// app.websocket.secure=false # true ⇒ wss://
@@ -280,6 +511,58 @@ cd frontend && npm test && npm run build
Um das Frontend aus dem Backend auszuliefern, `frontend/dist/*` nach Um das Frontend aus dem Backend auszuliefern, `frontend/dist/*` nach
`backend/src/main/resources/static/` kopieren und neu packen. `backend/src/main/resources/static/` kopieren und neu packen.
## Deployment als Docker-Container
Das [Dockerfile](Dockerfile) im Wurzelverzeichnis erledigt genau das in drei Stufen: Frontend
mit Node bauen, das Ergebnis in `backend/src/main/resources/static/` legen und mit Maven
zusammen zum Jar packen, dieses in ein schlankes JRE-Image kopieren. Herauskommt **ein**
Container, der Startseite und REST-API über Port 8080 ausliefert als Benutzer `swyx`, nicht
als `root`, mit einem Healthcheck gegen `/api/config`.
```bash
docker build -t swyxweb .
docker run -d -p 8080:8080 \
-e APP_WEBSOCKET_HOST=127.0.0.1 \
-e APP_WEBSOCKET_PORT=17654 \
--name swyxweb swyxweb
```
Alle `app.websocket.*`-Werte sind zur Laufzeit über Umgebungsvariablen setzbar
(`APP_WEBSOCKET_HOST`, `_PORT`, `_PATH`, `_SECURE`), das Bundle muss dafür nicht neu gebaut
werden. Der Heap wächst über `-XX:MaxRAMPercentage=75` mit dem Speicherlimit des Containers
mit; überschreiben lässt sich das mit `-e JAVA_OPTS=…`. Soll die WebSocket-Adresse ausnahmsweise
fest ins Bundle, geht das beim Bauen mit `--build-arg VITE_WS_URL=ws://…/ws`.
In die Registry schieben erledigt [docker_push.sh](docker_push.sh) es baut für `linux/amd64`
und pusht in die Paket-Registry des Gitea nach `gitea.appcreation.de/sven/swyxweb`
(`REGISTRY_IMAGE` im Skript). Die **Versionsnummer ist Pflicht** und wird zum Tag
des Images; sie wird bewusst nicht aus der `pom.xml` abgeleitet:
```bash
docker login gitea.appcreation.de
./docker_push.sh 0.9.13 # baut und pusht :0.9.13
./docker_push.sh 0.9.13 --dry-run # nur bauen, nicht pushen
```
Ohne Argument bricht das Skript mit der Kurzhilfe ab. Zulässig ist das Format `x.y.z`.
Ein lokaler Maven- oder npm-Lauf ist vorher **nicht** nötig; beides passiert im Image-Build.
Die beiden Build-Stufen laufen dabei nativ auf der Architektur des bauenden Rechners
(`--platform=$BUILDPLATFORM`) JS-Bundle und Jar sind architekturunabhängig, nur das
Laufzeit-Image wird für `linux/amd64` gezogen. Auf einem ARM-Mac spart das die Emulation.
**Der SwyxTray-Mock ist nicht dabei**: `SPRING_PROFILES_ACTIVE` bleibt leer, also fehlen `/ws`
und `/api/mock/**` (siehe [Ohne echte Telefonanlage testen](#ohne-echte-telefonanlage-testen)).
Für eine Vorführung ohne Telefonanlage lässt er sich am einzelnen Container einschalten:
`-e SPRING_PROFILES_ACTIVE=mock`.
Zwei Dinge, die der Container nicht lösen kann:
- **Läuft die Seite hinter HTTPS**, blockiert der Browser die unverschlüsselte Verbindung zur
SwyxTray-App. Dann braucht die App selbst TLS und `APP_WEBSOCKET_SECURE=true` (⇒ `wss://`).
- **Die WebSocket-Verbindung baut der Browser des Anwenders auf**, nicht der Container. Port
17654 muss also vom Arbeitsplatz aus erreichbar sein, nicht vom Docker-Host.
## Hinweise ## Hinweise
- **HTTPS-Seite ⇒ `wss://`**: Ein über HTTPS ausgelieferter Browser blockiert unverschlüsselte - **HTTPS-Seite ⇒ `wss://`**: Ein über HTTPS ausgelieferter Browser blockiert unverschlüsselte
@@ -288,7 +571,9 @@ Um das Frontend aus dem Backend auszuliefern, `frontend/dist/*` nach
Vite-Dev-Server auf Port 5173 sich verbinden kann. Für die Produktion einschränken siehe Vite-Dev-Server auf Port 5173 sich verbinden kann. Für die Produktion einschränken siehe
[WebSocketConfig.java](backend/src/main/java/de/appcreation/swyxweb/config/WebSocketConfig.java) [WebSocketConfig.java](backend/src/main/java/de/appcreation/swyxweb/config/WebSocketConfig.java)
und [CorsConfig.java](backend/src/main/java/de/appcreation/swyxweb/config/CorsConfig.java). und [CorsConfig.java](backend/src/main/java/de/appcreation/swyxweb/config/CorsConfig.java).
- **Firewall**: Port 17654 muss auf 192.168.180.135 eingehend freigegeben sein, sonst - **Firewall**: Die Verbindung geht per Vorgabe an `127.0.0.1`, bleibt also auf dem Rechner
scheitert der Verbindungsaufbau ohne aussagekräftige Browser-Fehlermeldung. und braucht keine Freigabe. Zeigt die Adresse auf einen anderen Rechner, muss dort Port
17654 eingehend freigegeben sein sonst scheitert der Verbindungsaufbau ohne
aussagekräftige Browser-Fehlermeldung.
- **System-Benachrichtigungen** verlangen eine Benutzerinteraktion zur Erlaubniserteilung und - **System-Benachrichtigungen** verlangen eine Benutzerinteraktion zur Erlaubniserteilung und
funktionieren nur über `https://` oder `localhost`. funktionieren nur über `https://` oder `localhost`.
+4
View File
@@ -19,6 +19,10 @@
</properties> </properties>
<dependencies> <dependencies>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-mongodb</artifactId>
</dependency>
<dependency> <dependency>
<groupId>org.springframework.boot</groupId> <groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-webmvc</artifactId> <artifactId>spring-boot-starter-webmvc</artifactId>
@@ -1,12 +1,16 @@
package de.appcreation.swyxweb; package de.appcreation.swyxweb;
import de.appcreation.swyxweb.config.WebSocketProperties; import de.appcreation.swyxweb.config.WebSocketProperties;
import de.appcreation.swyxweb.config.WebhookProperties;
import org.springframework.boot.SpringApplication; import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication; import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.boot.context.properties.EnableConfigurationProperties; import org.springframework.boot.context.properties.EnableConfigurationProperties;
import org.springframework.scheduling.annotation.EnableScheduling;
@SpringBootApplication @SpringBootApplication
@EnableConfigurationProperties(WebSocketProperties.class) // Zeitpläne wie die nächtliche Job-Bereinigung (JobCleanupService).
@EnableScheduling
@EnableConfigurationProperties({ WebSocketProperties.class, WebhookProperties.class })
public class BackendApplication { public class BackendApplication {
public static void main(String[] args) { public static void main(String[] args) {
@@ -0,0 +1,23 @@
package de.appcreation.swyxweb.config;
/**
* Name des Spring-Profils, unter dem der eingebaute SwyxTray-Mock läuft.
*
* <p>Der Mock ist bewusst <b>abgeschaltet, solange dieses Profil nicht gesetzt
* ist</b> so kann er nicht versehentlich in einer Produktivumgebung
* mitlaufen. Eingeschaltet wird er über
* {@code --spring.profiles.active=mock} bzw. {@code SPRING_PROFILES_ACTIVE=mock}.
*
* <p>Betroffen sind {@code SwyxTrayMockHandler} (der WebSocket-Endpunkt unter
* {@code /ws}), {@code WebSocketConfig} (seine Registrierung) und
* {@code MockController} ({@code /api/mock/**}). Ohne das Profil gibt es weder
* den einen noch die anderen; beide antworten dann mit 404.
*/
public final class MockProfile {
/** Name des Profils, siehe Klassenkommentar. */
public static final String NAME = "mock";
private MockProfile() {
}
}
@@ -2,6 +2,7 @@ package de.appcreation.swyxweb.config;
import de.appcreation.swyxweb.websocket.SwyxTrayMockHandler; import de.appcreation.swyxweb.websocket.SwyxTrayMockHandler;
import org.springframework.context.annotation.Configuration; import org.springframework.context.annotation.Configuration;
import org.springframework.context.annotation.Profile;
import org.springframework.web.socket.config.annotation.EnableWebSocket; import org.springframework.web.socket.config.annotation.EnableWebSocket;
import org.springframework.web.socket.config.annotation.WebSocketConfigurer; import org.springframework.web.socket.config.annotation.WebSocketConfigurer;
import org.springframework.web.socket.config.annotation.WebSocketHandlerRegistry; import org.springframework.web.socket.config.annotation.WebSocketHandlerRegistry;
@@ -9,7 +10,10 @@ import org.springframework.web.socket.config.annotation.WebSocketHandlerRegistry
/** /**
* Stellt den SwyxTray-Mock unter dem konfigurierten Pfad bereit, damit sich die * Stellt den SwyxTray-Mock unter dem konfigurierten Pfad bereit, damit sich die
* Startseite auch ohne die echte SwyxTray-App testen lässt. * Startseite auch ohne die echte SwyxTray-App testen lässt.
*
* <p>Nur mit dem Profil {@link MockProfile#NAME} siehe dort.
*/ */
@Profile(MockProfile.NAME)
@Configuration @Configuration
@EnableWebSocket @EnableWebSocket
public class WebSocketConfig implements WebSocketConfigurer { public class WebSocketConfig implements WebSocketConfigurer {
@@ -8,7 +8,7 @@ import org.springframework.boot.context.properties.ConfigurationProperties;
@ConfigurationProperties(prefix = "app.websocket") @ConfigurationProperties(prefix = "app.websocket")
public record WebSocketProperties(String host, int port, String path, boolean secure) { public record WebSocketProperties(String host, int port, String path, boolean secure) {
/** Fertige URL, z. B. ws://192.168.180.135:8080/ws */ /** Fertige URL, z. B. ws://127.0.0.1:8080/ws */
public String url() { public String url() {
return (secure ? "wss" : "ws") + "://" + host + ":" + port + path; return (secure ? "wss" : "ws") + "://" + host + ":" + port + path;
} }
@@ -0,0 +1,27 @@
package de.appcreation.swyxweb.config;
import org.springframework.boot.context.properties.ConfigurationProperties;
import org.springframework.boot.context.properties.bind.DefaultValue;
/**
* Einstellungen des Webhooks, über den ein fremdes System Adressdaten an die
* Startseite schickt.
*
* @param token Gemeinsames Geheimnis. Ist es gesetzt, muss der Aufrufer es im
* Kopf {@code X-Webhook-Token} mitschicken; ist es leer, nimmt
* der Webhook <b>jeden</b> Aufruf an.
* @param history So viele Nachrichten hält das Backend vor, damit ein später
* geöffneter Bereich die vorigen noch sieht.
* @param maxSize Größte erlaubte Nutzlast in Byte.
*/
@ConfigurationProperties(prefix = "app.webhook")
public record WebhookProperties(
@DefaultValue("") String token,
@DefaultValue("50") int history,
@DefaultValue("262144") int maxSize) {
/** Ob der Webhook ein Geheimnis verlangt. */
public boolean isSecured() {
return !token.isBlank();
}
}
@@ -0,0 +1,114 @@
package de.appcreation.swyxweb.storage;
import java.util.ArrayList;
import java.util.List;
import tools.jackson.databind.JsonNode;
import org.springframework.data.annotation.Id;
import org.springframework.data.mongodb.core.mapping.Document;
/**
* Ein Adressdaten-Eintrag Kunde, Kurier oder Telefonbuch-Eintrag , wie er
* in der MongoDB liegt (Sammlung {@code addresses}) und daraus auch wieder
* gelesen wird.
*
* <p>Die Kennung {@code id} vergibt die MongoDB. Die <b>Rufnummer erkennt die
* Dublette</b>: Kommt ein Eintrag zu einer bekannten Nummer erneut herein, wird
* er aktualisiert statt verdoppelt (siehe {@link ArchiveService#saveAddresses}).
* Einträge ohne Rufnummer werden deshalb übergangen ohne sie ließe sich die
* Dublette nicht erkennen.
*
* <p>{@link #manyFrom(JsonNode)} versteht jede der gelieferten Formen:
* <ul>
* <li>Webhook, ein Eintrag je Aufruf:
* {@code {"name":"SYSGEN GmbH","number":"+491602107449","description":"Kunde | …",
* "role":"customer","csc_id":100164,"job_ids":[21891263,21891262],"url":"https://…"}}
* die Felder ab {@code role} kann das Fremdsystem auch weglassen</li>
* <li>eine JSON-Liste solcher Einträge</li>
* <li>die Adress-Nachricht der SwyxTray-App (nur Name, Rufnummer, Beschreibung):
* {@code {"addresses":[{"name":…,"number":…,"description":…}, …],"type":"addresses"}}</li>
* </ul>
*
* @param id Kennung des Dokuments, vergibt die MongoDB
* @param number Rufnummer, so wie sie gewählt wird erkennt die Dublette
* @param name Anzeigename, z. B. "SYSGEN GmbH" oder "Rainer Peters (HH1003)"
* @param description Zusatz, z. B. "Kunde | SYSGEN, SYSTEME UND | NL Bremen"
* oder "Kurier | NL Hamburg | PKW"
* @param role Rolle im Fremdsystem, z. B. "customer"
* @param cscId Kundenkennung des Fremdsystems ({@code csc_id})
* @param jobIds Kennungen der zugehörigen Jobs ({@code job_ids}), passend
* zur Kennung in der Sammlung {@code jobs}
* @param url Sprungadresse des Fremdsystems zu diesem Eintrag, z. B.
* der TAPI-Wrapper zur Rufnummer
*/
@Document("addresses")
public record AddressEntry(
@Id String id,
String number,
String name,
String description,
String role,
Integer cscId,
List<Long> jobIds,
String url) {
/**
* Liest aus beliebigem JSON alle Adressdaten-Einträge heraus (siehe die
* Formen oben). Einträge ohne Rufnummer werden übergangen; JSON ohne
* Adressdaten ergibt eine leere Liste.
*/
public static List<AddressEntry> manyFrom(JsonNode json) {
List<AddressEntry> entries = new ArrayList<>();
if (json == null) return entries;
// Liste direkt, oder verpackt wie in der SwyxTray-Nachricht.
JsonNode list = json.isArray() ? json : json.path("addresses");
if (list.isArray()) {
for (JsonNode element : list) collect(element, entries);
} else {
collect(json, entries);
}
return entries;
}
private static void collect(JsonNode node, List<AddressEntry> entries) {
if (!node.isObject()) return;
String number = text(node, "number");
if (number == null) return;
entries.add(new AddressEntry(
null,
number,
text(node, "name"),
text(node, "description"),
text(node, "role"),
integer(node, "csc_id"),
longs(node, "job_ids"),
text(node, "url")));
}
/** Leere Zeichenketten schickt die SwyxTray-App als {@code ""}; hier werden sie {@code null}. */
private static String text(JsonNode node, String field) {
JsonNode value = node.path(field);
if (!value.isString()) return null;
String text = value.stringValue("");
return text.isBlank() ? null : text;
}
/** Ganzzahl der Nutzlast; alles andere wird {@code null}. */
private static Integer integer(JsonNode node, String field) {
JsonNode value = node.path(field);
return value.isIntegralNumber() ? value.intValue() : null;
}
/** Liste von Kennungen; fehlt sie oder ist sie keine Liste, wird sie {@code null}. */
private static List<Long> longs(JsonNode node, String field) {
JsonNode value = node.path(field);
if (!value.isArray()) return null;
List<Long> values = new ArrayList<>();
for (JsonNode element : value) {
if (element.isIntegralNumber()) values.add(element.longValue());
}
return values;
}
}
@@ -0,0 +1,155 @@
package de.appcreation.swyxweb.storage;
import java.util.List;
import java.util.concurrent.ExecutorService;
import java.util.regex.Pattern;
import java.util.concurrent.Executors;
import jakarta.annotation.PreDestroy;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.data.domain.Sort;
import org.springframework.data.mongodb.core.MongoTemplate;
import org.springframework.data.mongodb.core.query.Criteria;
import org.springframework.data.mongodb.core.query.Query;
import org.springframework.data.mongodb.core.query.Update;
import org.springframework.stereotype.Service;
/**
* Legt Adress- und Anrufdaten in der MongoDB ab (siehe
* {@code spring.mongodb.uri} in der application.properties) und liest die
* Adressdaten daraus wieder heraus.
*
* <p>Geschrieben wird <b>nebenbei und best-effort</b>: Die Aufrufe kehren
* sofort zurück, geschrieben wird auf einem eigenen Thread. Ist die Datenbank
* nicht erreichbar, bleibt es bei einer Warnung im Log Webhook-Quittung und
* Anrufanzeige hängen bewusst nicht an der Datenbank.
*/
@Service
public class ArchiveService {
private static final Logger log = LoggerFactory.getLogger(ArchiveService.class);
private final MongoTemplate mongo;
/** Ein Thread reicht: Er hält die Reihenfolge und begrenzt die Fehlversuche. */
private final ExecutorService worker = Executors.newSingleThreadExecutor(runnable -> {
Thread thread = new Thread(runnable, "mongo-archive");
thread.setDaemon(true);
return thread;
});
public ArchiveService(MongoTemplate mongo) {
this.mongo = mongo;
}
@PreDestroy
void shutdown() {
worker.shutdownNow();
}
/**
* Legt Adressdaten-Einträge in der Sammlung {@code addresses} ab alle
* Quellen (Webhooks Kunden/Kuriere, SwyxTray-Telefonbuch) in derselben
* Sammlung, je Eintrag nur Rufnummer, Name und Beschreibung. Die Sammlung
* ist ein Verzeichnis, kein Protokoll: Ein Eintrag zu einer bekannten
* Rufnummer wird aktualisiert statt verdoppelt die SwyxTray-App schickt
* bei jeder Cache-Änderung das ganze Telefonbuch.
*/
public void saveAddresses(List<AddressEntry> entries) {
if (entries.isEmpty()) return;
// Upsert über die Rufnummer: aktualisiert den bekannten Eintrag oder
// legt ihn an; die Dokument-Kennung vergibt dabei die MongoDB.
submit("Adressdaten", () -> entries.forEach(entry -> mongo.upsert(
new Query(Criteria.where("number").is(entry.number())),
new Update()
.set("name", entry.name())
.set("description", entry.description())
.set("role", entry.role())
.set("cscId", entry.cscId())
.set("jobIds", entry.jobIds())
.set("url", entry.url())
// Altbestand angleichen: Diese Felder wurden früher
// mitgeschrieben und sollen aus der Sammlung verschwinden.
.unset("receivedAt")
.unset("source"),
AddressEntry.class)));
}
/**
* Die gespeicherten Adressdaten, nach Namen sortiert gelesen direkt aus
* der MongoDB, ohne Zwischenstand im Backend oder Browser. Ein Suchbegriff
* wird als Teilzeichenkette in Name, Rufnummer und Beschreibung gesucht,
* ohne Beachtung der Groß-/Kleinschreibung.
*/
public List<AddressEntry> addresses(String query) {
Query find = new Query().with(Sort.by("name"));
if (query != null && !query.isBlank()) {
// Der Begriff ist Text, kein Muster Sonderzeichen werden zitiert.
String regex = Pattern.quote(query.trim());
find.addCriteria(new Criteria().orOperator(
Criteria.where("name").regex(regex, "i"),
Criteria.where("number").regex(regex, "i"),
Criteria.where("description").regex(regex, "i")));
}
return mongo.find(find, AddressEntry.class);
}
/**
* Legt einen Job in der Sammlung {@code jobs} ab. Die Kennung des
* Fremdsystems ist der Schlüssel ein erneut geschickter Job ersetzt
* seinen alten Stand.
*/
public void saveJob(JobEntry job) {
submit("Jobdaten", () -> mongo.save(job));
}
/**
* Der Job zur Kennung des Fremdsystems gelesen direkt aus der Sammlung
* {@code jobs}, wie {@link #addresses} ohne Zwischenstand im Backend.
*
* @return der Job, oder {@code null} wenn die Kennung nicht abgelegt ist
*/
public JobEntry job(long id) {
return mongo.findById(id, JobEntry.class);
}
/**
* Löscht alle Jobs, deren {@code ordertime} vor der Grenze liegt (Vergleich
* als Text, siehe {@link JobCleanupService}). Läuft anders als die
* Schreibzugriffe direkt: Der Aufrufer ist der nächtliche Zeitplan, der auf
* die Anzahl wartet und Fehler selbst behandelt bekommt hier genügt die
* Warnung im Log.
*
* @return wie viele Jobs gelöscht wurden; 0 auch, wenn die Datenbank nicht
* erreichbar war
*/
public long removeJobsBefore(String cutoffOrdertime) {
try {
return mongo.remove(
new Query(Criteria.where("ordertime").lt(cutoffOrdertime)),
JobEntry.class).getDeletedCount();
} catch (RuntimeException e) {
log.warn("MongoDB: alte Jobs nicht gelöscht ({}).", e.getMessage());
return 0;
}
}
/** Legt Anruf-Ereignisse in der Sammlung {@code calls} ab. */
public void saveCalls(List<StoredCall> calls) {
submit("Anrufdaten", () -> calls.forEach(mongo::save));
}
private void submit(String what, Runnable write) {
worker.execute(() -> {
try {
write.run();
} catch (RuntimeException e) {
// Nur die Meldung, kein Stacktrace: Bei nicht erreichbarer Datenbank
// käme sonst zu jedem Ereignis ein langer Treiber-Auszug ins Log.
log.warn("MongoDB: {} nicht gespeichert ({}).", what, e.getMessage());
}
});
}
}
@@ -0,0 +1,58 @@
package de.appcreation.swyxweb.storage;
import java.time.ZoneId;
import java.time.ZonedDateTime;
import java.time.format.DateTimeFormatter;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.scheduling.annotation.Scheduled;
import org.springframework.stereotype.Service;
/**
* Räumt die Sammlung {@code jobs} auf: Jede Nacht um 0 Uhr werden die Jobs
* gelöscht, deren {@code ordertime} mehr als vier Wochen zurückliegt.
*
* <p>Die {@code ordertime} liegt als ISO-8601-Zeichenkette mit Zeitzonenversatz
* in der Datenbank (z. B. {@code 2026-07-31T16:30:00+02:00}, siehe
* {@link JobEntry}). Verglichen wird als Text gegen eine Grenze im selben
* Format ISO-8601 sortiert als Text richtig; die wenigen Stunden Unschärfe
* durch unterschiedliche Zeitzonenversätze fallen bei einer Vier-Wochen-Grenze
* nicht ins Gewicht. Jobs ohne {@code ordertime} bleiben unangetastet ihr
* Alter ist nicht zu beurteilen.
*/
@Service
public class JobCleanupService {
private static final Logger log = LoggerFactory.getLogger(JobCleanupService.class);
/** „Mehr als vier Wochen in der Vergangenheit" die Grenze der Bereinigung. */
public static final int MAX_AGE_WEEKS = 4;
/** Zeitzone des Zeitplans wie der Grenze: „0 Uhr" heißt 0 Uhr deutscher Zeit. */
public static final ZoneId ZONE = ZoneId.of("Europe/Berlin");
private final ArchiveService archive;
public JobCleanupService(ArchiveService archive) {
this.archive = archive;
}
/** Jede Nacht um 0 Uhr; verpasste Läufe (Backend aus) werden nicht nachgeholt. */
@Scheduled(cron = "0 0 0 * * *", zone = "Europe/Berlin")
public void purgeOldJobs() {
String cutoff = cutoffOrdertime(ZonedDateTime.now(ZONE));
long removed = archive.removeJobsBefore(cutoff);
if (removed > 0) {
log.info("Job-Bereinigung: {} Job(s) mit ordertime vor {} gelöscht.", removed, cutoff);
} else {
log.debug("Job-Bereinigung: nichts zu löschen (Grenze {}).", cutoff);
}
}
/** Die Grenze im Format der {@code ordertime}, z. B. {@code 2026-07-31T00:00:00+02:00}. */
public static String cutoffOrdertime(ZonedDateTime now) {
return now.minusWeeks(MAX_AGE_WEEKS)
.format(DateTimeFormatter.ofPattern("yyyy-MM-dd'T'HH:mm:ssxxx"));
}
}
@@ -0,0 +1,149 @@
package de.appcreation.swyxweb.storage;
import java.time.Instant;
import java.util.List;
import com.fasterxml.jackson.annotation.JsonAlias;
import com.fasterxml.jackson.annotation.JsonIgnoreProperties;
import com.fasterxml.jackson.annotation.JsonProperty;
import tools.jackson.databind.JsonNode;
import tools.jackson.databind.ObjectMapper;
import org.springframework.data.annotation.Id;
import org.springframework.data.mongodb.core.mapping.Document;
import org.springframework.data.mongodb.core.mapping.Field;
/**
* Ein Job (Kurierauftrag), wie ihn das Fremdsystem über den Webhook schickt
* und wie er in der MongoDB liegt (Sammlung {@code jobs}).
*
* <p>Erkannt am Feld {@code "type":"job"} (siehe {@link #isJob}), gefüllt über
* {@link #from}. Die Kennung des Fremdsystems ist zugleich der Schlüssel in
* der Sammlung ein erneut geschickter Job (z. B. nach einer Änderung, siehe
* {@code modified}) ersetzt also seinen alten Stand.
*
* <p>Die Zeitangaben bleiben die Zeichenketten der Nutzlast
* (z. B. {@code 2026-08-13T13:35:27}, teils auch mit Zeitzonenversatz):
* ISO-8601 sortiert auch als Text richtig, und die Schreibweise des
* Fremdsystems geht nicht verloren.
*
* <p>Die Rufnummern hießen in einer älteren Form der Nutzlast {@code number}
* statt {@code phone} beide Schreibweisen werden angenommen.
*
* @param id Kennung des Fremdsystems, zugleich Schlüssel der Sammlung
* @param receivedAt Eingangszeit im Backend, beim letzten Eingang dieses Jobs
* @param url Sprungadresse des Fremdsystems zur Auftragsansicht
* @param state Zustand des Jobs im Fremdsystem
* @param ordertime Auftragszeit, z. B. "2026-08-13T13:35:27"
* @param orderdate Auftragsdatum, z. B. "2026-08-13"
* @param modified letzte Änderung im Fremdsystem
* @param finished wann der Job abgeschlossen wurde, sonst leer
* @param vehicle Fahrzeugart, z. B. "Transporter XL"
* @param service gebuchte Leistung, kann fehlen
* @param canceled ist der Job storniert?
* @param global bundesweite Vermittlung?
* @param customer der beauftragende Kunde
* @param courier der ausführende Kurier, solange keiner zugeteilt ist leer
* @param tours die Stationen des Jobs, per {@code sort} geordnet
*/
@Document("jobs")
@JsonIgnoreProperties(ignoreUnknown = true)
public record JobEntry(
@Id long id,
Instant receivedAt,
String url,
Integer state,
String ordertime,
String orderdate,
String modified,
String finished,
String vehicle,
String service,
Boolean canceled,
Boolean global,
Customer customer,
Courier courier,
List<Tour> tours) {
/**
* Der beauftragende Kunde.
*
* @param cscId Kundenkennung des Fremdsystems ({@code csc_id})
* @param name Firmenname
* @param hq Niederlassung, z. B. "Bremen"
* @param phone Rufnummer
*/
@JsonIgnoreProperties(ignoreUnknown = true)
public record Customer(
@JsonProperty("csc_id") Integer cscId,
String name,
String hq,
@JsonAlias("number") String phone) {
}
/**
* Der ausführende Kurier.
*
* @param crId Kurierkennung des Fremdsystems ({@code cr_id})
* @param sid Kurzkennung, z. B. "B1006"
* @param name Anzeigename
* @param phone Rufnummer
*/
@JsonIgnoreProperties(ignoreUnknown = true)
public record Courier(
@JsonProperty("cr_id") Integer crId,
String sid,
String name,
@JsonAlias("number") String phone) {
}
/**
* Eine Station des Jobs.
*
* @param id Kennung des Fremdsystems
* @param sort Reihenfolge innerhalb des Jobs, 1-basiert
* @param state Zustand der Station
* @param mode Art der Station, z. B. "pu" (Abholung) oder "del" (Zustellung)
* @param comp Firma an der Station
* @param person Ansprechperson
* @param phone Rufnummer an der Station, kann fehlen
* @param street Straße und Hausnummer
* @param zip Postleitzahl
* @param city Ort
* @param com Bemerkung
* @param remark Hinweise des Fremdsystems, mehrzeilig (Referenzen, Maße …)
* @param finished wann die Station abgeschlossen wurde, sonst leer
*/
@JsonIgnoreProperties(ignoreUnknown = true)
public record Tour(
// Ausdrücklich als "id" ablegen sonst macht Spring Data auch im
// Unterdokument ein "_id" daraus.
@Field("id") Long id,
Integer sort,
Integer state,
String mode,
String comp,
String person,
@JsonAlias("number") String phone,
String street,
String zip,
String city,
String com,
String remark,
String finished) {
}
/** Trägt die Nutzlast das Kennzeichen {@code "type":"job"}? */
public static boolean isJob(JsonNode json) {
return json != null && "job".equals(json.path("type").stringValue(""));
}
/** Füllt die Klasse aus der Webhook-Nutzlast und stempelt die Eingangszeit. */
public static JobEntry from(JsonNode json, ObjectMapper mapper, Instant receivedAt) {
JobEntry job = mapper.convertValue(json, JobEntry.class);
return new JobEntry(job.id(), receivedAt, job.url(), job.state(), job.ordertime(),
job.orderdate(), job.modified(), job.finished(), job.vehicle(), job.service(),
job.canceled(), job.global(), job.customer(), job.courier(), job.tours());
}
}
@@ -0,0 +1,37 @@
package de.appcreation.swyxweb.storage;
import java.time.Instant;
import org.springframework.data.annotation.Id;
import org.springframework.data.mongodb.core.mapping.Document;
/**
* Ein Anruf-Ereignis, wie es in der MongoDB liegt (Sammlung {@code calls}).
* Die Felder entsprechen dem {@code CallEvent} des Frontends die Ereignisse
* entstehen dort aus dem Vergleich zweier SwyxTray-Snapshots und kommen über
* {@code POST /api/calls} hierher.
*
* @param id von MongoDB vergeben
* @param receivedAt Eingangszeit im Backend
* @param line 1-basierte Leitungsnummer, wie in SwyxIt!
* @param event incoming, outgoing, connected oder ended
* @param direction bei connected/ended die ursprüngliche Richtung
* @param peer fertige Anzeigeform der App, z. B. "Muster GmbH (+493012345)"
* @param peerNumber Rufnummer der Gegenstelle
* @param peerName Name der Gegenstelle
* @param stateText Klartext der App zum Leitungszustand, z. B. "klingelt"
* @param state Rohzustand der App, z. B. "LSRinging"
*/
@Document("calls")
public record StoredCall(
@Id String id,
Instant receivedAt,
Integer line,
String event,
String direction,
String peer,
String peerNumber,
String peerName,
String stateText,
String state) {
}
@@ -0,0 +1,69 @@
package de.appcreation.swyxweb.web;
import java.util.List;
import tools.jackson.databind.JsonNode;
import org.springframework.http.HttpStatus;
import org.springframework.http.MediaType;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.ResponseStatus;
import org.springframework.web.bind.annotation.RestController;
import org.springframework.web.server.ResponseStatusException;
import de.appcreation.swyxweb.storage.AddressEntry;
import de.appcreation.swyxweb.storage.ArchiveService;
/**
* Adressdaten in der MongoDB: annehmen und wieder herausgeben.
*
* <p>{@code POST} nimmt die Adress-Nachricht der SwyxTray-App entgegen die
* WebSocket-Verbindung zur App hält nur der Browser, deshalb meldet die
* Startseite den Adress-Cache hierher. Angenommen wird jede Form, die
* {@link AddressEntry#manyFrom} versteht; die Adressdaten fremder Systeme
* kommen dagegen normalerweise über {@code POST /api/webhook} herein.
*
* <p>{@code GET} liefert die gespeicherten Einträge aus der MongoDB zurück,
* nach Namen sortiert.
*/
@RestController
@RequestMapping("/api/addresses")
public class AddressController {
private final ArchiveService archive;
public AddressController(ArchiveService archive) {
this.archive = archive;
}
/**
* Nimmt Adressdaten an. 202, weil nur die Annahme bestätigt wird
* geschrieben wird nebenbei (siehe {@link ArchiveService}).
*
* @return wie viele Einträge in der Nutzlast steckten
*/
@PostMapping(consumes = MediaType.APPLICATION_JSON_VALUE)
@ResponseStatus(HttpStatus.ACCEPTED)
public int receive(@RequestBody JsonNode payload) {
List<AddressEntry> entries = AddressEntry.manyFrom(payload);
if (entries.isEmpty()) {
throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "Keine Adressdaten in der Nutzlast.");
}
archive.saveAddresses(entries);
return entries.size();
}
/**
* Die gespeicherten Adressdaten gelesen direkt aus der MongoDB, jede
* Abfrage frisch. Mit {@code ?q=…} wird in der Datenbank gesucht
* (Teilzeichenkette in Name, Rufnummer und Beschreibung).
*/
@GetMapping
public List<AddressEntry> list(@RequestParam(name = "q", required = false) String query) {
return archive.addresses(query);
}
}
@@ -0,0 +1,75 @@
package de.appcreation.swyxweb.web;
import java.time.Instant;
import java.util.List;
import org.springframework.http.HttpStatus;
import org.springframework.http.MediaType;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.ResponseStatus;
import org.springframework.web.bind.annotation.RestController;
import org.springframework.web.server.ResponseStatusException;
import de.appcreation.swyxweb.storage.ArchiveService;
import de.appcreation.swyxweb.storage.StoredCall;
/**
* Nimmt Anrufdaten von der Startseite entgegen und legt sie in der MongoDB ab.
*
* <p>Die Anruf-Ereignisse entstehen im Browser: Die SwyxTray-App kennt keine
* Ereignisnachrichten, jede Änderung kommt als vollständiger Snapshot, und erst
* der Vergleich zweier Snapshots im Frontend ergibt "klingelt", "verbunden",
* "beendet". Das Backend sieht diese Verbindung nicht deshalb meldet die
* Seite die Ereignisse hierher.
*/
@RestController
@RequestMapping("/api/calls")
public class CallController {
/** So viele Ereignisse pro Aufruf; ein Snapshot-Vergleich liefert höchstens eine Handvoll. */
private static final int MAX_EVENTS = 100;
private final ArchiveService archive;
public CallController(ArchiveService archive) {
this.archive = archive;
}
/** Ein Anruf-Ereignis, wie es das Frontend schickt Felder wie im CallEvent. */
public record CallEventBody(
Integer line,
String event,
String direction,
String peer,
String peerNumber,
String peerName,
String stateText,
String state) {
}
/**
* Nimmt eine Liste von Ereignissen an. 202, weil nur die Annahme bestätigt
* wird geschrieben wird nebenbei (siehe {@link ArchiveService}).
*/
@PostMapping(consumes = MediaType.APPLICATION_JSON_VALUE)
@ResponseStatus(HttpStatus.ACCEPTED)
public int receive(@RequestBody List<CallEventBody> events) {
if (events == null || events.isEmpty()) {
throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "Leere Ereignisliste.");
}
if (events.size() > MAX_EVENTS) {
throw new ResponseStatusException(
HttpStatus.CONTENT_TOO_LARGE,
"Zu viele Ereignisse (%d, erlaubt sind %d).".formatted(events.size(), MAX_EVENTS));
}
Instant receivedAt = Instant.now();
archive.saveCalls(events.stream()
.map(e -> new StoredCall(null, receivedAt, e.line(), e.event(), e.direction(),
e.peer(), e.peerNumber(), e.peerName(), e.stateText(), e.state()))
.toList());
return events.size();
}
}
@@ -0,0 +1,39 @@
package de.appcreation.swyxweb.web;
import org.springframework.http.HttpStatus;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import org.springframework.web.server.ResponseStatusException;
import de.appcreation.swyxweb.storage.ArchiveService;
import de.appcreation.swyxweb.storage.JobEntry;
/**
* Jobs aus der MongoDB herausgeben. Herein kommen sie über den Webhook (siehe
* WebhookController); hier holt sich der Browser einen einzelnen Job zu einer
* Kennung aus {@code job_ids} eines Kunden etwa um beim Anruf-Popup die
* Sprungadresse ({@code url}) des Jobs zu öffnen.
*/
@RestController
@RequestMapping("/api/jobs")
public class JobController {
private final ArchiveService archive;
public JobController(ArchiveService archive) {
this.archive = archive;
}
/** Der Job zur Kennung des Fremdsystems; 404, wenn er nicht abgelegt ist. */
@GetMapping("/{id}")
public JobEntry get(@PathVariable long id) {
JobEntry job = archive.job(id);
if (job == null) {
throw new ResponseStatusException(HttpStatus.NOT_FOUND,
"Kein Job mit der Kennung " + id + " in der Ablage.");
}
return job;
}
}
@@ -1,7 +1,9 @@
package de.appcreation.swyxweb.web; package de.appcreation.swyxweb.web;
import de.appcreation.swyxweb.config.MockProfile;
import de.appcreation.swyxweb.websocket.LineState; import de.appcreation.swyxweb.websocket.LineState;
import de.appcreation.swyxweb.websocket.SwyxTrayMockHandler; import de.appcreation.swyxweb.websocket.SwyxTrayMockHandler;
import org.springframework.context.annotation.Profile;
import org.springframework.web.bind.annotation.PostMapping; import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam; import org.springframework.web.bind.annotation.RequestParam;
@@ -9,10 +11,16 @@ import org.springframework.web.bind.annotation.RestController;
/** /**
* Auslöser für den SwyxTray-Mock: simuliert einen eingehenden Anruf, damit sich * Auslöser für den SwyxTray-Mock: simuliert einen eingehenden Anruf, damit sich
* die Anrufmeldung der Startseite ohne echte Telefonanlage prüfen lässt. * die Anrufmeldung der Startseite ohne echte Telefonanlage prüfen lässt, und
* schaltet den Adress-Cache um.
* *
* <pre>curl -X POST "http://localhost:8080/api/mock/incoming-call?number=%2B493012345&name=Muster%20GmbH"</pre> * <pre>curl -X POST "http://localhost:8080/api/mock/incoming-call?number=%2B493012345&name=Muster%20GmbH"
* curl -X POST "http://localhost:8080/api/mock/address-cache?filled=false"</pre>
*
* <p>Nur mit dem Profil {@link MockProfile#NAME} vorhanden im Normalbetrieb
* gibt es {@code /api/mock/**} nicht.
*/ */
@Profile(MockProfile.NAME)
@RestController @RestController
@RequestMapping("/api/mock") @RequestMapping("/api/mock")
public class MockController { public class MockController {
@@ -29,4 +37,18 @@ public class MockController {
@RequestParam(required = false) String name) { @RequestParam(required = false) String name) {
return mock.simulateIncomingCall(number, name); return mock.simulateIncomingCall(number, name);
} }
/**
* Füllt oder leert den Adress-Cache des Mocks. Mit leerem Cache verhält er
* sich wie die zurzeit laufende Anlage, und der Bereich „Adressdaten" muss
* auf die Einzelabfragen ausweichen.
*
* <pre>curl -X POST "http://localhost:8080/api/mock/address-cache?filled=false"</pre>
*
* @return Anzahl der Einträge im Cache danach
*/
@PostMapping("/address-cache")
public int addressCache(@RequestParam(defaultValue = "true") boolean filled) {
return mock.setAddressCacheFilled(filled);
}
} }
@@ -0,0 +1,176 @@
package de.appcreation.swyxweb.web;
import java.time.Instant;
import java.util.List;
import tools.jackson.databind.JsonNode;
import tools.jackson.databind.ObjectMapper;
import org.springframework.http.HttpHeaders;
import org.springframework.http.HttpStatus;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.DeleteMapping;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestHeader;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import org.springframework.web.server.ResponseStatusException;
import org.springframework.web.servlet.mvc.method.annotation.SseEmitter;
import de.appcreation.swyxweb.config.WebhookProperties;
import de.appcreation.swyxweb.webhook.WebhookEvent;
import de.appcreation.swyxweb.webhook.WebhookService;
/**
* Webhook für Adress- und Jobdaten aus einem fremden System.
*
* <p>Je Datenart eine eigene, voneinander unabhängige URL die URL legt fest,
* wie die Nutzlast in der MongoDB abgelegt wird, ein Kennzeichen in der
* Nutzlast ist nicht nötig:
*
* <pre>curl -X POST https://swyxweb.appcreation.de/api/webhook/kunden \
* -H "Content-Type: application/json" \
* -H "X-Webhook-Token: …" \
* -d '{"name":"SYSGEN GmbH","number":"+49421409660","description":"Kunde | …"}'
*
* curl -X POST https://swyxweb.appcreation.de/api/webhook/kuriere \
* -H "Content-Type: application/json" \
* -H "X-Webhook-Token: …" \
* -d '{"name":"Rainer Peters (HH1003)","number":"+49171677xxxx","description":"Kurier | …"}'
*
* curl -X POST https://swyxweb.appcreation.de/api/webhook/jobs \
* -H "Content-Type: application/json" \
* -H "X-Webhook-Token: …" \
* -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
* <b>beliebiges</b> JSON an {@code "type":"job"} wird als Job abgelegt, alles
* andere als Adressdaten. Die Startseite zeigt jede Nachricht im Bereich
* „Webhook" unverändert an; das aufrufende System muss sich an kein festes
* Schema halten.
*
* <p>Der Weg zum Browser läuft über {@code GET /api/webhook/events}
* (Server-Sent Events), siehe {@link WebhookService}.
*/
@RestController
@RequestMapping("/api/webhook")
public class WebhookController {
private final WebhookService service;
private final WebhookProperties properties;
private final ObjectMapper mapper;
public WebhookController(WebhookService service, WebhookProperties properties, ObjectMapper mapper) {
this.service = service;
this.properties = properties;
this.mapper = mapper;
}
/** Quittung an das aufrufende System. */
public record Receipt(long id, Instant receivedAt) {
}
/** Generischer Webhook: Was die Nutzlast ist, wird ihr angesehen. */
@PostMapping(consumes = MediaType.APPLICATION_JSON_VALUE)
public Receipt receive(
@RequestBody JsonNode payload,
@RequestHeader(name = "X-Webhook-Token", required = false) String token) {
return accept(payload, token, null);
}
/** Adressdaten der Kunden; abgelegt in der Sammlung {@code addresses}. */
@PostMapping(path = "/kunden", consumes = MediaType.APPLICATION_JSON_VALUE)
public Receipt receiveKunden(
@RequestBody JsonNode payload,
@RequestHeader(name = "X-Webhook-Token", required = false) String token) {
return accept(payload, token, "kunden");
}
/** Adressdaten der Kuriere; abgelegt in der Sammlung {@code addresses}. */
@PostMapping(path = "/kuriere", consumes = MediaType.APPLICATION_JSON_VALUE)
public Receipt receiveKuriere(
@RequestBody JsonNode payload,
@RequestHeader(name = "X-Webhook-Token", required = false) String token) {
return accept(payload, token, "kuriere");
}
/** Jobs (Kurieraufträge); abgelegt in der Sammlung {@code jobs}. */
@PostMapping(path = "/jobs", consumes = MediaType.APPLICATION_JSON_VALUE)
public Receipt receiveJobs(
@RequestBody JsonNode payload,
@RequestHeader(name = "X-Webhook-Token", required = false) String token) {
return accept(payload, token, "jobs");
}
private Receipt accept(JsonNode payload, String token, String channel) {
requireToken(token);
if (payload == null || payload.isNull()) {
throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "Leere Nutzlast.");
}
// Die Größe wird erst nach dem Parsen geprüft; das Feld begrenzt vor allem,
// was sich im Verlauf ansammeln und im Browser anzeigen lässt.
int size = mapper.writeValueAsString(payload).getBytes(java.nio.charset.StandardCharsets.UTF_8).length;
if (size > properties.maxSize()) {
throw new ResponseStatusException(
HttpStatus.CONTENT_TOO_LARGE,
"Nutzlast ist zu groß (%d Byte, erlaubt sind %d).".formatted(size, properties.maxSize()));
}
WebhookEvent event = service.record(payload, channel);
return new Receipt(event.id(), event.receivedAt());
}
/**
* Strom der eingehenden Nachrichten für die Startseite. Der Browser schickt
* beim Wiederverbinden von selbst {@code Last-Event-ID} mit; alles Jüngere
* wird dann nachgeliefert.
*/
@GetMapping(path = "/events", produces = MediaType.TEXT_EVENT_STREAM_VALUE)
public ResponseEntity<SseEmitter> events(
@RequestHeader(name = "Last-Event-ID", required = false) String lastEventId) {
Long since = null;
try {
if (lastEventId != null && !lastEventId.isBlank()) since = Long.parseLong(lastEventId.trim());
} catch (NumberFormatException e) {
// Unbrauchbare Id: dann eben ohne Nachlieferung, statt den Aufruf abzulehnen.
since = null;
}
return ResponseEntity.ok()
.contentType(MediaType.TEXT_EVENT_STREAM)
.cacheControl(org.springframework.http.CacheControl.noCache().mustRevalidate())
// Ohne diesen Kopf sammelt nginx die Ereignisse im Puffer und gibt sie erst
// aus, wenn genug beisammen ist die Nachricht erschiene dann verspätet
// oder gar nicht. nginx wertet ihn aus, andere Proxys ignorieren ihn.
.header("X-Accel-Buffering", "no")
.header(HttpHeaders.CONNECTION, "keep-alive")
.body(service.subscribe(since));
}
/** Verlauf, damit ein später geöffneter Bereich die vorigen Nachrichten zeigt. */
@GetMapping("/history")
public List<WebhookEvent> history() {
return service.history();
}
/** Leert den Verlauf; die Startseite bietet das als „Leeren" an. */
@DeleteMapping("/history")
public int clear() {
return service.clear();
}
private void requireToken(String token) {
if (!properties.isSecured()) return;
// Konstante Laufzeit ist hier zweitrangig; der Vergleich läuft gegen ein
// Geheimnis fester Länge und hinter einer Netzgrenze.
if (token == null || !token.equals(properties.token())) {
throw new ResponseStatusException(HttpStatus.UNAUTHORIZED, "Ungültiges oder fehlendes X-Webhook-Token.");
}
}
}
@@ -0,0 +1,19 @@
package de.appcreation.swyxweb.webhook;
import java.time.Instant;
import tools.jackson.databind.JsonNode;
/**
* Eine über den Webhook eingegangene Nachricht.
*
* @param id fortlaufend ab 1; der Browser meldet sie beim Wiederverbinden
* als {@code Last-Event-ID} zurück, damit nichts verloren geht
* @param receivedAt Eingangszeit im Backend
* @param channel über welche URL die Nachricht kam: {@code kunden},
* {@code kuriere} oder {@code jobs}; {@code null} beim
* generischen {@code POST /api/webhook}
* @param payload das empfangene JSON, unverändert
*/
public record WebhookEvent(long id, Instant receivedAt, String channel, JsonNode payload) {
}
@@ -0,0 +1,251 @@
package de.appcreation.swyxweb.webhook;
import java.io.IOException;
import java.time.Instant;
import java.util.ArrayDeque;
import java.util.ArrayList;
import java.util.Deque;
import java.util.List;
import java.util.concurrent.CopyOnWriteArrayList;
import java.util.concurrent.Executors;
import java.util.concurrent.ScheduledExecutorService;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.atomic.AtomicLong;
import jakarta.annotation.PreDestroy;
import tools.jackson.databind.JsonNode;
import tools.jackson.databind.ObjectMapper;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.stereotype.Service;
import org.springframework.web.servlet.mvc.method.annotation.SseEmitter;
import de.appcreation.swyxweb.config.WebhookProperties;
import de.appcreation.swyxweb.storage.AddressEntry;
import de.appcreation.swyxweb.storage.ArchiveService;
import de.appcreation.swyxweb.storage.JobEntry;
/**
* Nimmt die Nachrichten des Webhooks entgegen und verteilt sie an die offenen
* Browser.
*
* <p>Der Weg zum Browser sind <b>Server-Sent Events</b>: Die Startseite hält
* eine {@code EventSource} auf {@code /api/webhook/events} offen. Das ist
* einseitig genau das, was hier gebraucht wird , läuft über dieselbe
* HTTPS-Verbindung wie die Seite und wird vom Browser nach einem Abbruch von
* selbst wieder aufgebaut. Die WebSocket-Verbindung der Startseite gehört
* dagegen der SwyxTray-App und steht dafür nicht zur Verfügung.
*
* <p>Die letzten {@link WebhookProperties#history()} Nachrichten bleiben im
* Speicher. Das hat zwei Gründe: Ein erst später geöffneter Bereich zeigt
* trotzdem, was vorher ankam, und nach einem Verbindungsabbruch lässt sich
* anhand der {@code Last-Event-ID} nachliefern, was in der Zwischenzeit
* eingegangen ist. Über einen Neustart hinaus wird nichts aufgehoben.
*/
@Service
public class WebhookService {
private static final Logger log = LoggerFactory.getLogger(WebhookService.class);
/** Ein Doppelpunkt-Kommentar hält die Verbindung durch Proxys hindurch offen. */
private static final long HEARTBEAT_SECONDS = 25;
private final WebhookProperties properties;
private final ObjectMapper mapper;
private final ArchiveService archive;
private final AtomicLong nextId = new AtomicLong(1);
/** Jüngste Nachricht zuletzt. Zugriff immer unter dem Monitor dieses Feldes. */
private final Deque<WebhookEvent> events = new ArrayDeque<>();
private final List<SseEmitter> subscribers = new CopyOnWriteArrayList<>();
private final ScheduledExecutorService heartbeat =
Executors.newSingleThreadScheduledExecutor(runnable -> {
Thread thread = new Thread(runnable, "webhook-heartbeat");
thread.setDaemon(true);
return thread;
});
public WebhookService(WebhookProperties properties, ObjectMapper mapper, ArchiveService archive) {
this.properties = properties;
this.mapper = mapper;
this.archive = archive;
heartbeat.scheduleWithFixedDelay(
this::sendHeartbeat, HEARTBEAT_SECONDS, HEARTBEAT_SECONDS, TimeUnit.SECONDS);
if (properties.isSecured()) {
log.info("Webhook: Aufrufe müssen den Kopf X-Webhook-Token mitschicken.");
} else {
log.warn("Webhook: app.webhook.token ist nicht gesetzt POST /api/webhook nimmt "
+ "jeden Aufruf an. Für eine öffentlich erreichbare Instanz ein Token setzen.");
}
}
@PreDestroy
void shutdown() {
heartbeat.shutdownNow();
subscribers.forEach(emitter -> emitter.complete());
subscribers.clear();
}
/**
* Wie {@link #record(JsonNode, String)}, für den generischen Webhook ohne
* eigene URL: Was die Nutzlast ist, wird ihr angesehen {@code "type":"job"}
* ist ein Job, alles andere sind Adressdaten.
*/
public WebhookEvent record(JsonNode payload) {
return record(payload, null);
}
/**
* Nimmt eine Nachricht an, hängt sie an den Verlauf und schickt sie sofort
* an alle offenen Browser.
*
* @param channel über welche URL die Nachricht kam ({@code kunden},
* {@code kuriere}, {@code jobs}) sie bestimmt, wie die
* Nutzlast in der MongoDB abgelegt wird; {@code null} heißt
* generischer Webhook, dann entscheidet die Form der Nutzlast
* @return das angelegte Ereignis, damit der Aufrufer die Id quittieren kann
*/
public WebhookEvent record(JsonNode payload, String channel) {
WebhookEvent event = new WebhookEvent(nextId.getAndIncrement(), Instant.now(), channel, payload);
synchronized (events) {
events.addLast(event);
while (events.size() > Math.max(1, properties.history())) {
events.removeFirst();
}
}
log.debug("Webhook: Nachricht {} angenommen ({} Empfänger).", event.id(), subscribers.size());
// Den Inhalt dauerhaft in die MongoDB nebenbei, die Quittung wartet
// nicht darauf.
try {
archiveByChannel(event, channel);
} catch (RuntimeException e) {
// Eine unerwartet geformte Nutzlast darf die Quittung nicht kippen.
log.warn("Webhook: Nachricht {} nicht in die MongoDB übernommen ({}).", event.id(), e.getMessage());
}
broadcast(event);
return event;
}
/**
* Die URL bestimmt die Ablage: Kunden und Kuriere sind Adressdaten, Jobs
* sind Jobs. Beim generischen Webhook ({@code channel == null}) entscheidet
* die Nutzlast selbst: {@code "type":"job"} ist ein Job, alles andere sind
* Adressdaten. Alle Adressdaten landen in derselben Sammlung.
*/
private void archiveByChannel(WebhookEvent event, String channel) {
JsonNode payload = event.payload();
switch (channel == null ? "" : channel) {
case "kunden", "kuriere" -> archive.saveAddresses(AddressEntry.manyFrom(payload));
case "jobs" -> archive.saveJob(JobEntry.from(payload, mapper, event.receivedAt()));
default -> {
if (JobEntry.isJob(payload)) {
archive.saveJob(JobEntry.from(payload, mapper, event.receivedAt()));
} else {
archive.saveAddresses(AddressEntry.manyFrom(payload));
}
}
}
}
/** Verlauf, älteste zuerst. */
public List<WebhookEvent> history() {
synchronized (events) {
return List.copyOf(events);
}
}
/** Leert den Verlauf. Die offenen Browser behalten, was sie schon zeigen. */
public int clear() {
synchronized (events) {
int removed = events.size();
events.clear();
return removed;
}
}
/**
* Meldet einen Browser an.
*
* @param lastEventId Id der zuletzt beim Browser angekommenen Nachricht oder
* {@code null}. Alles Jüngere wird sofort nachgeliefert
* so überbrückt ein Wiederverbinden die Lücke.
*/
public SseEmitter subscribe(Long lastEventId) {
// Kein Zeitlimit: Der Browser hält die Verbindung, der Heartbeat hält sie am Leben.
SseEmitter emitter = new SseEmitter(0L);
emitter.onCompletion(() -> subscribers.remove(emitter));
emitter.onTimeout(() -> {
subscribers.remove(emitter);
emitter.complete();
});
emitter.onError(e -> subscribers.remove(emitter));
List<WebhookEvent> missed = new ArrayList<>();
if (lastEventId != null) {
for (WebhookEvent event : history()) {
if (event.id() > lastEventId) missed.add(event);
}
}
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;
}
/** Anzahl der offenen Browser-Verbindungen für Diagnose und Test. */
public int subscriberCount() {
return subscribers.size();
}
private void broadcast(WebhookEvent event) {
SseEmitter.SseEventBuilder message = toSse(event);
for (SseEmitter emitter : subscribers) {
try {
emitter.send(message);
} catch (IOException | IllegalStateException e) {
// Browser weg oder Verbindung tot: abräumen, der Rest bekommt trotzdem alles.
subscribers.remove(emitter);
emitter.completeWithError(e);
}
}
}
private void sendHeartbeat() {
for (SseEmitter emitter : subscribers) {
try {
emitter.send(SseEmitter.event().comment("ping"));
} catch (IOException | IllegalStateException e) {
subscribers.remove(emitter);
emitter.completeWithError(e);
}
}
}
/**
* Die Nutzlast wird hier selbst zu Text gemacht und als solcher gesendet
* über {@code MediaType.APPLICATION_JSON} würde Spring den String ein
* zweites Mal in Anführungszeichen setzen.
*/
private SseEmitter.SseEventBuilder toSse(WebhookEvent event) {
return SseEmitter.event()
.id(Long.toString(event.id()))
.name("webhook")
.data(mapper.writeValueAsString(event));
}
}
@@ -0,0 +1,17 @@
package de.appcreation.swyxweb.websocket;
/**
* Adresseintrag im Mock Feldnamen wie in der Quittung auf {@code contacts}:
* {@code {"name":"Abt, Bettina","number":"7587","description":"S-SB"}}.
*
* <p>Die echte SwyxTray-App liest diese Einträge aus dem Telefonbuch des
* Swyx-Clients; leere Felder schickt sie als {@code ""}, nicht als {@code null}.
*/
public record MockContact(String name, String number, String description) {
/** Trifft der Suchbegriff? Teilzeichenkette in Name und Rufnummer, Schreibweise egal. */
public boolean matches(String lowerCaseQuery) {
return name.toLowerCase().contains(lowerCaseQuery)
|| number.toLowerCase().contains(lowerCaseQuery);
}
}
@@ -4,6 +4,7 @@ import java.io.IOException;
import java.net.URI; import java.net.URI;
import java.util.ArrayList; import java.util.ArrayList;
import java.util.Collections; import java.util.Collections;
import java.util.Comparator;
import java.util.LinkedHashMap; import java.util.LinkedHashMap;
import java.util.List; import java.util.List;
import java.util.Map; import java.util.Map;
@@ -13,8 +14,11 @@ import java.util.concurrent.atomic.AtomicInteger;
import tools.jackson.databind.JsonNode; import tools.jackson.databind.JsonNode;
import tools.jackson.databind.ObjectMapper; import tools.jackson.databind.ObjectMapper;
import de.appcreation.swyxweb.config.MockProfile;
import org.slf4j.Logger; import org.slf4j.Logger;
import org.slf4j.LoggerFactory; import org.slf4j.LoggerFactory;
import org.springframework.context.annotation.Profile;
import org.springframework.stereotype.Component; import org.springframework.stereotype.Component;
import org.springframework.web.socket.CloseStatus; import org.springframework.web.socket.CloseStatus;
import org.springframework.web.socket.TextMessage; import org.springframework.web.socket.TextMessage;
@@ -38,7 +42,24 @@ import org.springframework.web.socket.handler.TextWebSocketHandler;
* <p>Seit Protokoll 6 beantwortet der Mock zusätzlich {@code tabs}, {@code opentab} und * <p>Seit Protokoll 6 beantwortet der Mock zusätzlich {@code tabs}, {@code opentab} und
* {@code closetab}. Die echte App reicht diese Kommandos an das Firefox-Plugin durch; * {@code closetab}. Die echte App reicht diese Kommandos an das Firefox-Plugin durch;
* der Mock spielt das Plugin und führt eine Liste vorgetäuschter Tabs im Speicher. * der Mock spielt das Plugin und führt eine Liste vorgetäuschter Tabs im Speicher.
*
* <p>Seit Protokoll 7 beantwortet er {@code contacts} aus einem fest eingebauten
* Telefonbuch mit denselben Regeln wie die echte App: Teilzeichenkette in Name und
* Rufnummer, Schreibweise egal, nach Namen sortiert und bei
* {@link #CONTACT_RESULT_LIMIT} Treffern stillschweigend gekürzt.
*
* <p>Seit Protokoll 8 gibt er dasselbe Telefonbuch als <b>Adress-Cache</b> am Stück
* heraus: auf {@code addresses} und zusätzlich unaufgefordert als
* {@code {"type":"addresses","addresses":[…]}} direkt nach dem ersten Snapshot.
* Über {@code POST /api/mock/address-cache?filled=false} lässt sich der Cache leeren
* dann verhält sich der Mock wie die zurzeit laufende Anlage, deren Cache leer ist,
* und die Startseite muss auf die Einzelabfragen ausweichen.
*
* <p><b>Nur mit dem Profil {@link MockProfile#NAME}.</b> Ohne dieses Profil gibt es
* die Bean nicht, {@code /ws} ist dann nicht belegt der Mock kann also nicht
* versehentlich in einer Produktivumgebung mitlaufen.
*/ */
@Profile(MockProfile.NAME)
@Component @Component
public class SwyxTrayMockHandler extends TextWebSocketHandler { public class SwyxTrayMockHandler extends TextWebSocketHandler {
@@ -51,8 +72,17 @@ public class SwyxTrayMockHandler extends TextWebSocketHandler {
private static final List<String> MOCK_WINDOWS = List.of( private static final List<String> MOCK_WINDOWS = List.of(
"SwyxIt!", "SwyxWeb Google Chrome", "Kundenakte Muster GmbH"); "SwyxIt!", "SwyxWeb Google Chrome", "Kundenakte Muster GmbH");
/** Protokollstand der echten App seit der Tab-Verwaltung. */ /** Protokollstand der echten App seit dem Adress-Cache. */
private static final int PROTOCOL_VERSION = 6; private static final int PROTOCOL_VERSION = 8;
/** So viele Treffer meldet die echte App höchstens; gekürzt wird ohne Hinweis. */
private static final int CONTACT_RESULT_LIMIT = 100;
/** Längere Suchbegriffe weist die echte App mit einem Fehler zurück. */
private static final int CONTACT_QUERY_MAX_LENGTH = 128;
/** Vorgetäuschtes Telefonbuch; siehe {@link #buildDirectory()}. */
private static final List<MockContact> MOCK_CONTACTS = buildDirectory();
private final ObjectMapper mapper; private final ObjectMapper mapper;
private final Map<String, WebSocketSession> sessions = new ConcurrentHashMap<>(); private final Map<String, WebSocketSession> sessions = new ConcurrentHashMap<>();
@@ -61,6 +91,8 @@ public class SwyxTrayMockHandler extends TextWebSocketHandler {
private final Map<Integer, MockTab> tabs = Collections.synchronizedMap(new LinkedHashMap<>()); private final Map<Integer, MockTab> tabs = Collections.synchronizedMap(new LinkedHashMap<>());
private final AtomicInteger nextTabId = new AtomicInteger(41); private final AtomicInteger nextTabId = new AtomicInteger(41);
private final AtomicInteger nextSession = new AtomicInteger(); private final AtomicInteger nextSession = new AtomicInteger();
// Ist der Adress-Cache gefüllt? Zum Umschalten auf die Rückfallebene.
private volatile boolean addressCacheFilled = true;
// Serialisiert Begrüßung und Snapshot, damit die Reihenfolge garantiert ist. // Serialisiert Begrüßung und Snapshot, damit die Reihenfolge garantiert ist.
private final Object sendLock = new Object(); private final Object sendLock = new Object();
@@ -81,6 +113,8 @@ public class SwyxTrayMockHandler extends TextWebSocketHandler {
synchronized (sendLock) { synchronized (sendLock) {
sendText(session, toJson(hello())); sendText(session, toJson(hello()));
sendText(session, toJson(snapshot())); sendText(session, toJson(snapshot()));
// Der Cache kommt unaufgefordert hinterher wie bei der echten App.
sendText(session, toJson(addressesPush()));
} }
} }
@@ -134,6 +168,15 @@ public class SwyxTrayMockHandler extends TextWebSocketHandler {
sendText(session, toJson(result(id, true, null, null))); sendText(session, toJson(result(id, true, null, null)));
return; return;
} }
// Adressdaten des Swyx-Clients; auch sie rühren keine Leitung an.
case "contacts" -> {
sendText(session, toJson(contactsResult(id, searchContacts(request.path("query")))));
return;
}
case "addresses" -> {
sendText(session, toJson(addressesResult(id, addressCache())));
return;
}
default -> { /* weiter unten */ } default -> { /* weiter unten */ }
} }
@@ -262,6 +305,90 @@ public class SwyxTrayMockHandler extends TextWebSocketHandler {
return tab; return tab;
} }
/**
* Baut das vorgetäuschte Telefonbuch. Es ist bewusst groß genug, dass der
* Bereich „Adressdaten" wie gegen die echte Anlage arbeiten muss: einstellige
* Abfragen hängen am Deckel und werden verfeinert, zweistellige nicht.
*
* <p>Drei Sonderfälle sind absichtlich dabei:
* <ul>
* <li>dieselbe Person mit <b>zwei Durchwahlen</b> Namen sind nicht eindeutig,</li>
* <li>Einträge <b>ohne Beschreibung</b> (die echte App schickt dafür {@code ""}),</li>
* <li>eine <b>einstellige</b> Rufnummer, die in keinem Ziffernpaar vorkommt und
* deshalb nur über die einstelligen Abfragen gefunden wird.</li>
* </ul>
*/
private static List<MockContact> buildDirectory() {
List<String> surnames = List.of(
"Abt", "Ahrens", "Bauer", "Becker", "Beyer", "Cordes", "Dahme", "Diehl",
"Erbert", "Franke", "Gast", "Geier", "Grote", "Hansper", "Heymann", "Hill",
"Kaiser", "Lorenz", "Meyer", "Neumann", "Otto", "Peters", "Richter", "Schulz",
"Thiel", "Ulrich", "Vogel", "Weber", "Zeller", "Zimmer");
List<String> given = List.of("Anna", "Bernd", "Claudia", "Dirk", "Erika");
List<String> sites = List.of("HH-MT", "B-SB", "HB-GT", "L-SB", "S-MT");
List<MockContact> entries = new ArrayList<>();
int number = 4100;
for (String surname : surnames) {
for (String first : given) {
entries.add(new MockContact(surname + ", " + first, String.valueOf(number),
sites.get(entries.size() % sites.size())));
// Lücken wie in einer gewachsenen Anlage sonst lägen die
// Nummern so dicht, dass auch Ziffernpaare am Deckel hingen.
number += 7;
}
}
entries.add(new MockContact("Hotline_HH-MT_Verwaltung", "41240", "HH-MT"));
entries.add(new MockContact("Muster, Max", "4711", ""));
entries.add(new MockContact("Muster, Max", "53119", "B-GT"));
entries.add(new MockContact("Zentrale", "0", ""));
return List.copyOf(entries);
}
/**
* Simuliert {@code contacts}. Die echte App verlangt einen nicht leeren
* Suchbegriff von höchstens {@value #CONTACT_QUERY_MAX_LENGTH} Zeichen und
* beantwortet beide Verstöße mit derselben Meldung.
*/
private List<MockContact> searchContacts(JsonNode query) {
String wanted = query.isString() ? query.stringValue().trim() : "";
if (wanted.isEmpty() || wanted.length() > CONTACT_QUERY_MAX_LENGTH) {
throw new IllegalArgumentException(
"Feld 'query' fehlt oder ist zu lang (max. " + CONTACT_QUERY_MAX_LENGTH + " Zeichen).");
}
String needle = wanted.toLowerCase();
List<MockContact> hits = MOCK_CONTACTS.stream()
.filter(contact -> contact.matches(needle))
.sorted(Comparator.comparing((MockContact contact) -> contact.name())
.thenComparing(contact -> contact.number()))
.limit(CONTACT_RESULT_LIMIT)
.toList();
log.info("SwyxTray-Mock: {} Adresseintrag/-einträge zu '{}'", hits.size(), wanted);
return hits;
}
/** Der Adress-Cache leer, solange er abgeschaltet ist. */
private List<MockContact> addressCache() {
return addressCacheFilled ? MOCK_CONTACTS : List.of();
}
/**
* Schaltet den Adress-Cache um und schickt ihn allen Verbundenen neu so
* lässt sich beides prüfen: der Cache-Weg und die Rückfallebene über
* {@code contacts}.
*
* @return Anzahl der Einträge im Cache danach
*/
public int setAddressCacheFilled(boolean filled) {
addressCacheFilled = filled;
log.info("SwyxTray-Mock: Adress-Cache {}", filled ? "gefüllt" : "geleert");
String json = toJson(addressesPush());
synchronized (sendLock) {
sessions.values().forEach(session -> sendText(session, json));
}
return addressCache().size();
}
/** Löst einen eingehenden Anruf aus: Leitung belegen, Snapshot verschicken. */ /** Löst einen eingehenden Anruf aus: Leitung belegen, Snapshot verschicken. */
public LineState simulateIncomingCall(String number, String name) { public LineState simulateIncomingCall(String number, String name) {
int line = freeLine(); int line = freeLine();
@@ -364,6 +491,34 @@ public class SwyxTrayMockHandler extends TextWebSocketHandler {
return message; return message;
} }
/** Quittung auf {@code contacts}; trägt die gefundenen Adressdaten. */
private Map<String, Object> contactsResult(int id, List<MockContact> entries) {
Map<String, Object> message = new LinkedHashMap<>();
message.put("id", id);
message.put("ok", true);
message.put("contacts", entries);
message.put("type", "result");
return message;
}
/** Quittung auf {@code addresses}; trägt den gesamten Cache. */
private Map<String, Object> addressesResult(int id, List<MockContact> entries) {
Map<String, Object> message = new LinkedHashMap<>();
message.put("id", id);
message.put("ok", true);
message.put("addresses", entries);
message.put("type", "result");
return message;
}
/** Unaufgeforderte Cache-Nachricht ohne {@code id}, ohne {@code ok}. */
private Map<String, Object> addressesPush() {
Map<String, Object> message = new LinkedHashMap<>();
message.put("addresses", addressCache());
message.put("type", "addresses");
return message;
}
private Map<String, Object> result(int id, boolean ok, Integer line, String error) { private Map<String, Object> result(int id, boolean ok, Integer line, String error) {
Map<String, Object> message = new LinkedHashMap<>(); Map<String, Object> message = new LinkedHashMap<>();
message.put("id", id); message.put("id", id);
@@ -1,14 +1,41 @@
spring.application.name=swyxweb-backend spring.application.name=swyxweb-backend
# Port dieser Anwendung (REST-API und der eingebaute Test-Endpunkt /ws). # Der eingebaute SwyxTray-Mock (/ws und /api/mock/**) läuft nur mit dem Profil
# "mock" bewusst nicht voreingestellt, damit er nicht in einer Produktiv-
# umgebung mitläuft. Einschalten zum Testen ohne Telefonanlage:
# ./mvnw spring-boot:run -Dspring-boot.run.profiles=mock
# java -jar app.jar --spring.profiles.active=mock
# SPRING_PROFILES_ACTIVE=mock
# Port dieser Anwendung (REST-API und, mit Profil "mock", der Endpunkt /ws).
server.address=0.0.0.0 server.address=0.0.0.0
server.port=8080 server.port=8080
# Adresse des WebSocket-Servers, die das Frontend über /api/config als # Adresse des WebSocket-Servers, die das Frontend über /api/config als
# Verbindungsziel bekommt. Unabhängig vom Port dieser Anwendung. # Verbindungsziel bekommt. Unabhängig vom Port dieser Anwendung.
app.websocket.host=192.168.180.135 app.websocket.host=127.0.0.1
app.websocket.port=17654 app.websocket.port=17654
app.websocket.path=/ws app.websocket.path=/ws
app.websocket.secure=false app.websocket.secure=false
# MongoDB, in der Adressdaten (Webhook) und Anrufdaten dauerhaft abgelegt
# werden. Der Server verlangt keine Zugangsdaten. Der kurze Timeout hält
# Fehlversuche kurz, wenn die Datenbank nicht erreichbar ist gespeichert
# wird ohnehin nur nebenbei (siehe ArchiveService), die Seite läuft weiter.
spring.mongodb.uri=mongodb://192.168.180.25:27017/swyxweb?serverSelectionTimeoutMS=2000
# Webhook, über den ein fremdes System Adress- und Jobdaten an die Startseite
# schickt: je Datenart eine eigene URL (POST /api/webhook/kunden, …/kuriere,
# …/jobs), daneben der generische POST /api/webhook. Ist ein Token gesetzt,
# muss der Aufrufer es im Kopf
# X-Webhook-Token mitschicken; leer heißt, dass jeder Aufruf angenommen wird.
# Für die öffentlich erreichbare Instanz gehört hier ein Geheimnis hinein
# (oder von außen: APP_WEBHOOK_TOKEN).
app.webhook.token=
# So viele Nachrichten hält das Backend vor, damit ein später geöffneter
# Bereich die vorigen noch sieht. Nach einem Neustart ist der Verlauf leer.
app.webhook.history=50
# Größte erlaubte Nutzlast in Byte.
app.webhook.max-size=262144
logging.level.de.appcreation.swyxweb=DEBUG logging.level.de.appcreation.swyxweb=DEBUG
@@ -0,0 +1,74 @@
package de.appcreation.swyxweb;
import static org.mockito.ArgumentMatchers.argThat;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
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.result.MockMvcResultMatchers.jsonPath;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
import java.util.List;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.webmvc.test.autoconfigure.AutoConfigureMockMvc;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.http.MediaType;
import org.springframework.test.context.bean.override.mockito.MockitoBean;
import org.springframework.test.web.servlet.MockMvc;
import de.appcreation.swyxweb.storage.AddressEntry;
import de.appcreation.swyxweb.storage.ArchiveService;
/**
* Der Endpunkt nimmt den Adress-Cache der SwyxTray-App an und bestätigt nur
* die Annahme (202). Der {@link ArchiveService} ist ersetzt der Test läuft
* ohne MongoDB und hinterlässt dort nichts.
*/
@SpringBootTest
@AutoConfigureMockMvc
class AddressControllerTests {
@Autowired
MockMvc mvc;
@MockitoBean
ArchiveService archive;
@Test
void acceptsSwyxTrayMessageAndReturnsCount() throws Exception {
mvc.perform(post("/api/addresses")
.contentType(MediaType.APPLICATION_JSON)
.content("""
{"addresses":[{"name":"Abt, Bettina","number":"7587","description":"S-SB"},
{"name":"Abdul, Rokhsareh","number":"5215","description":"Globales Telefonbuch"}],
"type":"addresses"}"""))
.andExpect(status().isAccepted())
.andExpect(jsonPath("$").value(2));
verify(archive).saveAddresses(argThat(entries -> entries.size() == 2));
}
/** Die Suche läuft in der Datenbank der Begriff wird durchgereicht. */
@Test
void listPassesQueryToArchive() throws Exception {
when(archive.addresses("muster"))
.thenReturn(List.of(new AddressEntry("1", "+493012345", "Muster GmbH", null, null, null, null, null)));
mvc.perform(get("/api/addresses").param("q", "muster"))
.andExpect(status().isOk())
.andExpect(jsonPath("$[0].name").value("Muster GmbH"))
.andExpect(jsonPath("$[0].number").value("+493012345"));
verify(archive).addresses("muster");
}
@Test
void rejectsJsonWithoutAddresses() throws Exception {
mvc.perform(post("/api/addresses")
.contentType(MediaType.APPLICATION_JSON)
.content("{\"foo\":\"bar\"}"))
.andExpect(status().isBadRequest());
}
}
@@ -0,0 +1,96 @@
package de.appcreation.swyxweb;
import static org.assertj.core.api.Assertions.assertThat;
import java.util.List;
import tools.jackson.databind.ObjectMapper;
import org.junit.jupiter.api.Test;
import de.appcreation.swyxweb.storage.AddressEntry;
/**
* {@link AddressEntry#manyFrom} füllt die Klasse aus jeder Form, in der
* Adressdaten hereinkommen: einzelner Webhook-Eintrag (Kunde, Kurier, Job),
* eine Liste solcher Einträge oder die Adress-Nachricht der SwyxTray-App.
*/
class AddressEntryTests {
private final ObjectMapper mapper = new ObjectMapper();
private List<AddressEntry> parse(String json) {
return AddressEntry.manyFrom(mapper.readTree(json));
}
/** Ein Kunde, wie ihn das Fremdsystem über den Webhook schickt. */
@Test
void readsSingleWebhookEntry() {
List<AddressEntry> entries = parse("""
{"name":"SYSGEN GmbH","number":"+491602107449",
"description":"Kunde | SYSGEN, SYSTEME UND | NL Bremen",
"role":"customer","csc_id":100164,"job_ids":[21891263,21891262],
"url":"https://test.sb.assecutor.de/admin/tapi_wrapper.php?phoneNo=01602107449"}""");
assertThat(entries).hasSize(1);
AddressEntry entry = entries.getFirst();
assertThat(entry.name()).isEqualTo("SYSGEN GmbH");
assertThat(entry.number()).isEqualTo("+491602107449");
assertThat(entry.description()).isEqualTo("Kunde | SYSGEN, SYSTEME UND | NL Bremen");
assertThat(entry.role()).isEqualTo("customer");
assertThat(entry.cscId()).isEqualTo(100164);
assertThat(entry.jobIds()).containsExactly(21891263L, 21891262L);
assertThat(entry.url())
.isEqualTo("https://test.sb.assecutor.de/admin/tapi_wrapper.php?phoneNo=01602107449");
}
/** Die Felder ab {@code role} darf das Fremdsystem auch weglassen. */
@Test
void extraFieldsStayNullWhenAbsent() {
List<AddressEntry> entries = parse("""
{"name":"SYSGEN GmbH","number":"+49421409660","description":"Kunde | …"}""");
AddressEntry entry = entries.getFirst();
assertThat(entry.role()).isNull();
assertThat(entry.cscId()).isNull();
assertThat(entry.jobIds()).isNull();
assertThat(entry.url()).isNull();
}
/** Kuriere und Jobs kommen analog auch als Liste. */
@Test
void readsListOfEntries() {
List<AddressEntry> entries = parse("""
[{"name":"Rainer Peters (HH1003)","number":"+49171677xxxx",
"description":"Kurier | NL Hamburg | PKW | cr 2024"},
{"name":"SYSGEN GmbH","number":"+49421409660","description":"Kunde | …"}]""");
assertThat(entries).hasSize(2);
assertThat(entries.getFirst().description()).startsWith("Kurier |");
}
/** Die Adress-Nachricht der SwyxTray-App: verpackt in "addresses", mit "type". */
@Test
void readsSwyxTrayAddressMessage() {
List<AddressEntry> entries = parse("""
{"addresses":[{"name":"Abt, Bettina","number":"7587","description":"S-SB"},
{"name":"Abdul, Rokhsareh","number":"5215","description":"Globales Telefonbuch"}],
"type":"addresses"}""");
assertThat(entries).hasSize(2);
assertThat(entries).extracting(entry -> entry.number()).containsExactly("7587", "5215");
}
/**
* Ohne Rufnummer ist es kein Eintrag sie ist der Schlüssel der Sammlung,
* ohne sie ließe sich eine Dublette nicht erkennen. Anderes JSON ergibt nichts.
*/
@Test
void skipsUnusableJson() {
assertThat(parse("{\"foo\":\"bar\"}")).isEmpty();
assertThat(parse("{\"name\":\"Nur Name GmbH\"}")).isEmpty();
assertThat(parse("{\"addresses\":[{\"description\":\"nur Text\"}]}")).isEmpty();
assertThat(parse("[1,2,3]")).isEmpty();
assertThat(parse("\"nur eine Zeichenkette\"")).isEmpty();
}
}
@@ -1,13 +1,32 @@
package de.appcreation.swyxweb; package de.appcreation.swyxweb;
import static org.assertj.core.api.Assertions.assertThat;
import de.appcreation.swyxweb.web.MockController;
import de.appcreation.swyxweb.websocket.SwyxTrayMockHandler;
import org.junit.jupiter.api.Test; import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest; import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.context.ApplicationContext;
@SpringBootTest @SpringBootTest
class BackendApplicationTests { class BackendApplicationTests {
@Autowired
ApplicationContext context;
@Test @Test
void contextLoads() { void contextLoads() {
} }
/**
* Ohne das Profil „mock" also so, wie die Anwendung im Container läuft
* darf der SwyxTray-Mock nicht im Kontext stehen.
*/
@Test
void mockIsAbsentWithoutItsProfile() {
assertThat(context.getBeansOfType(SwyxTrayMockHandler.class)).isEmpty();
assertThat(context.getBeansOfType(MockController.class)).isEmpty();
}
} }
@@ -0,0 +1,56 @@
package de.appcreation.swyxweb;
import static org.mockito.ArgumentMatchers.argThat;
import static org.mockito.Mockito.verify;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.webmvc.test.autoconfigure.AutoConfigureMockMvc;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.http.MediaType;
import org.springframework.test.context.bean.override.mockito.MockitoBean;
import org.springframework.test.web.servlet.MockMvc;
import de.appcreation.swyxweb.storage.ArchiveService;
/**
* Der Endpunkt nimmt die Anruf-Ereignisse der Startseite an und bestätigt nur
* die Annahme (202). Der {@link ArchiveService} ist ersetzt der Test läuft
* ohne MongoDB und hinterlässt dort nichts.
*/
@SpringBootTest
@AutoConfigureMockMvc
class CallControllerTests {
@Autowired
MockMvc mvc;
@MockitoBean
ArchiveService archive;
@Test
void acceptsEventsAndReturnsCount() throws Exception {
mvc.perform(post("/api/calls")
.contentType(MediaType.APPLICATION_JSON)
.content("""
[{"line":1,"event":"incoming","peer":"Muster GmbH (+493012345)",
"peerNumber":"+493012345","peerName":"Muster GmbH",
"stateText":"klingelt","state":"LSRinging"},
{"line":1,"event":"ended","direction":"incoming"}]"""))
.andExpect(status().isAccepted())
.andExpect(jsonPath("$").value(2));
verify(archive).saveCalls(argThat(calls -> calls.size() == 2));
}
@Test
void rejectsEmptyList() throws Exception {
mvc.perform(post("/api/calls")
.contentType(MediaType.APPLICATION_JSON)
.content("[]"))
.andExpect(status().isBadRequest());
}
}
@@ -0,0 +1,32 @@
package de.appcreation.swyxweb;
import static org.assertj.core.api.Assertions.assertThat;
import java.time.ZonedDateTime;
import org.junit.jupiter.api.Test;
import de.appcreation.swyxweb.storage.JobCleanupService;
/**
* Die nächtliche Job-Bereinigung vergleicht die {@code ordertime} als Text
* hier wird die Grenze und der Vergleich gegen das Format der Nutzlast geprüft.
*/
class JobCleanupServiceTests {
@Test
void cutoffLiesFourWeeksBackInOrdertimeFormat() {
ZonedDateTime now = ZonedDateTime.of(2026, 8, 28, 0, 0, 0, 0, JobCleanupService.ZONE);
// Vier Wochen vor dem 28.08. ist der 31.07.; im Sommer gilt +02:00.
assertThat(JobCleanupService.cutoffOrdertime(now)).isEqualTo("2026-07-31T00:00:00+02:00");
}
@Test
void ordertimeStringsCompareChronologically() {
String cutoff = "2026-07-31T00:00:00+02:00";
assertThat("2026-07-30T18:14:02+02:00").isLessThan(cutoff);
assertThat("2026-08-01T08:00:00+02:00").isGreaterThan(cutoff);
}
}
@@ -0,0 +1,53 @@
package de.appcreation.swyxweb;
import static org.mockito.Mockito.when;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.webmvc.test.autoconfigure.AutoConfigureMockMvc;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.test.context.bean.override.mockito.MockitoBean;
import org.springframework.test.web.servlet.MockMvc;
import de.appcreation.swyxweb.storage.ArchiveService;
import de.appcreation.swyxweb.storage.JobEntry;
/**
* Der Endpunkt liefert einen einzelnen Job zur Kennung des Fremdsystems so
* kommt das Anruf-Popup von einer Kennung aus {@code job_ids} an die
* Sprungadresse des Jobs. Der {@link ArchiveService} ist ersetzt der Test
* läuft ohne MongoDB.
*/
@SpringBootTest
@AutoConfigureMockMvc
class JobControllerTests {
@Autowired
MockMvc mvc;
@MockitoBean
ArchiveService archive;
@Test
void returnsStoredJob() throws Exception {
when(archive.job(21891263L)).thenReturn(new JobEntry(21891263L, null,
"https://test.sb.assecutor.de/admin/jb_detail.php?job_id=21891263",
1, "2026-08-13T13:35:27", null, null, null, null, null, null, null, null, null, null));
mvc.perform(get("/api/jobs/21891263"))
.andExpect(status().isOk())
.andExpect(jsonPath("$.id").value(21891263))
.andExpect(jsonPath("$.url").value("https://test.sb.assecutor.de/admin/jb_detail.php?job_id=21891263"));
}
@Test
void unknownIdIsNotFound() throws Exception {
when(archive.job(4711L)).thenReturn(null);
mvc.perform(get("/api/jobs/4711"))
.andExpect(status().isNotFound());
}
}
@@ -0,0 +1,119 @@
package de.appcreation.swyxweb;
import static org.assertj.core.api.Assertions.assertThat;
import java.time.Instant;
import tools.jackson.databind.JsonNode;
import tools.jackson.databind.ObjectMapper;
import org.junit.jupiter.api.Test;
import de.appcreation.swyxweb.storage.JobEntry;
/**
* {@link JobEntry} wird aus der Webhook-Nutzlast mit {@code "type":"job"}
* gefüllt samt Kunde, Kurier und Stationen. Eine ältere Form der Nutzlast
* nannte die Rufnummern {@code number} statt {@code phone}; auch sie wird
* weiterhin verstanden.
*/
class JobEntryTests {
private final ObjectMapper mapper = new ObjectMapper();
private static final String SAMPLE = """
{
"type": "job",
"id": 21891263,
"url": "https://test.sb.assecutor.de/admin/jb_detail.php?job_id=21891263",
"state": 9,
"ordertime": "2026-08-13T13:35:27",
"orderdate": "2026-08-13",
"modified": "2026-08-13T13:35:27",
"finished": null,
"vehicle": "Transporter XL",
"service": null,
"canceled": false,
"global": false,
"customer": { "csc_id": 100164, "name": "SYSGEN GmbH", "hq": "Bremen",
"phone": "+491602107449" },
"courier": null,
"tours": [
{ "id": 22396758, "sort": 1, "state": 0, "mode": "del", "comp": "SYSGEN GmbH",
"person": "Frau Hoffmann", "phone": "+49421409660", "com": null,
"remark": "~~~\\nHebebühne benötigt.\\n~~~", "street": "Am Hallacker 48",
"zip": "28327", "city": "Bremen", "finished": null },
{ "id": 22396759, "sort": 2, "state": 0, "mode": "pu",
"comp": "Super Micro Computer B.V.", "person": null, "phone": null, "com": null,
"remark": "Abholreferenz: 8801420234\\nAnzahl an Paletten: 8",
"street": "Het Sterrenbeeld 12-16", "zip": "5215", "city": "'s-Hertogenbosch",
"finished": null }
]
}""";
@Test
void recognizesJobPayload() {
assertThat(JobEntry.isJob(mapper.readTree(SAMPLE))).isTrue();
assertThat(JobEntry.isJob(mapper.readTree("{\"name\":\"SYSGEN GmbH\"}"))).isFalse();
assertThat(JobEntry.isJob(mapper.readTree("{\"type\":\"addresses\",\"addresses\":[]}"))).isFalse();
}
@Test
void fillsAllFieldsFromSample() {
Instant receivedAt = Instant.parse("2026-08-28T10:00:00Z");
JsonNode json = mapper.readTree(SAMPLE);
JobEntry job = JobEntry.from(json, mapper, receivedAt);
assertThat(job.id()).isEqualTo(21891263L);
assertThat(job.receivedAt()).isEqualTo(receivedAt);
assertThat(job.url()).isEqualTo("https://test.sb.assecutor.de/admin/jb_detail.php?job_id=21891263");
assertThat(job.state()).isEqualTo(9);
assertThat(job.ordertime()).isEqualTo("2026-08-13T13:35:27");
assertThat(job.orderdate()).isEqualTo("2026-08-13");
assertThat(job.finished()).isNull();
assertThat(job.vehicle()).isEqualTo("Transporter XL");
assertThat(job.service()).isNull();
assertThat(job.canceled()).isFalse();
assertThat(job.global()).isFalse();
assertThat(job.customer().cscId()).isEqualTo(100164);
assertThat(job.customer().name()).isEqualTo("SYSGEN GmbH");
assertThat(job.customer().hq()).isEqualTo("Bremen");
assertThat(job.customer().phone()).isEqualTo("+491602107449");
// Noch kein Kurier zugeteilt.
assertThat(job.courier()).isNull();
assertThat(job.tours()).hasSize(2);
JobEntry.Tour first = job.tours().getFirst();
assertThat(first.id()).isEqualTo(22396758L);
assertThat(first.sort()).isEqualTo(1);
assertThat(first.mode()).isEqualTo("del");
assertThat(first.person()).isEqualTo("Frau Hoffmann");
assertThat(first.phone()).isEqualTo("+49421409660");
assertThat(first.remark()).contains("Hebebühne benötigt.");
assertThat(first.street()).isEqualTo("Am Hallacker 48");
// Fehlende Rufnummer der zweiten Station bleibt null.
assertThat(job.tours().get(1).phone()).isNull();
assertThat(job.tours().get(1).city()).isEqualTo("'s-Hertogenbosch");
}
/** Die ältere Form nannte die Rufnummern {@code number} statt {@code phone}. */
@Test
void acceptsLegacyNumberFields() {
JsonNode json = mapper.readTree("""
{
"type": "job",
"id": 21891253,
"courier": { "cr_id": 14116, "sid": "B1006", "name": "CA Kurier B1006",
"number": "+4917xxxxxxx" },
"tours": [ { "id": 22396738, "sort": 1, "number": "+4942037010xx" } ]
}""");
JobEntry job = JobEntry.from(json, mapper, Instant.parse("2026-08-28T10:00:00Z"));
assertThat(job.courier().phone()).isEqualTo("+4917xxxxxxx");
assertThat(job.tours().getFirst().phone()).isEqualTo("+4942037010xx");
}
}
@@ -0,0 +1,30 @@
package de.appcreation.swyxweb;
import static org.assertj.core.api.Assertions.assertThat;
import de.appcreation.swyxweb.web.MockController;
import de.appcreation.swyxweb.websocket.SwyxTrayMockHandler;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.context.ApplicationContext;
import org.springframework.test.context.ActiveProfiles;
/**
* Gegenstück zu {@link BackendApplicationTests#mockIsAbsentWithoutItsProfile()}:
* mit dem Profil „mock" muss der SwyxTray-Mock vollständig da sein.
*/
@SpringBootTest
@ActiveProfiles("mock")
class MockProfileTests {
@Autowired
ApplicationContext context;
@Test
void mockIsPresentWithItsProfile() {
assertThat(context.getBeansOfType(SwyxTrayMockHandler.class)).isNotEmpty();
assertThat(context.getBeansOfType(MockController.class)).isNotEmpty();
}
}
@@ -0,0 +1,198 @@
package de.appcreation.swyxweb;
import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.ArgumentMatchers.argThat;
import static org.mockito.Mockito.verify;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.delete;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
import de.appcreation.swyxweb.storage.ArchiveService;
import de.appcreation.swyxweb.webhook.WebhookService;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Nested;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.webmvc.test.autoconfigure.AutoConfigureMockMvc;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.http.MediaType;
import org.springframework.test.context.TestPropertySource;
import org.springframework.test.context.bean.override.mockito.MockitoBean;
import org.springframework.test.web.servlet.MockMvc;
/**
* Der Webhook nimmt beliebiges JSON an und hält es im Verlauf vor. Ohne
* gesetztes Token ist er offen so läuft er ohne Zutun des aufrufenden Systems.
*/
@SpringBootTest
@AutoConfigureMockMvc
class WebhookControllerTests {
@Autowired
MockMvc mvc;
@Autowired
WebhookService service;
/** Ersetzt: Die Tests laufen ohne MongoDB und hinterlassen dort nichts. */
@MockitoBean
ArchiveService archive;
@BeforeEach
void emptyHistory() {
service.clear();
}
@Test
void acceptsJsonAndReturnsReceipt() throws Exception {
mvc.perform(post("/api/webhook")
.contentType(MediaType.APPLICATION_JSON)
.content("{\"name\":\"Muster GmbH\",\"number\":\"+493012345\"}"))
.andExpect(status().isOk())
.andExpect(jsonPath("$.id").isNumber())
.andExpect(jsonPath("$.receivedAt").exists());
assertThat(service.history()).hasSize(1);
}
/** Kein festes Schema: Auch eine Liste ist gültige Nutzlast. */
@Test
void acceptsAnyJsonShape() throws Exception {
mvc.perform(post("/api/webhook")
.contentType(MediaType.APPLICATION_JSON)
.content("[{\"name\":\"Abt, Bettina\",\"number\":\"7587\"}]"))
.andExpect(status().isOk());
assertThat(service.history()).hasSize(1);
assertThat(service.history().getFirst().payload().isArray()).isTrue();
}
@Test
void historyKeepsOrderAndCanBeCleared() throws Exception {
mvc.perform(post("/api/webhook").contentType(MediaType.APPLICATION_JSON).content("{\"n\":1}"))
.andExpect(status().isOk());
mvc.perform(post("/api/webhook").contentType(MediaType.APPLICATION_JSON).content("{\"n\":2}"))
.andExpect(status().isOk());
mvc.perform(get("/api/webhook/history"))
.andExpect(status().isOk())
.andExpect(jsonPath("$.length()").value(2))
.andExpect(jsonPath("$[0].payload.n").value(1))
.andExpect(jsonPath("$[1].payload.n").value(2));
mvc.perform(delete("/api/webhook/history"))
.andExpect(status().isOk())
.andExpect(jsonPath("$").value(2));
assertThat(service.history()).isEmpty();
}
/**
* Je Datenart eine eigene URL: Kunden und Kuriere werden als Adressdaten
* abgelegt, Jobs als Jobs die Herkunft steht als {@code channel} am
* Ereignis und braucht kein Kennzeichen in der Nutzlast.
*/
@Test
void dedicatedUrlsRecordTheirChannel() throws Exception {
mvc.perform(post("/api/webhook/kunden")
.contentType(MediaType.APPLICATION_JSON)
.content("{\"name\":\"SYSGEN GmbH\",\"number\":\"+49421409660\"}"))
.andExpect(status().isOk())
.andExpect(jsonPath("$.id").isNumber());
mvc.perform(post("/api/webhook/kuriere")
.contentType(MediaType.APPLICATION_JSON)
.content("{\"name\":\"Rainer Peters (HH1003)\",\"number\":\"+49171677xxxx\"}"))
.andExpect(status().isOk());
mvc.perform(post("/api/webhook/jobs")
.contentType(MediaType.APPLICATION_JSON)
.content("{\"id\":4711,\"state\":1,\"customer\":{\"csc_id\":100164,\"name\":\"SYSGEN GmbH\"}}"))
.andExpect(status().isOk());
assertThat(service.history())
.extracting(event -> event.channel())
.containsExactly("kunden", "kuriere", "jobs");
// Kunden und Kuriere landen als Adressdaten in der Ablage, der Job als Job.
verify(archive).saveAddresses(argThat(entries ->
entries.size() == 1 && "SYSGEN GmbH".equals(entries.getFirst().name())));
verify(archive).saveAddresses(argThat(entries ->
entries.size() == 1 && "Rainer Peters (HH1003)".equals(entries.getFirst().name())));
verify(archive).saveJob(argThat(job -> job.id() == 4711));
}
/** Der generische Webhook trägt keine Herkunft die Nutzlast entscheidet. */
@Test
void genericWebhookHasNoChannel() throws Exception {
mvc.perform(post("/api/webhook")
.contentType(MediaType.APPLICATION_JSON)
.content("{\"name\":\"Muster GmbH\",\"number\":\"+493012345\"}"))
.andExpect(status().isOk());
assertThat(service.history().getFirst().channel()).isNull();
verify(archive).saveAddresses(argThat(entries -> entries.size() == 1));
}
/** Ohne Token in der Konfiguration darf der Kopf fehlen. */
@Test
void tokenIsNotRequiredWhenUnset() throws Exception {
mvc.perform(post("/api/webhook").contentType(MediaType.APPLICATION_JSON).content("{}"))
.andExpect(status().isOk());
}
@Test
void rejectsPayloadAboveTheLimit() throws Exception {
// max-size steht auf 256 KiB; ein Wert deutlich darüber muss abgelehnt werden.
String big = "{\"v\":\"" + "x".repeat(300_000) + "\"}";
mvc.perform(post("/api/webhook").contentType(MediaType.APPLICATION_JSON).content(big))
.andExpect(status().isContentTooLarge());
assertThat(service.history()).isEmpty();
}
@Nested
@SpringBootTest
@AutoConfigureMockMvc
@TestPropertySource(properties = "app.webhook.token=geheim")
class WithToken {
@Autowired
MockMvc mvc;
@Test
void rejectsCallWithoutToken() throws Exception {
mvc.perform(post("/api/webhook").contentType(MediaType.APPLICATION_JSON).content("{}"))
.andExpect(status().isUnauthorized());
}
/** Das Token gilt für alle Webhook-URLs gleichermaßen. */
@Test
void rejectsDedicatedUrlWithoutToken() throws Exception {
mvc.perform(post("/api/webhook/kunden").contentType(MediaType.APPLICATION_JSON).content("{}"))
.andExpect(status().isUnauthorized());
mvc.perform(post("/api/webhook/kuriere").contentType(MediaType.APPLICATION_JSON).content("{}"))
.andExpect(status().isUnauthorized());
mvc.perform(post("/api/webhook/jobs").contentType(MediaType.APPLICATION_JSON).content("{}"))
.andExpect(status().isUnauthorized());
}
@Test
void rejectsWrongToken() throws Exception {
mvc.perform(post("/api/webhook")
.contentType(MediaType.APPLICATION_JSON)
.header("X-Webhook-Token", "falsch")
.content("{}"))
.andExpect(status().isUnauthorized());
}
@Test
void acceptsCorrectToken() throws Exception {
mvc.perform(post("/api/webhook")
.contentType(MediaType.APPLICATION_JSON)
.header("X-Webhook-Token", "geheim")
.content("{}"))
.andExpect(status().isOk());
}
}
}
Executable
+99
View File
@@ -0,0 +1,99 @@
#!/usr/bin/env bash
set -euo pipefail
readonly SCRIPT_DIR="$(CDPATH= cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)"
# Paket-Registry des Gitea auf gitea.appcreation.de; der Pfad ist dort
# <Host>/<Besitzer>/<Image>.
readonly REGISTRY_IMAGE="gitea.appcreation.de/sven/swyxweb"
readonly PLATFORM="linux/amd64"
usage() {
cat <<'EOF'
Verwendung:
./docker_push.sh <x.y.z> [--dry-run]
Beispiel:
./docker_push.sh 0.9.13
./docker_push.sh 0.9.13 --dry-run # nur bauen, nicht pushen
Voraussetzungen:
- Docker Buildx ist installiert
- Login zur Registry wurde bereits ausgeführt:
docker login gitea.appcreation.de
Die Versionsnummer ist Pflicht: Sie wird zum Tag des Images und muss damit
bewusst gesetzt werden. Sie wird nicht aus der pom.xml abgeleitet.
Gebaut wird das Dockerfile im Wurzelverzeichnis: Es übersetzt Frontend *und*
Backend selbst, ein vorheriger lokaler Maven- oder npm-Lauf ist also nicht
nötig. Der SwyxTray-Mock ist im Image nicht enthalten (Profil "mock").
EOF
}
fail() {
echo "Fehler: $*" >&2
exit 1
}
require_command() {
command -v "$1" >/dev/null 2>&1 || fail "'$1' wurde nicht gefunden."
}
VERSION=""
DRY_RUN=false
for arg in "$@"; do
case "${arg}" in
-h|--help)
usage
exit 0
;;
--dry-run)
DRY_RUN=true
;;
-*)
fail "Unbekannte Option '${arg}'."
;;
*)
[[ -z "${VERSION}" ]] || fail "Es ist nur eine Versionsnummer erlaubt."
VERSION="${arg}"
;;
esac
done
if [[ -z "${VERSION}" ]]; then
usage >&2
echo >&2
fail "Es muss eine Versionsnummer angegeben werden, z. B. ./docker_push.sh 0.9.13"
fi
if [[ ! "${VERSION}" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]]; then
fail "Versionsnummer muss das Format x.y.z haben (war: '${VERSION}')."
fi
require_command docker
docker buildx version >/dev/null 2>&1 || fail "Docker Buildx ist nicht verfügbar."
cd "${SCRIPT_DIR}"
if [[ "${DRY_RUN}" == true ]]; then
echo "Baue Image ${REGISTRY_IMAGE}:${VERSION} für ${PLATFORM} (dry run, kein Push) ..."
docker buildx build \
--platform "${PLATFORM}" \
-f "${SCRIPT_DIR}/Dockerfile" \
-t "${REGISTRY_IMAGE}:${VERSION}" \
"${SCRIPT_DIR}"
echo "Fertig gebaut, nicht gepusht: ${REGISTRY_IMAGE}:${VERSION}"
exit 0
fi
echo "Baue und pushe Image ${REGISTRY_IMAGE}:${VERSION} für ${PLATFORM} ..."
docker buildx build \
--platform "${PLATFORM}" \
-f "${SCRIPT_DIR}/Dockerfile" \
-t "${REGISTRY_IMAGE}:${VERSION}" \
--push \
"${SCRIPT_DIR}"
echo "Fertig: ${REGISTRY_IMAGE}:${VERSION}"
+1 -1
View File
@@ -1,5 +1,5 @@
# Adresse des WebSocket-Servers; überschreibt den Wert aus /api/config. # Adresse des WebSocket-Servers; überschreibt den Wert aus /api/config.
VITE_WS_URL=ws://192.168.180.135:17654/ws VITE_WS_URL=ws://127.0.0.1:17654/ws
# Ziel des Dev-Proxys für /api, also der Port dieser Anwendung # Ziel des Dev-Proxys für /api, also der Port dieser Anwendung
# (Standard: http://localhost:8080) # (Standard: http://localhost:8080)
+16 -1
View File
@@ -3,7 +3,22 @@
<head> <head>
<meta charset="UTF-8" /> <meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>SwyxWeb</title> <title>SwyxWeb · STADTBOTE</title>
<!-- Signet als Favicon: roter Ring mit „S" (Markenrot #CA0D38). -->
<link
rel="icon"
type="image/svg+xml"
href="data:image/svg+xml,%3Csvg%20xmlns='http://www.w3.org/2000/svg'%20viewBox='0%200%20100%20100'%3E%3Cpath%20d='M%2055%2014.4%20A%2036%2036%200%201%200%2081.8%2033.1'%20fill='none'%20stroke='%23CA0D38'%20stroke-width='13'/%3E%3Ctext%20x='50'%20y='52'%20text-anchor='middle'%20dominant-baseline='central'%20font-family='Arial,Helvetica,sans-serif'%20font-weight='bold'%20font-size='54'%20fill='%23CA0D38'%3ES%3C/text%3E%3C/svg%3E"
/>
<!-- Hausschrift laut Styleguide: Red Hat Display (Überschriften) und
Red Hat Text (Fließtext), beide Google Fonts. Ohne Internetzugang
greift der Fallback auf Systemschriften. -->
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link
rel="stylesheet"
href="https://fonts.googleapis.com/css2?family=Red+Hat+Display:wght@400;600;700&family=Red+Hat+Text:wght@400;500;600&display=swap"
/>
</head> </head>
<body> <body>
<div id="root"></div> <div id="root"></div>
+81
View File
@@ -0,0 +1,81 @@
import { describe, expect, it } from 'vitest'
import { pickCaller, sameNumber, type StoredAddress } from './addresses'
/**
* SwyxIt! meldet Rufnummern mit Amtsholung ("001602107449"), die Webhooks
* liefern sie international ("+491602107449") der Abgleich muss beide
* Schreibweisen als denselben Anschluss erkennen.
*/
describe('sameNumber', () => {
it('erkennt dieselbe Nummer trotz unterschiedlicher Schreibweise', () => {
expect(sameNumber('+491602107449', '001602107449')).toBe(true)
expect(sameNumber('+491602107449', '01602107449')).toBe(true)
expect(sameNumber('+49421409660', '0421409660')).toBe(true)
expect(sameNumber('+491602107449', '+491602107449')).toBe(true)
})
it('unterscheidet verschiedene Anschlüsse', () => {
expect(sameNumber('+491602107449', '+491602107440')).toBe(false)
expect(sameNumber('+49421409660', '+494212409661')).toBe(false)
})
it('verlangt bei kurzen Nummern (Durchwahlen) die volle Übereinstimmung', () => {
expect(sameNumber('7587', '7587')).toBe(true)
expect(sameNumber('7587', '587')).toBe(false)
// Eine Durchwahl ist nicht "dieselbe Nummer" wie ein externer Anschluss.
expect(sameNumber('7449', '+491602107449')).toBe(false)
})
it('liefert ohne Ziffern kein Ergebnis', () => {
expect(sameNumber('', '+491602107449')).toBe(false)
expect(sameNumber('unbekannt', '+491602107449')).toBe(false)
})
})
describe('pickCaller', () => {
const entries: StoredAddress[] = [
{ number: '5215', name: 'Abdul, Rokhsareh', description: 'Globales Telefonbuch' },
{
number: '+491602107449',
name: 'SYSGEN GmbH',
description: 'Kunde | SYSGEN, SYSTEME UND | NL Bremen',
role: 'customer',
cscId: 100164,
url: 'https://test.sb.assecutor.de/admin/tapi_wrapper.php?phoneNo=01602107449',
},
{
number: '+49171677xxxx',
name: 'Rainer Peters (HH1003)',
role: 'courier',
jobIds: [21891233],
},
]
it('findet den Kunden zur gemeldeten Rufnummer', () => {
const match = pickCaller(entries, '001602107449')
expect(match?.name).toBe('SYSGEN GmbH')
expect(match?.url).toContain('tapi_wrapper')
})
it('findet auch den Kurier zur gemeldeten Rufnummer', () => {
const match = pickCaller(entries, '+49171677xxxx')
expect(match?.name).toBe('Rainer Peters (HH1003)')
expect(match?.jobIds).toEqual([21891233])
})
it('übergeht Telefonbuch-Einträge (ohne Rolle)', () => {
expect(pickCaller(entries, '5215')).toBeNull()
})
it('bevorzugt bei doppelter Rufnummer den Kunden vor dem Kurier', () => {
const both: StoredAddress[] = [
{ number: '+49404711', name: 'Kurier', role: 'courier' },
{ number: '+49404711', name: 'Kunde', role: 'customer' },
]
expect(pickCaller(both, '+49404711')?.name).toBe('Kunde')
})
it('liefert null, wenn keine Rufnummer passt', () => {
expect(pickCaller(entries, '+49404711')).toBeNull()
})
})
+108
View File
@@ -0,0 +1,108 @@
import type { Contact } from './swyx/protocol'
/** Ein Eintrag der Sammlung `addresses`, wie ihn `GET /api/addresses` liefert. */
export interface StoredAddress {
number?: string | null
name?: string | null
description?: string | null
/** Rolle im Fremdsystem, z. B. "customer". */
role?: string | null
/** Kundenkennung des Fremdsystems (`csc_id`). */
cscId?: number | null
/** Kennungen der zugehörigen Jobs. */
jobIds?: number[] | null
/** Sprungadresse des Fremdsystems zu diesem Eintrag, z. B. zum Auftrag. */
url?: string | null
}
/**
* Fragt die Adress-Ablage (MongoDB-Sammlung `addresses`) über das Backend ab
* den zusammengeführten Bestand aus den Webhooks (Kunden, Kuriere) und dem
* SwyxTray-Telefonbuch. Gesucht wird **in der Datenbank** (Teilzeichenkette in
* Name, Rufnummer und Beschreibung); im Browser wird nichts vorgehalten.
*/
export async function fetchAddressEntries(query = ''): Promise<StoredAddress[]> {
const wanted = query.trim()
const url = wanted ? `/api/addresses?q=${encodeURIComponent(wanted)}` : '/api/addresses'
const response = await fetch(url)
if (!response.ok) {
throw new Error(`Adress-Ablage nicht abrufbar (/api/addresses antwortete mit HTTP ${response.status}).`)
}
return (await response.json()) as StoredAddress[]
}
/** Wie {@link fetchAddressEntries}, verengt auf die Felder der Adressanzeige. */
export async function fetchAddresses(query = ''): Promise<Contact[]> {
const entries = await fetchAddressEntries(query)
return entries.map((entry) => ({
name: entry.name ?? undefined,
number: entry.number ?? undefined,
description: entry.description ?? undefined,
}))
}
/** Nur die Ziffern, ohne führende Nullen (Amtsholung, nationale Schreibweise). */
function significantDigits(number: string): string {
return number.replace(/\D/g, '').replace(/^0+/, '')
}
/**
* Meinen zwei Rufnummern denselben Anschluss? `+491602107449`, `01602107449`
* und die von SwyxIt! gemeldete Form mit Amtsholung `001602107449`
* unterscheiden sich nur in Vorwahl-Schreibweise und führenden Nullen
* verglichen werden deshalb die letzten (bis zu neun) signifikanten Ziffern.
* Kurze Nummern (interne Durchwahlen) müssen ganz übereinstimmen.
*/
export function sameNumber(a: string, b: string): boolean {
const left = significantDigits(a)
const right = significantDigits(b)
if (!left || !right) return false
const length = Math.min(left.length, right.length)
if (length < 6) return left === right
const tail = Math.min(9, length)
return left.slice(-tail) === right.slice(-tail)
}
/**
* Der erste Kunde oder Kurier aus `entries`, dessen Rufnummer passt Einträge
* ohne Rolle (SwyxTray-Telefonbuch) zählen nicht. Passen beide Rollen, gewinnt
* der Kunde.
*/
export function pickCaller(entries: StoredAddress[], number: string): StoredAddress | null {
const withRole = (role: string) =>
entries.find(
(entry) => entry.role === role && entry.number && sameNumber(entry.number, number),
)
return withRole('customer') ?? withRole('courier') ?? null
}
/**
* Sucht in der Ablage den Kunden oder Kurier zur Rufnummer eines Anrufs. Die
* Datenbank sucht als Teilzeichenkette abgefragt werden deshalb die letzten
* Ziffern, das genaue Passen prüft {@link sameNumber} hier im Browser.
*/
export async function findCallerByNumber(number: string): Promise<StoredAddress | null> {
const digits = significantDigits(number)
if (!digits) return null
const entries = await fetchAddressEntries(digits.slice(-9))
return pickCaller(entries, number)
}
/**
* Meldet den Adress-Cache der SwyxTray-App an das Backend, das die Einträge in
* der MongoDB ablegt (Aktualisierung statt Verdopplung, siehe ArchiveService).
*
* Die WebSocket-Verbindung zur App hält nur der Browser; das Backend käme an
* diese Adressdaten sonst nicht heran. Fire-and-forget wie bei den
* Anrufdaten: Ist das Backend nicht erreichbar, läuft die Anzeige weiter.
*/
export function reportAddresses(contacts: Contact[]): void {
if (contacts.length === 0) return
void fetch('/api/addresses', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ addresses: contacts }),
}).catch((e: unknown) => {
console.warn('Adressdaten nicht an das Backend gemeldet:', e)
})
}
+50
View File
@@ -0,0 +1,50 @@
/**
* Umschaltbares Erscheinungsbild (CI/CD): STADTBOTE (Vorgabe) oder HANSETRANS.
*
* Beide Vorgaben stammen aus dem HANSETRANS-Brandbook (Stand 2025/10): gleiche
* Schriftfamilie (Red Hat Display/Text), aber eigene Farben, Wortmarke und
* Bildmarke je Marke. Die Umschaltung läuft über `data-brand` am Wurzelelement
* die Farb-Token dazu stehen in der index.css.
*/
export type Brand = 'stadtbote' | 'hansetrans'
const STORAGE_KEY = 'swyxweb.brand'
export function loadBrand(): Brand {
try {
return localStorage.getItem(STORAGE_KEY) === 'hansetrans' ? 'hansetrans' : 'stadtbote'
} catch {
// Privater Modus o. Ä. dann eben immer die Vorgabe.
return 'stadtbote'
}
}
const TITLES: Record<Brand, string> = {
stadtbote: 'SwyxWeb · STADTBOTE',
hansetrans: 'SwyxWeb · HANSETRANS',
}
/**
* Favicons als Daten-URI: das STADTBOTE-Signet (roter Ring mit „S",
* Markenrot #CA0D38) und die HANSETRANS-Bildmarke (grünes Achteck #68B022
* mit „Fahrbahn"-Schwung), jeweils wie in den Signet-Komponenten.
*/
const FAVICONS: Record<Brand, string> = {
stadtbote:
"data:image/svg+xml,%3Csvg%20xmlns='http://www.w3.org/2000/svg'%20viewBox='0%200%20100%20100'%3E%3Cpath%20d='M%2055%2014.4%20A%2036%2036%200%201%200%2081.8%2033.1'%20fill='none'%20stroke='%23CA0D38'%20stroke-width='13'/%3E%3Ctext%20x='50'%20y='52'%20text-anchor='middle'%20dominant-baseline='central'%20font-family='Arial,Helvetica,sans-serif'%20font-weight='bold'%20font-size='54'%20fill='%23CA0D38'%3ES%3C/text%3E%3C/svg%3E",
hansetrans:
"data:image/svg+xml,%3Csvg%20xmlns='http://www.w3.org/2000/svg'%20viewBox='0%200%20100%20100'%3E%3Cpath%20fill='%2368B022'%20fill-rule='evenodd'%20d='M29.3%200%20H70.7%20L100%2029.3%20V70.7%20L70.7%20100%20H29.3%20L0%2070.7%20V29.3%20Z%20M4%2052%20C38%2046%2070%2040%2096%2031%20C66%2044%2036%2054%206%2062%20Z%20M10%2076%20C46%2066%2074%2052%2096%2035%20C76%2056%2048%2072%2016%2084%20Z'/%3E%3C/svg%3E",
}
/** Stellt das Erscheinungsbild um und merkt es sich für den nächsten Start. */
export function applyBrand(brand: Brand): void {
document.documentElement.dataset.brand = brand
document.title = TITLES[brand]
const icon = document.querySelector<HTMLLinkElement>("link[rel='icon']")
if (icon) icon.href = FAVICONS[brand]
try {
localStorage.setItem(STORAGE_KEY, brand)
} catch {
// absichtlich still dann gilt die Wahl nur für diese Sitzung.
}
}
+22
View File
@@ -0,0 +1,22 @@
import type { CallEvent } from './swyx/protocol'
/**
* Meldet Anruf-Ereignisse an das Backend, das sie in der MongoDB ablegt.
*
* Die Ereignisse entstehen nur hier im Browser (aus dem Vergleich zweier
* SwyxTray-Snapshots); das Backend sieht die WebSocket-Verbindung nicht.
* Fire-and-forget: Ist das Backend nicht erreichbar, läuft die Anzeige
* unverändert weiter die Speicherung ist Beiwerk, nicht Voraussetzung.
*/
export function reportCallEvents(events: CallEvent[]): void {
if (events.length === 0) return
void fetch('/api/calls', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(events),
// Auch beim Schließen des Tabs noch absetzen, z. B. das "ended" beim Auflegen.
keepalive: true,
}).catch((e: unknown) => {
console.warn('Anrufdaten nicht an das Backend gemeldet:', e)
})
}
@@ -185,10 +185,6 @@ export default function BrowserTabsPanel({ active, disabled, busy, onList, onOpe
</div> </div>
</form> </form>
<p className="note">
Die Tabs gehören zum Firefox auf dem Rechner der SwyxTray-App. Ohne verbundenes Plugin
beantwortet die App die drei Kommandos nach etwa fünf Sekunden mit einem Fehler.
</p>
{hint && <p className="note note--error">{hint}</p>} {hint && <p className="note note--error">{hint}</p>}
{outcome && <p className="note note--success">{outcome}</p>} {outcome && <p className="note note--success">{outcome}</p>}
</> </>
+164
View File
@@ -0,0 +1,164 @@
import { useCallback, useEffect, useRef, useState } from 'react'
import { describeContact, type Contact } from '../swyx/protocol'
import { fetchAddresses } from '../addresses'
interface Props {
/** Panel ist sichtbar erst dann wird abgefragt. */
active: boolean
/** Keine Verbindung zur SwyxTray-App Anrufen entfällt. */
disabled: boolean
/**
* Adress-Cache, den die App unaufgefordert schickt. Er wird hier nicht
* angezeigt, sondern ist das Signal, die Ablage neu abzufragen das Backend
* hat ihn gerade hineingeschrieben.
*/
cache: Contact[] | null
onDial: (number: string) => void
}
/** So lange bekommt das Backend Zeit, den Telefonbuch-Push wegzuschreiben. */
const ARCHIVE_WRITE_DELAY_MS = 1500
/** Entprellung der Eingabe erst dann geht die Abfrage an die Datenbank. */
const SEARCH_DEBOUNCE_MS = 300
/**
* Adressdaten aus der **Adress-Ablage des Backends** (MongoDB-Sammlung
* `addresses`) dem zusammengeführten Bestand aus den Webhooks (Kunden,
* Kuriere) und dem Telefonbuch des Swyx-Clients.
*
* Es gibt keinen Bestand im Browser: Jede Eingabe im Suchfeld fragt
* entprellt die Datenbank ab, gesucht wird dort als Teilzeichenkette in
* Name, Rufnummer und Beschreibung. Angezeigt wird immer das frische
* Abfrageergebnis; der Bereich funktioniert damit auch ohne Verbindung zur
* SwyxTray-App.
*
* Das Telefonbuch der App fließt über den **Cache-Push** beim Verbinden
* (Protokoll 8) in die Ablage die Startseite meldet ihn ans Backend; das
* Panel fragt die Ablage danach neu ab.
*/
export default function ContactsPanel({ active, disabled, cache, onDial }: Props) {
// Das jeweils letzte Abfrageergebnis kein Bestand, nur die Anzeige.
const [results, setResults] = useState<Contact[] | null>(null)
const [loadError, setLoadError] = useState<string | null>(null)
// Bewusst nicht gemerkt: Nach einem Neustart beginnt die Suche leer.
const [filter, setFilter] = useState('')
// Zählt die Abfragen: Eine überholte darf das Ergebnis einer neueren nicht
// mehr überschreiben Antworten kommen nicht zwingend in Reihenfolge.
const queryIdRef = useRef(0)
// Der aktuelle Suchbegriff für Abfragen außerhalb des Eingabe-Effekts
// (Cache-Push) ohne den Effekt neu anzustoßen.
const filterRef = useRef(filter)
filterRef.current = filter
/** Fragt die Ablage mit dem aktuellen Suchbegriff ab. */
const search = useCallback(async () => {
const ticket = ++queryIdRef.current
try {
const entries = await fetchAddresses(filterRef.current)
if (ticket !== queryIdRef.current) return
setResults(entries)
setLoadError(null)
} catch (e) {
if (ticket !== queryIdRef.current) return
setLoadError(e instanceof Error ? e.message : String(e))
}
}, [])
// Jede Eingabe fragt die Datenbank ab entprellt; die erste Abfrage nach
// dem Öffnen läuft sofort. Beim erneuten Öffnen des Bereichs ebenfalls
// frisch abfragen, damit nie ein alter Stand stehen bleibt.
useEffect(() => {
if (!active) return
const timer = setTimeout(() => void search(), results === null ? 0 : SEARCH_DEBOUNCE_MS)
return () => clearTimeout(timer)
// `results` absichtlich nicht in den Abhängigkeiten: Es würde nach jeder
// Antwort eine weitere Abfrage anstoßen.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [active, filter, search])
// Telefonbuch-Push der App: Die Startseite hat ihn ans Backend gemeldet;
// nach einer kurzen Schreibfrist die Ablage neu abfragen.
useEffect(() => {
if (!cache || cache.length === 0) return
const timer = setTimeout(() => void search(), ARCHIVE_WRITE_DELAY_MS)
return () => clearTimeout(timer)
}, [cache, search])
return (
<>
<h2>Adressdaten</h2>
{/* Die `.row` ist nicht bloß Zierde: `.field` wächst (`flex: 1 1 260px`)
und würde als direktes Kind der Karte einer Spalte in die *Höhe*
wachsen und den freien Platz aufsaugen. In der Zeile wächst es in
die Breite, wie in allen anderen Bereichen auch. */}
<div className="row">
<label className="field">
<span className="field__label">Suchen</span>
<input
className="field__input"
type="search"
value={filter}
spellCheck={false}
autoComplete="off"
placeholder="Name, Rufnummer oder Kürzel"
onChange={(e) => setFilter(e.target.value)}
/>
</label>
</div>
{loadError && <p className="note note--error">{loadError}</p>}
{results === null ? (
!loadError && <p className="note">Adressdaten werden abgefragt </p>
) : (
<>
<p className="note">
{filter.trim() ? `${results.length} Treffer` : `${results.length} Einträge`}
</p>
{results.length === 0 ? (
!filter.trim() && (
<p className="note">
Die Ablage ist noch leer sie füllt sich über die Webhooks und über das
Telefonbuch der SwyxTray-App.
</p>
)
) : (
<ul className="contacts">
{results.map((contact, index) => (
// Namen sind nicht eindeutig dieselbe Person kommt mit
// mehreren Durchwahlen vor; deshalb die Position mit hinein.
<li
key={`${contact.number ?? ''}-${contact.name ?? ''}-${index}`}
className="contacts__item"
>
<span className="contacts__info">
<span className="contacts__name">{describeContact(contact)}</span>
{contact.description && (
<span className="contacts__description">{contact.description}</span>
)}
</span>
{contact.number && <span className="contacts__number">{contact.number}</span>}
{/* Ohne Verbindung zur Tray-App gibt es nichts zu wählen
der Knopf erscheint dann gar nicht erst. */}
{contact.number && !disabled && (
<button
type="button"
className="button button--small"
onClick={() => onDial(contact.number!)}
>
Anrufen
</button>
)}
</li>
))}
</ul>
)}
</>
)}
</>
)
}
@@ -0,0 +1,115 @@
import { useEffect, useRef, useState } from 'react'
import type { StoredAddress } from '../addresses'
import { fetchJob } from '../jobs'
interface Props {
/** Der Kunde oder Kurier aus der Adress-Ablage, dessen Rufnummer zum Anruf passt. */
customer: StoredAddress
/**
* Nimmt den Anruf an; solange er klingelt gesetzt, danach `undefined`
* der Knopf „Anruf annehmen" verschwindet dann von selbst.
*/
onAnswer?: () => void
onClose: () => void
}
/**
* Popup zu einem eingehenden Anruf: zeigt die Daten des Anrufers aus der
* Adress-Ablage (Kunde oder Kurier), dazu je Job-Kennung des Eintrags
* (`job_ids`) einen Knopf, der den Job aus der Ablage holt und dessen
* Sprungadresse (`url`) in einem neuen Tab öffnet. Gleiches Overlay wie der
* Wartedialog (siehe LoadingDialog zur Begründung gegen `<dialog>`).
*/
export default function CustomerCallPopup({ customer, onAnswer, onClose }: Props) {
const closeRef = useRef<HTMLButtonElement>(null)
// Meldung, wenn ein Job nicht zu öffnen war (nicht abgelegt, ohne
// Sprungadresse, Ablage nicht erreichbar) das Popup bleibt dann offen.
const [jobNote, setJobNote] = useState<string | null>(null)
// Kennung des Jobs, der gerade geholt wird; sperrt derweil alle Job-Knöpfe.
const [busyJobId, setBusyJobId] = useState<number | null>(null)
// Fokus in den Dialog holen, damit Escape sofort greift.
useEffect(() => {
closeRef.current?.focus()
}, [])
useEffect(() => {
function onKeyDown(event: KeyboardEvent) {
if (event.key === 'Escape') onClose()
}
document.addEventListener('keydown', onKeyDown)
return () => document.removeEventListener('keydown', onKeyDown)
}, [onClose])
const jobIds = customer.jobIds ?? []
async function openJob(jobId: number) {
setBusyJobId(jobId)
setJobNote(null)
try {
const job = await fetchJob(jobId)
if (!job.url) {
setJobNote(`Zu Job ${jobId} ist keine Sprungadresse abgelegt.`)
return
}
window.open(job.url, '_blank', 'noopener,noreferrer')
// Der neue Tab ist offen; das Popup hat damit seinen Zweck erfüllt.
onClose()
} catch (e) {
setJobNote(e instanceof Error ? e.message : String(e))
} finally {
setBusyJobId(null)
}
}
return (
<div className="overlay">
<div className="dialog" role="dialog" aria-modal="true" aria-labelledby="caller-popup-title">
<p className="dialog__status">Eingehender Anruf von</p>
<h2 className="dialog__title" id="caller-popup-title">
{customer.name ?? customer.number}
</h2>
{customer.number && <p className="note">{customer.number}</p>}
{customer.description && <p className="note">{customer.description}</p>}
{customer.cscId != null && <p className="note">Kundennummer {customer.cscId}</p>}
{jobIds.length > 0 && (
<>
<p className="dialog__status">Zugeordnete Jobs</p>
<div className="dialog__jobs">
{jobIds.map((jobId) => (
<button
key={jobId}
type="button"
className="button"
disabled={busyJobId != null}
onClick={() => void openJob(jobId)}
>
{busyJobId === jobId ? `Job ${jobId}` : `Job ${jobId}`}
</button>
))}
</div>
</>
)}
{jobNote && <p className="note note--error">{jobNote}</p>}
<div className="dialog__actions">
{onAnswer && (
// Das Popup bleibt nach dem Annehmen offen: Die Job-Knöpfe werden
// ja gerade während des Gesprächs gebraucht.
<button
type="button"
className="button button--accept dialog__actions-start"
onClick={onAnswer}
>
Anruf annehmen
</button>
)}
<button ref={closeRef} type="button" className="button" onClick={onClose}>
Schließen
</button>
</div>
</div>
</div>
)
}
@@ -0,0 +1,18 @@
/**
* Die HANSETRANS-Bildmarke aus dem Brandbook: ein geöffnetes Achteck mit
* „Fahrbahn"-Schwung. Als Inline-SVG angenähert, weil das Brandbook keine
* Vektordaten enthält; die Farbe kommt über currentColor vom Umfeld, der
* Schwung ist ein echtes Loch (evenodd) und zeigt den Seitenhintergrund
* so, wie das Brandbook die Marke auf hellen und dunklen Flächen zeigt.
*/
export default function HansetransSignet({ className }: { className?: string }) {
return (
<svg className={className} viewBox="0 0 100 100" aria-hidden="true" focusable="false">
<path
fill="currentColor"
fillRule="evenodd"
d="M29.3 0 H70.7 L100 29.3 V70.7 L70.7 100 H29.3 L0 70.7 V29.3 Z M4 52 C38 46 70 40 96 31 C66 44 36 54 6 62 Z M10 76 C46 66 74 52 96 35 C76 56 48 72 16 84 Z"
/>
</svg>
)
}
@@ -1,48 +0,0 @@
import { type CallEvent } from '../swyx/protocol'
interface Props {
call: CallEvent
busy: boolean
onAnswer: (line: number) => void
onHangup: (line: number) => void
}
/** Meldung für einen eingehenden, noch klingelnden Anruf. */
export default function IncomingCallCard({ call, busy, onAnswer, onHangup }: Props) {
// Name und Nummer getrennt anzeigen, sonst die fertige Anzeigeform der App.
const title = call.peerName ?? call.peerNumber ?? call.peer ?? `Leitung ${call.line}`
const subtitle = call.peerName ? call.peerNumber : undefined
return (
<section className="card card--ringing" role="alert">
<div className="ringing">
<span className="ringing__icon" aria-hidden="true">
</span>
<div className="ringing__text">
<p className="ringing__label">Eingehender Anruf · Leitung {call.line}</p>
<p className="ringing__number">{title}</p>
{subtitle && <p className="ringing__meta">{subtitle}</p>}
</div>
<div className="ringing__actions">
<button
type="button"
className="button button--accept"
disabled={busy}
onClick={() => onAnswer(call.line)}
>
Annehmen
</button>
<button
type="button"
className="button button--reject"
disabled={busy}
onClick={() => onHangup(call.line)}
>
Ablehnen
</button>
</div>
</div>
</section>
)
}
+74
View File
@@ -0,0 +1,74 @@
import { useEffect, useRef, type ReactNode } from 'react'
interface Props {
title: string
/** Zusatzzeile unter dem Balken, z. B. „287 Einträge bisher". */
detail?: ReactNode
done: number
/** 0 heißt: Umfang noch unbekannt dann läuft der Balken unbestimmt. */
total: number
onCancel?: () => void
}
/**
* Wartedialog mit Fortschritt. Bewusst ein eigenes Overlay statt `<dialog>`:
* die Panels der Startseite liegen in `hidden`-Bereichen, und `showModal()`
* verhielte sich darin je nach Browser unterschiedlich.
*/
export default function LoadingDialog({ title, detail, done, total, onCancel }: Props) {
const cancelRef = useRef<HTMLButtonElement>(null)
// Der Abbrechen-Knopf ist das einzige Bedienelement er bekommt den Fokus,
// damit Escape und Leertaste sofort greifen.
useEffect(() => {
cancelRef.current?.focus()
}, [])
useEffect(() => {
if (!onCancel) return
function onKeyDown(event: KeyboardEvent) {
if (event.key === 'Escape') onCancel!()
}
document.addEventListener('keydown', onKeyDown)
return () => document.removeEventListener('keydown', onKeyDown)
}, [onCancel])
const percent = total > 0 ? Math.min(100, Math.round((done / total) * 100)) : 0
return (
<div className="overlay">
<div className="dialog" role="dialog" aria-modal="true" aria-labelledby="loading-dialog-title">
<h2 className="dialog__title" id="loading-dialog-title">
{title}
</h2>
<div
className="progress"
role="progressbar"
aria-valuenow={total > 0 ? done : undefined}
aria-valuemin={0}
aria-valuemax={total > 0 ? total : undefined}
aria-valuetext={total > 0 ? `${done} von ${total}` : 'läuft'}
>
<div
className={`progress__bar${total > 0 ? '' : ' progress__bar--unknown'}`}
style={total > 0 ? { width: `${percent}%` } : undefined}
/>
</div>
<p className="dialog__status" aria-live="polite">
{total > 0 ? `${done} von ${total} Abfragen` : 'Abfragen laufen …'}
{detail && <span className="dialog__detail">{detail}</span>}
</p>
{onCancel && (
<div className="dialog__actions">
<button ref={cancelRef} type="button" className="button" onClick={onCancel}>
Abbrechen
</button>
</div>
)}
</div>
</div>
)
}
@@ -0,0 +1,29 @@
/**
* Das STADTBOTE-Signet aus dem Corporate-Design-Manual: ein offener roter Ring
* mit einem „S" in der Mitte. Als Inline-SVG nachgebaut, weil das Brandbook das
* Logo nur als Pixelbild enthält; die Farbe kommt über currentColor vom Umfeld.
*/
export default function StadtboteSignet({ className }: { className?: string }) {
return (
<svg className={className} viewBox="0 0 100 100" aria-hidden="true" focusable="false">
<path
d="M 55 14.4 A 36 36 0 1 0 81.8 33.1"
fill="none"
stroke="currentColor"
strokeWidth="13"
/>
<text
x="50"
y="52"
textAnchor="middle"
dominantBaseline="central"
fontFamily="'Red Hat Display', 'Red Hat Text', system-ui, sans-serif"
fontWeight="700"
fontSize="54"
fill="currentColor"
>
S
</text>
</svg>
)
}
+7
View File
@@ -1,6 +1,8 @@
export interface Tab<Id extends string> { export interface Tab<Id extends string> {
id: Id id: Id
label: string label: string
/** Anzahl noch nicht gesehener Nachrichten; 0 oder fehlend blendet ihn aus. */
badge?: number
} }
interface Props<Id extends string> { interface Props<Id extends string> {
@@ -35,6 +37,11 @@ export default function Tabs<Id extends string>({ tabs, active, onChange }: Prop
onClick={() => onChange(tab.id)} onClick={() => onChange(tab.id)}
> >
{tab.label} {tab.label}
{tab.badge ? (
<span className="tabs__badge" aria-label={`${tab.badge} neu`}>
{tab.badge > 99 ? '99+' : tab.badge}
</span>
) : null}
</button> </button>
))} ))}
</nav> </nav>
+85
View File
@@ -0,0 +1,85 @@
import { formatPayload, summarize, type WebhookEvent } from '../webhook'
import type { WebhookStatus } from '../hooks/useWebhook'
interface Props {
events: WebhookEvent[]
status: WebhookStatus
error: string | null
onClear: () => void
}
const timeFormat = new Intl.DateTimeFormat('de-DE', {
hour: '2-digit',
minute: '2-digit',
second: '2-digit',
})
const dateFormat = new Intl.DateTimeFormat('de-DE', {
day: '2-digit',
month: '2-digit',
year: 'numeric',
})
/** Eingangszeit; das Datum nur, wenn die Nachricht nicht von heute ist. */
function describeTime(receivedAt: string): string {
const at = new Date(receivedAt)
if (Number.isNaN(at.getTime())) return receivedAt
const today = new Date()
const sameDay =
at.getFullYear() === today.getFullYear() &&
at.getMonth() === today.getMonth() &&
at.getDate() === today.getDate()
return sameDay ? timeFormat.format(at) : `${dateFormat.format(at)} ${timeFormat.format(at)}`
}
const STATUS_TEXT: Record<WebhookStatus, string> = {
connecting: 'Verbindung wird aufgebaut …',
open: 'Verbunden neue Nachrichten erscheinen sofort.',
closed: 'Nicht verbunden. Seite neu laden, um den Strom wieder zu öffnen.',
}
/**
* Zeigt die über den Webhook eingegangenen Nachrichten jüngste zuerst, jede
* mit ihrem JSON im Original.
*
* Bewusst ohne Deutung der Nutzlast: Das aufrufende System muss sich an kein
* Schema halten, deshalb wird angezeigt, was ankam. Nur die Kopfzeile fasst
* `name` und `number` zusammen, wenn es sie gibt.
*/
export default function WebhookPanel({ events, status, error, onClear }: Props) {
return (
<>
<div className="card__header">
<h2>Webhook</h2>
<button
type="button"
className="button button--ghost"
onClick={onClear}
disabled={events.length === 0}
>
Leeren
</button>
</div>
<p className={`note${status === 'closed' ? ' note--error' : ''}`}>{STATUS_TEXT[status]}</p>
{error && <p className="note note--error">{error}</p>}
{events.length === 0 ? (
<p className="note">Noch keine Nachricht eingegangen.</p>
) : (
<ul className="webhook">
{events.map((event) => (
<li key={event.id} className="webhook__item">
<div className="webhook__meta">
<span className="webhook__time">{describeTime(event.receivedAt)}</span>
<span className="webhook__id">#{event.id}</span>
<span className="webhook__summary">{summarize(event.payload)}</span>
</div>
<pre className="webhook__json">{formatPayload(event.payload)}</pre>
</li>
))}
</ul>
)}
</>
)
}
+31 -1
View File
@@ -1,11 +1,41 @@
/** Fallback-Adresse des WebSocket-Servers, falls weder .env noch /api/config etwas liefern. */ /** Fallback-Adresse des WebSocket-Servers, falls weder .env noch /api/config etwas liefern. */
export const DEFAULT_WS_HOST = '192.168.180.135' export const DEFAULT_WS_HOST = '127.0.0.1'
export const DEFAULT_WS_PORT = 17654 export const DEFAULT_WS_PORT = 17654
export const DEFAULT_WS_PATH = '/ws' export const DEFAULT_WS_PATH = '/ws'
export const DEFAULT_WS_URL = export const DEFAULT_WS_URL =
import.meta.env.VITE_WS_URL ?? `ws://${DEFAULT_WS_HOST}:${DEFAULT_WS_PORT}${DEFAULT_WS_PATH}` import.meta.env.VITE_WS_URL ?? `ws://${DEFAULT_WS_HOST}:${DEFAULT_WS_PORT}${DEFAULT_WS_PATH}`
const SAVED_WS_URL_KEY = 'swyxweb.wsUrl'
/**
* Zuletzt erfolgreich verbundene Adresse. localStorage kann fehlen oder gesperrt
* sein (Privatmodus, Browser-Einstellung) dann wird schlicht nichts gemerkt.
*/
export function loadSavedWsUrl(): string | null {
try {
return localStorage.getItem(SAVED_WS_URL_KEY)
} catch {
return null
}
}
export function saveWsUrl(url: string): void {
try {
localStorage.setItem(SAVED_WS_URL_KEY, url)
} catch {
// Ohne Speicher kein Wiederverbinden nach Neustart mehr geht nicht verloren.
}
}
export function clearSavedWsUrl(): void {
try {
localStorage.removeItem(SAVED_WS_URL_KEY)
} catch {
// Siehe saveWsUrl.
}
}
export interface ClientConfig { export interface ClientConfig {
websocketUrl: string websocketUrl: string
host: string host: string
+43 -1
View File
@@ -1,6 +1,9 @@
import { useCallback, useEffect, useMemo, useRef, useState } from 'react' import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { SwyxTrayClient, type ConnectionStatus, type RawMessage } from '../swyx/SwyxTrayClient' import { SwyxTrayClient, type ConnectionStatus, type RawMessage } from '../swyx/SwyxTrayClient'
import { mergeSnapshot, type CallEvent, type SnapshotMessage } from '../swyx/protocol' import { mergeSnapshot, type CallEvent, type Contact, type SnapshotMessage } from '../swyx/protocol'
import { loadDirectory as loadDirectoryFrom, type SweepOptions } from '../swyx/directory'
import { reportCallEvents } from '../calls'
import { reportAddresses } from '../addresses'
export interface LogEntry extends RawMessage { export interface LogEntry extends RawMessage {
id: number id: number
@@ -31,6 +34,8 @@ export function useSwyxTray() {
// Belegte Leitungen, nach Leitungsnummer; jeder Snapshot schreibt sie neu. // Belegte Leitungen, nach Leitungsnummer; jeder Snapshot schreibt sie neu.
const [lines, setLines] = useState<Record<number, CallEvent>>({}) const [lines, setLines] = useState<Record<number, CallEvent>>({})
const [tray, setTray] = useState<TrayState>({}) const [tray, setTray] = useState<TrayState>({})
// Adress-Cache der App; null heißt „noch keine Cache-Nachricht erhalten".
const [addressCache, setAddressCache] = useState<Contact[] | null>(null)
const [busy, setBusy] = useState(false) const [busy, setBusy] = useState(false)
const clientRef = useRef<SwyxTrayClient | null>(null) const clientRef = useRef<SwyxTrayClient | null>(null)
@@ -54,9 +59,18 @@ export function useSwyxTray() {
linesRef.current = {} linesRef.current = {}
setLines({}) setLines({})
setTray({}) setTray({})
// Auch der Cache gehörte zur alten Verbindung; die App schickt ihn
// beim Verbinden erneut.
setAddressCache(null)
} }
}), }),
client.on('error', (message) => setError(message)), client.on('error', (message) => setError(message)),
// Unaufgefordert: beim Verbinden und wenn die App ihren Cache erneuert.
client.on('addresses', (contacts) => {
setAddressCache(contacts)
// Dauerhaft in die MongoDB, über das Backend die Anzeige wartet nicht darauf.
reportAddresses(contacts)
}),
client.on('raw', (message) => { client.on('raw', (message) => {
setLog((entries) => { setLog((entries) => {
const next = [...entries, { ...message, id: logIdRef.current++ }] const next = [...entries, { ...message, id: logIdRef.current++ }]
@@ -81,6 +95,8 @@ export function useSwyxTray() {
setLines(next) setLines(next)
if (events.length > 0) { if (events.length > 0) {
setHistory((entries) => [...[...events].reverse(), ...entries].slice(0, MAX_HISTORY)) setHistory((entries) => [...[...events].reverse(), ...entries].slice(0, MAX_HISTORY))
// Dauerhaft in die MongoDB, über das Backend die Anzeige wartet nicht darauf.
reportCallEvents(events)
} }
}), }),
] ]
@@ -123,6 +139,30 @@ export function useSwyxTray() {
const openTab = useCallback((url: string) => run(() => client.openTab(url)), [client, run]) const openTab = useCallback((url: string) => run(() => client.openTab(url)), [client, run])
const closeTab = useCallback((tabId: number) => run(() => client.closeTab(tabId)), [client, run]) const closeTab = useCallback((tabId: number) => run(() => client.closeTab(tabId)), [client, run])
/**
* Holt den **gesamten** Adressbestand über den Adress-Cache der App
* (`addresses`), sonst über rund 110 Einzelabfragen. Der zweite Weg braucht
* einige Sekunden; daher Fortschritt und Abbruchmöglichkeit. Der Busy-Zustand
* gilt für den ganzen Vorgang, nicht für jede Einzelabfrage.
*/
const loadDirectory = useCallback(
(options: SweepOptions = {}) =>
run(async () => {
const directory = await loadDirectoryFrom(
{
listAddresses: async () => (await client.listAddresses()).addresses ?? [],
search: async (query) => (await client.searchContacts(query)).contacts ?? [],
},
options,
)
// Auch dieser Bestand dauerhaft in die MongoDB der unaufgeforderte
// Adress-Push deckt ältere App-Versionen ohne Cache nicht ab.
reportAddresses(directory.contacts)
return directory
}),
[client, run],
)
const sendRaw = useCallback( const sendRaw = useCallback(
(text: string) => { (text: string) => {
try { try {
@@ -157,6 +197,7 @@ export function useSwyxTray() {
calls, calls,
ringingCall, ringingCall,
tray, tray,
addressCache,
busy, busy,
connect, connect,
disconnect, disconnect,
@@ -168,6 +209,7 @@ export function useSwyxTray() {
listTabs, listTabs,
openTab, openTab,
closeTab, closeTab,
loadDirectory,
sendRaw, sendRaw,
clearLog, clearLog,
} }
+92
View File
@@ -0,0 +1,92 @@
import { useCallback, useEffect, useRef, useState } from 'react'
import {
clearHistory,
fetchHistory,
isWebhookEvent,
mergeEvent,
mergeEvents,
type WebhookEvent,
} from '../webhook'
export type WebhookStatus = 'connecting' | 'open' | 'closed'
export interface WebhookState {
events: WebhookEvent[]
status: WebhookStatus
error: string | null
clear: () => Promise<void>
}
/**
* Hört auf die Nachrichten des Webhooks.
*
* Der Strom läuft über eine `EventSource` auf `/api/webhook/events` und wird
* **unabhängig vom geöffneten Bereich** gehalten: So ist schon alles da, wenn
* der Bereich „Webhook" aufgeschlagen wird. Bricht die Verbindung ab, verbindet
* der Browser von selbst neu und schickt dabei die `Last-Event-ID` mit das
* Backend liefert dann nach, was in der Lücke ankam.
*
* Zusätzlich wird beim Start der Verlauf geholt, damit auch Nachrichten von vor
* dem Laden der Seite erscheinen.
*/
export function useWebhook(): WebhookState {
const [events, setEvents] = useState<WebhookEvent[]>([])
const [status, setStatus] = useState<WebhookStatus>('connecting')
const [error, setError] = useState<string | null>(null)
const sourceRef = useRef<EventSource | null>(null)
useEffect(() => {
const controller = new AbortController()
fetchHistory(controller.signal)
.then((history) => {
if (controller.signal.aborted) return
setEvents((current) => mergeEvents(current, history))
})
.catch((e: unknown) => {
if (controller.signal.aborted) return
setError(e instanceof Error ? e.message : String(e))
})
const source = new EventSource('/api/webhook/events')
sourceRef.current = source
source.onopen = () => {
setStatus('open')
setError(null)
}
source.addEventListener('webhook', (event) => {
try {
const parsed: unknown = JSON.parse((event as MessageEvent<string>).data)
if (isWebhookEvent(parsed)) setEvents((current) => mergeEvent(current, parsed))
} catch {
// Unlesbare Nachricht: lieber übergehen als die Anzeige abstürzen lassen.
}
})
source.onerror = () => {
// Der Browser verbindet von selbst neu; nur der endgültige Abbruch ist final.
setStatus(source.readyState === EventSource.CLOSED ? 'closed' : 'connecting')
}
return () => {
controller.abort()
source.close()
sourceRef.current = null
}
}, [])
const clear = useCallback(async () => {
try {
await clearHistory()
setEvents([])
setError(null)
} catch (e: unknown) {
setError(e instanceof Error ? e.message : String(e))
}
}, [])
return { events, status, error, clear }
}
+428 -127
View File
@@ -1,26 +1,76 @@
/*
* Farb- und Schriftvorgaben aus dem HANSETRANS Brandbook. Vorgabe ist das
* STADTBOTE-Erscheinungsbild: Primär Rot #CA0D38 (Pantone 185 C) und Grau
* #7C7F7E (Pantone 424 C), Off-Black #222222 für Fließtext, Grauabstufungen
* als Flächen. Schrift: Red Hat Display (Überschriften, Versalien, Laufweite
* 4 %) und Red Hat Text (Fließtext). Buttons: primär vollflächig, sekundär mit
* Rahmen, Kästen und Linien mit leichter Abrundung.
* Das HANSETRANS-Erscheinungsbild wird weiter unten über data-brand
* am Wurzelelement zugeschaltet.
*/
:root { :root {
--bg: #0f1116; --bg: #ffffff;
--surface: #171a21; --surface: #ffffff;
--surface-2: #1f2530; --surface-2: #f6f6f6;
--border: #2b3341; --border: #dcdcdd;
--text: #e6e9ef; --border-strong: #b4b4b5;
--text-muted: #98a2b3; --text: #222222;
--accent: #4f8cff; --text-muted: #7c7f7e;
--ok: #35c98a; /* Markenrot laut Styleguide (RGB 202/13/56, Pantone 185 C) in beiden
--warn: #e0b341; Farbmodi identisch, das Brandbook kennt nur diesen einen Rotwert. */
--err: #f2585b; --brand: #ca0d38;
color-scheme: dark; --brand-strong: #a30a2d;
--brand-soft: rgb(202 13 56 / 8%);
--accent: #ca0d38;
/* Kopfzeile: Bild- und Wortmarke; bei STADTBOTE beide im Markenrot. */
--logo-mark: #ca0d38;
--logo-word: #ca0d38;
--ok: #2f9e6e;
--warn: #b98a1c;
--err: #d92b1f;
color-scheme: light;
} }
@media (prefers-color-scheme: light) { @media (prefers-color-scheme: dark) {
:root { :root {
--bg: #f5f7fa; --bg: #17181a;
--surface: #ffffff; --surface: #1f2023;
--surface-2: #eef1f6; --surface-2: #28292d;
--border: #d8dee9; --border: #3a3b40;
--text: #1b2230; --border-strong: #55565c;
--text-muted: #5c6779; --text: #f1f1f2;
color-scheme: light; --text-muted: #a4a7a8;
--brand-soft: rgb(202 13 56 / 14%);
--ok: #43c78f;
--warn: #e0b341;
--err: #f2665b;
color-scheme: dark;
}
}
/*
* HANSETRANS-Erscheinungsbild (HANSETRANS Brandbook 2025/10, Styleguide):
* HANSETRANS Grün #68B022 (Pantone 361 C) als zentrale Akzentfarbe,
* Dunkelgrün #3D821C, HANSETRANS Grau #4B4A4D als Farbe der Wortmarke,
* Off-Black #222222 für Fließtext, Grauabstufungen #B4B4B5/#DCDCDD/#F6F6F6.
* Umgeschaltet über data-brand am Wurzelelement (Bereich „Verbindung").
*/
:root[data-brand='hansetrans'] {
--brand: #68b022;
--brand-strong: #3d821c;
--brand-soft: rgb(104 176 34 / 10%);
--accent: #68b022;
--text-muted: #4b4a4d;
--logo-mark: #68b022;
--logo-word: #4b4a4d;
}
@media (prefers-color-scheme: dark) {
:root[data-brand='hansetrans'] {
--brand-soft: rgb(104 176 34 / 16%);
--text-muted: #a4a7a8;
/* Auf dunklen Flächen bleibt das Achteck grün, der Schriftzug wird weiß. */
--logo-word: #f1f1f2;
} }
} }
@@ -32,9 +82,30 @@ body {
margin: 0; margin: 0;
background: var(--bg); background: var(--bg);
color: var(--text); color: var(--text);
font-family: system-ui, -apple-system, 'Segoe UI', Roboto, sans-serif; font-family: 'Red Hat Text', system-ui, -apple-system, 'Segoe UI', Roboto, sans-serif;
font-size: 15px; font-size: 15px;
line-height: 1.5; line-height: 1.4;
}
h1,
h2,
h3 {
/* Überschriften laut STADTBOTE-Styleguide: Red Hat Display Semibold in
Versalien, Laufweite 4 %, Zeilenabstand 100 %. */
font-family: 'Red Hat Display', 'Red Hat Text', system-ui, sans-serif;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.04em;
line-height: 1;
}
/* HANSETRANS setzt Überschriften gemischt geschrieben mit leicht negativer
Laufweite (Red Hat Display Semibold, Laufweite 20). */
:root[data-brand='hansetrans'] h1,
:root[data-brand='hansetrans'] h2,
:root[data-brand='hansetrans'] h3 {
text-transform: none;
letter-spacing: -0.02em;
} }
code { code {
@@ -59,14 +130,69 @@ code {
flex-wrap: wrap; flex-wrap: wrap;
} }
.page__header h1 { /* Abgerundete Linie als Trennlinie unter dem Kopf Gestaltungselement des CI. */
.page__header::after {
content: '';
flex-basis: 100%;
height: 3px;
border-radius: 999px;
background: var(--brand);
}
.page__header-tools {
display: flex;
align-items: center;
gap: 10px;
}
/* Marken-Umschalter im Badge-Stil, damit er neben dem Status nicht aus der
Reihe fällt. Die Wahl gilt sofort und bleibt über Neustarts erhalten. */
.brand-switch {
appearance: none;
padding: 6px 12px;
border-radius: 999px;
border: 1px solid var(--border);
background: var(--surface);
color: var(--text);
font: inherit;
font-size: 13px;
cursor: pointer;
}
.brand-switch:hover {
border-color: var(--border-strong);
}
.brand {
display: flex;
align-items: center;
gap: 14px;
color: var(--logo-mark);
}
.brand__signet {
width: 46px;
height: 46px;
flex: 0 0 auto;
}
.brand__name {
margin: 0; margin: 0;
font-size: 26px; font-weight: 700;
letter-spacing: -0.02em; font-size: 24px;
letter-spacing: 0.06em;
color: var(--logo-word);
}
/* Die HANSETRANS-Wortmarke steht kursiv („Brandon Grotesque Bold Italic"
hier mit der Hausschrift angenähert) in HANSETRANS Grau bzw. Weiß. */
:root[data-brand='hansetrans'] .brand__name {
font-style: italic;
letter-spacing: 0.02em;
} }
.page__subtitle { .page__subtitle {
margin: 2px 0 0; margin: 4px 0 0;
color: var(--text-muted); color: var(--text-muted);
} }
@@ -76,15 +202,10 @@ code {
gap: 16px; gap: 16px;
} }
.page__footer {
color: var(--text-muted);
font-size: 13px;
}
.card { .card {
background: var(--surface); background: var(--surface);
border: 1px solid var(--border); border: 1px solid var(--border);
border-radius: 12px; border-radius: 8px;
padding: 18px; padding: 18px;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
@@ -93,8 +214,8 @@ code {
.card h2 { .card h2 {
margin: 0; margin: 0;
font-size: 16px; font-size: 15px;
font-weight: 600; color: var(--text);
} }
.card__header { .card__header {
@@ -134,9 +255,9 @@ code {
.field__input { .field__input {
width: 100%; width: 100%;
padding: 9px 12px; padding: 9px 12px;
border-radius: 8px; border-radius: 6px;
border: 1px solid var(--border); border: 1px solid var(--border-strong);
background: var(--surface-2); background: var(--surface);
color: var(--text); color: var(--text);
font: inherit; font: inherit;
font-family: ui-monospace, SFMono-Regular, Menlo, monospace; font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
@@ -144,27 +265,37 @@ code {
} }
.field__input:focus { .field__input:focus {
outline: 2px solid var(--accent); outline: 2px solid var(--brand);
outline-offset: 1px; outline-offset: 1px;
border-color: var(--brand);
} }
.field__input:disabled { .field__input:disabled {
opacity: 0.55; opacity: 0.55;
background: var(--surface-2);
} }
/* Sekundärbutton laut Styleguide: schlichter roter Rahmen, leichte Abrundung. */
.button { .button {
padding: 9px 16px; padding: 9px 16px;
border-radius: 8px; border-radius: 6px;
border: 1px solid var(--border); border: 1px solid var(--accent);
background: var(--surface-2); background: transparent;
color: var(--text); color: var(--accent);
font: inherit; font: inherit;
font-weight: 500; font-weight: 500;
cursor: pointer; cursor: pointer;
} }
/* Auch Links treten als Knopf auf (z. B. „Auftrag öffnen" im Anruf-Popup). */
a.button {
display: inline-flex;
align-items: center;
text-decoration: none;
}
.button:hover:not(:disabled) { .button:hover:not(:disabled) {
border-color: var(--accent); background: var(--brand-soft);
} }
.button:disabled { .button:disabled {
@@ -172,13 +303,20 @@ code {
cursor: not-allowed; cursor: not-allowed;
} }
/* Primärbutton laut Styleguide: vollflächig im Markenrot. */
.button--primary { .button--primary {
background: var(--accent); background: var(--brand);
border-color: var(--accent); border-color: var(--brand);
color: #fff; color: #fff;
} }
.button--primary:hover:not(:disabled) {
background: var(--brand-strong);
border-color: var(--brand-strong);
}
.button--ghost { .button--ghost {
border-color: transparent;
background: transparent; background: transparent;
padding: 5px 10px; padding: 5px 10px;
font-size: 13px; font-size: 13px;
@@ -200,21 +338,25 @@ code {
.tabs { .tabs {
display: flex; display: flex;
gap: 4px; gap: 18px;
border-bottom: 1px solid var(--border); border-bottom: 1px solid var(--border);
flex-wrap: wrap;
} }
.tabs__button { .tabs__button {
padding: 9px 16px; padding: 10px 2px;
border: 1px solid transparent; border: none;
/* Die aktive Kante überdeckt die Linie der Leiste. */ border-bottom: 3px solid transparent;
border-bottom: none;
margin-bottom: -1px; margin-bottom: -1px;
border-radius: 10px 10px 0 0; border-radius: 0;
background: transparent; background: transparent;
color: var(--text-muted); color: var(--text-muted);
font: inherit; font: inherit;
font-weight: 500; font-family: 'Red Hat Display', 'Red Hat Text', system-ui, sans-serif;
font-weight: 600;
font-size: 13px;
text-transform: uppercase;
letter-spacing: 0.04em;
cursor: pointer; cursor: pointer;
} }
@@ -223,9 +365,8 @@ code {
} }
.tabs__button--active { .tabs__button--active {
background: var(--surface); color: var(--accent);
border-color: var(--border); border-bottom-color: var(--brand);
color: var(--text);
} }
[role='tabpanel'] { [role='tabpanel'] {
@@ -286,75 +427,15 @@ code {
} }
} }
/* --- Eingehender Anruf --- */
.card--ringing {
border-color: var(--ok);
box-shadow: 0 0 0 1px var(--ok);
}
.ringing {
display: flex;
align-items: center;
gap: 16px;
flex-wrap: wrap;
}
.ringing__icon {
font-size: 30px;
animation: shake 1s ease-in-out infinite;
}
.ringing__text {
flex: 1 1 200px;
min-width: 0;
}
.ringing__label {
margin: 0;
font-size: 12px;
text-transform: uppercase;
letter-spacing: 0.06em;
color: var(--text-muted);
}
.ringing__number {
margin: 2px 0 0;
font-size: 22px;
font-weight: 600;
word-break: break-all;
}
.ringing__meta {
margin: 2px 0 0;
color: var(--text-muted);
font-size: 13px;
}
.ringing__actions {
display: flex;
gap: 8px;
}
@keyframes shake {
25% {
transform: rotate(-14deg);
}
75% {
transform: rotate(14deg);
}
}
@media (prefers-reduced-motion: reduce) {
.ringing__icon {
animation: none;
}
}
.button--accept { .button--accept {
background: var(--ok); background: var(--ok);
border-color: var(--ok); border-color: var(--ok);
color: #06281c; color: #fff;
}
.button--accept:hover:not(:disabled) {
background: var(--ok);
opacity: 0.9;
} }
.button--reject { .button--reject {
@@ -363,6 +444,11 @@ code {
color: #fff; color: #fff;
} }
.button--reject:hover:not(:disabled) {
background: var(--err);
opacity: 0.9;
}
.button--small { .button--small {
padding: 5px 12px; padding: 5px 12px;
font-size: 13px; font-size: 13px;
@@ -387,7 +473,7 @@ code {
gap: 12px; gap: 12px;
flex-wrap: wrap; flex-wrap: wrap;
padding: 10px 12px; padding: 10px 12px;
border-radius: 8px; border-radius: 6px;
border: 1px solid var(--border); border: 1px solid var(--border);
background: var(--surface-2); background: var(--surface-2);
} }
@@ -400,7 +486,7 @@ code {
.history__line { .history__line {
flex: 0 0 auto; flex: 0 0 auto;
padding: 2px 8px; padding: 2px 8px;
border-radius: 6px; border-radius: 4px;
background: var(--border); background: var(--border);
font-family: ui-monospace, SFMono-Regular, Menlo, monospace; font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
font-size: 12px; font-size: 12px;
@@ -463,8 +549,8 @@ code {
} }
.history__badge--ended { .history__badge--ended {
border-color: var(--err); border-color: var(--border-strong);
color: var(--err); color: var(--text-muted);
} }
.history__number { .history__number {
@@ -492,7 +578,7 @@ code {
align-items: center; align-items: center;
gap: 12px; gap: 12px;
padding: 10px 12px; padding: 10px 12px;
border-radius: 8px; border-radius: 6px;
border: 1px solid var(--border); border: 1px solid var(--border);
background: var(--surface-2); background: var(--surface-2);
} }
@@ -504,7 +590,7 @@ code {
.tablist__id { .tablist__id {
flex: 0 0 auto; flex: 0 0 auto;
padding: 2px 8px; padding: 2px 8px;
border-radius: 6px; border-radius: 4px;
background: var(--border); background: var(--border);
font-family: ui-monospace, SFMono-Regular, Menlo, monospace; font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
font-size: 12px; font-size: 12px;
@@ -547,11 +633,147 @@ code {
white-space: nowrap; white-space: nowrap;
} }
.overlay {
position: fixed;
inset: 0;
z-index: 50;
display: flex;
align-items: center;
justify-content: center;
padding: 24px;
background: rgb(0 0 0 / 55%);
}
.dialog {
width: min(420px, 100%);
padding: 24px;
border-radius: 8px;
border: 1px solid var(--border);
background: var(--surface);
box-shadow: 0 24px 60px rgb(0 0 0 / 25%);
}
.dialog__title {
margin: 0 0 16px;
font-size: 17px;
}
.dialog__status {
margin: 12px 0 0;
font-size: 14px;
color: var(--text-muted);
}
.dialog__detail {
display: block;
}
.dialog__actions {
display: flex;
justify-content: flex-end;
gap: 8px;
margin-top: 20px;
}
/* Schiebt einen Knopf an den linken Rand der Aktionszeile („Anruf annehmen"). */
.dialog__actions-start {
margin-right: auto;
}
/* Die Job-Knöpfe des Anruf-Popups: einer je Kennung, umbrechend. */
.dialog__jobs {
display: flex;
flex-wrap: wrap;
gap: 8px;
margin-top: 8px;
}
.progress {
height: 8px;
border-radius: 999px;
background: var(--surface-2);
border: 1px solid var(--border);
overflow: hidden;
}
.progress__bar {
height: 100%;
border-radius: 999px;
background: var(--brand);
transition: width 120ms linear;
}
/* Umfang noch unbekannt: ein wanderndes Stück statt eines Füllstands. */
.progress__bar--unknown {
width: 35%;
animation: progress-slide 1.1s ease-in-out infinite;
}
@keyframes progress-slide {
from {
transform: translateX(-100%);
}
to {
transform: translateX(300%);
}
}
.contacts {
list-style: none;
margin: 0;
padding: 0;
display: flex;
flex-direction: column;
gap: 8px;
/* Mehrere hundert Einträge die Liste darf die Seite nicht sprengen. */
max-height: 520px;
overflow-y: auto;
}
.contacts__item {
display: flex;
align-items: center;
gap: 12px;
padding: 10px 12px;
border-radius: 6px;
border: 1px solid var(--border);
background: var(--surface-2);
}
.contacts__info {
display: flex;
flex-direction: column;
flex: 1 1 auto;
min-width: 0;
}
.contacts__name {
font-weight: 600;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.contacts__description {
font-size: 13px;
color: var(--text-muted);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.contacts__number {
flex: 0 0 auto;
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
font-size: 14px;
font-weight: 600;
}
.log { .log {
height: 300px; height: 300px;
overflow-y: auto; overflow-y: auto;
padding: 12px; padding: 12px;
border-radius: 8px; border-radius: 6px;
border: 1px solid var(--border); border: 1px solid var(--border);
background: var(--surface-2); background: var(--surface-2);
font-family: ui-monospace, SFMono-Regular, Menlo, monospace; font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
@@ -595,3 +817,82 @@ code {
.log__entry--system { .log__entry--system {
color: var(--text-muted); color: var(--text-muted);
} }
/* Webhook: eingegangene Nachrichten, jüngste zuerst. */
.webhook {
list-style: none;
margin: 0;
padding: 0;
display: flex;
flex-direction: column;
gap: 12px;
}
.webhook__item {
display: flex;
flex-direction: column;
gap: 8px;
padding: 10px 12px;
border-radius: 6px;
border: 1px solid var(--border);
background: var(--surface-2);
}
.webhook__meta {
display: flex;
align-items: baseline;
gap: 8px;
/* Bei schmalem Fenster darf die Kopfzeile umbrechen, statt zu überlaufen. */
flex-wrap: wrap;
}
.webhook__time {
font-variant-numeric: tabular-nums;
color: var(--text-muted);
font-size: 13px;
}
.webhook__id {
padding: 1px 8px;
border-radius: 4px;
background: var(--border);
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
font-size: 12px;
font-weight: 600;
}
.webhook__summary {
flex: 1 1 auto;
min-width: 0;
font-weight: 600;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.webhook__json {
margin: 0;
padding: 10px 12px;
border-radius: 4px;
background: var(--surface);
border: 1px solid var(--border);
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
font-size: 13px;
line-height: 1.45;
/* Lange Zeilen scrollen im Block, statt die Seite breit zu ziehen. */
overflow-x: auto;
/* Ein einzelner Riesenwert soll die Karte nicht sprengen. */
max-height: 320px;
overflow-y: auto;
}
.tabs__badge {
margin-left: 8px;
padding: 1px 7px;
border-radius: 999px;
background: var(--brand);
color: #fff;
font-size: 12px;
font-weight: 700;
font-variant-numeric: tabular-nums;
}
+154
View File
@@ -0,0 +1,154 @@
import { afterEach, describe, expect, it, vi } from 'vitest'
import { Job, fetchJob } from './jobs'
/**
* Dieselbe Beispiel-Nutzlast wie in den Backend-Tests (JobEntryTests.java)
* beide Seiten verstehen denselben Webhook.
*/
const SAMPLE = `{
"type": "job",
"id": 21891263,
"url": "https://test.sb.assecutor.de/admin/jb_detail.php?job_id=21891263",
"state": 9,
"ordertime": "2026-08-13T13:35:27",
"orderdate": "2026-08-13",
"modified": "2026-08-13T13:35:27",
"finished": null,
"vehicle": "Transporter XL",
"service": null,
"canceled": false,
"global": false,
"customer": { "csc_id": 100164, "name": "SYSGEN GmbH", "hq": "Bremen",
"phone": "+491602107449" },
"courier": null,
"tours": [
{ "id": 22396758, "sort": 1, "state": 0, "mode": "del", "comp": "SYSGEN GmbH",
"person": "Frau Hoffmann", "phone": "+49421409660", "com": null,
"remark": "~~~\\nHebebühne benötigt.\\n~~~", "street": "Am Hallacker 48",
"zip": "28327", "city": "Bremen", "finished": null },
{ "id": 22396759, "sort": 2, "state": 0, "mode": "pu",
"comp": "Super Micro Computer B.V.", "person": null, "phone": null, "com": null,
"remark": "Abholreferenz: 8801420234\\nAnzahl an Paletten: 8",
"street": "Het Sterrenbeeld 12-16", "zip": "5215", "city": "'s-Hertogenbosch",
"finished": null }
]
}`
describe('Job.isJob', () => {
it('erkennt die Job-Nutzlast am Kennzeichen "type":"job"', () => {
expect(Job.isJob(JSON.parse(SAMPLE))).toBe(true)
expect(Job.isJob({ name: 'SYSGEN GmbH' })).toBe(false)
expect(Job.isJob({ type: 'addresses', addresses: [] })).toBe(false)
expect(Job.isJob(null)).toBe(false)
})
})
describe('Job.fromJson', () => {
it('füllt alle Felder aus der Beispiel-Nutzlast', () => {
const job = Job.fromJson(JSON.parse(SAMPLE))
expect(job.id).toBe(21891263)
expect(job.url).toBe('https://test.sb.assecutor.de/admin/jb_detail.php?job_id=21891263')
expect(job.state).toBe(9)
expect(job.ordertime).toBe('2026-08-13T13:35:27')
expect(job.orderdate).toBe('2026-08-13')
expect(job.finished).toBeUndefined()
expect(job.vehicle).toBe('Transporter XL')
expect(job.service).toBeUndefined()
expect(job.canceled).toBe(false)
expect(job.global).toBe(false)
expect(job.customer?.cscId).toBe(100164)
expect(job.customer?.name).toBe('SYSGEN GmbH')
expect(job.customer?.hq).toBe('Bremen')
expect(job.customer?.phone).toBe('+491602107449')
// Noch kein Kurier zugeteilt (in der Nutzlast null).
expect(job.courier).toBeUndefined()
expect(job.tours).toHaveLength(2)
const first = job.tours[0]
expect(first.id).toBe(22396758)
expect(first.sort).toBe(1)
expect(first.mode).toBe('del')
expect(first.person).toBe('Frau Hoffmann')
expect(first.phone).toBe('+49421409660')
expect(first.remark).toContain('Hebebühne benötigt.')
expect(first.street).toBe('Am Hallacker 48')
// Fehlende Rufnummer und leere Felder der zweiten Station bleiben undefined.
expect(job.tours[1].phone).toBeUndefined()
expect(job.tours[1].city).toBe("'s-Hertogenbosch")
})
it('versteht die ältere Form mit "number" statt "phone"', () => {
const job = Job.fromJson({
type: 'job',
id: 21891253,
courier: { cr_id: 14116, sid: 'B1006', name: 'CA Kurier B1006', number: '+4917xxxxxxx' },
tours: [{ id: 22396738, sort: 1, number: '+4942037010xx' }],
})
expect(job.courier?.phone).toBe('+4917xxxxxxx')
expect(job.tours[0].phone).toBe('+4942037010xx')
})
it('kommt mit kargen Nutzlasten aus, verlangt aber die Kennung', () => {
const bare = Job.fromJson({ id: 4711 })
expect(bare.id).toBe(4711)
expect(bare.customer).toBeUndefined()
expect(bare.tours).toEqual([])
expect(() => Job.fromJson({ state: 1 })).toThrowError(/"id" fehlt/)
expect(() => Job.fromJson('kein Objekt')).toThrowError(/"id" fehlt/)
})
})
describe('Job.toJson', () => {
it('liefert wieder die Form des Webhooks', () => {
const job = Job.fromJson(JSON.parse(SAMPLE))
const json = job.toJson()
expect(json.type).toBe('job')
expect(json.id).toBe(21891263)
expect(json.canceled).toBe(false)
expect((json.customer as Record<string, unknown>).csc_id).toBe(100164)
expect((json.customer as Record<string, unknown>).phone).toBe('+491602107449')
expect((json.tours as unknown[]).length).toBe(2)
// Einmal hin und zurück ändert nichts mehr.
expect(Job.fromJson(json)).toEqual(job)
})
})
/**
* Über diesen Weg öffnet das Anruf-Popup einen Job aus `job_ids` des Kunden:
* Kennung → Job aus der Ablage → Sprungadresse (`url`).
*/
describe('fetchJob', () => {
afterEach(() => {
vi.unstubAllGlobals()
})
it('holt den Job zur Kennung vom Backend', async () => {
const fetchMock = vi.fn().mockResolvedValue(new Response(SAMPLE, { status: 200 }))
vi.stubGlobal('fetch', fetchMock)
const job = await fetchJob(21891263)
expect(fetchMock).toHaveBeenCalledWith('/api/jobs/21891263')
expect(job.id).toBe(21891263)
expect(job.url).toBe('https://test.sb.assecutor.de/admin/jb_detail.php?job_id=21891263')
})
it('meldet eine unbekannte Kennung verständlich', async () => {
vi.stubGlobal('fetch', vi.fn().mockResolvedValue(new Response('', { status: 404 })))
await expect(fetchJob(4711)).rejects.toThrowError(/Kein Job mit der Kennung 4711/)
})
it('meldet eine nicht erreichbare Ablage verständlich', async () => {
vi.stubGlobal('fetch', vi.fn().mockResolvedValue(new Response('', { status: 502 })))
await expect(fetchJob(4711)).rejects.toThrowError(/HTTP 502/)
})
})
+273
View File
@@ -0,0 +1,273 @@
/**
* Jobs (Kurieraufträge), wie sie das Fremdsystem über den Webhook schickt
* (`POST /api/webhook/jobs` bzw. `"type":"job"` am generischen Webhook) und wie
* sie das Backend in der MongoDB-Sammlung `jobs` ablegt (JobEntry.java).
*
* Die Klasse {@link Job} ist das typisierte Gegenstück im Browser:
* {@link Job.fromJson} übernimmt beliebiges JSON und liefert einen geprüften
* Job unbekannte Felder werden übergangen, fehlende bleiben `undefined`.
* {@link Job.toJson} liefert wieder die Form des Webhooks.
*
* Die Zeitangaben bleiben die Zeichenketten der Nutzlast
* (z. B. `2026-08-13T13:35:27`, teils auch mit Zeitzonenversatz): ISO-8601
* sortiert auch als Text richtig, und die Schreibweise des Fremdsystems geht
* nicht verloren.
*
* Die Rufnummern hießen in einer älteren Form der Nutzlast `number` statt
* `phone` beide Schreibweisen werden angenommen.
*/
/**
* Holt einen einzelnen Job über das Backend aus der Sammlung `jobs` etwa zu
* einer Kennung aus `job_ids` eines Kunden, um dessen Sprungadresse (`url`) zu
* öffnen.
*
* @throws wenn die Kennung nicht abgelegt oder die Ablage nicht erreichbar ist
*/
export async function fetchJob(id: number): Promise<Job> {
const response = await fetch(`/api/jobs/${id}`)
if (response.status === 404) {
throw new Error(`Kein Job mit der Kennung ${id} in der Ablage.`)
}
if (!response.ok) {
throw new Error(`Job-Ablage nicht abrufbar (/api/jobs/${id} antwortete mit HTTP ${response.status}).`)
}
return Job.fromJson(await response.json())
}
/** Zeichenkette der Nutzlast; leer, `null` oder fehlend wird `undefined`. */
function text(value: unknown): string | undefined {
return typeof value === 'string' && value !== '' ? value : undefined
}
/** Ganzzahl der Nutzlast; alles andere wird `undefined`. */
function integer(value: unknown): number | undefined {
return typeof value === 'number' && Number.isFinite(value) ? value : undefined
}
/** Wahrheitswert der Nutzlast; alles andere wird `undefined`. */
function bool(value: unknown): boolean | undefined {
return typeof value === 'boolean' ? value : undefined
}
function record(value: unknown): Record<string, unknown> {
return typeof value === 'object' && value !== null ? (value as Record<string, unknown>) : {}
}
/** Der beauftragende Kunde. */
export class JobCustomer {
constructor(
/** Kundenkennung des Fremdsystems (`csc_id`). */
readonly cscId?: number,
/** Firmenname. */
readonly name?: string,
/** Niederlassung, z. B. "Bremen". */
readonly hq?: string,
/** Rufnummer. */
readonly phone?: string,
) {}
static fromJson(value: unknown): JobCustomer {
const json = record(value)
return new JobCustomer(
integer(json.csc_id),
text(json.name),
text(json.hq),
text(json.phone) ?? text(json.number),
)
}
toJson(): Record<string, unknown> {
return { csc_id: this.cscId, name: this.name, hq: this.hq, phone: this.phone }
}
}
/** Der ausführende Kurier. */
export class JobCourier {
constructor(
/** Kurierkennung des Fremdsystems (`cr_id`). */
readonly crId?: number,
/** Kurzkennung, z. B. "B1006". */
readonly sid?: string,
/** Anzeigename. */
readonly name?: string,
/** Rufnummer. */
readonly phone?: string,
) {}
static fromJson(value: unknown): JobCourier {
const json = record(value)
return new JobCourier(
integer(json.cr_id),
text(json.sid),
text(json.name),
text(json.phone) ?? text(json.number),
)
}
toJson(): Record<string, unknown> {
return { cr_id: this.crId, sid: this.sid, name: this.name, phone: this.phone }
}
}
/** Eine Station des Jobs. */
export class JobTour {
constructor(
/** Kennung des Fremdsystems. */
readonly id?: number,
/** Reihenfolge innerhalb des Jobs, 1-basiert. */
readonly sort?: number,
/** Zustand der Station. */
readonly state?: number,
/** Art der Station, z. B. "pu" (Abholung) oder "del" (Zustellung). */
readonly mode?: string,
/** Firma an der Station. */
readonly comp?: string,
/** Ansprechperson. */
readonly person?: string,
/** Rufnummer an der Station, kann fehlen. */
readonly phone?: string,
/** Straße und Hausnummer. */
readonly street?: string,
/** Postleitzahl. */
readonly zip?: string,
/** Ort. */
readonly city?: string,
/** Bemerkung. */
readonly com?: string,
/** Hinweise des Fremdsystems, mehrzeilig (Referenzen, Maße …). */
readonly remark?: string,
/** Wann die Station abgeschlossen wurde, sonst leer. */
readonly finished?: string,
) {}
static fromJson(value: unknown): JobTour {
const json = record(value)
return new JobTour(
integer(json.id),
integer(json.sort),
integer(json.state),
text(json.mode),
text(json.comp),
text(json.person),
text(json.phone) ?? text(json.number),
text(json.street),
text(json.zip),
text(json.city),
text(json.com),
text(json.remark),
text(json.finished),
)
}
toJson(): Record<string, unknown> {
return {
id: this.id,
sort: this.sort,
state: this.state,
mode: this.mode,
comp: this.comp,
person: this.person,
phone: this.phone,
street: this.street,
zip: this.zip,
city: this.city,
com: this.com,
remark: this.remark,
finished: this.finished,
}
}
}
/** Ein Job (Kurierauftrag) mit Kunde, Kurier und Stationen. */
export class Job {
constructor(
/** Kennung des Fremdsystems im Backend zugleich Schlüssel der Sammlung. */
readonly id: number,
/** Sprungadresse des Fremdsystems zur Auftragsansicht. */
readonly url?: string,
/** Zustand des Jobs im Fremdsystem. */
readonly state?: number,
/** Auftragszeit, z. B. "2026-08-13T13:35:27". */
readonly ordertime?: string,
/** Auftragsdatum, z. B. "2026-08-13". */
readonly orderdate?: string,
/** Letzte Änderung im Fremdsystem. */
readonly modified?: string,
/** Wann der Job abgeschlossen wurde, sonst leer. */
readonly finished?: string,
/** Fahrzeugart, z. B. "Transporter XL". */
readonly vehicle?: string,
/** Gebuchte Leistung, kann fehlen. */
readonly service?: string,
/** Ist der Job storniert? */
readonly canceled?: boolean,
/** Bundesweite Vermittlung? */
readonly global?: boolean,
/** Der beauftragende Kunde. */
readonly customer?: JobCustomer,
/** Der ausführende Kurier, solange keiner zugeteilt ist `undefined`. */
readonly courier?: JobCourier,
/** Die Stationen des Jobs, in der Reihenfolge der Nutzlast (`sort`). */
readonly tours: JobTour[] = [],
) {}
/** Trägt die Nutzlast das Kennzeichen `"type":"job"`? */
static isJob(value: unknown): boolean {
return record(value).type === 'job'
}
/**
* Füllt die Klasse aus der Webhook-Nutzlast.
*
* @throws wenn die Kennung fehlt ohne sie ist es kein Job
*/
static fromJson(value: unknown): Job {
const json = record(value)
const id = integer(json.id)
if (id === undefined) {
throw new Error('Nutzlast ist kein Job das Feld "id" fehlt.')
}
const tours = Array.isArray(json.tours) ? json.tours.map(JobTour.fromJson) : []
// `null` heißt: noch kein Kurier zugeteilt dann bleibt es undefined.
const hasCustomer = json.customer !== undefined && json.customer !== null
const hasCourier = json.courier !== undefined && json.courier !== null
return new Job(
id,
text(json.url),
integer(json.state),
text(json.ordertime),
text(json.orderdate),
text(json.modified),
text(json.finished),
text(json.vehicle),
text(json.service),
bool(json.canceled),
bool(json.global),
hasCustomer ? JobCustomer.fromJson(json.customer) : undefined,
hasCourier ? JobCourier.fromJson(json.courier) : undefined,
tours,
)
}
/** Die Form des Webhooks, z. B. zum Weiterreichen oder Anzeigen. */
toJson(): Record<string, unknown> {
return {
type: 'job',
id: this.id,
url: this.url,
state: this.state,
ordertime: this.ordertime,
orderdate: this.orderdate,
modified: this.modified,
finished: this.finished,
vehicle: this.vehicle,
service: this.service,
canceled: this.canceled,
global: this.global,
customer: this.customer?.toJson(),
courier: this.courier?.toJson(),
tours: this.tours.map((tour) => tour.toJson()),
}
}
}
+4
View File
@@ -1,8 +1,12 @@
import { StrictMode } from 'react' import { StrictMode } from 'react'
import { createRoot } from 'react-dom/client' import { createRoot } from 'react-dom/client'
import App from './App.tsx' import App from './App.tsx'
import { applyBrand, loadBrand } from './brand'
import './index.css' import './index.css'
// Vor dem ersten Rendern, damit die Seite nicht kurz in der falschen Marke aufblitzt.
applyBrand(loadBrand())
const container = document.getElementById('root') const container = document.getElementById('root')
if (!container) { if (!container) {
throw new Error('Root-Element #root nicht gefunden') throw new Error('Root-Element #root nicht gefunden')
+160 -53
View File
@@ -1,19 +1,29 @@
import { useEffect, useRef, useState, type FormEvent, type ReactNode } from 'react' import { useEffect, useRef, useState, type FormEvent, type ReactNode } from 'react'
import { DEFAULT_WS_URL, fetchClientConfig } from '../config' import { DEFAULT_WS_URL, fetchClientConfig, clearSavedWsUrl, loadSavedWsUrl, saveWsUrl } from '../config'
import { useSwyxTray } from '../hooks/useSwyxTray' import { useSwyxTray } from '../hooks/useSwyxTray'
import { useCallNotifications } from '../hooks/useCallNotifications' import { useCallNotifications } from '../hooks/useCallNotifications'
import { useWebhook } from '../hooks/useWebhook'
import { countUnseen } from '../webhook'
import { applyBrand, loadBrand, type Brand } from '../brand'
import { findCallerByNumber, type StoredAddress } from '../addresses'
import StatusBadge from '../components/StatusBadge' import StatusBadge from '../components/StatusBadge'
import CustomerCallPopup from '../components/CustomerCallPopup'
import StadtboteSignet from '../components/StadtboteSignet'
import HansetransSignet from '../components/HansetransSignet'
import MessageLog from '../components/MessageLog' import MessageLog from '../components/MessageLog'
import IncomingCallCard from '../components/IncomingCallCard'
import DialPanel from '../components/DialPanel' import DialPanel from '../components/DialPanel'
import BrowserTabsPanel from '../components/BrowserTabsPanel' import BrowserTabsPanel from '../components/BrowserTabsPanel'
import ContactsPanel from '../components/ContactsPanel'
import WebhookPanel from '../components/WebhookPanel'
import Tabs, { panelId, tabId, type Tab } from '../components/Tabs' import Tabs, { panelId, tabId, type Tab } from '../components/Tabs'
import { ActiveCallList, CallHistory } from '../components/CallList' import { ActiveCallList, CallHistory } from '../components/CallList'
type TabName = 'calls' | 'browsertabs' | 'connection' type TabName = 'calls' | 'contacts' | 'webhook' | 'browsertabs' | 'connection'
const TABS: Tab<TabName>[] = [ const TABS: Tab<TabName>[] = [
{ id: 'calls', label: 'Anrufe' }, { id: 'calls', label: 'Anrufe' },
{ id: 'contacts', label: 'Adressdaten' },
{ id: 'webhook', label: 'Webhook' },
{ id: 'browsertabs', label: 'Tabs' }, { id: 'browsertabs', label: 'Tabs' },
{ id: 'connection', label: 'Verbindung' }, { id: 'connection', label: 'Verbindung' },
] ]
@@ -31,7 +41,9 @@ export default function HomePage() {
const [url, setUrl] = useState(DEFAULT_WS_URL) const [url, setUrl] = useState(DEFAULT_WS_URL)
const [configNote, setConfigNote] = useState<string | null>(null) const [configNote, setConfigNote] = useState<string | null>(null)
const [tab, setTab] = useState<TabName>('calls') const [tab, setTab] = useState<TabName>('calls')
const [draft, setDraft] = useState('') // Erscheinungsbild (CI/CD): STADTBOTE oder HANSETRANS, gemerkt über Neustarts.
const [brand, setBrand] = useState<Brand>(loadBrand)
useEffect(() => applyBrand(brand), [brand])
const tray = useSwyxTray() const tray = useSwyxTray()
const { status, error, log, history, calls, ringingCall, busy, tray: appState } = tray const { status, error, log, history, calls, ringingCall, busy, tray: appState } = tray
@@ -40,15 +52,78 @@ 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
// 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
// an der SwyxTray-Verbindung, und soll auch dann mitschreiben, wenn gerade ein
// anderer Bereich offen ist.
const webhook = useWebhook()
// Eine Webhook-Nachricht kommt unabhängig davon an, welcher Bereich offen ist.
// Damit sie nicht unbemerkt bleibt, trägt der Tab die Zahl der noch nicht
// gesehenen Nachrichten; gesehen ist alles, was bei offenem Bereich ankam.
const [seenWebhookId, setSeenWebhookId] = useState(0)
const newestWebhookId = webhook.events[0]?.id ?? 0
useEffect(() => {
if (tab === 'webhook') setSeenWebhookId(newestWebhookId)
}, [tab, newestWebhookId])
const unseenWebhook = countUnseen(webhook.events, seenWebhookId)
const tabsWithBadges = TABS.map((entry) =>
entry.id === 'webhook' ? { ...entry, badge: unseenWebhook } : entry,
)
// Der Benutzer soll seine eingetippte Adresse nicht durch die Backend-Antwort verlieren. // Der Benutzer soll seine eingetippte Adresse nicht durch die Backend-Antwort verlieren.
const urlTouched = useRef(false) const urlTouched = useRef(false)
// Adresse des laufenden Verbindungsversuchs; gemerkt wird sie erst, wenn die
// Verbindung tatsächlich zustande kommt.
const pendingUrl = useRef<string | null>(null)
useEffect(() => { useEffect(() => {
// Mit der zuletzt erfolgreich verbundenen Adresse direkt wieder verbinden
// die Backend-Konfiguration muss dann nicht gefragt werden.
const saved = loadSavedWsUrl()
if (saved) {
urlTouched.current = true
setUrl(saved)
pendingUrl.current = saved
tray.connect(saved)
return
}
const controller = new AbortController() const controller = new AbortController()
fetchClientConfig(controller.signal) fetchClientConfig(controller.signal)
.then((backendUrl) => { .then((backendUrl) => {
if (controller.signal.aborted) return if (controller.signal.aborted) return
setConfigNote(`Adresse vom Backend übernommen: ${backendUrl}`)
if (!urlTouched.current) setUrl(backendUrl) if (!urlTouched.current) setUrl(backendUrl)
}) })
.catch((e: unknown) => { .catch((e: unknown) => {
@@ -57,43 +132,64 @@ export default function HomePage() {
setConfigNote(`Backend-Konfiguration nicht abrufbar (${message}) Standardadresse wird verwendet.`) setConfigNote(`Backend-Konfiguration nicht abrufbar (${message}) Standardadresse wird verwendet.`)
}) })
return () => controller.abort() return () => controller.abort()
// eslint-disable-next-line react-hooks/exhaustive-deps -- nur beim Laden der Seite
}, []) }, [])
// Erst die zustande gekommene Verbindung zählt: dann die Adresse merken, damit
// sie nach Verbindungsverlust oder Neustart automatisch wieder aufgebaut wird.
useEffect(() => {
if (status === 'open' && pendingUrl.current) saveWsUrl(pendingUrl.current)
}, [status])
function handleConnect(event: FormEvent) { function handleConnect(event: FormEvent) {
event.preventDefault() event.preventDefault()
const target = url.trim() const target = url.trim()
if (target) tray.connect(target) if (!target) return
pendingUrl.current = target
tray.connect(target)
} }
function handleSendRaw(event: FormEvent) { // Manuelles Trennen heißt auch: nach einem Neustart nicht wieder verbinden.
event.preventDefault() function handleDisconnect() {
const message = draft.trim() clearSavedWsUrl()
if (message && tray.sendRaw(message)) setDraft('') pendingUrl.current = null
tray.disconnect()
} }
return ( return (
<div className="page"> <div className="page">
<header className="page__header"> <header className="page__header">
<div> <div className="brand">
<h1>SwyxWeb</h1> {brand === 'hansetrans' ? (
<p className="page__subtitle">Telefonie über die SwyxTray-App</p> <HansetransSignet className="brand__signet" />
) : (
<StadtboteSignet className="brand__signet" />
)}
<div>
<h1 className="brand__name">{brand === 'hansetrans' ? 'HANSETRANS' : 'Stadtbote'}</h1>
<p className="page__subtitle">SwyxWeb</p>
</div>
</div>
<div className="page__header-tools">
{/* Erscheinungsbild (CI/CD) umschalten im Badge-Stil, damit es
neben dem Verbindungsstatus nicht aus der Reihe fällt. */}
<select
className="brand-switch"
aria-label="Erscheinungsbild"
title="Erscheinungsbild"
value={brand}
onChange={(e) => setBrand(e.target.value as Brand)}
>
<option value="stadtbote">STADTBOTE</option>
<option value="hansetrans">HANSETRANS</option>
</select>
<StatusBadge status={status} />
</div> </div>
<StatusBadge status={status} />
</header> </header>
<main className="page__main"> <main className="page__main">
{/* Steht bewusst über den Tabs: Ein klingelnder Anruf darf nicht davon <Tabs tabs={tabsWithBadges} active={tab} onChange={setTab} />
abhängen, welcher Bereich gerade offen ist. */}
{ringingCall && (
<IncomingCallCard
call={ringingCall}
busy={busy}
onAnswer={(line) => void tray.answer(line)}
onHangup={(line) => void tray.hangup(line)}
/>
)}
<Tabs tabs={TABS} active={tab} onChange={setTab} />
<TabPanel id="calls" active={tab === 'calls'}> <TabPanel id="calls" active={tab === 'calls'}>
<section className="card"> <section className="card">
@@ -130,6 +226,29 @@ export default function HomePage() {
</section> </section>
</TabPanel> </TabPanel>
<TabPanel id="contacts" active={tab === 'contacts'}>
<section className="card">
<ContactsPanel
active={tab === 'contacts'}
disabled={!isConnected}
cache={tray.addressCache}
onDial={(number) => void tray.dial(number)}
/>
{error && <p className="note note--error">{error}</p>}
</section>
</TabPanel>
<TabPanel id="webhook" active={tab === 'webhook'}>
<section className="card">
<WebhookPanel
events={webhook.events}
status={webhook.status}
error={webhook.error}
onClear={() => void webhook.clear()}
/>
</section>
</TabPanel>
<TabPanel id="browsertabs" active={tab === 'browsertabs'}> <TabPanel id="browsertabs" active={tab === 'browsertabs'}>
<section className="card"> <section className="card">
<BrowserTabsPanel <BrowserTabsPanel
@@ -159,7 +278,7 @@ export default function HomePage() {
value={url} value={url}
spellCheck={false} spellCheck={false}
autoComplete="off" autoComplete="off"
placeholder="ws://192.168.180.135:17654/ws" placeholder="ws://127.0.0.1:17654/ws"
onChange={(e) => { onChange={(e) => {
urlTouched.current = true urlTouched.current = true
setUrl(e.target.value) setUrl(e.target.value)
@@ -173,7 +292,7 @@ export default function HomePage() {
<button <button
type="button" type="button"
className="button" className="button"
onClick={tray.disconnect} onClick={handleDisconnect}
disabled={!isBusyConnection} disabled={!isBusyConnection}
> >
Trennen Trennen
@@ -196,26 +315,8 @@ export default function HomePage() {
<section className="card"> <section className="card">
<h2>Diagnose</h2> <h2>Diagnose</h2>
<MessageLog entries={log} /> <MessageLog entries={log} />
<form className="row" onSubmit={handleSendRaw}> <div className="row">
<label className="field">
<span className="field__label">Rohnachricht senden</span>
<input
className="field__input"
type="text"
value={draft}
disabled={!isConnected}
placeholder={'{"id":1,"cmd":"status"}'}
onChange={(e) => setDraft(e.target.value)}
/>
</label>
<div className="row__actions"> <div className="row__actions">
<button
type="submit"
className="button button--primary"
disabled={!isConnected || !draft.trim()}
>
Senden
</button>
<button <button
type="button" type="button"
className="button" className="button"
@@ -233,16 +334,22 @@ export default function HomePage() {
Log leeren Log leeren
</button> </button>
</div> </div>
</form> </div>
</section> </section>
</TabPanel> </TabPanel>
</main> </main>
<footer className="page__footer"> {caller && (
SwyxTray · Kommandos <code>call</code> / <code>answer</code> / <code>hangup</code> /{' '} <CustomerCallPopup
<code>tabs</code> / <code>opentab</code> / <code>closetab</code> · Zustand über{' '} customer={caller}
<code>snapshot</code> · Standardziel <code>{DEFAULT_WS_URL}</code> // Nur solange es klingelt: Danach fällt der Annehmen-Knopf im Popup
</footer> // von selbst weg, die Kundendaten und Job-Knöpfe bleiben stehen.
onAnswer={
ringingCall && !busy ? () => void tray.answer(ringingCall.line) : undefined
}
onClose={() => setCaller(null)}
/>
)}
</div> </div>
) )
} }
+88 -3
View File
@@ -1,8 +1,11 @@
import { import {
COMMANDS, COMMANDS,
CONTACT_QUERY_MAX_LENGTH,
parseAddresses,
parseHello, parseHello,
parseResult, parseResult,
parseSnapshot, parseSnapshot,
type Contact,
type HelloMessage, type HelloMessage,
type ResultMessage, type ResultMessage,
type SnapshotMessage, type SnapshotMessage,
@@ -20,6 +23,8 @@ interface Listeners {
status: (status: ConnectionStatus) => void status: (status: ConnectionStatus) => void
hello: (hello: HelloMessage) => void hello: (hello: HelloMessage) => void
snapshot: (snapshot: SnapshotMessage) => void snapshot: (snapshot: SnapshotMessage) => void
/** Unaufgeforderter Adress-Cache der App; auch eine leere Liste zählt. */
addresses: (contacts: Contact[]) => void
raw: (message: RawMessage) => void raw: (message: RawMessage) => void
error: (message: string) => void error: (message: string) => void
} }
@@ -36,6 +41,48 @@ const DEFAULT_TIMEOUT_MS = 10000
// selbst fünf Sekunden, bevor sie mit einem Fehler antwortet. // selbst fünf Sekunden, bevor sie mit einem Fehler antwortet.
const TAB_TIMEOUT_MS = 15000 const TAB_TIMEOUT_MS = 15000
/** Loopback gilt dem Browser als vertrauenswürdig dorthin ist `ws://` auch von HTTPS aus erlaubt. */
function isLoopback(hostname: string): boolean {
const host = hostname.replace(/^\[|\]$/g, '').toLowerCase()
return (
host === 'localhost' ||
host.endsWith('.localhost') ||
host === '::1' ||
/^127\./.test(host)
)
}
/**
* Eine über HTTPS ausgelieferte Seite darf keine unverschlüsselte `ws://`-Verbindung
* öffnen. Der Browser wirft dafür nur ein nichtssagendes SecurityError
* ("The operation is insecure."), deshalb wird der Grund hier selbst benannt.
*/
export function describeConnectFailure(url: string, message: string): string {
let target: URL | null = null
try {
target = new URL(url)
} catch {
// Wirklich kaputte Adresse dann bleibt es bei der Browser-Meldung.
}
if (
target &&
target.protocol === 'ws:' &&
typeof window !== 'undefined' &&
window.location.protocol === 'https:' &&
!isLoopback(target.hostname)
) {
return (
`Diese Seite läuft über HTTPS; der Browser blockiert deshalb die unverschlüsselte ` +
`Verbindung zu ${url} (Mixed Content). Die SwyxTray-App muss dafür selbst TLS ` +
`anbieten (wss://). Läuft sie auf demselben Rechner wie der Browser, ist ` +
`ws://127.0.0.1:${target.port || '17654'}${target.pathname} erlaubt.`
)
}
return `Ungültige WebSocket-Adresse: ${message}`
}
/** /**
* WebSocket-Client für die SwyxTray-App. Die Verbindung wird im Browser * WebSocket-Client für die SwyxTray-App. Die Verbindung wird im Browser
* aufgebaut; Quittungen werden über die numerische `id` zugeordnet, * aufgebaut; Quittungen werden über die numerische `id` zugeordnet,
@@ -50,6 +97,7 @@ export class SwyxTrayClient {
status: new Set(), status: new Set(),
hello: new Set(), hello: new Set(),
snapshot: new Set(), snapshot: new Set(),
addresses: new Set(),
raw: new Set(), raw: new Set(),
error: new Set(), error: new Set(),
} }
@@ -179,6 +227,35 @@ export class SwyxTrayClient {
return this.send(COMMANDS.closeTab, { tabId }, TAB_TIMEOUT_MS) return this.send(COMMANDS.closeTab, { tabId }, TAB_TIMEOUT_MS)
} }
/**
* Durchsucht die Adressdaten des Swyx-Clients. Gesucht wird als
* Teilzeichenkette in Name und Rufnummer; die App liefert höchstens 100
* Treffer und kürzt ohne Hinweis. Ein leerer
* Suchbegriff wäre ein Fehler der App er wird hier gar nicht erst gesendet.
*/
searchContacts(query: string): Promise<ResultMessage> {
const wanted = query.trim()
if (!wanted) {
return Promise.reject(new Error('Bitte einen Suchbegriff angeben.'))
}
if (wanted.length > CONTACT_QUERY_MAX_LENGTH) {
return Promise.reject(
new Error(`Der Suchbegriff darf höchstens ${CONTACT_QUERY_MAX_LENGTH} Zeichen lang sein.`),
)
}
return this.send(COMMANDS.contacts, { query: wanted })
}
/**
* Holt den Adress-Cache der App am Stück ohne Suchbegriff und ohne Deckel.
* Ältere App-Versionen kennen das Kommando nicht und antworten mit
* `"Unbekanntes Kommando 'addresses'."`; die Startseite weicht dann auf die
* Einzelabfragen aus.
*/
listAddresses(): Promise<ResultMessage> {
return this.send(COMMANDS.addresses)
}
ping(): Promise<ResultMessage> { ping(): Promise<ResultMessage> {
return this.send(COMMANDS.ping) return this.send(COMMANDS.ping)
} }
@@ -200,7 +277,7 @@ export class SwyxTrayClient {
socket = new WebSocket(url) socket = new WebSocket(url)
} catch (e) { } catch (e) {
const message = e instanceof Error ? e.message : String(e) const message = e instanceof Error ? e.message : String(e)
this.emit('error', `Ungültige WebSocket-Adresse: ${message}`) this.emit('error', describeConnectFailure(url, message))
this.emit('status', 'closed') this.emit('status', 'closed')
return return
} }
@@ -270,14 +347,22 @@ export class SwyxTrayClient {
return return
} }
// Die App schickt keine Ereignisse: Jede Änderung auch ein eingehender // Die App schickt keine Anruf-Ereignisse: Jede Änderung auch ein
// Anruf kommt als vollständiger Snapshot. // eingehender Anruf kommt als vollständiger Snapshot.
const snapshot = parseSnapshot(parsed) const snapshot = parseSnapshot(parsed)
if (snapshot) { if (snapshot) {
this.emit('snapshot', snapshot) this.emit('snapshot', snapshot)
return return
} }
// Der Adress-Cache dagegen kommt unaufgefordert beim Verbinden und wenn
// die App ihn erneuert.
const addresses = parseAddresses(parsed)
if (addresses) {
this.emit('addresses', addresses)
return
}
const hello = parseHello(parsed) const hello = parseHello(parsed)
if (hello) { if (hello) {
this.emit('hello', hello) this.emit('hello', hello)
+282
View File
@@ -0,0 +1,282 @@
import { describe, expect, it, vi } from 'vitest'
import { CONTACT_RESULT_LIMIT, type Contact } from './protocol'
import {
contactKey,
filterContacts,
initialQueries,
loadDirectory,
sortContacts,
sweepDirectory,
type DirectoryProgress,
} from './directory'
/**
* Spielt die SwyxTray-App nach mit genau den Regeln, die gegen die laufende
* App sondiert wurden: Teilzeichenkette in **Name und Rufnummer** (nicht in der
* Beschreibung), Schreibweise egal, nach Namen sortiert und stillschweigend bei
* {@link CONTACT_RESULT_LIMIT} Treffern gekürzt.
*/
function fakeApp(contacts: Contact[]) {
const calls: string[] = []
const search = async (query: string): Promise<Contact[]> => {
calls.push(query)
const needle = query.toLowerCase()
return contacts
.filter(
(c) =>
(c.name ?? '').toLowerCase().includes(needle) ||
(c.number ?? '').toLowerCase().includes(needle),
)
.sort((a, b) => (a.name ?? '').localeCompare(b.name ?? ''))
.slice(0, CONTACT_RESULT_LIMIT)
}
return { search, calls }
}
/**
* Bestand mit vierstelligen Rufnummern wie in der Anlage. Die Namen bestehen
* bewusst nur aus Buchstaben: eine Ziffer im Namen ließe die Ziffernabfragen
* auch über den Namen treffen, was es in der Anlage nicht gibt.
*/
function directoryOf(count: number, step = 1, from = 4000): Contact[] {
const letter = (i: number) => String.fromCharCode(97 + (i % 26))
return Array.from({ length: count }, (_, i) => ({
name: `Muster${letter(Math.floor(i / 676))}${letter(Math.floor(i / 26))}${letter(i)}, Max`,
number: String(from + i * step),
description: 'HH-MT',
}))
}
describe('initialQueries', () => {
it('fragt zehn Ziffern und hundert Ziffernpaare ab', () => {
const queries = initialQueries()
expect(queries).toHaveLength(110)
expect(new Set(queries).size).toBe(110)
expect(queries).toContain('0')
expect(queries).toContain('00')
expect(queries).toContain('99')
})
})
describe('sweepDirectory', () => {
it('findet den gesamten Bestand', async () => {
// 404 Einträge der Umfang der echten Anlage, hier aber auf einem
// lückenlosen Nummernblock: dann hängt "40" am Deckel und der Durchlauf
// muss verfeinern. Vollständig wird er trotzdem.
const contacts = directoryOf(404)
const { search } = fakeApp(contacts)
const result = await sweepDirectory(search)
expect(result.contacts).toHaveLength(404)
expect(result.complete).toBe(true)
expect(result.aborted).toBe(false)
expect(new Set(result.contacts.map(contactKey))).toEqual(new Set(contacts.map(contactKey)))
})
it('kommt ohne Verfeinerung aus, solange keine Abfrage gekürzt wird', async () => {
// Rufnummern mit Lücken, wie in einer gewachsenen Anlage.
const { search, calls } = fakeApp(directoryOf(404, 7))
const result = await sweepDirectory(search)
// Genau die 110 Abfragen der Grundliste gemessen ist bei der echten
// Anlage keine davon am Deckel (Höchstwert 59 Treffer).
expect(calls).toHaveLength(110)
expect(result.queries).toBe(110)
})
it('meldet jeden Eintrag nur einmal, auch wenn ihn mehrere Abfragen finden', async () => {
// "4123" wird von "41", "12", "23", "1", "2", "3", "4" gefunden.
const { search } = fakeApp([{ name: 'Muster, Max', number: '4123' }])
const result = await sweepDirectory(search)
expect(result.contacts).toHaveLength(1)
})
it('findet auch eine einstellige Rufnummer dafür sind die Ziffernabfragen da', async () => {
// "0" steckt in keinem Ziffernpaar; ohne die einstelligen Abfragen fehlte
// die Zentrale im Bestand.
const contacts = [...directoryOf(20), { name: 'Zentrale', number: '0', description: '' }]
const { search } = fakeApp(contacts)
const result = await sweepDirectory(search)
expect(result.contacts.map((c) => c.number)).toContain('0')
})
it('verfeinert eine gekürzte Abfrage und wird trotzdem vollständig', async () => {
// Alle 105 Nummern beginnen mit "12"; diese Abfrage hängt also am Deckel.
const contacts = Array.from({ length: 105 }, (_, i) => ({
name: `Muster${String.fromCharCode(97 + (i % 26))}${i}, Max`.replace(/\d/g, ''),
number: `12${i % 10}${String(i).padStart(3, '0')}`,
}))
const { search, calls } = fakeApp(contacts)
const result = await sweepDirectory(search)
expect(result.contacts).toHaveLength(105)
expect(result.complete).toBe(true)
// Verfeinert wurde vorn und hinten beides gehört dazu, weil eine
// Teilzeichenkette an beiden Enden weitergehen kann.
expect(calls.length).toBeGreaterThan(110)
expect(calls).toContain('120')
expect(calls).toContain('012')
})
it('gibt auf, wenn sich eine gekürzte Abfrage nicht mehr verfeinern lässt', async () => {
// Über hundert Einträge mit derselben dreistelligen Nummer: die Abfrage
// "123" bleibt gekürzt, egal wie oft verfeinert wird.
const contacts = directoryOf(130, 1).map((c) => ({ ...c, number: '123' }))
const { search } = fakeApp(contacts)
const result = await sweepDirectory(search)
expect(result.complete).toBe(false)
expect(result.contacts.length).toBeLessThan(130)
})
it('meldet den Fortschritt und zählt bis zum Ende hoch', async () => {
const onProgress = vi.fn<(p: DirectoryProgress) => void>()
const { search } = fakeApp(directoryOf(30))
const result = await sweepDirectory(search, { onProgress })
const reports = onProgress.mock.calls.map(([p]) => p)
expect(reports[0]).toEqual({ done: 0, total: 110, found: 0 })
expect(reports.at(-1)).toEqual({ done: 110, total: 110, found: 30 })
// Monoton sonst spränge der Balken im Wartedialog zurück.
expect(reports.map((p) => p.done)).toEqual([...reports.map((p) => p.done)].sort((a, b) => a - b))
expect(result.queries).toBe(110)
})
it('bricht ab, ohne den bisherigen Stand wegzuwerfen', async () => {
const contacts = directoryOf(404)
const controller = new AbortController()
const { search, calls } = fakeApp(contacts)
const result = await sweepDirectory(
async (query) => {
// Nach zwanzig Abfragen abbrechen wie ein Klick auf „Abbrechen".
if (calls.length >= 20) controller.abort()
return search(query)
},
{ signal: controller.signal, concurrency: 1 },
)
expect(result.aborted).toBe(true)
expect(result.complete).toBe(false)
// Kein Fehler, kein leeres Ergebnis: das bereits Geladene bleibt erhalten.
expect(result.contacts.length).toBeGreaterThan(0)
expect(result.contacts.length).toBeLessThan(404)
expect(calls.length).toBeLessThan(110)
})
it('reicht den Fehler einer Abfrage durch ein Abbruch ist etwas anderes', async () => {
await expect(
sweepDirectory(async () => {
throw new Error('Keine Verbindung zur SwyxTray-App.')
}),
).rejects.toThrow('Keine Verbindung zur SwyxTray-App.')
})
})
describe('loadDirectory', () => {
const cached: Contact[] = [
{ name: 'Muster GmbH', number: '+493012345', description: 'Globales Telefonbuch' },
{ name: 'Abt, Bettina', number: '7587', description: 'S-SB' },
]
it('nimmt den Adress-Cache der App und fragt dann nichts mehr', async () => {
const { search, calls } = fakeApp(directoryOf(404, 7))
const result = await loadDirectory({ listAddresses: async () => cached, search })
expect(result.source).toBe('cache')
expect(result.queries).toBe(1)
expect(result.complete).toBe(true)
// Eine einzige Nachricht statt 110 darum geht es beim Cache.
expect(calls).toHaveLength(0)
expect(result.contacts.map((c) => c.name)).toEqual(['Abt, Bettina', 'Muster GmbH'])
})
it('meldet jeden Eintrag des Caches nur einmal', async () => {
const doppelt = [...cached, { ...cached[0] }]
const { search } = fakeApp([])
const result = await loadDirectory({ listAddresses: async () => doppelt, search })
expect(result.contacts).toHaveLength(2)
})
it('weicht auf die Einzelabfragen aus, wenn die App das Kommando nicht kennt', async () => {
// Genau der Fall der Anlage am 21.08.2026.
const { search, calls } = fakeApp(directoryOf(404, 7))
const result = await loadDirectory({
listAddresses: async () => {
throw new Error("Unbekanntes Kommando 'addresses'.")
},
search,
})
expect(result.source).toBe('sweep')
expect(result.contacts).toHaveLength(404)
expect(calls).toHaveLength(110)
})
it('weicht auch bei leerem Cache aus so meldet sich die Anlage zurzeit', async () => {
const { search } = fakeApp(directoryOf(40, 7))
const result = await loadDirectory({ listAddresses: async () => [], search })
expect(result.source).toBe('sweep')
expect(result.contacts).toHaveLength(40)
})
it('fragt nach einem Abbruch gar nicht erst', async () => {
const controller = new AbortController()
controller.abort()
const listAddresses = vi.fn(async () => cached)
const { search, calls } = fakeApp(directoryOf(10))
const result = await loadDirectory({ listAddresses, search }, { signal: controller.signal })
expect(listAddresses).not.toHaveBeenCalled()
expect(calls).toHaveLength(0)
expect(result.aborted).toBe(true)
})
})
describe('sortContacts', () => {
it('sortiert nach Namen, bei gleichem Namen nach Rufnummer', () => {
const sorted = sortContacts([
{ name: 'Bandorf, Volker', number: '53119' },
{ name: 'Abt, Bettina', number: '7587' },
{ name: 'Bandorf, Volker', number: '41295' },
])
expect(sorted.map((c) => `${c.name} ${c.number}`)).toEqual([
'Abt, Bettina 7587',
'Bandorf, Volker 41295',
'Bandorf, Volker 53119',
])
})
it('lässt die Vorlage unangetastet', () => {
const input = [{ name: 'Z' }, { name: 'A' }]
sortContacts(input)
expect(input.map((c) => c.name)).toEqual(['Z', 'A'])
})
})
describe('filterContacts', () => {
const contacts: Contact[] = [
{ name: 'Abt, Bettina', number: '7587', description: 'S-SB' },
{ name: 'Muster, Max', number: '4711', description: 'HH-MT' },
]
it('gibt ohne Filter alles zurück', () => {
expect(filterContacts(contacts, ' ')).toEqual(contacts)
})
it('sucht in Namen und Rufnummer, Schreibweise egal', () => {
expect(filterContacts(contacts, 'bettina')).toHaveLength(1)
expect(filterContacts(contacts, '471')).toEqual([contacts[1]])
})
it('sucht auch in der Beschreibung anders als die App', () => {
// Der Bestand liegt vollständig im Browser; das Kürzel ist damit filterbar.
expect(filterContacts(contacts, 'hh-mt')).toEqual([contacts[1]])
})
})
Binary file not shown.
+122
View File
@@ -2,10 +2,13 @@ import { describe, expect, it } from 'vitest'
import { import {
callStateOf, callStateOf,
describeCall, describeCall,
describeContact,
describeTab, describeTab,
parseAddresses,
mergeSnapshot, mergeSnapshot,
parseHello, parseHello,
parseResult, parseResult,
parseContacts,
parseSnapshot, parseSnapshot,
parseTabs, parseTabs,
type CallEvent, type CallEvent,
@@ -79,6 +82,22 @@ describe('parseHello', () => {
?.protocol, ?.protocol,
).toBe(6) ).toBe(6)
}) })
it('liest die Protokollversion des Adress-Caches', () => {
// Wörtlich mitgeschnitten am 21.08.2026 gegen die laufende Anlage.
expect(
parseHello(JSON.parse('{"app":"SwyxTray","version":"1.0.0.0","protocol":8,"session":3,"type":"hello"}'))
?.protocol,
).toBe(8)
})
it('liest die Protokollversion der Adressdaten', () => {
// Wörtlich mitgeschnitten, nachdem die App `contacts` gelernt hatte.
expect(
parseHello(JSON.parse('{"app":"SwyxTray","version":"1.0.0.0","protocol":7,"session":3,"type":"hello"}'))
?.protocol,
).toBe(7)
})
}) })
describe('callStateOf', () => { describe('callStateOf', () => {
@@ -296,6 +315,56 @@ describe('parseResult', () => {
const result = parseResult(JSON.parse('{"id":11,"ok":true,"line":4,"type":"result"}')) const result = parseResult(JSON.parse('{"id":11,"ok":true,"line":4,"type":"result"}'))
expect(result?.tabs).toBeUndefined() expect(result?.tabs).toBeUndefined()
expect(result?.tabId).toBeUndefined() expect(result?.tabId).toBeUndefined()
expect(result?.contacts).toBeUndefined()
})
it('liest die Quittung auf contacts', () => {
// Wörtlich mitgeschnitten Suchbegriff "abt".
const raw =
'{"id":20,"ok":true,"contacts":[' +
'{"name":"Abt, Bettina","number":"7587","description":"S-SB"},' +
'{"name":"Abdul, Rokhsareh","number":"5215","description":""}],"type":"result"}'
expect(parseResult(JSON.parse(raw))?.contacts).toEqual([
{ name: 'Abt, Bettina', number: '7587', description: 'S-SB' },
// Die leere Beschreibung der App wird zu undefined.
{ name: 'Abdul, Rokhsareh', number: '5215', description: undefined },
])
})
it('liest die leere Trefferliste als leere Liste, nicht als "nicht gefragt"', () => {
expect(parseResult(JSON.parse('{"id":21,"ok":true,"contacts":[],"type":"result"}'))?.contacts).toEqual(
[],
)
})
it('liest die Fehlerquittung auf einen leeren Suchbegriff', () => {
const raw =
'{"id":22,"ok":false,' +
'"error":"Feld \'query\' fehlt oder ist zu lang (max. 128 Zeichen).","type":"result"}'
expect(parseResult(JSON.parse(raw))).toMatchObject({
id: 22,
ok: false,
error: "Feld 'query' fehlt oder ist zu lang (max. 128 Zeichen).",
})
})
it('liest die Quittung auf addresses', () => {
const raw =
'{"type":"result","id":5,"ok":true,"addresses":[' +
'{"name":"Muster GmbH","number":"+493012345","description":"Globales Telefonbuch"}]}'
expect(parseResult(JSON.parse(raw))?.addresses).toEqual([
{ name: 'Muster GmbH', number: '+493012345', description: 'Globales Telefonbuch' },
])
})
it('liest die Fehlerquittung älterer App-Versionen auf addresses', () => {
// Genau das antwortet die Anlage am 21.08.2026 noch.
const raw = '{"id":5,"ok":false,"error":"Unbekanntes Kommando \'addresses\'.","type":"result"}'
expect(parseResult(JSON.parse(raw))).toMatchObject({
id: 5,
ok: false,
error: "Unbekanntes Kommando 'addresses'.",
})
}) })
it('hält einen Snapshot für keine Quittung', () => { it('hält einen Snapshot für keine Quittung', () => {
@@ -324,6 +393,59 @@ describe('describeTab', () => {
}) })
}) })
describe('parseAddresses', () => {
it('liest die unaufgeforderte Cache-Nachricht', () => {
const raw =
'{"type":"addresses","addresses":[' +
'{"name":"Muster GmbH","number":"+493012345","description":"Globales Telefonbuch"}]}'
expect(parseAddresses(JSON.parse(raw))).toEqual([
{ name: 'Muster GmbH', number: '+493012345', description: 'Globales Telefonbuch' },
])
})
it('hält den leeren Cache für eine Antwort, nicht für ein Versehen', () => {
// Wörtlich mitgeschnitten: genau das schickt die Anlage zurzeit.
expect(parseAddresses(JSON.parse('{"addresses":[],"type":"addresses"}'))).toEqual([])
})
it('hält andere Nachrichten für keine Cache-Nachricht', () => {
expect(parseAddresses(JSON.parse(HELLO))).toBeNull()
expect(parseAddresses(snapshot())).toBeNull()
})
it('ist keine Quittung sie trägt weder id noch ok', () => {
expect(parseResult(JSON.parse('{"addresses":[],"type":"addresses"}'))).toBeNull()
})
})
describe('parseContacts', () => {
it('nimmt einen Eintrag ohne Namen hin die Rufnummer genügt', () => {
expect(parseContacts([{ number: '4711' }])).toEqual([
{ name: undefined, number: '4711', description: undefined },
])
})
it('verwirft Einträge ohne Namen und ohne Rufnummer', () => {
// Anzeigen ließe sich so ein Eintrag nicht, wählen erst recht nicht.
expect(parseContacts([{ description: 'HH-MT' }, 'Unfug', null, { name: 'Muster, Max' }])).toEqual([
{ name: 'Muster, Max', number: undefined, description: undefined },
])
})
it('hält die Reihenfolge der App ein sie sortiert bereits nach Namen', () => {
const entries = [{ name: 'Abt, Bettina', number: '7587' }, { name: 'Zentrale', number: '0' }]
expect(parseContacts(entries).map((c) => c.name)).toEqual(['Abt, Bettina', 'Zentrale'])
})
})
describe('describeContact', () => {
it('nimmt den Namen, sonst die Rufnummer', () => {
expect(describeContact({ name: 'Muster, Max', number: '4711' })).toBe('Muster, Max')
expect(describeContact({ number: '4711' })).toBe('4711')
expect(describeContact({})).toBe('Ohne Namen')
})
})
describe('describeCall', () => { describe('describeCall', () => {
it('bevorzugt die fertige Anzeigeform der App', () => { it('bevorzugt die fertige Anzeigeform der App', () => {
expect(describeCall({ line: 1, event: 'incoming', peer: 'Muster GmbH (+49301)' })).toBe( expect(describeCall({ line: 1, event: 'incoming', peer: 'Muster GmbH (+49301)' })).toBe(
+83 -3
View File
@@ -1,7 +1,7 @@
/** /**
* Protokoll der SwyxTray-App, aufgezeichnet gegen `ws://192.168.180.135:17654/ws` * Protokoll der SwyxTray-App, aufgezeichnet gegen `ws://192.168.180.135:17654/ws`
* (App-Version 1.0.0.0, `protocol: 1`; seit der Tab-Verwaltung meldet die App * (App-Version 1.0.0.0, `protocol: 1`; mit der Tab-Verwaltung meldete die App
* `protocol: 6`). * `protocol: 6`, mit der Adresssuche `protocol: 7`, mit dem Adress-Cache `protocol: 8`).
* *
* App → Seite: * App → Seite:
* { "app":"SwyxTray","version":"1.0.0.0","protocol":1,"session":7,"type":"hello" } * { "app":"SwyxTray","version":"1.0.0.0","protocol":1,"session":7,"type":"hello" }
@@ -31,7 +31,30 @@
* { "id":14,"cmd":"closetab","tabId":43 } → { …,"ok":true } * { "id":14,"cmd":"closetab","tabId":43 } → { …,"ok":true }
* Ist kein Plugin verbunden, antwortet die App nach rund fünf Sekunden mit `ok:false`. * Ist kein Plugin verbunden, antwortet die App nach rund fünf Sekunden mit `ok:false`.
* *
* **Die App kennt keine Ereignisnachrichten.** Jede Zustandsänderung auch ein * Protokoll 7 bringt die Adressdaten des Swyx-Clients das globale Telefonbuch:
* { "id":20,"cmd":"contacts","query":"abt" }
* → { "type":"result","id":20,"ok":true,
* "contacts":[{"name":"Abt, Bettina","number":"7587","description":"S-SB"}, …] }
* Gesucht wird als Teilzeichenkette in **Name und Rufnummer**, Schreibweise egal;
* die Beschreibung wird nicht durchsucht. Sortiert nach Namen, höchstens
* {@link CONTACT_RESULT_LIMIT} Treffer gekürzt wird stillschweigend. `query` ist
* Pflicht, darf nicht leer sein und höchstens {@link CONTACT_QUERY_MAX_LENGTH}
* Zeichen lang; sonst antwortet die App mit
* `"Feld 'query' fehlt oder ist zu lang (max. 128 Zeichen)."`.
*
* Protokoll 8 bringt den **Adress-Cache**: die App hält die Adressdaten selbst vor
* und gibt sie am Stück heraus ohne Suchbegriff und ohne Deckel.
* { "id":5,"cmd":"addresses" }
* → { "type":"result","id":5,"ok":true,
* "addresses":[{"name":"Muster GmbH","number":"+493012345",
* "description":"Globales Telefonbuch"}] }
* Dieselben Daten schickt die App außerdem **unaufgefordert** als eigene Nachricht,
* direkt nach dem ersten Snapshot und wenn sich ihr Cache ändert:
* { "type":"addresses","addresses":[…] }
* Beide tragen dieselben Einträge wie `contacts` Name, Rufnummer, Beschreibung.
* Der Push ist die einzige Nachricht der App **ohne** `id` und ohne `ok`.
*
* **Sonst kennt die App keine Ereignisnachrichten.** Jede Zustandsänderung auch ein
* eingehender Anruf kommt als vollständiger `snapshot` über *alle* Leitungen. * eingehender Anruf kommt als vollständiger `snapshot` über *alle* Leitungen.
* Die Anrufmeldung der Seite entsteht deshalb aus dem Vergleich zweier Snapshots * Die Anrufmeldung der Seite entsteht deshalb aus dem Vergleich zweier Snapshots
* (siehe {@link mergeSnapshot}). * (siehe {@link mergeSnapshot}).
@@ -66,6 +89,10 @@ export interface ResultMessage {
tabs?: BrowserTab[] tabs?: BrowserTab[]
/** Nur bei `opentab`: Kennung des neu geöffneten Tabs. */ /** Nur bei `opentab`: Kennung des neu geöffneten Tabs. */
tabId?: number tabId?: number
/** Nur bei `contacts`: die gefundenen Adressdaten. */
contacts?: Contact[]
/** Nur bei `addresses`: der gesamte Adress-Cache der App. */
addresses?: Contact[]
error?: string error?: string
} }
@@ -79,6 +106,26 @@ export interface BrowserTab {
active: boolean active: boolean
} }
/**
* Ein Eintrag aus den Adressdaten des Swyx-Clients, wie ihn die Quittung auf
* `contacts` meldet. Leere Felder schickt die App als `""`; hier bleiben sie
* `undefined`.
*/
export interface Contact {
/** Anzeigename, meist „Nachname, Vorname". */
name?: string
/** Rufnummer, meist die interne Durchwahl so, wie sie an `call` geht. */
number?: string
/** Zusatz der Telefonanlage, z. B. der Standort („HH-MT"). */
description?: string
}
/** Mehr Treffer meldet die App nicht; sie kürzt ohne Hinweis. */
export const CONTACT_RESULT_LIMIT = 100
/** Längere Suchbegriffe weist die App mit einem Fehler zurück. */
export const CONTACT_QUERY_MAX_LENGTH = 128
/** Begrüßung beim Verbindungsaufbau. */ /** Begrüßung beim Verbindungsaufbau. */
export interface HelloMessage { export interface HelloMessage {
app?: string app?: string
@@ -115,6 +162,8 @@ export const COMMANDS = {
tabs: 'tabs', tabs: 'tabs',
openTab: 'opentab', openTab: 'opentab',
closeTab: 'closetab', closeTab: 'closetab',
contacts: 'contacts',
addresses: 'addresses',
} as const } as const
function isRecord(value: unknown): value is Record<string, unknown> { function isRecord(value: unknown): value is Record<string, unknown> {
@@ -207,6 +256,8 @@ export function parseResult(raw: unknown): ResultMessage | null {
focused: asBoolean(raw.focused), focused: asBoolean(raw.focused),
tabs: Array.isArray(raw.tabs) ? parseTabs(raw.tabs) : undefined, tabs: Array.isArray(raw.tabs) ? parseTabs(raw.tabs) : undefined,
tabId: asLine(raw.tabId), tabId: asLine(raw.tabId),
contacts: Array.isArray(raw.contacts) ? parseContacts(raw.contacts) : undefined,
addresses: Array.isArray(raw.addresses) ? parseContacts(raw.addresses) : undefined,
error: asString(raw.error), error: asString(raw.error),
} }
} }
@@ -236,6 +287,35 @@ export function describeTab(tab: BrowserTab): string {
return tab.title ?? tab.url ?? `Tab ${tab.id}` return tab.title ?? tab.url ?? `Tab ${tab.id}`
} }
/**
* Liest die Adressdaten aus der Quittung auf `contacts`. Einträge ohne Namen
* *und* ohne Rufnummer fallen weg sie ließen sich weder anzeigen noch wählen.
*/
export function parseContacts(raw: unknown[]): Contact[] {
return raw.flatMap((entry) => {
if (!isRecord(entry)) return []
const name = asString(entry.name)
const number = asString(entry.number)
if (name === undefined && number === undefined) return []
return [{ name, number, description: asString(entry.description) }]
})
}
/** Anzeigename eines Adresseintrags: Name, sonst die Rufnummer. */
export function describeContact(contact: Contact): string {
return contact.name ?? contact.number ?? 'Ohne Namen'
}
/**
* Liest die unaufgeforderte Cache-Nachricht `{"type":"addresses","addresses":[…]}`.
* Ein leeres Feld ist ein gültiger Zustand die App meldet damit einen leeren
* Cache und darf deshalb nicht wie „keine Nachricht" behandelt werden.
*/
export function parseAddresses(raw: unknown): Contact[] | null {
if (!isRecord(raw) || raw.type !== 'addresses') return null
return Array.isArray(raw.addresses) ? parseContacts(raw.addresses) : []
}
/** Liest die Begrüßung. */ /** Liest die Begrüßung. */
export function parseHello(raw: unknown): HelloMessage | null { export function parseHello(raw: unknown): HelloMessage | null {
if (!isRecord(raw) || raw.type !== 'hello') return null if (!isRecord(raw) || raw.type !== 'hello') return null
+106
View File
@@ -0,0 +1,106 @@
import { describe, expect, it } from 'vitest'
import {
countUnseen,
formatPayload,
isWebhookEvent,
mergeEvent,
mergeEvents,
summarize,
type WebhookEvent,
} from './webhook'
function event(id: number, payload: unknown = {}): WebhookEvent {
return { id, receivedAt: '2026-08-24T10:00:00Z', payload }
}
describe('isWebhookEvent', () => {
it('erkennt die Form des Backends', () => {
expect(isWebhookEvent(event(1))).toBe(true)
})
it('lässt jede Nutzlast durchgehen auch null', () => {
expect(isWebhookEvent({ id: 1, receivedAt: '2026-08-24T10:00:00Z', payload: null })).toBe(true)
})
it('weist alles ohne id oder Zeitstempel ab', () => {
expect(isWebhookEvent(null)).toBe(false)
expect(isWebhookEvent({ id: '1', receivedAt: 'x' })).toBe(false)
expect(isWebhookEvent({ id: 1 })).toBe(false)
})
})
describe('mergeEvent', () => {
it('stellt die jüngste Nachricht nach vorn', () => {
const merged = mergeEvent([event(1)], event(2))
expect(merged.map((e) => e.id)).toEqual([2, 1])
})
it('nimmt eine bereits bekannte id nicht doppelt auf', () => {
// Genau dieser Fall tritt im Betrieb auf: Der Verlauf wird geholt, während
// dieselbe Nachricht schon über den Ereignisstrom kam.
const merged = mergeEvent([event(2), event(1)], event(2, { neu: true }))
expect(merged.map((e) => e.id)).toEqual([2, 1])
expect(merged[0].payload).toEqual({ neu: true })
})
it('deckelt die Liste', () => {
const merged = mergeEvent([event(3), event(2), event(1)], event(4), 3)
expect(merged.map((e) => e.id)).toEqual([4, 3, 2])
})
it('sortiert auch dann nach id, wenn Nachrichten verspätet nachkommen', () => {
// Nach einem Wiederverbinden liefert das Backend Verpasstes nach.
const merged = mergeEvents([event(5)], [event(3), event(4)])
expect(merged.map((e) => e.id)).toEqual([5, 4, 3])
})
})
describe('summarize', () => {
it('fasst Adressdaten aus name und number zusammen', () => {
expect(summarize({ name: 'Muster GmbH', number: '+493012345' })).toBe('Muster GmbH · +493012345')
})
it('kommt mit nur einem der beiden Felder aus', () => {
expect(summarize({ name: 'Abt, Bettina' })).toBe('Abt, Bettina')
expect(summarize({ number: '7587' })).toBe('7587')
})
it('zählt die Einträge einer Liste', () => {
expect(summarize([{ name: 'A' }, { name: 'B' }])).toBe('2 Einträge')
expect(summarize([{ name: 'A' }])).toBe('1 Eintrag')
})
it('beschreibt fremde Formen, statt zu raten', () => {
expect(summarize({ kunde: 4711, quelle: 'CRM' })).toBe('2 Felder: kunde, quelle')
expect(summarize({})).toBe('leeres Objekt')
expect(summarize(null)).toBe('null')
expect(summarize(42)).toBe('42')
})
})
describe('formatPayload', () => {
it('rückt ein', () => {
expect(formatPayload({ a: 1 })).toBe('{\n "a": 1\n}')
})
it('kommt mit einfachen Werten zurecht', () => {
expect(formatPayload('text')).toBe('"text"')
expect(formatPayload(null)).toBe('null')
expect(formatPayload(undefined)).toBe('undefined')
})
})
describe('countUnseen', () => {
it('zählt, was seit dem letzten Blick dazukam', () => {
expect(countUnseen([event(3), event(2), event(1)], 1)).toBe(2)
})
it('zählt ohne vorherigen Blick alles', () => {
expect(countUnseen([event(2), event(1)], 0)).toBe(2)
})
it('zählt nichts, wenn die jüngste Nachricht gesehen ist', () => {
expect(countUnseen([event(2), event(1)], 2)).toBe(0)
expect(countUnseen([], 0)).toBe(0)
})
})
+125
View File
@@ -0,0 +1,125 @@
/**
* Webhook: Adressdaten, die ein fremdes System per POST an das Backend schickt.
*
* Das Backend nimmt unter `POST /api/webhook` **beliebiges** JSON an und reicht
* es über Server-Sent Events (`/api/webhook/events`) an die offenen Browser
* weiter. Dieses Modul enthält nur die reine Logik das Holen und Anzeigen
* stehen in `hooks/useWebhook.ts` und `components/WebhookPanel.tsx`.
*/
/** Eine über den Webhook eingegangene Nachricht, so wie das Backend sie liefert. */
export interface WebhookEvent {
/** Fortlaufend ab 1. Eindeutig bis zum Neustart des Backends. */
id: number
/** Eingangszeit im Backend, ISO-8601. */
receivedAt: string
/**
* Über welche URL die Nachricht kam: `kunden`, `kuriere` oder `jobs`;
* fehlt (bzw. `null`) beim generischen `POST /api/webhook`.
*/
channel?: string | null
/** Das empfangene JSON, unverändert. Objekt, Liste oder ein einfacher Wert. */
payload: unknown
}
/** So viele Nachrichten hält die Anzeige vor; das Backend deckelt zusätzlich. */
export const MAX_EVENTS = 50
/**
* Prüft grob, ob eine Nachricht die Form des Backends hat. Die Nutzlast bleibt
* bewusst ungeprüft sie darf alles sein.
*/
export function isWebhookEvent(value: unknown): value is WebhookEvent {
if (typeof value !== 'object' || value === null) return false
const candidate = value as Partial<WebhookEvent>
return typeof candidate.id === 'number' && typeof candidate.receivedAt === 'string'
}
/**
* Nimmt eine Nachricht in die Liste auf: jüngste zuerst, ohne Doppel und
* gedeckelt.
*
* Doppel entstehen im Normalbetrieb: Der Bereich holt beim Öffnen den Verlauf
* und hört gleichzeitig auf den Ereignisstrom, und nach einem Abbruch liefert
* das Backend anhand der `Last-Event-ID` nach. Entschieden wird über die `id`.
*/
export function mergeEvent(
events: WebhookEvent[],
incoming: WebhookEvent,
max = MAX_EVENTS,
): WebhookEvent[] {
const without = events.filter((event) => event.id !== incoming.id)
return [incoming, ...without].sort((a, b) => b.id - a.id).slice(0, max)
}
/** Wie {@link mergeEvent}, aber für den Verlauf am Stück. */
export function mergeEvents(
events: WebhookEvent[],
incoming: WebhookEvent[],
max = MAX_EVENTS,
): WebhookEvent[] {
return incoming.reduce((all, event) => mergeEvent(all, event, max), events)
}
/**
* Wie viele Nachrichten jünger sind als die zuletzt gesehene.
*
* Der Bereich „Webhook" ist nicht immer offen, die Nachrichten kommen aber
* trotzdem an. Daraus wird der Zähler am Tab.
*/
export function countUnseen(events: WebhookEvent[], seenId: number): number {
return events.filter((event) => event.id > seenId).length
}
/** Die Nutzlast lesbar eingerückt. */
export function formatPayload(payload: unknown): string {
try {
return JSON.stringify(payload, null, 2) ?? String(payload)
} catch {
// Zirkuläre Strukturen kann es über JSON nicht geben; bleibt die Notbremse.
return String(payload)
}
}
/**
* Kurzfassung für die Kopfzeile eines Eintrags. Adressdaten tragen üblicherweise
* `name` und `number`; alles andere wird der Form nach beschrieben, statt zu
* raten.
*/
export function summarize(payload: unknown): string {
if (Array.isArray(payload)) {
return payload.length === 1 ? '1 Eintrag' : `${payload.length} Einträge`
}
if (typeof payload === 'object' && payload !== null) {
const record = payload as Record<string, unknown>
const name = typeof record.name === 'string' ? record.name.trim() : ''
const number = typeof record.number === 'string' ? record.number.trim() : ''
if (name && number) return `${name} · ${number}`
if (name) return name
if (number) return number
const keys = Object.keys(record)
if (keys.length === 0) return 'leeres Objekt'
return keys.length === 1 ? `1 Feld: ${keys[0]}` : `${keys.length} Felder: ${keys.join(', ')}`
}
if (payload === null) return 'null'
return String(payload)
}
/** Holt den Verlauf des Backends die Nachrichten vor dem Öffnen des Bereichs. */
export async function fetchHistory(signal?: AbortSignal): Promise<WebhookEvent[]> {
const response = await fetch('/api/webhook/history', { signal })
if (!response.ok) {
throw new Error(`/api/webhook/history antwortete mit HTTP ${response.status}`)
}
const data: unknown = await response.json()
if (!Array.isArray(data)) return []
return data.filter(isWebhookEvent)
}
/** Leert den Verlauf im Backend. */
export async function clearHistory(): Promise<void> {
const response = await fetch('/api/webhook/history', { method: 'DELETE' })
if (!response.ok) {
throw new Error(`Verlauf konnte nicht geleert werden (HTTP ${response.status})`)
}
}