MongoDB-Ablage für Adressen, Anrufe und Jobs; Webhook-URLs je Datenart

Adress-, Anruf- und Jobdaten landen dauerhaft in der MongoDB
(ArchiveService, best-effort auf eigenem Thread): Die Startseite meldet
Adress-Cache und Anruf-Ereignisse an POST /api/addresses bzw. /api/calls,
der Webhook legt seine Nutzlasten selbst ab. Adressen tragen eine
generierte Kennung, die Rufnummer erkennt per Upsert die Dublette.

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

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

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-27 10:24:53 +02:00
co-authored by Claude Fable 5
parent 1598852785
commit 97c3778180
21 changed files with 1011 additions and 26 deletions
+4
View File
@@ -19,6 +19,10 @@
</properties> </properties>
<dependencies> <dependencies>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-mongodb</artifactId>
</dependency>
<dependency> <dependency>
<groupId>org.springframework.boot</groupId> <groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-webmvc</artifactId> <artifactId>spring-boot-starter-webmvc</artifactId>
@@ -0,0 +1,85 @@
package de.appcreation.swyxweb.storage;
import java.time.Instant;
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>Alle Quellen liefern dieselben drei Felder, nur unterschiedlich verpackt;
* {@link #manyFrom(JsonNode)} versteht jede dieser Formen:
* <ul>
* <li>Webhook, ein Eintrag je Aufruf:
* {@code {"name":"SYSGEN GmbH","number":"+49421409660","description":"Kunde | …"}}</li>
* <li>eine JSON-Liste solcher Einträge</li>
* <li>die Adress-Nachricht der SwyxTray-App:
* {@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 receivedAt Eingangszeit im Backend, bei bekannten Einträgen die des
* letzten Eingangs
* @param source woher der Eintrag stammt: {@code kunden}, {@code kuriere},
* {@code webhook} (generischer Webhook) oder {@code swyxtray}
* @param name Anzeigename, z. B. "SYSGEN GmbH" oder "Rainer Peters (HH1003)"
* @param description Zusatz, z. B. "Kunde | SYSGEN, SYSTEME UND | NL Bremen | csc 100164"
* oder "Kurier | NL Hamburg | PKW | cr 2024"
*/
@Document("addresses")
public record AddressEntry(
@Id String id,
String number,
Instant receivedAt,
String source,
String name,
String description) {
/**
* 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, null, null, text(node, "name"), text(node, "description")));
}
/** 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;
}
}
@@ -0,0 +1,106 @@
package de.appcreation.swyxweb.storage;
import java.time.Instant;
import java.util.List;
import java.util.concurrent.ExecutorService;
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. 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.
*
* @param receivedAt Eingangszeit im Backend
* @param source {@code kunden}, {@code kuriere}, {@code webhook}
* (generischer Webhook) oder {@code swyxtray}
*/
public void saveAddresses(Instant receivedAt, String source, 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("receivedAt", receivedAt)
.set("source", source)
.set("name", entry.name())
.set("description", entry.description()),
AddressEntry.class)));
}
/** Die gespeicherten Adressdaten, nach Namen sortiert. Liest direkt aus der MongoDB. */
public List<AddressEntry> addresses() {
return mongo.find(new Query().with(Sort.by("name")), 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));
}
/** 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,119 @@
package de.appcreation.swyxweb.storage;
import java.time.Instant;
import java.util.List;
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;
/**
* 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-07-31T16:30:00+02:00}): ISO-8601 sortiert auch als Text
* richtig, und der Zeitzonenversatz des Fremdsystems geht nicht verloren.
*
* @param id Kennung des Fremdsystems, zugleich Schlüssel der Sammlung
* @param receivedAt Eingangszeit im Backend, beim letzten Eingang dieses Jobs
* @param state Zustand des Jobs im Fremdsystem
* @param ordertime Auftragszeit, z. B. "2026-07-31T16:30:00+02:00"
* @param orderdate Auftragsdatum, z. B. "2026-07-31"
* @param modified letzte Änderung im Fremdsystem
* @param vehicle Fahrzeugart, z. B. "Transporter"
* @param customer der beauftragende Kunde
* @param courier der ausführende Kurier
* @param tours die Stationen des Jobs, per {@code sort} geordnet
*/
@Document("jobs")
@JsonIgnoreProperties(ignoreUnknown = true)
public record JobEntry(
@Id long id,
Instant receivedAt,
Integer state,
String ordertime,
String orderdate,
String modified,
String vehicle,
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"
*/
@JsonIgnoreProperties(ignoreUnknown = true)
public record Customer(@JsonProperty("csc_id") Integer cscId, String name, String hq) {
}
/**
* Der ausführende Kurier.
*
* @param crId Kurierkennung des Fremdsystems ({@code cr_id})
* @param sid Kurzkennung, z. B. "B1006"
* @param name Anzeigename
* @param number Rufnummer
*/
@JsonIgnoreProperties(ignoreUnknown = true)
public record Courier(@JsonProperty("cr_id") Integer crId, String sid, String name, String number) {
}
/**
* 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
* @param comp Firma an der Station
* @param person Ansprechperson
* @param number Rufnummer an der Station, kann fehlen
* @param street Straße und Hausnummer
* @param zip Postleitzahl
* @param city Ort
* @param com Bemerkung
* @param finished wann die Station abgeschlossen wurde, sonst leer
*/
@JsonIgnoreProperties(ignoreUnknown = true)
public record Tour(
Long id,
Integer sort,
Integer state,
String mode,
String comp,
String person,
String number,
String street,
String zip,
String city,
String com,
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.state(), job.ordertime(), job.orderdate(),
job.modified(), job.vehicle(), 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,65 @@
package de.appcreation.swyxweb.web;
import java.time.Instant;
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.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(Instant.now(), "swyxtray", entries);
return entries.size();
}
/** Die gespeicherten Adressdaten gelesen direkt aus der MongoDB. */
@GetMapping
public List<AddressEntry> list() {
return archive.addresses();
}
}
@@ -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();
}
}
@@ -25,16 +25,32 @@ import de.appcreation.swyxweb.webhook.WebhookEvent;
import de.appcreation.swyxweb.webhook.WebhookService; import de.appcreation.swyxweb.webhook.WebhookService;
/** /**
* Webhook für Adressdaten aus einem fremden System. * Webhook für Adress- und Jobdaten aus einem fremden System.
* *
* <pre>curl -X POST https://swyxweb.appcreation.de/api/webhook \ * <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 "Content-Type: application/json" \
* -H "X-Webhook-Token: …" \ * -H "X-Webhook-Token: …" \
* -d '{"name":"Muster GmbH","number":"+493012345"}'</pre> * -d '{"name":"SYSGEN GmbH","number":"+49421409660","description":"Kunde | …"}'
* *
* <p>Angenommen wird <b>beliebiges</b> JSON Objekt wie Liste. Die Startseite * curl -X POST https://swyxweb.appcreation.de/api/webhook/kuriere \
* zeigt es im Bereich „Webhook" unverändert an; das aufrufende System muss sich * -H "Content-Type: application/json" \
* also an kein festes Schema halten. * -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} * <p>Der Weg zum Browser läuft über {@code GET /api/webhook/events}
* (Server-Sent Events), siehe {@link WebhookService}. * (Server-Sent Events), siehe {@link WebhookService}.
@@ -57,11 +73,39 @@ public class WebhookController {
public record Receipt(long id, Instant receivedAt) { public record Receipt(long id, Instant receivedAt) {
} }
/** Generischer Webhook: Was die Nutzlast ist, wird ihr angesehen. */
@PostMapping(consumes = MediaType.APPLICATION_JSON_VALUE) @PostMapping(consumes = MediaType.APPLICATION_JSON_VALUE)
public Receipt receive( public Receipt receive(
@RequestBody JsonNode payload, @RequestBody JsonNode payload,
@RequestHeader(name = "X-Webhook-Token", required = false) String token) { @RequestHeader(name = "X-Webhook-Token", required = false) String token) {
return accept(payload, token, null);
}
/** Adressdaten der Kunden; abgelegt mit {@code source: "kunden"}. */
@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 mit {@code source: "kuriere"}. */
@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); requireToken(token);
if (payload == null || payload.isNull()) { if (payload == null || payload.isNull()) {
@@ -73,11 +117,11 @@ public class WebhookController {
int size = mapper.writeValueAsString(payload).getBytes(java.nio.charset.StandardCharsets.UTF_8).length; int size = mapper.writeValueAsString(payload).getBytes(java.nio.charset.StandardCharsets.UTF_8).length;
if (size > properties.maxSize()) { if (size > properties.maxSize()) {
throw new ResponseStatusException( throw new ResponseStatusException(
HttpStatus.PAYLOAD_TOO_LARGE, HttpStatus.CONTENT_TOO_LARGE,
"Nutzlast ist zu groß (%d Byte, erlaubt sind %d).".formatted(size, properties.maxSize())); "Nutzlast ist zu groß (%d Byte, erlaubt sind %d).".formatted(size, properties.maxSize()));
} }
WebhookEvent event = service.record(payload); WebhookEvent event = service.record(payload, channel);
return new Receipt(event.id(), event.receivedAt()); return new Receipt(event.id(), event.receivedAt());
} }
@@ -10,7 +10,10 @@ import tools.jackson.databind.JsonNode;
* @param id fortlaufend ab 1; der Browser meldet sie beim Wiederverbinden * @param id fortlaufend ab 1; der Browser meldet sie beim Wiederverbinden
* als {@code Last-Event-ID} zurück, damit nichts verloren geht * als {@code Last-Event-ID} zurück, damit nichts verloren geht
* @param receivedAt Eingangszeit im Backend * @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 * @param payload das empfangene JSON, unverändert
*/ */
public record WebhookEvent(long id, Instant receivedAt, JsonNode payload) { public record WebhookEvent(long id, Instant receivedAt, String channel, JsonNode payload) {
} }
@@ -23,6 +23,9 @@ import org.springframework.stereotype.Service;
import org.springframework.web.servlet.mvc.method.annotation.SseEmitter; import org.springframework.web.servlet.mvc.method.annotation.SseEmitter;
import de.appcreation.swyxweb.config.WebhookProperties; import de.appcreation.swyxweb.config.WebhookProperties;
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 * Nimmt die Nachrichten des Webhooks entgegen und verteilt sie an die offenen
@@ -51,6 +54,7 @@ public class WebhookService {
private final WebhookProperties properties; private final WebhookProperties properties;
private final ObjectMapper mapper; private final ObjectMapper mapper;
private final ArchiveService archive;
private final AtomicLong nextId = new AtomicLong(1); private final AtomicLong nextId = new AtomicLong(1);
/** Jüngste Nachricht zuletzt. Zugriff immer unter dem Monitor dieses Feldes. */ /** Jüngste Nachricht zuletzt. Zugriff immer unter dem Monitor dieses Feldes. */
@@ -64,9 +68,10 @@ public class WebhookService {
return thread; return thread;
}); });
public WebhookService(WebhookProperties properties, ObjectMapper mapper) { public WebhookService(WebhookProperties properties, ObjectMapper mapper, ArchiveService archive) {
this.properties = properties; this.properties = properties;
this.mapper = mapper; this.mapper = mapper;
this.archive = archive;
heartbeat.scheduleWithFixedDelay( heartbeat.scheduleWithFixedDelay(
this::sendHeartbeat, HEARTBEAT_SECONDS, HEARTBEAT_SECONDS, TimeUnit.SECONDS); this::sendHeartbeat, HEARTBEAT_SECONDS, HEARTBEAT_SECONDS, TimeUnit.SECONDS);
@@ -85,14 +90,27 @@ public class WebhookService {
subscribers.clear(); 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 * Nimmt eine Nachricht an, hängt sie an den Verlauf und schickt sie sofort
* an alle offenen Browser. * 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 * @return das angelegte Ereignis, damit der Aufrufer die Id quittieren kann
*/ */
public WebhookEvent record(JsonNode payload) { public WebhookEvent record(JsonNode payload, String channel) {
WebhookEvent event = new WebhookEvent(nextId.getAndIncrement(), Instant.now(), payload); WebhookEvent event = new WebhookEvent(nextId.getAndIncrement(), Instant.now(), channel, payload);
synchronized (events) { synchronized (events) {
events.addLast(event); events.addLast(event);
@@ -102,10 +120,40 @@ public class WebhookService {
} }
log.debug("Webhook: Nachricht {} angenommen ({} Empfänger).", event.id(), subscribers.size()); 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); broadcast(event);
return event; return event;
} }
/**
* Die URL bestimmt die Ablage: Kunden und Kuriere sind Adressdaten (die
* Herkunft wird als {@code source} vermerkt), Jobs sind Jobs. Beim
* generischen Webhook ({@code channel == null}) entscheidet die Nutzlast
* selbst: {@code "type":"job"} ist ein Job, alles andere sind Adressdaten.
*/
private void archiveByChannel(WebhookEvent event, String channel) {
JsonNode payload = event.payload();
switch (channel == null ? "" : channel) {
case "kunden", "kuriere" ->
archive.saveAddresses(event.receivedAt(), channel, 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(event.receivedAt(), "webhook", AddressEntry.manyFrom(payload));
}
}
}
}
/** Verlauf, älteste zuerst. */ /** Verlauf, älteste zuerst. */
public List<WebhookEvent> history() { public List<WebhookEvent> history() {
synchronized (events) { synchronized (events) {
@@ -18,8 +18,16 @@ app.websocket.port=17654
app.websocket.path=/ws app.websocket.path=/ws
app.websocket.secure=false app.websocket.secure=false
# Webhook, über den ein fremdes System Adressdaten an die Startseite schickt: # MongoDB, in der Adressdaten (Webhook) und Anrufdaten dauerhaft abgelegt
# POST /api/webhook. Ist ein Token gesetzt, muss der Aufrufer es im Kopf # 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. # X-Webhook-Token mitschicken; leer heißt, dass jeder Aufruf angenommen wird.
# Für die öffentlich erreichbare Instanz gehört hier ein Geheimnis hinein # Für die öffentlich erreichbare Instanz gehört hier ein Geheimnis hinein
# (oder von außen: APP_WEBHOOK_TOKEN). # (oder von außen: APP_WEBHOOK_TOKEN).
@@ -0,0 +1,57 @@
package de.appcreation.swyxweb;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.argThat;
import static org.mockito.ArgumentMatchers.eq;
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 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(any(), eq("swyxtray"), argThat(entries -> entries.size() == 2));
}
@Test
void rejectsJsonWithoutAddresses() throws Exception {
mvc.perform(post("/api/addresses")
.contentType(MediaType.APPLICATION_JSON)
.content("{\"foo\":\"bar\"}"))
.andExpect(status().isBadRequest());
}
}
@@ -0,0 +1,76 @@
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":"+49421409660",
"description":"Kunde | SYSGEN, SYSTEME UND | NL Bremen | csc 100164"}""");
assertThat(entries).hasSize(1);
AddressEntry entry = entries.getFirst();
assertThat(entry.name()).isEqualTo("SYSGEN GmbH");
assertThat(entry.number()).isEqualTo("+49421409660");
assertThat(entry.description()).isEqualTo("Kunde | SYSGEN, SYSTEME UND | NL Bremen | csc 100164");
}
/** 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(AddressEntry::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();
}
}
@@ -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,81 @@
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.
*/
class JobEntryTests {
private final ObjectMapper mapper = new ObjectMapper();
private static final String SAMPLE = """
{
"type": "job",
"id": 21891253,
"state": 2,
"ordertime": "2026-07-31T16:30:00+02:00",
"orderdate": "2026-07-31",
"modified": "2026-07-30T18:14:02+02:00",
"vehicle": "Transporter",
"customer": { "csc_id": 868374, "name": "B+M isol nord GmbH", "hq": "Bremen" },
"courier": { "cr_id": 14116, "sid": "B1006", "name": "CA Kurier B1006", "number": "+4917xxxxxxx" },
"tours": [
{ "id": 22396738, "sort": 1, "state": 1, "mode": "", "comp": "B+M isol nord GmbH",
"person": "", "number": "+4942037010xx", "street": "Löwenhof 2", "zip": "28844",
"city": "Weyhe-Dreye", "com": "", "finished": "2026-07-30T18:13:11+02:00" },
{ "id": 22396739, "sort": 2, "state": 1, "mode": "", "comp": "Reichspräsident Ebert Kaserne",
"person": "", "number": null, "street": "Osdorfer Landstr. 365", "zip": "22589",
"city": "Hamburg", "com": "", "finished": "2026-07-30T18:13:51+02:00" }
]
}""";
@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-26T10:00:00Z");
JsonNode json = mapper.readTree(SAMPLE);
JobEntry job = JobEntry.from(json, mapper, receivedAt);
assertThat(job.id()).isEqualTo(21891253L);
assertThat(job.receivedAt()).isEqualTo(receivedAt);
assertThat(job.state()).isEqualTo(2);
assertThat(job.ordertime()).isEqualTo("2026-07-31T16:30:00+02:00");
assertThat(job.orderdate()).isEqualTo("2026-07-31");
assertThat(job.vehicle()).isEqualTo("Transporter");
assertThat(job.customer().cscId()).isEqualTo(868374);
assertThat(job.customer().name()).isEqualTo("B+M isol nord GmbH");
assertThat(job.customer().hq()).isEqualTo("Bremen");
assertThat(job.courier().crId()).isEqualTo(14116);
assertThat(job.courier().sid()).isEqualTo("B1006");
assertThat(job.courier().number()).isEqualTo("+4917xxxxxxx");
assertThat(job.tours()).hasSize(2);
JobEntry.Tour first = job.tours().getFirst();
assertThat(first.id()).isEqualTo(22396738L);
assertThat(first.sort()).isEqualTo(1);
assertThat(first.street()).isEqualTo("Löwenhof 2");
assertThat(first.finished()).isEqualTo("2026-07-30T18:13:11+02:00");
// Fehlende Rufnummer der zweiten Station bleibt null.
assertThat(job.tours().get(1).number()).isNull();
}
}
@@ -1,12 +1,18 @@
package de.appcreation.swyxweb; package de.appcreation.swyxweb;
import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.argThat;
import static org.mockito.ArgumentMatchers.eq;
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.delete;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
import de.appcreation.swyxweb.storage.ArchiveService;
import de.appcreation.swyxweb.webhook.WebhookEvent;
import de.appcreation.swyxweb.webhook.WebhookService; import de.appcreation.swyxweb.webhook.WebhookService;
import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Nested; import org.junit.jupiter.api.Nested;
@@ -16,6 +22,7 @@ import org.springframework.boot.webmvc.test.autoconfigure.AutoConfigureMockMvc;
import org.springframework.boot.test.context.SpringBootTest; import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.http.MediaType; import org.springframework.http.MediaType;
import org.springframework.test.context.TestPropertySource; import org.springframework.test.context.TestPropertySource;
import org.springframework.test.context.bean.override.mockito.MockitoBean;
import org.springframework.test.web.servlet.MockMvc; import org.springframework.test.web.servlet.MockMvc;
/** /**
@@ -32,6 +39,10 @@ class WebhookControllerTests {
@Autowired @Autowired
WebhookService service; WebhookService service;
/** Ersetzt: Die Tests laufen ohne MongoDB und hinterlassen dort nichts. */
@MockitoBean
ArchiveService archive;
@BeforeEach @BeforeEach
void emptyHistory() { void emptyHistory() {
service.clear(); service.clear();
@@ -81,6 +92,49 @@ class WebhookControllerTests {
assertThat(service.history()).isEmpty(); 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(WebhookEvent::channel)
.containsExactly("kunden", "kuriere", "jobs");
// Kunden und Kuriere landen als Adressdaten in der Ablage, der Job als Job.
verify(archive).saveAddresses(any(), eq("kunden"), argThat(entries -> entries.size() == 1));
verify(archive).saveAddresses(any(), eq("kuriere"), argThat(entries -> entries.size() == 1));
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(any(), eq("webhook"), argThat(entries -> entries.size() == 1));
}
/** Ohne Token in der Konfiguration darf der Kopf fehlen. */ /** Ohne Token in der Konfiguration darf der Kopf fehlen. */
@Test @Test
void tokenIsNotRequiredWhenUnset() throws Exception { void tokenIsNotRequiredWhenUnset() throws Exception {
@@ -93,7 +147,7 @@ class WebhookControllerTests {
// max-size steht auf 256 KiB; ein Wert deutlich darüber muss abgelehnt werden. // max-size steht auf 256 KiB; ein Wert deutlich darüber muss abgelehnt werden.
String big = "{\"v\":\"" + "x".repeat(300_000) + "\"}"; String big = "{\"v\":\"" + "x".repeat(300_000) + "\"}";
mvc.perform(post("/api/webhook").contentType(MediaType.APPLICATION_JSON).content(big)) mvc.perform(post("/api/webhook").contentType(MediaType.APPLICATION_JSON).content(big))
.andExpect(status().isPayloadTooLarge()); .andExpect(status().isContentTooLarge());
assertThat(service.history()).isEmpty(); assertThat(service.history()).isEmpty();
} }
@@ -113,6 +167,17 @@ class WebhookControllerTests {
.andExpect(status().isUnauthorized()); .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 @Test
void rejectsWrongToken() throws Exception { void rejectsWrongToken() throws Exception {
mvc.perform(post("/api/webhook") mvc.perform(post("/api/webhook")
+20
View File
@@ -0,0 +1,20 @@
import type { Contact } from './swyx/protocol'
/**
* 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)
})
}
+22
View File
@@ -0,0 +1,22 @@
import type { CallEvent } from './swyx/protocol'
/**
* Meldet Anruf-Ereignisse an das Backend, das sie in der MongoDB ablegt.
*
* Die Ereignisse entstehen nur hier im Browser (aus dem Vergleich zweier
* SwyxTray-Snapshots); das Backend sieht die WebSocket-Verbindung nicht.
* Fire-and-forget: Ist das Backend nicht erreichbar, läuft die Anzeige
* unverändert weiter die Speicherung ist Beiwerk, nicht Voraussetzung.
*/
export function reportCallEvents(events: CallEvent[]): void {
if (events.length === 0) return
void fetch('/api/calls', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(events),
// Auch beim Schließen des Tabs noch absetzen, z. B. das "ended" beim Auflegen.
keepalive: true,
}).catch((e: unknown) => {
console.warn('Anrufdaten nicht an das Backend gemeldet:', e)
})
}
+17 -5
View File
@@ -2,6 +2,8 @@ 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 Contact, 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 { 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
@@ -64,7 +66,11 @@ export function useSwyxTray() {
}), }),
client.on('error', (message) => setError(message)), client.on('error', (message) => setError(message)),
// Unaufgefordert: beim Verbinden und wenn die App ihren Cache erneuert. // Unaufgefordert: beim Verbinden und wenn die App ihren Cache erneuert.
client.on('addresses', (contacts) => setAddressCache(contacts)), 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++ }]
@@ -89,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)
} }
}), }),
] ]
@@ -139,15 +147,19 @@ export function useSwyxTray() {
*/ */
const loadDirectory = useCallback( const loadDirectory = useCallback(
(options: SweepOptions = {}) => (options: SweepOptions = {}) =>
run(() => run(async () => {
loadDirectoryFrom( const directory = await loadDirectoryFrom(
{ {
listAddresses: async () => (await client.listAddresses()).addresses ?? [], listAddresses: async () => (await client.listAddresses()).addresses ?? [],
search: async (query) => (await client.searchContacts(query)).contacts ?? [], search: async (query) => (await client.searchContacts(query)).contacts ?? [],
}, },
options, 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], [client, run],
) )
+3 -6
View File
@@ -14,11 +14,11 @@
--border-strong: #b4b4b5; --border-strong: #b4b4b5;
--text: #222222; --text: #222222;
--text-muted: #7c7f7e; --text-muted: #7c7f7e;
/* Vollflächen (Buttons, Balken) immer das Markenrot mit weißer Schrift. */ /* Markenrot laut Styleguide (RGB 202/13/56, Pantone 185 C) in beiden
Farbmodi identisch, das Brandbook kennt nur diesen einen Rotwert. */
--brand: #ca0d38; --brand: #ca0d38;
--brand-strong: #a30a2d; --brand-strong: #a30a2d;
--brand-soft: rgb(202 13 56 / 8%); --brand-soft: rgb(202 13 56 / 8%);
/* Rot als Text- und Konturfarbe; im Dunkelmodus eine hellere Abstufung. */
--accent: #ca0d38; --accent: #ca0d38;
--ok: #2f9e6e; --ok: #2f9e6e;
--warn: #b98a1c; --warn: #b98a1c;
@@ -35,10 +35,7 @@
--border-strong: #55565c; --border-strong: #55565c;
--text: #f1f1f2; --text: #f1f1f2;
--text-muted: #a4a7a8; --text-muted: #a4a7a8;
--brand: #ca0d38; --brand-soft: rgb(202 13 56 / 14%);
--brand-strong: #e33e60;
--brand-soft: rgb(227 62 96 / 14%);
--accent: #ef7d95;
--ok: #43c78f; --ok: #43c78f;
--warn: #e0b341; --warn: #e0b341;
--err: #f2665b; --err: #f2665b;
+5
View File
@@ -13,6 +13,11 @@ export interface WebhookEvent {
id: number id: number
/** Eingangszeit im Backend, ISO-8601. */ /** Eingangszeit im Backend, ISO-8601. */
receivedAt: string 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. */ /** Das empfangene JSON, unverändert. Objekt, Liste oder ein einfacher Wert. */
payload: unknown payload: unknown
} }