Compare commits
14
Commits
6e9de54119
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
afd6d03a71 | ||
|
|
ab5e402ee9 | ||
|
|
3a86bc1eee | ||
|
|
ee81f25942 | ||
|
|
0fa690a67d | ||
|
|
607cbaecbc | ||
|
|
a9b40c7e6a | ||
|
|
715720fbb3 | ||
|
|
97c3778180 | ||
|
|
1598852785 | ||
|
|
521ceb044b | ||
|
|
01594a1f3d | ||
|
|
b2f608055a | ||
|
|
2a2bce860e |
@@ -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
@@ -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
|
||||||
|
|||||||
Vendored
+15
-5
@@ -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",
|
||||||
|
|||||||
Vendored
+24
@@ -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
@@ -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"]
|
||||||
@@ -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`.
|
||||||
|
|||||||
@@ -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
@@ -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,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
@@ -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>
|
||||||
|
|||||||
@@ -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()
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -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)
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -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.
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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>}
|
||||||
</>
|
</>
|
||||||
|
|||||||
@@ -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>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
|||||||
@@ -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
@@ -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
|
||||||
|
|||||||
@@ -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,
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
@@ -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;
|
||||||
|
}
|
||||||
|
|||||||
@@ -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/)
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -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()),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
@@ -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>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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.
@@ -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(
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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)
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -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})`)
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user