diff --git a/backend/pom.xml b/backend/pom.xml index 24a39c1..9737374 100644 --- a/backend/pom.xml +++ b/backend/pom.xml @@ -19,6 +19,10 @@ + + org.springframework.boot + spring-boot-starter-data-mongodb + org.springframework.boot spring-boot-starter-webmvc diff --git a/backend/src/main/java/de/appcreation/swyxweb/storage/AddressEntry.java b/backend/src/main/java/de/appcreation/swyxweb/storage/AddressEntry.java new file mode 100644 index 0000000..a62df39 --- /dev/null +++ b/backend/src/main/java/de/appcreation/swyxweb/storage/AddressEntry.java @@ -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. + * + *

Die Kennung {@code id} vergibt die MongoDB. Die Rufnummer erkennt die + * Dublette: 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. + * + *

Alle Quellen liefern dieselben drei Felder, nur unterschiedlich verpackt; + * {@link #manyFrom(JsonNode)} versteht jede dieser Formen: + *

+ * + * @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 manyFrom(JsonNode json) { + List 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 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; + } +} diff --git a/backend/src/main/java/de/appcreation/swyxweb/storage/ArchiveService.java b/backend/src/main/java/de/appcreation/swyxweb/storage/ArchiveService.java new file mode 100644 index 0000000..8d56b17 --- /dev/null +++ b/backend/src/main/java/de/appcreation/swyxweb/storage/ArchiveService.java @@ -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. + * + *

Geschrieben wird nebenbei und best-effort: 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 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 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 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()); + } + }); + } +} diff --git a/backend/src/main/java/de/appcreation/swyxweb/storage/JobEntry.java b/backend/src/main/java/de/appcreation/swyxweb/storage/JobEntry.java new file mode 100644 index 0000000..0138a2b --- /dev/null +++ b/backend/src/main/java/de/appcreation/swyxweb/storage/JobEntry.java @@ -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}). + * + *

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. + * + *

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 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()); + } +} diff --git a/backend/src/main/java/de/appcreation/swyxweb/storage/StoredCall.java b/backend/src/main/java/de/appcreation/swyxweb/storage/StoredCall.java new file mode 100644 index 0000000..12f08bc --- /dev/null +++ b/backend/src/main/java/de/appcreation/swyxweb/storage/StoredCall.java @@ -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) { +} diff --git a/backend/src/main/java/de/appcreation/swyxweb/web/AddressController.java b/backend/src/main/java/de/appcreation/swyxweb/web/AddressController.java new file mode 100644 index 0000000..09f4374 --- /dev/null +++ b/backend/src/main/java/de/appcreation/swyxweb/web/AddressController.java @@ -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. + * + *

{@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. + * + *

{@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 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 list() { + return archive.addresses(); + } +} diff --git a/backend/src/main/java/de/appcreation/swyxweb/web/CallController.java b/backend/src/main/java/de/appcreation/swyxweb/web/CallController.java new file mode 100644 index 0000000..7387bdf --- /dev/null +++ b/backend/src/main/java/de/appcreation/swyxweb/web/CallController.java @@ -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. + * + *

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 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(); + } +} diff --git a/backend/src/main/java/de/appcreation/swyxweb/web/WebhookController.java b/backend/src/main/java/de/appcreation/swyxweb/web/WebhookController.java index 37cb5cd..5db248e 100644 --- a/backend/src/main/java/de/appcreation/swyxweb/web/WebhookController.java +++ b/backend/src/main/java/de/appcreation/swyxweb/web/WebhookController.java @@ -25,16 +25,32 @@ import de.appcreation.swyxweb.webhook.WebhookEvent; import de.appcreation.swyxweb.webhook.WebhookService; /** - * Webhook für Adressdaten aus einem fremden System. + * Webhook für Adress- und Jobdaten aus einem fremden System. * - *

curl -X POST https://swyxweb.appcreation.de/api/webhook \
+ * 

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: + * + *

curl -X POST https://swyxweb.appcreation.de/api/webhook/kunden \
  *   -H "Content-Type: application/json" \
  *   -H "X-Webhook-Token: …" \
- *   -d '{"name":"Muster GmbH","number":"+493012345"}'
+ * -d '{"name":"SYSGEN GmbH","number":"+49421409660","description":"Kunde | …"}' * - *

Angenommen wird beliebiges JSON – Objekt wie Liste. Die Startseite - * zeigt es im Bereich „Webhook" unverändert an; das aufrufende System muss sich - * also an kein festes Schema halten. + * 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":[…]}'

+ * + *

Daneben bleibt der generische {@code POST /api/webhook}: Er nimmt + * beliebiges JSON an – {@code "type":"job"} wird als Job abgelegt, alles + * andere als Adressdaten. Die Startseite zeigt jede Nachricht im Bereich + * „Webhook" unverändert an; das aufrufende System muss sich an kein festes + * Schema halten. * *

Der Weg zum Browser läuft über {@code GET /api/webhook/events} * (Server-Sent Events), siehe {@link WebhookService}. @@ -57,11 +73,39 @@ public class WebhookController { 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 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); 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; if (size > properties.maxSize()) { throw new ResponseStatusException( - HttpStatus.PAYLOAD_TOO_LARGE, + HttpStatus.CONTENT_TOO_LARGE, "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()); } diff --git a/backend/src/main/java/de/appcreation/swyxweb/webhook/WebhookEvent.java b/backend/src/main/java/de/appcreation/swyxweb/webhook/WebhookEvent.java index a5c8198..41c3b55 100644 --- a/backend/src/main/java/de/appcreation/swyxweb/webhook/WebhookEvent.java +++ b/backend/src/main/java/de/appcreation/swyxweb/webhook/WebhookEvent.java @@ -10,7 +10,10 @@ import tools.jackson.databind.JsonNode; * @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, JsonNode payload) { +public record WebhookEvent(long id, Instant receivedAt, String channel, JsonNode payload) { } diff --git a/backend/src/main/java/de/appcreation/swyxweb/webhook/WebhookService.java b/backend/src/main/java/de/appcreation/swyxweb/webhook/WebhookService.java index baa84e8..5aa5a08 100644 --- a/backend/src/main/java/de/appcreation/swyxweb/webhook/WebhookService.java +++ b/backend/src/main/java/de/appcreation/swyxweb/webhook/WebhookService.java @@ -23,6 +23,9 @@ 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 @@ -51,6 +54,7 @@ public class WebhookService { 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. */ @@ -64,9 +68,10 @@ public class WebhookService { return thread; }); - public WebhookService(WebhookProperties properties, ObjectMapper mapper) { + 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); @@ -85,14 +90,27 @@ public class WebhookService { 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) { - WebhookEvent event = new WebhookEvent(nextId.getAndIncrement(), Instant.now(), payload); + public WebhookEvent record(JsonNode payload, String channel) { + WebhookEvent event = new WebhookEvent(nextId.getAndIncrement(), Instant.now(), channel, payload); synchronized (events) { events.addLast(event); @@ -102,10 +120,40 @@ public class WebhookService { } 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 (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. */ public List history() { synchronized (events) { diff --git a/backend/src/main/resources/application.properties b/backend/src/main/resources/application.properties index 51a5a0f..8f81825 100644 --- a/backend/src/main/resources/application.properties +++ b/backend/src/main/resources/application.properties @@ -18,8 +18,16 @@ app.websocket.port=17654 app.websocket.path=/ws app.websocket.secure=false -# Webhook, über den ein fremdes System Adressdaten an die Startseite schickt: -# POST /api/webhook. Ist ein Token gesetzt, muss der Aufrufer es im Kopf +# 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). diff --git a/backend/src/test/java/de/appcreation/swyxweb/AddressControllerTests.java b/backend/src/test/java/de/appcreation/swyxweb/AddressControllerTests.java new file mode 100644 index 0000000..bd87739 --- /dev/null +++ b/backend/src/test/java/de/appcreation/swyxweb/AddressControllerTests.java @@ -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()); + } +} diff --git a/backend/src/test/java/de/appcreation/swyxweb/AddressEntryTests.java b/backend/src/test/java/de/appcreation/swyxweb/AddressEntryTests.java new file mode 100644 index 0000000..800827a --- /dev/null +++ b/backend/src/test/java/de/appcreation/swyxweb/AddressEntryTests.java @@ -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 parse(String json) { + return AddressEntry.manyFrom(mapper.readTree(json)); + } + + /** Ein Kunde, wie ihn das Fremdsystem über den Webhook schickt. */ + @Test + void readsSingleWebhookEntry() { + List 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 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 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(); + } +} diff --git a/backend/src/test/java/de/appcreation/swyxweb/CallControllerTests.java b/backend/src/test/java/de/appcreation/swyxweb/CallControllerTests.java new file mode 100644 index 0000000..e0ab169 --- /dev/null +++ b/backend/src/test/java/de/appcreation/swyxweb/CallControllerTests.java @@ -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()); + } +} diff --git a/backend/src/test/java/de/appcreation/swyxweb/JobEntryTests.java b/backend/src/test/java/de/appcreation/swyxweb/JobEntryTests.java new file mode 100644 index 0000000..ef5fbca --- /dev/null +++ b/backend/src/test/java/de/appcreation/swyxweb/JobEntryTests.java @@ -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(); + } +} diff --git a/backend/src/test/java/de/appcreation/swyxweb/WebhookControllerTests.java b/backend/src/test/java/de/appcreation/swyxweb/WebhookControllerTests.java index 79ad1b0..7394208 100644 --- a/backend/src/test/java/de/appcreation/swyxweb/WebhookControllerTests.java +++ b/backend/src/test/java/de/appcreation/swyxweb/WebhookControllerTests.java @@ -1,12 +1,18 @@ package de.appcreation.swyxweb; 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.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.WebhookEvent; import de.appcreation.swyxweb.webhook.WebhookService; import org.junit.jupiter.api.BeforeEach; 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.http.MediaType; import org.springframework.test.context.TestPropertySource; +import org.springframework.test.context.bean.override.mockito.MockitoBean; import org.springframework.test.web.servlet.MockMvc; /** @@ -32,6 +39,10 @@ class WebhookControllerTests { @Autowired WebhookService service; + /** Ersetzt: Die Tests laufen ohne MongoDB und hinterlassen dort nichts. */ + @MockitoBean + ArchiveService archive; + @BeforeEach void emptyHistory() { service.clear(); @@ -81,6 +92,49 @@ class WebhookControllerTests { 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. */ @Test void tokenIsNotRequiredWhenUnset() throws Exception { @@ -93,7 +147,7 @@ class WebhookControllerTests { // 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().isPayloadTooLarge()); + .andExpect(status().isContentTooLarge()); assertThat(service.history()).isEmpty(); } @@ -113,6 +167,17 @@ class WebhookControllerTests { .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") diff --git a/frontend/src/addresses.ts b/frontend/src/addresses.ts new file mode 100644 index 0000000..070d1e8 --- /dev/null +++ b/frontend/src/addresses.ts @@ -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) + }) +} diff --git a/frontend/src/calls.ts b/frontend/src/calls.ts new file mode 100644 index 0000000..90afa13 --- /dev/null +++ b/frontend/src/calls.ts @@ -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) + }) +} diff --git a/frontend/src/hooks/useSwyxTray.ts b/frontend/src/hooks/useSwyxTray.ts index 92d6ba5..db899cd 100644 --- a/frontend/src/hooks/useSwyxTray.ts +++ b/frontend/src/hooks/useSwyxTray.ts @@ -2,6 +2,8 @@ import { useCallback, useEffect, useMemo, useRef, useState } from 'react' import { SwyxTrayClient, type ConnectionStatus, type RawMessage } from '../swyx/SwyxTrayClient' 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 { id: number @@ -64,7 +66,11 @@ export function useSwyxTray() { }), client.on('error', (message) => setError(message)), // 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) => { setLog((entries) => { const next = [...entries, { ...message, id: logIdRef.current++ }] @@ -89,6 +95,8 @@ export function useSwyxTray() { setLines(next) if (events.length > 0) { 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( (options: SweepOptions = {}) => - run(() => - loadDirectoryFrom( + 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], ) diff --git a/frontend/src/index.css b/frontend/src/index.css index df54ff2..71d3992 100644 --- a/frontend/src/index.css +++ b/frontend/src/index.css @@ -14,11 +14,11 @@ --border-strong: #b4b4b5; --text: #222222; --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-strong: #a30a2d; --brand-soft: rgb(202 13 56 / 8%); - /* Rot als Text- und Konturfarbe; im Dunkelmodus eine hellere Abstufung. */ --accent: #ca0d38; --ok: #2f9e6e; --warn: #b98a1c; @@ -35,10 +35,7 @@ --border-strong: #55565c; --text: #f1f1f2; --text-muted: #a4a7a8; - --brand: #ca0d38; - --brand-strong: #e33e60; - --brand-soft: rgb(227 62 96 / 14%); - --accent: #ef7d95; + --brand-soft: rgb(202 13 56 / 14%); --ok: #43c78f; --warn: #e0b341; --err: #f2665b; diff --git a/frontend/src/webhook.ts b/frontend/src/webhook.ts index d91abf8..8830856 100644 --- a/frontend/src/webhook.ts +++ b/frontend/src/webhook.ts @@ -13,6 +13,11 @@ export interface WebhookEvent { 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 }