Compare commits
2
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
97c3778180 | ||
|
|
1598852785 |
@@ -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")
|
||||||
|
|||||||
+16
-1
@@ -3,7 +3,22 @@
|
|||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8" />
|
<meta charset="UTF-8" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
<title>SwyxWeb</title>
|
<title>SwyxWeb · STADTBOTE</title>
|
||||||
|
<!-- Signet als Favicon: roter Ring mit „S" (Markenrot #CA0D38). -->
|
||||||
|
<link
|
||||||
|
rel="icon"
|
||||||
|
type="image/svg+xml"
|
||||||
|
href="data:image/svg+xml,%3Csvg%20xmlns='http://www.w3.org/2000/svg'%20viewBox='0%200%20100%20100'%3E%3Cpath%20d='M%2055%2014.4%20A%2036%2036%200%201%200%2081.8%2033.1'%20fill='none'%20stroke='%23CA0D38'%20stroke-width='13'/%3E%3Ctext%20x='50'%20y='52'%20text-anchor='middle'%20dominant-baseline='central'%20font-family='Arial,Helvetica,sans-serif'%20font-weight='bold'%20font-size='54'%20fill='%23CA0D38'%3ES%3C/text%3E%3C/svg%3E"
|
||||||
|
/>
|
||||||
|
<!-- Hausschrift laut Styleguide: Red Hat Display (Überschriften) und
|
||||||
|
Red Hat Text (Fließtext), beide Google Fonts. Ohne Internetzugang
|
||||||
|
greift der Fallback auf Systemschriften. -->
|
||||||
|
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
||||||
|
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
||||||
|
<link
|
||||||
|
rel="stylesheet"
|
||||||
|
href="https://fonts.googleapis.com/css2?family=Red+Hat+Display:wght@400;600;700&family=Red+Hat+Text:wght@400;500;600&display=swap"
|
||||||
|
/>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div id="root"></div>
|
<div id="root"></div>
|
||||||
|
|||||||
@@ -0,0 +1,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)
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
/**
|
||||||
|
* Das STADTBOTE-Signet aus dem Corporate-Design-Manual: ein offener roter Ring
|
||||||
|
* mit einem „S" in der Mitte. Als Inline-SVG nachgebaut, weil das Brandbook das
|
||||||
|
* Logo nur als Pixelbild enthält; die Farbe kommt über currentColor vom Umfeld.
|
||||||
|
*/
|
||||||
|
export default function StadtboteSignet({ className }: { className?: string }) {
|
||||||
|
return (
|
||||||
|
<svg className={className} viewBox="0 0 100 100" aria-hidden="true" focusable="false">
|
||||||
|
<path
|
||||||
|
d="M 55 14.4 A 36 36 0 1 0 81.8 33.1"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth="13"
|
||||||
|
/>
|
||||||
|
<text
|
||||||
|
x="50"
|
||||||
|
y="52"
|
||||||
|
textAnchor="middle"
|
||||||
|
dominantBaseline="central"
|
||||||
|
fontFamily="'Red Hat Display', 'Red Hat Text', system-ui, sans-serif"
|
||||||
|
fontWeight="700"
|
||||||
|
fontSize="54"
|
||||||
|
fill="currentColor"
|
||||||
|
>
|
||||||
|
S
|
||||||
|
</text>
|
||||||
|
</svg>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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],
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
+147
-68
@@ -1,26 +1,45 @@
|
|||||||
|
/*
|
||||||
|
* Farb- und Schriftvorgaben aus dem STADTBOTE-Styleguide (HANSETRANS Brandbook):
|
||||||
|
* Primär Rot #CA0D38 (Pantone 185 C) und Grau #7C7F7E (Pantone 424 C),
|
||||||
|
* Off-Black #222222 für Fließtext, Grauabstufungen als Flächen.
|
||||||
|
* Schrift: Red Hat Display (Überschriften, Versalien, Laufweite 4 %) und
|
||||||
|
* Red Hat Text (Fließtext). Buttons: primär vollflächig, sekundär mit Rahmen,
|
||||||
|
* Kästen und Linien mit leichter Abrundung.
|
||||||
|
*/
|
||||||
:root {
|
:root {
|
||||||
--bg: #0f1116;
|
--bg: #ffffff;
|
||||||
--surface: #171a21;
|
--surface: #ffffff;
|
||||||
--surface-2: #1f2530;
|
--surface-2: #f6f6f6;
|
||||||
--border: #2b3341;
|
--border: #dcdcdd;
|
||||||
--text: #e6e9ef;
|
--border-strong: #b4b4b5;
|
||||||
--text-muted: #98a2b3;
|
--text: #222222;
|
||||||
--accent: #4f8cff;
|
--text-muted: #7c7f7e;
|
||||||
--ok: #35c98a;
|
/* Markenrot laut Styleguide (RGB 202/13/56, Pantone 185 C) – in beiden
|
||||||
--warn: #e0b341;
|
Farbmodi identisch, das Brandbook kennt nur diesen einen Rotwert. */
|
||||||
--err: #f2585b;
|
--brand: #ca0d38;
|
||||||
color-scheme: dark;
|
--brand-strong: #a30a2d;
|
||||||
|
--brand-soft: rgb(202 13 56 / 8%);
|
||||||
|
--accent: #ca0d38;
|
||||||
|
--ok: #2f9e6e;
|
||||||
|
--warn: #b98a1c;
|
||||||
|
--err: #d92b1f;
|
||||||
|
color-scheme: light;
|
||||||
}
|
}
|
||||||
|
|
||||||
@media (prefers-color-scheme: light) {
|
@media (prefers-color-scheme: dark) {
|
||||||
:root {
|
:root {
|
||||||
--bg: #f5f7fa;
|
--bg: #17181a;
|
||||||
--surface: #ffffff;
|
--surface: #1f2023;
|
||||||
--surface-2: #eef1f6;
|
--surface-2: #28292d;
|
||||||
--border: #d8dee9;
|
--border: #3a3b40;
|
||||||
--text: #1b2230;
|
--border-strong: #55565c;
|
||||||
--text-muted: #5c6779;
|
--text: #f1f1f2;
|
||||||
color-scheme: light;
|
--text-muted: #a4a7a8;
|
||||||
|
--brand-soft: rgb(202 13 56 / 14%);
|
||||||
|
--ok: #43c78f;
|
||||||
|
--warn: #e0b341;
|
||||||
|
--err: #f2665b;
|
||||||
|
color-scheme: dark;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -32,9 +51,21 @@ body {
|
|||||||
margin: 0;
|
margin: 0;
|
||||||
background: var(--bg);
|
background: var(--bg);
|
||||||
color: var(--text);
|
color: var(--text);
|
||||||
font-family: system-ui, -apple-system, 'Segoe UI', Roboto, sans-serif;
|
font-family: 'Red Hat Text', system-ui, -apple-system, 'Segoe UI', Roboto, sans-serif;
|
||||||
font-size: 15px;
|
font-size: 15px;
|
||||||
line-height: 1.5;
|
line-height: 1.4;
|
||||||
|
}
|
||||||
|
|
||||||
|
h1,
|
||||||
|
h2,
|
||||||
|
h3 {
|
||||||
|
/* Überschriften laut Styleguide: Red Hat Display Semibold in Versalien,
|
||||||
|
Laufweite 4 %, Zeilenabstand 100 %. */
|
||||||
|
font-family: 'Red Hat Display', 'Red Hat Text', system-ui, sans-serif;
|
||||||
|
font-weight: 600;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.04em;
|
||||||
|
line-height: 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
code {
|
code {
|
||||||
@@ -59,14 +90,38 @@ code {
|
|||||||
flex-wrap: wrap;
|
flex-wrap: wrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
.page__header h1 {
|
/* Abgerundete Linie als Trennlinie unter dem Kopf – Gestaltungselement des CI. */
|
||||||
|
.page__header::after {
|
||||||
|
content: '';
|
||||||
|
flex-basis: 100%;
|
||||||
|
height: 3px;
|
||||||
|
border-radius: 999px;
|
||||||
|
background: var(--brand);
|
||||||
|
}
|
||||||
|
|
||||||
|
.brand {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 14px;
|
||||||
|
color: var(--accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.brand__signet {
|
||||||
|
width: 46px;
|
||||||
|
height: 46px;
|
||||||
|
flex: 0 0 auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.brand__name {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
font-size: 26px;
|
font-weight: 700;
|
||||||
letter-spacing: -0.02em;
|
font-size: 24px;
|
||||||
|
letter-spacing: 0.06em;
|
||||||
|
color: var(--accent);
|
||||||
}
|
}
|
||||||
|
|
||||||
.page__subtitle {
|
.page__subtitle {
|
||||||
margin: 2px 0 0;
|
margin: 4px 0 0;
|
||||||
color: var(--text-muted);
|
color: var(--text-muted);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -84,7 +139,7 @@ code {
|
|||||||
.card {
|
.card {
|
||||||
background: var(--surface);
|
background: var(--surface);
|
||||||
border: 1px solid var(--border);
|
border: 1px solid var(--border);
|
||||||
border-radius: 12px;
|
border-radius: 8px;
|
||||||
padding: 18px;
|
padding: 18px;
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
@@ -93,8 +148,8 @@ code {
|
|||||||
|
|
||||||
.card h2 {
|
.card h2 {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
font-size: 16px;
|
font-size: 15px;
|
||||||
font-weight: 600;
|
color: var(--text);
|
||||||
}
|
}
|
||||||
|
|
||||||
.card__header {
|
.card__header {
|
||||||
@@ -134,9 +189,9 @@ code {
|
|||||||
.field__input {
|
.field__input {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
padding: 9px 12px;
|
padding: 9px 12px;
|
||||||
border-radius: 8px;
|
border-radius: 6px;
|
||||||
border: 1px solid var(--border);
|
border: 1px solid var(--border-strong);
|
||||||
background: var(--surface-2);
|
background: var(--surface);
|
||||||
color: var(--text);
|
color: var(--text);
|
||||||
font: inherit;
|
font: inherit;
|
||||||
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
|
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
|
||||||
@@ -144,27 +199,30 @@ code {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.field__input:focus {
|
.field__input:focus {
|
||||||
outline: 2px solid var(--accent);
|
outline: 2px solid var(--brand);
|
||||||
outline-offset: 1px;
|
outline-offset: 1px;
|
||||||
|
border-color: var(--brand);
|
||||||
}
|
}
|
||||||
|
|
||||||
.field__input:disabled {
|
.field__input:disabled {
|
||||||
opacity: 0.55;
|
opacity: 0.55;
|
||||||
|
background: var(--surface-2);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Sekundärbutton laut Styleguide: schlichter roter Rahmen, leichte Abrundung. */
|
||||||
.button {
|
.button {
|
||||||
padding: 9px 16px;
|
padding: 9px 16px;
|
||||||
border-radius: 8px;
|
border-radius: 6px;
|
||||||
border: 1px solid var(--border);
|
border: 1px solid var(--accent);
|
||||||
background: var(--surface-2);
|
background: transparent;
|
||||||
color: var(--text);
|
color: var(--accent);
|
||||||
font: inherit;
|
font: inherit;
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
}
|
}
|
||||||
|
|
||||||
.button:hover:not(:disabled) {
|
.button:hover:not(:disabled) {
|
||||||
border-color: var(--accent);
|
background: var(--brand-soft);
|
||||||
}
|
}
|
||||||
|
|
||||||
.button:disabled {
|
.button:disabled {
|
||||||
@@ -172,13 +230,20 @@ code {
|
|||||||
cursor: not-allowed;
|
cursor: not-allowed;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Primärbutton laut Styleguide: vollflächig im Markenrot. */
|
||||||
.button--primary {
|
.button--primary {
|
||||||
background: var(--accent);
|
background: var(--brand);
|
||||||
border-color: var(--accent);
|
border-color: var(--brand);
|
||||||
color: #fff;
|
color: #fff;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.button--primary:hover:not(:disabled) {
|
||||||
|
background: var(--brand-strong);
|
||||||
|
border-color: var(--brand-strong);
|
||||||
|
}
|
||||||
|
|
||||||
.button--ghost {
|
.button--ghost {
|
||||||
|
border-color: transparent;
|
||||||
background: transparent;
|
background: transparent;
|
||||||
padding: 5px 10px;
|
padding: 5px 10px;
|
||||||
font-size: 13px;
|
font-size: 13px;
|
||||||
@@ -200,21 +265,25 @@ code {
|
|||||||
|
|
||||||
.tabs {
|
.tabs {
|
||||||
display: flex;
|
display: flex;
|
||||||
gap: 4px;
|
gap: 18px;
|
||||||
border-bottom: 1px solid var(--border);
|
border-bottom: 1px solid var(--border);
|
||||||
|
flex-wrap: wrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
.tabs__button {
|
.tabs__button {
|
||||||
padding: 9px 16px;
|
padding: 10px 2px;
|
||||||
border: 1px solid transparent;
|
border: none;
|
||||||
/* Die aktive Kante überdeckt die Linie der Leiste. */
|
border-bottom: 3px solid transparent;
|
||||||
border-bottom: none;
|
|
||||||
margin-bottom: -1px;
|
margin-bottom: -1px;
|
||||||
border-radius: 10px 10px 0 0;
|
border-radius: 0;
|
||||||
background: transparent;
|
background: transparent;
|
||||||
color: var(--text-muted);
|
color: var(--text-muted);
|
||||||
font: inherit;
|
font: inherit;
|
||||||
font-weight: 500;
|
font-family: 'Red Hat Display', 'Red Hat Text', system-ui, sans-serif;
|
||||||
|
font-weight: 600;
|
||||||
|
font-size: 13px;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.04em;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -223,9 +292,8 @@ code {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.tabs__button--active {
|
.tabs__button--active {
|
||||||
background: var(--surface);
|
color: var(--accent);
|
||||||
border-color: var(--border);
|
border-bottom-color: var(--brand);
|
||||||
color: var(--text);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
[role='tabpanel'] {
|
[role='tabpanel'] {
|
||||||
@@ -354,7 +422,12 @@ code {
|
|||||||
.button--accept {
|
.button--accept {
|
||||||
background: var(--ok);
|
background: var(--ok);
|
||||||
border-color: var(--ok);
|
border-color: var(--ok);
|
||||||
color: #06281c;
|
color: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.button--accept:hover:not(:disabled) {
|
||||||
|
background: var(--ok);
|
||||||
|
opacity: 0.9;
|
||||||
}
|
}
|
||||||
|
|
||||||
.button--reject {
|
.button--reject {
|
||||||
@@ -363,6 +436,11 @@ code {
|
|||||||
color: #fff;
|
color: #fff;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.button--reject:hover:not(:disabled) {
|
||||||
|
background: var(--err);
|
||||||
|
opacity: 0.9;
|
||||||
|
}
|
||||||
|
|
||||||
.button--small {
|
.button--small {
|
||||||
padding: 5px 12px;
|
padding: 5px 12px;
|
||||||
font-size: 13px;
|
font-size: 13px;
|
||||||
@@ -387,7 +465,7 @@ code {
|
|||||||
gap: 12px;
|
gap: 12px;
|
||||||
flex-wrap: wrap;
|
flex-wrap: wrap;
|
||||||
padding: 10px 12px;
|
padding: 10px 12px;
|
||||||
border-radius: 8px;
|
border-radius: 6px;
|
||||||
border: 1px solid var(--border);
|
border: 1px solid var(--border);
|
||||||
background: var(--surface-2);
|
background: var(--surface-2);
|
||||||
}
|
}
|
||||||
@@ -400,7 +478,7 @@ code {
|
|||||||
.history__line {
|
.history__line {
|
||||||
flex: 0 0 auto;
|
flex: 0 0 auto;
|
||||||
padding: 2px 8px;
|
padding: 2px 8px;
|
||||||
border-radius: 6px;
|
border-radius: 4px;
|
||||||
background: var(--border);
|
background: var(--border);
|
||||||
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
|
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
@@ -463,8 +541,8 @@ code {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.history__badge--ended {
|
.history__badge--ended {
|
||||||
border-color: var(--err);
|
border-color: var(--border-strong);
|
||||||
color: var(--err);
|
color: var(--text-muted);
|
||||||
}
|
}
|
||||||
|
|
||||||
.history__number {
|
.history__number {
|
||||||
@@ -492,7 +570,7 @@ code {
|
|||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 12px;
|
gap: 12px;
|
||||||
padding: 10px 12px;
|
padding: 10px 12px;
|
||||||
border-radius: 8px;
|
border-radius: 6px;
|
||||||
border: 1px solid var(--border);
|
border: 1px solid var(--border);
|
||||||
background: var(--surface-2);
|
background: var(--surface-2);
|
||||||
}
|
}
|
||||||
@@ -504,7 +582,7 @@ code {
|
|||||||
.tablist__id {
|
.tablist__id {
|
||||||
flex: 0 0 auto;
|
flex: 0 0 auto;
|
||||||
padding: 2px 8px;
|
padding: 2px 8px;
|
||||||
border-radius: 6px;
|
border-radius: 4px;
|
||||||
background: var(--border);
|
background: var(--border);
|
||||||
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
|
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
@@ -561,15 +639,15 @@ code {
|
|||||||
.dialog {
|
.dialog {
|
||||||
width: min(420px, 100%);
|
width: min(420px, 100%);
|
||||||
padding: 24px;
|
padding: 24px;
|
||||||
border-radius: 12px;
|
border-radius: 8px;
|
||||||
border: 1px solid var(--border);
|
border: 1px solid var(--border);
|
||||||
background: var(--surface);
|
background: var(--surface);
|
||||||
box-shadow: 0 24px 60px rgb(0 0 0 / 45%);
|
box-shadow: 0 24px 60px rgb(0 0 0 / 25%);
|
||||||
}
|
}
|
||||||
|
|
||||||
.dialog__title {
|
.dialog__title {
|
||||||
margin: 0 0 16px;
|
margin: 0 0 16px;
|
||||||
font-size: 18px;
|
font-size: 17px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.dialog__status {
|
.dialog__status {
|
||||||
@@ -591,14 +669,15 @@ code {
|
|||||||
.progress {
|
.progress {
|
||||||
height: 8px;
|
height: 8px;
|
||||||
border-radius: 999px;
|
border-radius: 999px;
|
||||||
background: var(--border);
|
background: var(--surface-2);
|
||||||
|
border: 1px solid var(--border);
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
|
|
||||||
.progress__bar {
|
.progress__bar {
|
||||||
height: 100%;
|
height: 100%;
|
||||||
border-radius: 999px;
|
border-radius: 999px;
|
||||||
background: var(--accent);
|
background: var(--brand);
|
||||||
transition: width 120ms linear;
|
transition: width 120ms linear;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -634,7 +713,7 @@ code {
|
|||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 12px;
|
gap: 12px;
|
||||||
padding: 10px 12px;
|
padding: 10px 12px;
|
||||||
border-radius: 8px;
|
border-radius: 6px;
|
||||||
border: 1px solid var(--border);
|
border: 1px solid var(--border);
|
||||||
background: var(--surface-2);
|
background: var(--surface-2);
|
||||||
}
|
}
|
||||||
@@ -672,7 +751,7 @@ code {
|
|||||||
height: 300px;
|
height: 300px;
|
||||||
overflow-y: auto;
|
overflow-y: auto;
|
||||||
padding: 12px;
|
padding: 12px;
|
||||||
border-radius: 8px;
|
border-radius: 6px;
|
||||||
border: 1px solid var(--border);
|
border: 1px solid var(--border);
|
||||||
background: var(--surface-2);
|
background: var(--surface-2);
|
||||||
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
|
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
|
||||||
@@ -732,7 +811,7 @@ code {
|
|||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: 8px;
|
gap: 8px;
|
||||||
padding: 10px 12px;
|
padding: 10px 12px;
|
||||||
border-radius: 8px;
|
border-radius: 6px;
|
||||||
border: 1px solid var(--border);
|
border: 1px solid var(--border);
|
||||||
background: var(--surface-2);
|
background: var(--surface-2);
|
||||||
}
|
}
|
||||||
@@ -753,7 +832,7 @@ code {
|
|||||||
|
|
||||||
.webhook__id {
|
.webhook__id {
|
||||||
padding: 1px 8px;
|
padding: 1px 8px;
|
||||||
border-radius: 6px;
|
border-radius: 4px;
|
||||||
background: var(--border);
|
background: var(--border);
|
||||||
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
|
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
@@ -772,7 +851,7 @@ code {
|
|||||||
.webhook__json {
|
.webhook__json {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
padding: 10px 12px;
|
padding: 10px 12px;
|
||||||
border-radius: 6px;
|
border-radius: 4px;
|
||||||
background: var(--surface);
|
background: var(--surface);
|
||||||
border: 1px solid var(--border);
|
border: 1px solid var(--border);
|
||||||
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
|
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
|
||||||
@@ -789,8 +868,8 @@ code {
|
|||||||
margin-left: 8px;
|
margin-left: 8px;
|
||||||
padding: 1px 7px;
|
padding: 1px 7px;
|
||||||
border-radius: 999px;
|
border-radius: 999px;
|
||||||
background: var(--accent);
|
background: var(--brand);
|
||||||
color: var(--surface);
|
color: #fff;
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
font-variant-numeric: tabular-nums;
|
font-variant-numeric: tabular-nums;
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import { useCallNotifications } from '../hooks/useCallNotifications'
|
|||||||
import { useWebhook } from '../hooks/useWebhook'
|
import { useWebhook } from '../hooks/useWebhook'
|
||||||
import { countUnseen } from '../webhook'
|
import { countUnseen } from '../webhook'
|
||||||
import StatusBadge from '../components/StatusBadge'
|
import StatusBadge from '../components/StatusBadge'
|
||||||
|
import StadtboteSignet from '../components/StadtboteSignet'
|
||||||
import MessageLog from '../components/MessageLog'
|
import MessageLog from '../components/MessageLog'
|
||||||
import IncomingCallCard from '../components/IncomingCallCard'
|
import IncomingCallCard from '../components/IncomingCallCard'
|
||||||
import DialPanel from '../components/DialPanel'
|
import DialPanel from '../components/DialPanel'
|
||||||
@@ -99,9 +100,12 @@ export default function HomePage() {
|
|||||||
return (
|
return (
|
||||||
<div className="page">
|
<div className="page">
|
||||||
<header className="page__header">
|
<header className="page__header">
|
||||||
|
<div className="brand">
|
||||||
|
<StadtboteSignet className="brand__signet" />
|
||||||
<div>
|
<div>
|
||||||
<h1>SwyxWeb</h1>
|
<h1 className="brand__name">Stadtbote</h1>
|
||||||
<p className="page__subtitle">Telefonie über die SwyxTray-App</p>
|
<p className="page__subtitle">SwyxWeb · Telefonie über die SwyxTray-App</p>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<StatusBadge status={status} />
|
<StatusBadge status={status} />
|
||||||
</header>
|
</header>
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user