Compare commits
10
Commits
521ceb044b
..
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
afd6d03a71 | ||
|
|
ab5e402ee9 | ||
|
|
3a86bc1eee | ||
|
|
ee81f25942 | ||
|
|
0fa690a67d | ||
|
|
607cbaecbc | ||
|
|
a9b40c7e6a | ||
|
|
715720fbb3 | ||
|
|
97c3778180 | ||
|
|
1598852785 |
@@ -16,6 +16,7 @@ frontend/.env.local
|
||||
# sonst greift die Ausnahme für die Datei darin nicht.
|
||||
.vscode/*
|
||||
!.vscode/launch.json
|
||||
!.vscode/tasks.json
|
||||
*.iml
|
||||
.DS_Store
|
||||
*.log
|
||||
|
||||
Vendored
+6
-2
@@ -9,7 +9,10 @@
|
||||
"mainClass": "de.appcreation.swyxweb.BackendApplication",
|
||||
"projectName": "backend",
|
||||
"cwd": "${workspaceFolder}/backend",
|
||||
"console": "internalConsole"
|
||||
"console": "internalConsole",
|
||||
// Ein noch laufendes Backend würde Port 8080 belegen und weiter den
|
||||
// alten Stand ausliefern – vor dem Start wird es deshalb beendet.
|
||||
"preLaunchTask": "Backend-Port 8080 freimachen"
|
||||
},
|
||||
{
|
||||
// Wie oben, aber der eingebaute SwyxTray-Mock wird über das Profil "mock"
|
||||
@@ -28,7 +31,8 @@
|
||||
"--spring.profiles.active=mock",
|
||||
"--app.websocket.host=localhost",
|
||||
"--app.websocket.port=8080"
|
||||
]
|
||||
],
|
||||
"preLaunchTask": "Backend-Port 8080 freimachen"
|
||||
},
|
||||
{
|
||||
"type": "node-terminal",
|
||||
|
||||
Vendored
+24
@@ -0,0 +1,24 @@
|
||||
{
|
||||
"version": "2.0.0",
|
||||
"tasks": [
|
||||
{
|
||||
// Beendet ein noch laufendes Backend (den Lauscher auf Port 8080), damit
|
||||
// der Debug-Start immer den frischen Stand startet statt an der
|
||||
// Portbelegung zu scheitern – egal ob der Altlauf aus VS Code,
|
||||
// "mvnw spring-boot:run" oder einem Terminal stammt. Nur -sTCP:LISTEN:
|
||||
// ohne die Einschränkung träfe lsof auch Prozesse mit offener Verbindung
|
||||
// zum Backend, etwa den Vite-Dev-Server (Proxy). Wartet kurz, bis der
|
||||
// Port wirklich frei ist; "exit 0" auch ohne Treffer, sonst bräche der
|
||||
// Launch ab.
|
||||
"label": "Backend-Port 8080 freimachen",
|
||||
"type": "shell",
|
||||
"command": "pids=$(lsof -ti tcp:8080 -sTCP:LISTEN); if [ -n \"$pids\" ]; then echo \"Beende laufendes Backend (PID $pids) …\"; kill $pids; for i in $(seq 1 25); do lsof -ti tcp:8080 -sTCP:LISTEN >/dev/null || break; sleep 0.2; done; fi; exit 0",
|
||||
"presentation": {
|
||||
"reveal": "silent",
|
||||
"panel": "shared",
|
||||
"close": true
|
||||
},
|
||||
"problemMatcher": []
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -19,6 +19,10 @@
|
||||
</properties>
|
||||
|
||||
<dependencies>
|
||||
<dependency>
|
||||
<groupId>org.springframework.boot</groupId>
|
||||
<artifactId>spring-boot-starter-data-mongodb</artifactId>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.springframework.boot</groupId>
|
||||
<artifactId>spring-boot-starter-webmvc</artifactId>
|
||||
|
||||
@@ -5,8 +5,11 @@ import de.appcreation.swyxweb.config.WebhookProperties;
|
||||
import org.springframework.boot.SpringApplication;
|
||||
import org.springframework.boot.autoconfigure.SpringBootApplication;
|
||||
import org.springframework.boot.context.properties.EnableConfigurationProperties;
|
||||
import org.springframework.scheduling.annotation.EnableScheduling;
|
||||
|
||||
@SpringBootApplication
|
||||
// Zeitpläne wie die nächtliche Job-Bereinigung (JobCleanupService).
|
||||
@EnableScheduling
|
||||
@EnableConfigurationProperties({ WebSocketProperties.class, WebhookProperties.class })
|
||||
public class BackendApplication {
|
||||
|
||||
|
||||
@@ -0,0 +1,114 @@
|
||||
package de.appcreation.swyxweb.storage;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
|
||||
import tools.jackson.databind.JsonNode;
|
||||
|
||||
import org.springframework.data.annotation.Id;
|
||||
import org.springframework.data.mongodb.core.mapping.Document;
|
||||
|
||||
/**
|
||||
* Ein Adressdaten-Eintrag – Kunde, Kurier oder Telefonbuch-Eintrag –, wie er
|
||||
* in der MongoDB liegt (Sammlung {@code addresses}) und daraus auch wieder
|
||||
* gelesen wird.
|
||||
*
|
||||
* <p>Die Kennung {@code id} vergibt die MongoDB. Die <b>Rufnummer erkennt die
|
||||
* Dublette</b>: Kommt ein Eintrag zu einer bekannten Nummer erneut herein, wird
|
||||
* er aktualisiert statt verdoppelt (siehe {@link ArchiveService#saveAddresses}).
|
||||
* Einträge ohne Rufnummer werden deshalb übergangen – ohne sie ließe sich die
|
||||
* Dublette nicht erkennen.
|
||||
*
|
||||
* <p>{@link #manyFrom(JsonNode)} versteht jede der gelieferten Formen:
|
||||
* <ul>
|
||||
* <li>Webhook, ein Eintrag je Aufruf:
|
||||
* {@code {"name":"SYSGEN GmbH","number":"+491602107449","description":"Kunde | …",
|
||||
* "role":"customer","csc_id":100164,"job_ids":[21891263,21891262],"url":"https://…"}}
|
||||
* – die Felder ab {@code role} kann das Fremdsystem auch weglassen</li>
|
||||
* <li>eine JSON-Liste solcher Einträge</li>
|
||||
* <li>die Adress-Nachricht der SwyxTray-App (nur Name, Rufnummer, Beschreibung):
|
||||
* {@code {"addresses":[{"name":…,"number":…,"description":…}, …],"type":"addresses"}}</li>
|
||||
* </ul>
|
||||
*
|
||||
* @param id Kennung des Dokuments, vergibt die MongoDB
|
||||
* @param number Rufnummer, so wie sie gewählt wird – erkennt die Dublette
|
||||
* @param name Anzeigename, z. B. "SYSGEN GmbH" oder "Rainer Peters (HH1003)"
|
||||
* @param description Zusatz, z. B. "Kunde | SYSGEN, SYSTEME UND | NL Bremen"
|
||||
* oder "Kurier | NL Hamburg | PKW"
|
||||
* @param role Rolle im Fremdsystem, z. B. "customer"
|
||||
* @param cscId Kundenkennung des Fremdsystems ({@code csc_id})
|
||||
* @param jobIds Kennungen der zugehörigen Jobs ({@code job_ids}), passend
|
||||
* zur Kennung in der Sammlung {@code jobs}
|
||||
* @param url Sprungadresse des Fremdsystems zu diesem Eintrag, z. B.
|
||||
* der TAPI-Wrapper zur Rufnummer
|
||||
*/
|
||||
@Document("addresses")
|
||||
public record AddressEntry(
|
||||
@Id String id,
|
||||
String number,
|
||||
String name,
|
||||
String description,
|
||||
String role,
|
||||
Integer cscId,
|
||||
List<Long> jobIds,
|
||||
String url) {
|
||||
|
||||
/**
|
||||
* Liest aus beliebigem JSON alle Adressdaten-Einträge heraus (siehe die
|
||||
* Formen oben). Einträge ohne Rufnummer werden übergangen; JSON ohne
|
||||
* Adressdaten ergibt eine leere Liste.
|
||||
*/
|
||||
public static List<AddressEntry> manyFrom(JsonNode json) {
|
||||
List<AddressEntry> entries = new ArrayList<>();
|
||||
if (json == null) return entries;
|
||||
|
||||
// Liste direkt, oder verpackt wie in der SwyxTray-Nachricht.
|
||||
JsonNode list = json.isArray() ? json : json.path("addresses");
|
||||
if (list.isArray()) {
|
||||
for (JsonNode element : list) collect(element, entries);
|
||||
} else {
|
||||
collect(json, entries);
|
||||
}
|
||||
return entries;
|
||||
}
|
||||
|
||||
private static void collect(JsonNode node, List<AddressEntry> entries) {
|
||||
if (!node.isObject()) return;
|
||||
String number = text(node, "number");
|
||||
if (number == null) return;
|
||||
entries.add(new AddressEntry(
|
||||
null,
|
||||
number,
|
||||
text(node, "name"),
|
||||
text(node, "description"),
|
||||
text(node, "role"),
|
||||
integer(node, "csc_id"),
|
||||
longs(node, "job_ids"),
|
||||
text(node, "url")));
|
||||
}
|
||||
|
||||
/** Leere Zeichenketten schickt die SwyxTray-App als {@code ""}; hier werden sie {@code null}. */
|
||||
private static String text(JsonNode node, String field) {
|
||||
JsonNode value = node.path(field);
|
||||
if (!value.isString()) return null;
|
||||
String text = value.stringValue("");
|
||||
return text.isBlank() ? null : text;
|
||||
}
|
||||
|
||||
/** Ganzzahl der Nutzlast; alles andere wird {@code null}. */
|
||||
private static Integer integer(JsonNode node, String field) {
|
||||
JsonNode value = node.path(field);
|
||||
return value.isIntegralNumber() ? value.intValue() : null;
|
||||
}
|
||||
|
||||
/** Liste von Kennungen; fehlt sie oder ist sie keine Liste, wird sie {@code null}. */
|
||||
private static List<Long> longs(JsonNode node, String field) {
|
||||
JsonNode value = node.path(field);
|
||||
if (!value.isArray()) return null;
|
||||
List<Long> values = new ArrayList<>();
|
||||
for (JsonNode element : value) {
|
||||
if (element.isIntegralNumber()) values.add(element.longValue());
|
||||
}
|
||||
return values;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,155 @@
|
||||
package de.appcreation.swyxweb.storage;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.concurrent.ExecutorService;
|
||||
import java.util.regex.Pattern;
|
||||
import java.util.concurrent.Executors;
|
||||
|
||||
import jakarta.annotation.PreDestroy;
|
||||
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import org.springframework.data.domain.Sort;
|
||||
import org.springframework.data.mongodb.core.MongoTemplate;
|
||||
import org.springframework.data.mongodb.core.query.Criteria;
|
||||
import org.springframework.data.mongodb.core.query.Query;
|
||||
import org.springframework.data.mongodb.core.query.Update;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
/**
|
||||
* Legt Adress- und Anrufdaten in der MongoDB ab (siehe
|
||||
* {@code spring.mongodb.uri} in der application.properties) und liest die
|
||||
* Adressdaten daraus wieder heraus.
|
||||
*
|
||||
* <p>Geschrieben wird <b>nebenbei und best-effort</b>: Die Aufrufe kehren
|
||||
* sofort zurück, geschrieben wird auf einem eigenen Thread. Ist die Datenbank
|
||||
* nicht erreichbar, bleibt es bei einer Warnung im Log – Webhook-Quittung und
|
||||
* Anrufanzeige hängen bewusst nicht an der Datenbank.
|
||||
*/
|
||||
@Service
|
||||
public class ArchiveService {
|
||||
|
||||
private static final Logger log = LoggerFactory.getLogger(ArchiveService.class);
|
||||
|
||||
private final MongoTemplate mongo;
|
||||
|
||||
/** Ein Thread reicht: Er hält die Reihenfolge und begrenzt die Fehlversuche. */
|
||||
private final ExecutorService worker = Executors.newSingleThreadExecutor(runnable -> {
|
||||
Thread thread = new Thread(runnable, "mongo-archive");
|
||||
thread.setDaemon(true);
|
||||
return thread;
|
||||
});
|
||||
|
||||
public ArchiveService(MongoTemplate mongo) {
|
||||
this.mongo = mongo;
|
||||
}
|
||||
|
||||
@PreDestroy
|
||||
void shutdown() {
|
||||
worker.shutdownNow();
|
||||
}
|
||||
|
||||
/**
|
||||
* Legt Adressdaten-Einträge in der Sammlung {@code addresses} ab – alle
|
||||
* Quellen (Webhooks Kunden/Kuriere, SwyxTray-Telefonbuch) in derselben
|
||||
* Sammlung, je Eintrag nur Rufnummer, Name und Beschreibung. Die Sammlung
|
||||
* ist ein Verzeichnis, kein Protokoll: Ein Eintrag zu einer bekannten
|
||||
* Rufnummer wird aktualisiert statt verdoppelt – die SwyxTray-App schickt
|
||||
* bei jeder Cache-Änderung das ganze Telefonbuch.
|
||||
*/
|
||||
public void saveAddresses(List<AddressEntry> entries) {
|
||||
if (entries.isEmpty()) return;
|
||||
// Upsert über die Rufnummer: aktualisiert den bekannten Eintrag oder
|
||||
// legt ihn an; die Dokument-Kennung vergibt dabei die MongoDB.
|
||||
submit("Adressdaten", () -> entries.forEach(entry -> mongo.upsert(
|
||||
new Query(Criteria.where("number").is(entry.number())),
|
||||
new Update()
|
||||
.set("name", entry.name())
|
||||
.set("description", entry.description())
|
||||
.set("role", entry.role())
|
||||
.set("cscId", entry.cscId())
|
||||
.set("jobIds", entry.jobIds())
|
||||
.set("url", entry.url())
|
||||
// Altbestand angleichen: Diese Felder wurden früher
|
||||
// mitgeschrieben und sollen aus der Sammlung verschwinden.
|
||||
.unset("receivedAt")
|
||||
.unset("source"),
|
||||
AddressEntry.class)));
|
||||
}
|
||||
|
||||
/**
|
||||
* Die gespeicherten Adressdaten, nach Namen sortiert – gelesen direkt aus
|
||||
* der MongoDB, ohne Zwischenstand im Backend oder Browser. Ein Suchbegriff
|
||||
* wird als Teilzeichenkette in Name, Rufnummer und Beschreibung gesucht,
|
||||
* ohne Beachtung der Groß-/Kleinschreibung.
|
||||
*/
|
||||
public List<AddressEntry> addresses(String query) {
|
||||
Query find = new Query().with(Sort.by("name"));
|
||||
if (query != null && !query.isBlank()) {
|
||||
// Der Begriff ist Text, kein Muster – Sonderzeichen werden zitiert.
|
||||
String regex = Pattern.quote(query.trim());
|
||||
find.addCriteria(new Criteria().orOperator(
|
||||
Criteria.where("name").regex(regex, "i"),
|
||||
Criteria.where("number").regex(regex, "i"),
|
||||
Criteria.where("description").regex(regex, "i")));
|
||||
}
|
||||
return mongo.find(find, AddressEntry.class);
|
||||
}
|
||||
|
||||
/**
|
||||
* Legt einen Job in der Sammlung {@code jobs} ab. Die Kennung des
|
||||
* Fremdsystems ist der Schlüssel – ein erneut geschickter Job ersetzt
|
||||
* seinen alten Stand.
|
||||
*/
|
||||
public void saveJob(JobEntry job) {
|
||||
submit("Jobdaten", () -> mongo.save(job));
|
||||
}
|
||||
|
||||
/**
|
||||
* Der Job zur Kennung des Fremdsystems – gelesen direkt aus der Sammlung
|
||||
* {@code jobs}, wie {@link #addresses} ohne Zwischenstand im Backend.
|
||||
*
|
||||
* @return der Job, oder {@code null} wenn die Kennung nicht abgelegt ist
|
||||
*/
|
||||
public JobEntry job(long id) {
|
||||
return mongo.findById(id, JobEntry.class);
|
||||
}
|
||||
|
||||
/**
|
||||
* Löscht alle Jobs, deren {@code ordertime} vor der Grenze liegt (Vergleich
|
||||
* als Text, siehe {@link JobCleanupService}). Läuft anders als die
|
||||
* Schreibzugriffe direkt: Der Aufrufer ist der nächtliche Zeitplan, der auf
|
||||
* die Anzahl wartet und Fehler selbst behandelt bekommt – hier genügt die
|
||||
* Warnung im Log.
|
||||
*
|
||||
* @return wie viele Jobs gelöscht wurden; 0 auch, wenn die Datenbank nicht
|
||||
* erreichbar war
|
||||
*/
|
||||
public long removeJobsBefore(String cutoffOrdertime) {
|
||||
try {
|
||||
return mongo.remove(
|
||||
new Query(Criteria.where("ordertime").lt(cutoffOrdertime)),
|
||||
JobEntry.class).getDeletedCount();
|
||||
} catch (RuntimeException e) {
|
||||
log.warn("MongoDB: alte Jobs nicht gelöscht ({}).", e.getMessage());
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
|
||||
/** Legt Anruf-Ereignisse in der Sammlung {@code calls} ab. */
|
||||
public void saveCalls(List<StoredCall> calls) {
|
||||
submit("Anrufdaten", () -> calls.forEach(mongo::save));
|
||||
}
|
||||
|
||||
private void submit(String what, Runnable write) {
|
||||
worker.execute(() -> {
|
||||
try {
|
||||
write.run();
|
||||
} catch (RuntimeException e) {
|
||||
// Nur die Meldung, kein Stacktrace: Bei nicht erreichbarer Datenbank
|
||||
// käme sonst zu jedem Ereignis ein langer Treiber-Auszug ins Log.
|
||||
log.warn("MongoDB: {} nicht gespeichert ({}).", what, e.getMessage());
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
package de.appcreation.swyxweb.storage;
|
||||
|
||||
import java.time.ZoneId;
|
||||
import java.time.ZonedDateTime;
|
||||
import java.time.format.DateTimeFormatter;
|
||||
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import org.springframework.scheduling.annotation.Scheduled;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
/**
|
||||
* Räumt die Sammlung {@code jobs} auf: Jede Nacht um 0 Uhr werden die Jobs
|
||||
* gelöscht, deren {@code ordertime} mehr als vier Wochen zurückliegt.
|
||||
*
|
||||
* <p>Die {@code ordertime} liegt als ISO-8601-Zeichenkette mit Zeitzonenversatz
|
||||
* in der Datenbank (z. B. {@code 2026-07-31T16:30:00+02:00}, siehe
|
||||
* {@link JobEntry}). Verglichen wird als Text gegen eine Grenze im selben
|
||||
* Format – ISO-8601 sortiert als Text richtig; die wenigen Stunden Unschärfe
|
||||
* durch unterschiedliche Zeitzonenversätze fallen bei einer Vier-Wochen-Grenze
|
||||
* nicht ins Gewicht. Jobs ohne {@code ordertime} bleiben unangetastet – ihr
|
||||
* Alter ist nicht zu beurteilen.
|
||||
*/
|
||||
@Service
|
||||
public class JobCleanupService {
|
||||
|
||||
private static final Logger log = LoggerFactory.getLogger(JobCleanupService.class);
|
||||
|
||||
/** „Mehr als vier Wochen in der Vergangenheit" – die Grenze der Bereinigung. */
|
||||
public static final int MAX_AGE_WEEKS = 4;
|
||||
|
||||
/** Zeitzone des Zeitplans wie der Grenze: „0 Uhr" heißt 0 Uhr deutscher Zeit. */
|
||||
public static final ZoneId ZONE = ZoneId.of("Europe/Berlin");
|
||||
|
||||
private final ArchiveService archive;
|
||||
|
||||
public JobCleanupService(ArchiveService archive) {
|
||||
this.archive = archive;
|
||||
}
|
||||
|
||||
/** Jede Nacht um 0 Uhr; verpasste Läufe (Backend aus) werden nicht nachgeholt. */
|
||||
@Scheduled(cron = "0 0 0 * * *", zone = "Europe/Berlin")
|
||||
public void purgeOldJobs() {
|
||||
String cutoff = cutoffOrdertime(ZonedDateTime.now(ZONE));
|
||||
long removed = archive.removeJobsBefore(cutoff);
|
||||
if (removed > 0) {
|
||||
log.info("Job-Bereinigung: {} Job(s) mit ordertime vor {} gelöscht.", removed, cutoff);
|
||||
} else {
|
||||
log.debug("Job-Bereinigung: nichts zu löschen (Grenze {}).", cutoff);
|
||||
}
|
||||
}
|
||||
|
||||
/** Die Grenze im Format der {@code ordertime}, z. B. {@code 2026-07-31T00:00:00+02:00}. */
|
||||
public static String cutoffOrdertime(ZonedDateTime now) {
|
||||
return now.minusWeeks(MAX_AGE_WEEKS)
|
||||
.format(DateTimeFormatter.ofPattern("yyyy-MM-dd'T'HH:mm:ssxxx"));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,149 @@
|
||||
package de.appcreation.swyxweb.storage;
|
||||
|
||||
import java.time.Instant;
|
||||
import java.util.List;
|
||||
|
||||
import com.fasterxml.jackson.annotation.JsonAlias;
|
||||
import com.fasterxml.jackson.annotation.JsonIgnoreProperties;
|
||||
import com.fasterxml.jackson.annotation.JsonProperty;
|
||||
|
||||
import tools.jackson.databind.JsonNode;
|
||||
import tools.jackson.databind.ObjectMapper;
|
||||
|
||||
import org.springframework.data.annotation.Id;
|
||||
import org.springframework.data.mongodb.core.mapping.Document;
|
||||
import org.springframework.data.mongodb.core.mapping.Field;
|
||||
|
||||
/**
|
||||
* Ein Job (Kurierauftrag), wie ihn das Fremdsystem über den Webhook schickt
|
||||
* und wie er in der MongoDB liegt (Sammlung {@code jobs}).
|
||||
*
|
||||
* <p>Erkannt am Feld {@code "type":"job"} (siehe {@link #isJob}), gefüllt über
|
||||
* {@link #from}. Die Kennung des Fremdsystems ist zugleich der Schlüssel in
|
||||
* der Sammlung – ein erneut geschickter Job (z. B. nach einer Änderung, siehe
|
||||
* {@code modified}) ersetzt also seinen alten Stand.
|
||||
*
|
||||
* <p>Die Zeitangaben bleiben die Zeichenketten der Nutzlast
|
||||
* (z. B. {@code 2026-08-13T13:35:27}, teils auch mit Zeitzonenversatz):
|
||||
* ISO-8601 sortiert auch als Text richtig, und die Schreibweise des
|
||||
* Fremdsystems geht nicht verloren.
|
||||
*
|
||||
* <p>Die Rufnummern hießen in einer älteren Form der Nutzlast {@code number}
|
||||
* statt {@code phone} – beide Schreibweisen werden angenommen.
|
||||
*
|
||||
* @param id Kennung des Fremdsystems, zugleich Schlüssel der Sammlung
|
||||
* @param receivedAt Eingangszeit im Backend, beim letzten Eingang dieses Jobs
|
||||
* @param url Sprungadresse des Fremdsystems zur Auftragsansicht
|
||||
* @param state Zustand des Jobs im Fremdsystem
|
||||
* @param ordertime Auftragszeit, z. B. "2026-08-13T13:35:27"
|
||||
* @param orderdate Auftragsdatum, z. B. "2026-08-13"
|
||||
* @param modified letzte Änderung im Fremdsystem
|
||||
* @param finished wann der Job abgeschlossen wurde, sonst leer
|
||||
* @param vehicle Fahrzeugart, z. B. "Transporter XL"
|
||||
* @param service gebuchte Leistung, kann fehlen
|
||||
* @param canceled ist der Job storniert?
|
||||
* @param global bundesweite Vermittlung?
|
||||
* @param customer der beauftragende Kunde
|
||||
* @param courier der ausführende Kurier, solange keiner zugeteilt ist leer
|
||||
* @param tours die Stationen des Jobs, per {@code sort} geordnet
|
||||
*/
|
||||
@Document("jobs")
|
||||
@JsonIgnoreProperties(ignoreUnknown = true)
|
||||
public record JobEntry(
|
||||
@Id long id,
|
||||
Instant receivedAt,
|
||||
String url,
|
||||
Integer state,
|
||||
String ordertime,
|
||||
String orderdate,
|
||||
String modified,
|
||||
String finished,
|
||||
String vehicle,
|
||||
String service,
|
||||
Boolean canceled,
|
||||
Boolean global,
|
||||
Customer customer,
|
||||
Courier courier,
|
||||
List<Tour> tours) {
|
||||
|
||||
/**
|
||||
* Der beauftragende Kunde.
|
||||
*
|
||||
* @param cscId Kundenkennung des Fremdsystems ({@code csc_id})
|
||||
* @param name Firmenname
|
||||
* @param hq Niederlassung, z. B. "Bremen"
|
||||
* @param phone Rufnummer
|
||||
*/
|
||||
@JsonIgnoreProperties(ignoreUnknown = true)
|
||||
public record Customer(
|
||||
@JsonProperty("csc_id") Integer cscId,
|
||||
String name,
|
||||
String hq,
|
||||
@JsonAlias("number") String phone) {
|
||||
}
|
||||
|
||||
/**
|
||||
* Der ausführende Kurier.
|
||||
*
|
||||
* @param crId Kurierkennung des Fremdsystems ({@code cr_id})
|
||||
* @param sid Kurzkennung, z. B. "B1006"
|
||||
* @param name Anzeigename
|
||||
* @param phone Rufnummer
|
||||
*/
|
||||
@JsonIgnoreProperties(ignoreUnknown = true)
|
||||
public record Courier(
|
||||
@JsonProperty("cr_id") Integer crId,
|
||||
String sid,
|
||||
String name,
|
||||
@JsonAlias("number") String phone) {
|
||||
}
|
||||
|
||||
/**
|
||||
* Eine Station des Jobs.
|
||||
*
|
||||
* @param id Kennung des Fremdsystems
|
||||
* @param sort Reihenfolge innerhalb des Jobs, 1-basiert
|
||||
* @param state Zustand der Station
|
||||
* @param mode Art der Station, z. B. "pu" (Abholung) oder "del" (Zustellung)
|
||||
* @param comp Firma an der Station
|
||||
* @param person Ansprechperson
|
||||
* @param phone Rufnummer an der Station, kann fehlen
|
||||
* @param street Straße und Hausnummer
|
||||
* @param zip Postleitzahl
|
||||
* @param city Ort
|
||||
* @param com Bemerkung
|
||||
* @param remark Hinweise des Fremdsystems, mehrzeilig (Referenzen, Maße …)
|
||||
* @param finished wann die Station abgeschlossen wurde, sonst leer
|
||||
*/
|
||||
@JsonIgnoreProperties(ignoreUnknown = true)
|
||||
public record Tour(
|
||||
// Ausdrücklich als "id" ablegen – sonst macht Spring Data auch im
|
||||
// Unterdokument ein "_id" daraus.
|
||||
@Field("id") Long id,
|
||||
Integer sort,
|
||||
Integer state,
|
||||
String mode,
|
||||
String comp,
|
||||
String person,
|
||||
@JsonAlias("number") String phone,
|
||||
String street,
|
||||
String zip,
|
||||
String city,
|
||||
String com,
|
||||
String remark,
|
||||
String finished) {
|
||||
}
|
||||
|
||||
/** Trägt die Nutzlast das Kennzeichen {@code "type":"job"}? */
|
||||
public static boolean isJob(JsonNode json) {
|
||||
return json != null && "job".equals(json.path("type").stringValue(""));
|
||||
}
|
||||
|
||||
/** Füllt die Klasse aus der Webhook-Nutzlast und stempelt die Eingangszeit. */
|
||||
public static JobEntry from(JsonNode json, ObjectMapper mapper, Instant receivedAt) {
|
||||
JobEntry job = mapper.convertValue(json, JobEntry.class);
|
||||
return new JobEntry(job.id(), receivedAt, job.url(), job.state(), job.ordertime(),
|
||||
job.orderdate(), job.modified(), job.finished(), job.vehicle(), job.service(),
|
||||
job.canceled(), job.global(), job.customer(), job.courier(), job.tours());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
package de.appcreation.swyxweb.storage;
|
||||
|
||||
import java.time.Instant;
|
||||
|
||||
import org.springframework.data.annotation.Id;
|
||||
import org.springframework.data.mongodb.core.mapping.Document;
|
||||
|
||||
/**
|
||||
* Ein Anruf-Ereignis, wie es in der MongoDB liegt (Sammlung {@code calls}).
|
||||
* Die Felder entsprechen dem {@code CallEvent} des Frontends – die Ereignisse
|
||||
* entstehen dort aus dem Vergleich zweier SwyxTray-Snapshots und kommen über
|
||||
* {@code POST /api/calls} hierher.
|
||||
*
|
||||
* @param id von MongoDB vergeben
|
||||
* @param receivedAt Eingangszeit im Backend
|
||||
* @param line 1-basierte Leitungsnummer, wie in SwyxIt!
|
||||
* @param event incoming, outgoing, connected oder ended
|
||||
* @param direction bei connected/ended die ursprüngliche Richtung
|
||||
* @param peer fertige Anzeigeform der App, z. B. "Muster GmbH (+493012345)"
|
||||
* @param peerNumber Rufnummer der Gegenstelle
|
||||
* @param peerName Name der Gegenstelle
|
||||
* @param stateText Klartext der App zum Leitungszustand, z. B. "klingelt"
|
||||
* @param state Rohzustand der App, z. B. "LSRinging"
|
||||
*/
|
||||
@Document("calls")
|
||||
public record StoredCall(
|
||||
@Id String id,
|
||||
Instant receivedAt,
|
||||
Integer line,
|
||||
String event,
|
||||
String direction,
|
||||
String peer,
|
||||
String peerNumber,
|
||||
String peerName,
|
||||
String stateText,
|
||||
String state) {
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
package de.appcreation.swyxweb.web;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
import tools.jackson.databind.JsonNode;
|
||||
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.http.MediaType;
|
||||
import org.springframework.web.bind.annotation.GetMapping;
|
||||
import org.springframework.web.bind.annotation.PostMapping;
|
||||
import org.springframework.web.bind.annotation.RequestBody;
|
||||
import org.springframework.web.bind.annotation.RequestMapping;
|
||||
import org.springframework.web.bind.annotation.RequestParam;
|
||||
import org.springframework.web.bind.annotation.ResponseStatus;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
import org.springframework.web.server.ResponseStatusException;
|
||||
|
||||
import de.appcreation.swyxweb.storage.AddressEntry;
|
||||
import de.appcreation.swyxweb.storage.ArchiveService;
|
||||
|
||||
/**
|
||||
* Adressdaten in der MongoDB: annehmen und wieder herausgeben.
|
||||
*
|
||||
* <p>{@code POST} nimmt die Adress-Nachricht der SwyxTray-App entgegen – die
|
||||
* WebSocket-Verbindung zur App hält nur der Browser, deshalb meldet die
|
||||
* Startseite den Adress-Cache hierher. Angenommen wird jede Form, die
|
||||
* {@link AddressEntry#manyFrom} versteht; die Adressdaten fremder Systeme
|
||||
* kommen dagegen normalerweise über {@code POST /api/webhook} herein.
|
||||
*
|
||||
* <p>{@code GET} liefert die gespeicherten Einträge aus der MongoDB zurück,
|
||||
* nach Namen sortiert.
|
||||
*/
|
||||
@RestController
|
||||
@RequestMapping("/api/addresses")
|
||||
public class AddressController {
|
||||
|
||||
private final ArchiveService archive;
|
||||
|
||||
public AddressController(ArchiveService archive) {
|
||||
this.archive = archive;
|
||||
}
|
||||
|
||||
/**
|
||||
* Nimmt Adressdaten an. 202, weil nur die Annahme bestätigt wird –
|
||||
* geschrieben wird nebenbei (siehe {@link ArchiveService}).
|
||||
*
|
||||
* @return wie viele Einträge in der Nutzlast steckten
|
||||
*/
|
||||
@PostMapping(consumes = MediaType.APPLICATION_JSON_VALUE)
|
||||
@ResponseStatus(HttpStatus.ACCEPTED)
|
||||
public int receive(@RequestBody JsonNode payload) {
|
||||
List<AddressEntry> entries = AddressEntry.manyFrom(payload);
|
||||
if (entries.isEmpty()) {
|
||||
throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "Keine Adressdaten in der Nutzlast.");
|
||||
}
|
||||
archive.saveAddresses(entries);
|
||||
return entries.size();
|
||||
}
|
||||
|
||||
/**
|
||||
* Die gespeicherten Adressdaten – gelesen direkt aus der MongoDB, jede
|
||||
* Abfrage frisch. Mit {@code ?q=…} wird in der Datenbank gesucht
|
||||
* (Teilzeichenkette in Name, Rufnummer und Beschreibung).
|
||||
*/
|
||||
@GetMapping
|
||||
public List<AddressEntry> list(@RequestParam(name = "q", required = false) String query) {
|
||||
return archive.addresses(query);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
package de.appcreation.swyxweb.web;
|
||||
|
||||
import java.time.Instant;
|
||||
import java.util.List;
|
||||
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.http.MediaType;
|
||||
import org.springframework.web.bind.annotation.PostMapping;
|
||||
import org.springframework.web.bind.annotation.RequestBody;
|
||||
import org.springframework.web.bind.annotation.RequestMapping;
|
||||
import org.springframework.web.bind.annotation.ResponseStatus;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
import org.springframework.web.server.ResponseStatusException;
|
||||
|
||||
import de.appcreation.swyxweb.storage.ArchiveService;
|
||||
import de.appcreation.swyxweb.storage.StoredCall;
|
||||
|
||||
/**
|
||||
* Nimmt Anrufdaten von der Startseite entgegen und legt sie in der MongoDB ab.
|
||||
*
|
||||
* <p>Die Anruf-Ereignisse entstehen im Browser: Die SwyxTray-App kennt keine
|
||||
* Ereignisnachrichten, jede Änderung kommt als vollständiger Snapshot, und erst
|
||||
* der Vergleich zweier Snapshots im Frontend ergibt "klingelt", "verbunden",
|
||||
* "beendet". Das Backend sieht diese Verbindung nicht – deshalb meldet die
|
||||
* Seite die Ereignisse hierher.
|
||||
*/
|
||||
@RestController
|
||||
@RequestMapping("/api/calls")
|
||||
public class CallController {
|
||||
|
||||
/** So viele Ereignisse pro Aufruf; ein Snapshot-Vergleich liefert höchstens eine Handvoll. */
|
||||
private static final int MAX_EVENTS = 100;
|
||||
|
||||
private final ArchiveService archive;
|
||||
|
||||
public CallController(ArchiveService archive) {
|
||||
this.archive = archive;
|
||||
}
|
||||
|
||||
/** Ein Anruf-Ereignis, wie es das Frontend schickt – Felder wie im CallEvent. */
|
||||
public record CallEventBody(
|
||||
Integer line,
|
||||
String event,
|
||||
String direction,
|
||||
String peer,
|
||||
String peerNumber,
|
||||
String peerName,
|
||||
String stateText,
|
||||
String state) {
|
||||
}
|
||||
|
||||
/**
|
||||
* Nimmt eine Liste von Ereignissen an. 202, weil nur die Annahme bestätigt
|
||||
* wird – geschrieben wird nebenbei (siehe {@link ArchiveService}).
|
||||
*/
|
||||
@PostMapping(consumes = MediaType.APPLICATION_JSON_VALUE)
|
||||
@ResponseStatus(HttpStatus.ACCEPTED)
|
||||
public int receive(@RequestBody List<CallEventBody> events) {
|
||||
if (events == null || events.isEmpty()) {
|
||||
throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "Leere Ereignisliste.");
|
||||
}
|
||||
if (events.size() > MAX_EVENTS) {
|
||||
throw new ResponseStatusException(
|
||||
HttpStatus.CONTENT_TOO_LARGE,
|
||||
"Zu viele Ereignisse (%d, erlaubt sind %d).".formatted(events.size(), MAX_EVENTS));
|
||||
}
|
||||
|
||||
Instant receivedAt = Instant.now();
|
||||
archive.saveCalls(events.stream()
|
||||
.map(e -> new StoredCall(null, receivedAt, e.line(), e.event(), e.direction(),
|
||||
e.peer(), e.peerNumber(), e.peerName(), e.stateText(), e.state()))
|
||||
.toList());
|
||||
return events.size();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
package de.appcreation.swyxweb.web;
|
||||
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.web.bind.annotation.GetMapping;
|
||||
import org.springframework.web.bind.annotation.PathVariable;
|
||||
import org.springframework.web.bind.annotation.RequestMapping;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
import org.springframework.web.server.ResponseStatusException;
|
||||
|
||||
import de.appcreation.swyxweb.storage.ArchiveService;
|
||||
import de.appcreation.swyxweb.storage.JobEntry;
|
||||
|
||||
/**
|
||||
* Jobs aus der MongoDB herausgeben. Herein kommen sie über den Webhook (siehe
|
||||
* WebhookController); hier holt sich der Browser einen einzelnen Job zu einer
|
||||
* Kennung aus {@code job_ids} eines Kunden – etwa um beim Anruf-Popup die
|
||||
* Sprungadresse ({@code url}) des Jobs zu öffnen.
|
||||
*/
|
||||
@RestController
|
||||
@RequestMapping("/api/jobs")
|
||||
public class JobController {
|
||||
|
||||
private final ArchiveService archive;
|
||||
|
||||
public JobController(ArchiveService archive) {
|
||||
this.archive = archive;
|
||||
}
|
||||
|
||||
/** Der Job zur Kennung des Fremdsystems; 404, wenn er nicht abgelegt ist. */
|
||||
@GetMapping("/{id}")
|
||||
public JobEntry get(@PathVariable long id) {
|
||||
JobEntry job = archive.job(id);
|
||||
if (job == null) {
|
||||
throw new ResponseStatusException(HttpStatus.NOT_FOUND,
|
||||
"Kein Job mit der Kennung " + id + " in der Ablage.");
|
||||
}
|
||||
return job;
|
||||
}
|
||||
}
|
||||
@@ -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.
|
||||
*
|
||||
* <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 "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
|
||||
* 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":[…]}'</pre>
|
||||
*
|
||||
* <p>Daneben bleibt der generische {@code POST /api/webhook}: Er nimmt
|
||||
* <b>beliebiges</b> JSON an – {@code "type":"job"} wird als Job abgelegt, alles
|
||||
* andere als Adressdaten. Die Startseite zeigt jede Nachricht im Bereich
|
||||
* „Webhook" unverändert an; das aufrufende System muss sich an kein festes
|
||||
* Schema halten.
|
||||
*
|
||||
* <p>Der Weg zum Browser läuft über {@code GET /api/webhook/events}
|
||||
* (Server-Sent Events), siehe {@link WebhookService}.
|
||||
@@ -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 in der Sammlung {@code addresses}. */
|
||||
@PostMapping(path = "/kunden", consumes = MediaType.APPLICATION_JSON_VALUE)
|
||||
public Receipt receiveKunden(
|
||||
@RequestBody JsonNode payload,
|
||||
@RequestHeader(name = "X-Webhook-Token", required = false) String token) {
|
||||
return accept(payload, token, "kunden");
|
||||
}
|
||||
|
||||
/** Adressdaten der Kuriere; abgelegt in der Sammlung {@code addresses}. */
|
||||
@PostMapping(path = "/kuriere", consumes = MediaType.APPLICATION_JSON_VALUE)
|
||||
public Receipt receiveKuriere(
|
||||
@RequestBody JsonNode payload,
|
||||
@RequestHeader(name = "X-Webhook-Token", required = false) String token) {
|
||||
return accept(payload, token, "kuriere");
|
||||
}
|
||||
|
||||
/** Jobs (Kurieraufträge); abgelegt in der Sammlung {@code jobs}. */
|
||||
@PostMapping(path = "/jobs", consumes = MediaType.APPLICATION_JSON_VALUE)
|
||||
public Receipt receiveJobs(
|
||||
@RequestBody JsonNode payload,
|
||||
@RequestHeader(name = "X-Webhook-Token", required = false) String token) {
|
||||
return accept(payload, token, "jobs");
|
||||
}
|
||||
|
||||
private Receipt accept(JsonNode payload, String token, String channel) {
|
||||
requireToken(token);
|
||||
|
||||
if (payload == null || payload.isNull()) {
|
||||
@@ -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());
|
||||
}
|
||||
|
||||
|
||||
@@ -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) {
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -81,18 +86,31 @@ public class WebhookService {
|
||||
@PreDestroy
|
||||
void shutdown() {
|
||||
heartbeat.shutdownNow();
|
||||
subscribers.forEach(SseEmitter::complete);
|
||||
subscribers.forEach(emitter -> emitter.complete());
|
||||
subscribers.clear();
|
||||
}
|
||||
|
||||
/**
|
||||
* Wie {@link #record(JsonNode, String)}, für den generischen Webhook ohne
|
||||
* eigene URL: Was die Nutzlast ist, wird ihr angesehen – {@code "type":"job"}
|
||||
* ist ein Job, alles andere sind Adressdaten.
|
||||
*/
|
||||
public WebhookEvent record(JsonNode payload) {
|
||||
return record(payload, null);
|
||||
}
|
||||
|
||||
/**
|
||||
* Nimmt eine Nachricht an, hängt sie an den Verlauf und schickt sie sofort
|
||||
* an alle offenen Browser.
|
||||
*
|
||||
* @param channel über welche URL die Nachricht kam ({@code kunden},
|
||||
* {@code kuriere}, {@code jobs}) – sie bestimmt, wie die
|
||||
* Nutzlast in der MongoDB abgelegt wird; {@code null} heißt
|
||||
* generischer Webhook, dann entscheidet die Form der Nutzlast
|
||||
* @return das angelegte Ereignis, damit der Aufrufer die Id quittieren kann
|
||||
*/
|
||||
public WebhookEvent record(JsonNode payload) {
|
||||
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,39 @@ 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, Jobs
|
||||
* sind Jobs. Beim generischen Webhook ({@code channel == null}) entscheidet
|
||||
* die Nutzlast selbst: {@code "type":"job"} ist ein Job, alles andere sind
|
||||
* Adressdaten. Alle Adressdaten landen in derselben Sammlung.
|
||||
*/
|
||||
private void archiveByChannel(WebhookEvent event, String channel) {
|
||||
JsonNode payload = event.payload();
|
||||
switch (channel == null ? "" : channel) {
|
||||
case "kunden", "kuriere" -> archive.saveAddresses(AddressEntry.manyFrom(payload));
|
||||
case "jobs" -> archive.saveJob(JobEntry.from(payload, mapper, event.receivedAt()));
|
||||
default -> {
|
||||
if (JobEntry.isJob(payload)) {
|
||||
archive.saveJob(JobEntry.from(payload, mapper, event.receivedAt()));
|
||||
} else {
|
||||
archive.saveAddresses(AddressEntry.manyFrom(payload));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Verlauf, älteste zuerst. */
|
||||
public List<WebhookEvent> history() {
|
||||
synchronized (events) {
|
||||
|
||||
@@ -359,7 +359,8 @@ public class SwyxTrayMockHandler extends TextWebSocketHandler {
|
||||
String needle = wanted.toLowerCase();
|
||||
List<MockContact> hits = MOCK_CONTACTS.stream()
|
||||
.filter(contact -> contact.matches(needle))
|
||||
.sorted(Comparator.comparing(MockContact::name).thenComparing(MockContact::number))
|
||||
.sorted(Comparator.comparing((MockContact contact) -> contact.name())
|
||||
.thenComparing(contact -> contact.number()))
|
||||
.limit(CONTACT_RESULT_LIMIT)
|
||||
.toList();
|
||||
log.info("SwyxTray-Mock: {} Adresseintrag/-einträge zu '{}'", hits.size(), wanted);
|
||||
|
||||
@@ -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).
|
||||
|
||||
@@ -0,0 +1,74 @@
|
||||
package de.appcreation.swyxweb;
|
||||
|
||||
import static org.mockito.ArgumentMatchers.argThat;
|
||||
import static org.mockito.Mockito.verify;
|
||||
import static org.mockito.Mockito.when;
|
||||
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
|
||||
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
|
||||
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
|
||||
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.boot.webmvc.test.autoconfigure.AutoConfigureMockMvc;
|
||||
import org.springframework.boot.test.context.SpringBootTest;
|
||||
import org.springframework.http.MediaType;
|
||||
import org.springframework.test.context.bean.override.mockito.MockitoBean;
|
||||
import org.springframework.test.web.servlet.MockMvc;
|
||||
|
||||
import de.appcreation.swyxweb.storage.AddressEntry;
|
||||
import de.appcreation.swyxweb.storage.ArchiveService;
|
||||
|
||||
/**
|
||||
* Der Endpunkt nimmt den Adress-Cache der SwyxTray-App an und bestätigt nur
|
||||
* die Annahme (202). Der {@link ArchiveService} ist ersetzt – der Test läuft
|
||||
* ohne MongoDB und hinterlässt dort nichts.
|
||||
*/
|
||||
@SpringBootTest
|
||||
@AutoConfigureMockMvc
|
||||
class AddressControllerTests {
|
||||
|
||||
@Autowired
|
||||
MockMvc mvc;
|
||||
|
||||
@MockitoBean
|
||||
ArchiveService archive;
|
||||
|
||||
@Test
|
||||
void acceptsSwyxTrayMessageAndReturnsCount() throws Exception {
|
||||
mvc.perform(post("/api/addresses")
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content("""
|
||||
{"addresses":[{"name":"Abt, Bettina","number":"7587","description":"S-SB"},
|
||||
{"name":"Abdul, Rokhsareh","number":"5215","description":"Globales Telefonbuch"}],
|
||||
"type":"addresses"}"""))
|
||||
.andExpect(status().isAccepted())
|
||||
.andExpect(jsonPath("$").value(2));
|
||||
|
||||
verify(archive).saveAddresses(argThat(entries -> entries.size() == 2));
|
||||
}
|
||||
|
||||
/** Die Suche läuft in der Datenbank – der Begriff wird durchgereicht. */
|
||||
@Test
|
||||
void listPassesQueryToArchive() throws Exception {
|
||||
when(archive.addresses("muster"))
|
||||
.thenReturn(List.of(new AddressEntry("1", "+493012345", "Muster GmbH", null, null, null, null, null)));
|
||||
|
||||
mvc.perform(get("/api/addresses").param("q", "muster"))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(jsonPath("$[0].name").value("Muster GmbH"))
|
||||
.andExpect(jsonPath("$[0].number").value("+493012345"));
|
||||
|
||||
verify(archive).addresses("muster");
|
||||
}
|
||||
|
||||
@Test
|
||||
void rejectsJsonWithoutAddresses() throws Exception {
|
||||
mvc.perform(post("/api/addresses")
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content("{\"foo\":\"bar\"}"))
|
||||
.andExpect(status().isBadRequest());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,96 @@
|
||||
package de.appcreation.swyxweb;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
import tools.jackson.databind.ObjectMapper;
|
||||
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import de.appcreation.swyxweb.storage.AddressEntry;
|
||||
|
||||
/**
|
||||
* {@link AddressEntry#manyFrom} füllt die Klasse aus jeder Form, in der
|
||||
* Adressdaten hereinkommen: einzelner Webhook-Eintrag (Kunde, Kurier, Job),
|
||||
* eine Liste solcher Einträge oder die Adress-Nachricht der SwyxTray-App.
|
||||
*/
|
||||
class AddressEntryTests {
|
||||
|
||||
private final ObjectMapper mapper = new ObjectMapper();
|
||||
|
||||
private List<AddressEntry> parse(String json) {
|
||||
return AddressEntry.manyFrom(mapper.readTree(json));
|
||||
}
|
||||
|
||||
/** Ein Kunde, wie ihn das Fremdsystem über den Webhook schickt. */
|
||||
@Test
|
||||
void readsSingleWebhookEntry() {
|
||||
List<AddressEntry> entries = parse("""
|
||||
{"name":"SYSGEN GmbH","number":"+491602107449",
|
||||
"description":"Kunde | SYSGEN, SYSTEME UND | NL Bremen",
|
||||
"role":"customer","csc_id":100164,"job_ids":[21891263,21891262],
|
||||
"url":"https://test.sb.assecutor.de/admin/tapi_wrapper.php?phoneNo=01602107449"}""");
|
||||
|
||||
assertThat(entries).hasSize(1);
|
||||
AddressEntry entry = entries.getFirst();
|
||||
assertThat(entry.name()).isEqualTo("SYSGEN GmbH");
|
||||
assertThat(entry.number()).isEqualTo("+491602107449");
|
||||
assertThat(entry.description()).isEqualTo("Kunde | SYSGEN, SYSTEME UND | NL Bremen");
|
||||
assertThat(entry.role()).isEqualTo("customer");
|
||||
assertThat(entry.cscId()).isEqualTo(100164);
|
||||
assertThat(entry.jobIds()).containsExactly(21891263L, 21891262L);
|
||||
assertThat(entry.url())
|
||||
.isEqualTo("https://test.sb.assecutor.de/admin/tapi_wrapper.php?phoneNo=01602107449");
|
||||
}
|
||||
|
||||
/** Die Felder ab {@code role} darf das Fremdsystem auch weglassen. */
|
||||
@Test
|
||||
void extraFieldsStayNullWhenAbsent() {
|
||||
List<AddressEntry> entries = parse("""
|
||||
{"name":"SYSGEN GmbH","number":"+49421409660","description":"Kunde | …"}""");
|
||||
|
||||
AddressEntry entry = entries.getFirst();
|
||||
assertThat(entry.role()).isNull();
|
||||
assertThat(entry.cscId()).isNull();
|
||||
assertThat(entry.jobIds()).isNull();
|
||||
assertThat(entry.url()).isNull();
|
||||
}
|
||||
|
||||
/** Kuriere und Jobs kommen analog – auch als Liste. */
|
||||
@Test
|
||||
void readsListOfEntries() {
|
||||
List<AddressEntry> entries = parse("""
|
||||
[{"name":"Rainer Peters (HH1003)","number":"+49171677xxxx",
|
||||
"description":"Kurier | NL Hamburg | PKW | cr 2024"},
|
||||
{"name":"SYSGEN GmbH","number":"+49421409660","description":"Kunde | …"}]""");
|
||||
|
||||
assertThat(entries).hasSize(2);
|
||||
assertThat(entries.getFirst().description()).startsWith("Kurier |");
|
||||
}
|
||||
|
||||
/** Die Adress-Nachricht der SwyxTray-App: verpackt in "addresses", mit "type". */
|
||||
@Test
|
||||
void readsSwyxTrayAddressMessage() {
|
||||
List<AddressEntry> entries = parse("""
|
||||
{"addresses":[{"name":"Abt, Bettina","number":"7587","description":"S-SB"},
|
||||
{"name":"Abdul, Rokhsareh","number":"5215","description":"Globales Telefonbuch"}],
|
||||
"type":"addresses"}""");
|
||||
|
||||
assertThat(entries).hasSize(2);
|
||||
assertThat(entries).extracting(entry -> entry.number()).containsExactly("7587", "5215");
|
||||
}
|
||||
|
||||
/**
|
||||
* Ohne Rufnummer ist es kein Eintrag – sie ist der Schlüssel der Sammlung,
|
||||
* ohne sie ließe sich eine Dublette nicht erkennen. Anderes JSON ergibt nichts.
|
||||
*/
|
||||
@Test
|
||||
void skipsUnusableJson() {
|
||||
assertThat(parse("{\"foo\":\"bar\"}")).isEmpty();
|
||||
assertThat(parse("{\"name\":\"Nur Name GmbH\"}")).isEmpty();
|
||||
assertThat(parse("{\"addresses\":[{\"description\":\"nur Text\"}]}")).isEmpty();
|
||||
assertThat(parse("[1,2,3]")).isEmpty();
|
||||
assertThat(parse("\"nur eine Zeichenkette\"")).isEmpty();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
package de.appcreation.swyxweb;
|
||||
|
||||
import static org.mockito.ArgumentMatchers.argThat;
|
||||
import static org.mockito.Mockito.verify;
|
||||
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
|
||||
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
|
||||
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
|
||||
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.boot.webmvc.test.autoconfigure.AutoConfigureMockMvc;
|
||||
import org.springframework.boot.test.context.SpringBootTest;
|
||||
import org.springframework.http.MediaType;
|
||||
import org.springframework.test.context.bean.override.mockito.MockitoBean;
|
||||
import org.springframework.test.web.servlet.MockMvc;
|
||||
|
||||
import de.appcreation.swyxweb.storage.ArchiveService;
|
||||
|
||||
/**
|
||||
* Der Endpunkt nimmt die Anruf-Ereignisse der Startseite an und bestätigt nur
|
||||
* die Annahme (202). Der {@link ArchiveService} ist ersetzt – der Test läuft
|
||||
* ohne MongoDB und hinterlässt dort nichts.
|
||||
*/
|
||||
@SpringBootTest
|
||||
@AutoConfigureMockMvc
|
||||
class CallControllerTests {
|
||||
|
||||
@Autowired
|
||||
MockMvc mvc;
|
||||
|
||||
@MockitoBean
|
||||
ArchiveService archive;
|
||||
|
||||
@Test
|
||||
void acceptsEventsAndReturnsCount() throws Exception {
|
||||
mvc.perform(post("/api/calls")
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content("""
|
||||
[{"line":1,"event":"incoming","peer":"Muster GmbH (+493012345)",
|
||||
"peerNumber":"+493012345","peerName":"Muster GmbH",
|
||||
"stateText":"klingelt","state":"LSRinging"},
|
||||
{"line":1,"event":"ended","direction":"incoming"}]"""))
|
||||
.andExpect(status().isAccepted())
|
||||
.andExpect(jsonPath("$").value(2));
|
||||
|
||||
verify(archive).saveCalls(argThat(calls -> calls.size() == 2));
|
||||
}
|
||||
|
||||
@Test
|
||||
void rejectsEmptyList() throws Exception {
|
||||
mvc.perform(post("/api/calls")
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content("[]"))
|
||||
.andExpect(status().isBadRequest());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
package de.appcreation.swyxweb;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
|
||||
import java.time.ZonedDateTime;
|
||||
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import de.appcreation.swyxweb.storage.JobCleanupService;
|
||||
|
||||
/**
|
||||
* Die nächtliche Job-Bereinigung vergleicht die {@code ordertime} als Text –
|
||||
* hier wird die Grenze und der Vergleich gegen das Format der Nutzlast geprüft.
|
||||
*/
|
||||
class JobCleanupServiceTests {
|
||||
|
||||
@Test
|
||||
void cutoffLiesFourWeeksBackInOrdertimeFormat() {
|
||||
ZonedDateTime now = ZonedDateTime.of(2026, 8, 28, 0, 0, 0, 0, JobCleanupService.ZONE);
|
||||
|
||||
// Vier Wochen vor dem 28.08. ist der 31.07.; im Sommer gilt +02:00.
|
||||
assertThat(JobCleanupService.cutoffOrdertime(now)).isEqualTo("2026-07-31T00:00:00+02:00");
|
||||
}
|
||||
|
||||
@Test
|
||||
void ordertimeStringsCompareChronologically() {
|
||||
String cutoff = "2026-07-31T00:00:00+02:00";
|
||||
|
||||
assertThat("2026-07-30T18:14:02+02:00").isLessThan(cutoff);
|
||||
assertThat("2026-08-01T08:00:00+02:00").isGreaterThan(cutoff);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
package de.appcreation.swyxweb;
|
||||
|
||||
import static org.mockito.Mockito.when;
|
||||
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
|
||||
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
|
||||
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
|
||||
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.boot.webmvc.test.autoconfigure.AutoConfigureMockMvc;
|
||||
import org.springframework.boot.test.context.SpringBootTest;
|
||||
import org.springframework.test.context.bean.override.mockito.MockitoBean;
|
||||
import org.springframework.test.web.servlet.MockMvc;
|
||||
|
||||
import de.appcreation.swyxweb.storage.ArchiveService;
|
||||
import de.appcreation.swyxweb.storage.JobEntry;
|
||||
|
||||
/**
|
||||
* Der Endpunkt liefert einen einzelnen Job zur Kennung des Fremdsystems – so
|
||||
* kommt das Anruf-Popup von einer Kennung aus {@code job_ids} an die
|
||||
* Sprungadresse des Jobs. Der {@link ArchiveService} ist ersetzt – der Test
|
||||
* läuft ohne MongoDB.
|
||||
*/
|
||||
@SpringBootTest
|
||||
@AutoConfigureMockMvc
|
||||
class JobControllerTests {
|
||||
|
||||
@Autowired
|
||||
MockMvc mvc;
|
||||
|
||||
@MockitoBean
|
||||
ArchiveService archive;
|
||||
|
||||
@Test
|
||||
void returnsStoredJob() throws Exception {
|
||||
when(archive.job(21891263L)).thenReturn(new JobEntry(21891263L, null,
|
||||
"https://test.sb.assecutor.de/admin/jb_detail.php?job_id=21891263",
|
||||
1, "2026-08-13T13:35:27", null, null, null, null, null, null, null, null, null, null));
|
||||
|
||||
mvc.perform(get("/api/jobs/21891263"))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(jsonPath("$.id").value(21891263))
|
||||
.andExpect(jsonPath("$.url").value("https://test.sb.assecutor.de/admin/jb_detail.php?job_id=21891263"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void unknownIdIsNotFound() throws Exception {
|
||||
when(archive.job(4711L)).thenReturn(null);
|
||||
|
||||
mvc.perform(get("/api/jobs/4711"))
|
||||
.andExpect(status().isNotFound());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,119 @@
|
||||
package de.appcreation.swyxweb;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
|
||||
import java.time.Instant;
|
||||
|
||||
import tools.jackson.databind.JsonNode;
|
||||
import tools.jackson.databind.ObjectMapper;
|
||||
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import de.appcreation.swyxweb.storage.JobEntry;
|
||||
|
||||
/**
|
||||
* {@link JobEntry} wird aus der Webhook-Nutzlast mit {@code "type":"job"}
|
||||
* gefüllt – samt Kunde, Kurier und Stationen. Eine ältere Form der Nutzlast
|
||||
* nannte die Rufnummern {@code number} statt {@code phone}; auch sie wird
|
||||
* weiterhin verstanden.
|
||||
*/
|
||||
class JobEntryTests {
|
||||
|
||||
private final ObjectMapper mapper = new ObjectMapper();
|
||||
|
||||
private static final String SAMPLE = """
|
||||
{
|
||||
"type": "job",
|
||||
"id": 21891263,
|
||||
"url": "https://test.sb.assecutor.de/admin/jb_detail.php?job_id=21891263",
|
||||
"state": 9,
|
||||
"ordertime": "2026-08-13T13:35:27",
|
||||
"orderdate": "2026-08-13",
|
||||
"modified": "2026-08-13T13:35:27",
|
||||
"finished": null,
|
||||
"vehicle": "Transporter XL",
|
||||
"service": null,
|
||||
"canceled": false,
|
||||
"global": false,
|
||||
"customer": { "csc_id": 100164, "name": "SYSGEN GmbH", "hq": "Bremen",
|
||||
"phone": "+491602107449" },
|
||||
"courier": null,
|
||||
"tours": [
|
||||
{ "id": 22396758, "sort": 1, "state": 0, "mode": "del", "comp": "SYSGEN GmbH",
|
||||
"person": "Frau Hoffmann", "phone": "+49421409660", "com": null,
|
||||
"remark": "~~~\\nHebebühne benötigt.\\n~~~", "street": "Am Hallacker 48",
|
||||
"zip": "28327", "city": "Bremen", "finished": null },
|
||||
{ "id": 22396759, "sort": 2, "state": 0, "mode": "pu",
|
||||
"comp": "Super Micro Computer B.V.", "person": null, "phone": null, "com": null,
|
||||
"remark": "Abholreferenz: 8801420234\\nAnzahl an Paletten: 8",
|
||||
"street": "Het Sterrenbeeld 12-16", "zip": "5215", "city": "'s-Hertogenbosch",
|
||||
"finished": null }
|
||||
]
|
||||
}""";
|
||||
|
||||
@Test
|
||||
void recognizesJobPayload() {
|
||||
assertThat(JobEntry.isJob(mapper.readTree(SAMPLE))).isTrue();
|
||||
assertThat(JobEntry.isJob(mapper.readTree("{\"name\":\"SYSGEN GmbH\"}"))).isFalse();
|
||||
assertThat(JobEntry.isJob(mapper.readTree("{\"type\":\"addresses\",\"addresses\":[]}"))).isFalse();
|
||||
}
|
||||
|
||||
@Test
|
||||
void fillsAllFieldsFromSample() {
|
||||
Instant receivedAt = Instant.parse("2026-08-28T10:00:00Z");
|
||||
JsonNode json = mapper.readTree(SAMPLE);
|
||||
|
||||
JobEntry job = JobEntry.from(json, mapper, receivedAt);
|
||||
|
||||
assertThat(job.id()).isEqualTo(21891263L);
|
||||
assertThat(job.receivedAt()).isEqualTo(receivedAt);
|
||||
assertThat(job.url()).isEqualTo("https://test.sb.assecutor.de/admin/jb_detail.php?job_id=21891263");
|
||||
assertThat(job.state()).isEqualTo(9);
|
||||
assertThat(job.ordertime()).isEqualTo("2026-08-13T13:35:27");
|
||||
assertThat(job.orderdate()).isEqualTo("2026-08-13");
|
||||
assertThat(job.finished()).isNull();
|
||||
assertThat(job.vehicle()).isEqualTo("Transporter XL");
|
||||
assertThat(job.service()).isNull();
|
||||
assertThat(job.canceled()).isFalse();
|
||||
assertThat(job.global()).isFalse();
|
||||
|
||||
assertThat(job.customer().cscId()).isEqualTo(100164);
|
||||
assertThat(job.customer().name()).isEqualTo("SYSGEN GmbH");
|
||||
assertThat(job.customer().hq()).isEqualTo("Bremen");
|
||||
assertThat(job.customer().phone()).isEqualTo("+491602107449");
|
||||
|
||||
// Noch kein Kurier zugeteilt.
|
||||
assertThat(job.courier()).isNull();
|
||||
|
||||
assertThat(job.tours()).hasSize(2);
|
||||
JobEntry.Tour first = job.tours().getFirst();
|
||||
assertThat(first.id()).isEqualTo(22396758L);
|
||||
assertThat(first.sort()).isEqualTo(1);
|
||||
assertThat(first.mode()).isEqualTo("del");
|
||||
assertThat(first.person()).isEqualTo("Frau Hoffmann");
|
||||
assertThat(first.phone()).isEqualTo("+49421409660");
|
||||
assertThat(first.remark()).contains("Hebebühne benötigt.");
|
||||
assertThat(first.street()).isEqualTo("Am Hallacker 48");
|
||||
// Fehlende Rufnummer der zweiten Station bleibt null.
|
||||
assertThat(job.tours().get(1).phone()).isNull();
|
||||
assertThat(job.tours().get(1).city()).isEqualTo("'s-Hertogenbosch");
|
||||
}
|
||||
|
||||
/** Die ältere Form nannte die Rufnummern {@code number} statt {@code phone}. */
|
||||
@Test
|
||||
void acceptsLegacyNumberFields() {
|
||||
JsonNode json = mapper.readTree("""
|
||||
{
|
||||
"type": "job",
|
||||
"id": 21891253,
|
||||
"courier": { "cr_id": 14116, "sid": "B1006", "name": "CA Kurier B1006",
|
||||
"number": "+4917xxxxxxx" },
|
||||
"tours": [ { "id": 22396738, "sort": 1, "number": "+4942037010xx" } ]
|
||||
}""");
|
||||
|
||||
JobEntry job = JobEntry.from(json, mapper, Instant.parse("2026-08-28T10:00:00Z"));
|
||||
|
||||
assertThat(job.courier().phone()).isEqualTo("+4917xxxxxxx");
|
||||
assertThat(job.tours().getFirst().phone()).isEqualTo("+4942037010xx");
|
||||
}
|
||||
}
|
||||
@@ -1,12 +1,15 @@
|
||||
package de.appcreation.swyxweb;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.mockito.ArgumentMatchers.argThat;
|
||||
import static org.mockito.Mockito.verify;
|
||||
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.delete;
|
||||
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
|
||||
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
|
||||
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
|
||||
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
|
||||
|
||||
import de.appcreation.swyxweb.storage.ArchiveService;
|
||||
import de.appcreation.swyxweb.webhook.WebhookService;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Nested;
|
||||
@@ -16,6 +19,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 +36,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 +89,51 @@ 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(event -> event.channel())
|
||||
.containsExactly("kunden", "kuriere", "jobs");
|
||||
|
||||
// Kunden und Kuriere landen als Adressdaten in der Ablage, der Job als Job.
|
||||
verify(archive).saveAddresses(argThat(entries ->
|
||||
entries.size() == 1 && "SYSGEN GmbH".equals(entries.getFirst().name())));
|
||||
verify(archive).saveAddresses(argThat(entries ->
|
||||
entries.size() == 1 && "Rainer Peters (HH1003)".equals(entries.getFirst().name())));
|
||||
verify(archive).saveJob(argThat(job -> job.id() == 4711));
|
||||
}
|
||||
|
||||
/** Der generische Webhook trägt keine Herkunft – die Nutzlast entscheidet. */
|
||||
@Test
|
||||
void genericWebhookHasNoChannel() throws Exception {
|
||||
mvc.perform(post("/api/webhook")
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content("{\"name\":\"Muster GmbH\",\"number\":\"+493012345\"}"))
|
||||
.andExpect(status().isOk());
|
||||
|
||||
assertThat(service.history().getFirst().channel()).isNull();
|
||||
verify(archive).saveAddresses(argThat(entries -> entries.size() == 1));
|
||||
}
|
||||
|
||||
/** Ohne Token in der Konfiguration darf der Kopf fehlen. */
|
||||
@Test
|
||||
void tokenIsNotRequiredWhenUnset() throws Exception {
|
||||
@@ -93,7 +146,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 +166,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")
|
||||
|
||||
+16
-1
@@ -3,7 +3,22 @@
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<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>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
|
||||
@@ -0,0 +1,81 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { pickCaller, sameNumber, type StoredAddress } from './addresses'
|
||||
|
||||
/**
|
||||
* SwyxIt! meldet Rufnummern mit Amtsholung ("001602107449"), die Webhooks
|
||||
* liefern sie international ("+491602107449") – der Abgleich muss beide
|
||||
* Schreibweisen als denselben Anschluss erkennen.
|
||||
*/
|
||||
describe('sameNumber', () => {
|
||||
it('erkennt dieselbe Nummer trotz unterschiedlicher Schreibweise', () => {
|
||||
expect(sameNumber('+491602107449', '001602107449')).toBe(true)
|
||||
expect(sameNumber('+491602107449', '01602107449')).toBe(true)
|
||||
expect(sameNumber('+49421409660', '0421409660')).toBe(true)
|
||||
expect(sameNumber('+491602107449', '+491602107449')).toBe(true)
|
||||
})
|
||||
|
||||
it('unterscheidet verschiedene Anschlüsse', () => {
|
||||
expect(sameNumber('+491602107449', '+491602107440')).toBe(false)
|
||||
expect(sameNumber('+49421409660', '+494212409661')).toBe(false)
|
||||
})
|
||||
|
||||
it('verlangt bei kurzen Nummern (Durchwahlen) die volle Übereinstimmung', () => {
|
||||
expect(sameNumber('7587', '7587')).toBe(true)
|
||||
expect(sameNumber('7587', '587')).toBe(false)
|
||||
// Eine Durchwahl ist nicht "dieselbe Nummer" wie ein externer Anschluss.
|
||||
expect(sameNumber('7449', '+491602107449')).toBe(false)
|
||||
})
|
||||
|
||||
it('liefert ohne Ziffern kein Ergebnis', () => {
|
||||
expect(sameNumber('', '+491602107449')).toBe(false)
|
||||
expect(sameNumber('unbekannt', '+491602107449')).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('pickCaller', () => {
|
||||
const entries: StoredAddress[] = [
|
||||
{ number: '5215', name: 'Abdul, Rokhsareh', description: 'Globales Telefonbuch' },
|
||||
{
|
||||
number: '+491602107449',
|
||||
name: 'SYSGEN GmbH',
|
||||
description: 'Kunde | SYSGEN, SYSTEME UND | NL Bremen',
|
||||
role: 'customer',
|
||||
cscId: 100164,
|
||||
url: 'https://test.sb.assecutor.de/admin/tapi_wrapper.php?phoneNo=01602107449',
|
||||
},
|
||||
{
|
||||
number: '+49171677xxxx',
|
||||
name: 'Rainer Peters (HH1003)',
|
||||
role: 'courier',
|
||||
jobIds: [21891233],
|
||||
},
|
||||
]
|
||||
|
||||
it('findet den Kunden zur gemeldeten Rufnummer', () => {
|
||||
const match = pickCaller(entries, '001602107449')
|
||||
expect(match?.name).toBe('SYSGEN GmbH')
|
||||
expect(match?.url).toContain('tapi_wrapper')
|
||||
})
|
||||
|
||||
it('findet auch den Kurier zur gemeldeten Rufnummer', () => {
|
||||
const match = pickCaller(entries, '+49171677xxxx')
|
||||
expect(match?.name).toBe('Rainer Peters (HH1003)')
|
||||
expect(match?.jobIds).toEqual([21891233])
|
||||
})
|
||||
|
||||
it('übergeht Telefonbuch-Einträge (ohne Rolle)', () => {
|
||||
expect(pickCaller(entries, '5215')).toBeNull()
|
||||
})
|
||||
|
||||
it('bevorzugt bei doppelter Rufnummer den Kunden vor dem Kurier', () => {
|
||||
const both: StoredAddress[] = [
|
||||
{ number: '+49404711', name: 'Kurier', role: 'courier' },
|
||||
{ number: '+49404711', name: 'Kunde', role: 'customer' },
|
||||
]
|
||||
expect(pickCaller(both, '+49404711')?.name).toBe('Kunde')
|
||||
})
|
||||
|
||||
it('liefert null, wenn keine Rufnummer passt', () => {
|
||||
expect(pickCaller(entries, '+49404711')).toBeNull()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,108 @@
|
||||
import type { Contact } from './swyx/protocol'
|
||||
|
||||
/** Ein Eintrag der Sammlung `addresses`, wie ihn `GET /api/addresses` liefert. */
|
||||
export interface StoredAddress {
|
||||
number?: string | null
|
||||
name?: string | null
|
||||
description?: string | null
|
||||
/** Rolle im Fremdsystem, z. B. "customer". */
|
||||
role?: string | null
|
||||
/** Kundenkennung des Fremdsystems (`csc_id`). */
|
||||
cscId?: number | null
|
||||
/** Kennungen der zugehörigen Jobs. */
|
||||
jobIds?: number[] | null
|
||||
/** Sprungadresse des Fremdsystems zu diesem Eintrag, z. B. zum Auftrag. */
|
||||
url?: string | null
|
||||
}
|
||||
|
||||
/**
|
||||
* Fragt die Adress-Ablage (MongoDB-Sammlung `addresses`) über das Backend ab –
|
||||
* den zusammengeführten Bestand aus den Webhooks (Kunden, Kuriere) und dem
|
||||
* SwyxTray-Telefonbuch. Gesucht wird **in der Datenbank** (Teilzeichenkette in
|
||||
* Name, Rufnummer und Beschreibung); im Browser wird nichts vorgehalten.
|
||||
*/
|
||||
export async function fetchAddressEntries(query = ''): Promise<StoredAddress[]> {
|
||||
const wanted = query.trim()
|
||||
const url = wanted ? `/api/addresses?q=${encodeURIComponent(wanted)}` : '/api/addresses'
|
||||
const response = await fetch(url)
|
||||
if (!response.ok) {
|
||||
throw new Error(`Adress-Ablage nicht abrufbar (/api/addresses antwortete mit HTTP ${response.status}).`)
|
||||
}
|
||||
return (await response.json()) as StoredAddress[]
|
||||
}
|
||||
|
||||
/** Wie {@link fetchAddressEntries}, verengt auf die Felder der Adressanzeige. */
|
||||
export async function fetchAddresses(query = ''): Promise<Contact[]> {
|
||||
const entries = await fetchAddressEntries(query)
|
||||
return entries.map((entry) => ({
|
||||
name: entry.name ?? undefined,
|
||||
number: entry.number ?? undefined,
|
||||
description: entry.description ?? undefined,
|
||||
}))
|
||||
}
|
||||
|
||||
/** Nur die Ziffern, ohne führende Nullen (Amtsholung, nationale Schreibweise). */
|
||||
function significantDigits(number: string): string {
|
||||
return number.replace(/\D/g, '').replace(/^0+/, '')
|
||||
}
|
||||
|
||||
/**
|
||||
* Meinen zwei Rufnummern denselben Anschluss? `+491602107449`, `01602107449`
|
||||
* und die von SwyxIt! gemeldete Form mit Amtsholung `001602107449`
|
||||
* unterscheiden sich nur in Vorwahl-Schreibweise und führenden Nullen –
|
||||
* verglichen werden deshalb die letzten (bis zu neun) signifikanten Ziffern.
|
||||
* Kurze Nummern (interne Durchwahlen) müssen ganz übereinstimmen.
|
||||
*/
|
||||
export function sameNumber(a: string, b: string): boolean {
|
||||
const left = significantDigits(a)
|
||||
const right = significantDigits(b)
|
||||
if (!left || !right) return false
|
||||
const length = Math.min(left.length, right.length)
|
||||
if (length < 6) return left === right
|
||||
const tail = Math.min(9, length)
|
||||
return left.slice(-tail) === right.slice(-tail)
|
||||
}
|
||||
|
||||
/**
|
||||
* Der erste Kunde oder Kurier aus `entries`, dessen Rufnummer passt – Einträge
|
||||
* ohne Rolle (SwyxTray-Telefonbuch) zählen nicht. Passen beide Rollen, gewinnt
|
||||
* der Kunde.
|
||||
*/
|
||||
export function pickCaller(entries: StoredAddress[], number: string): StoredAddress | null {
|
||||
const withRole = (role: string) =>
|
||||
entries.find(
|
||||
(entry) => entry.role === role && entry.number && sameNumber(entry.number, number),
|
||||
)
|
||||
return withRole('customer') ?? withRole('courier') ?? null
|
||||
}
|
||||
|
||||
/**
|
||||
* Sucht in der Ablage den Kunden oder Kurier zur Rufnummer eines Anrufs. Die
|
||||
* Datenbank sucht als Teilzeichenkette – abgefragt werden deshalb die letzten
|
||||
* Ziffern, das genaue Passen prüft {@link sameNumber} hier im Browser.
|
||||
*/
|
||||
export async function findCallerByNumber(number: string): Promise<StoredAddress | null> {
|
||||
const digits = significantDigits(number)
|
||||
if (!digits) return null
|
||||
const entries = await fetchAddressEntries(digits.slice(-9))
|
||||
return pickCaller(entries, number)
|
||||
}
|
||||
|
||||
/**
|
||||
* Meldet den Adress-Cache der SwyxTray-App an das Backend, das die Einträge in
|
||||
* der MongoDB ablegt (Aktualisierung statt Verdopplung, siehe ArchiveService).
|
||||
*
|
||||
* Die WebSocket-Verbindung zur App hält nur der Browser; das Backend käme an
|
||||
* diese Adressdaten sonst nicht heran. Fire-and-forget wie bei den
|
||||
* Anrufdaten: Ist das Backend nicht erreichbar, läuft die Anzeige weiter.
|
||||
*/
|
||||
export function reportAddresses(contacts: Contact[]): void {
|
||||
if (contacts.length === 0) return
|
||||
void fetch('/api/addresses', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ addresses: contacts }),
|
||||
}).catch((e: unknown) => {
|
||||
console.warn('Adressdaten nicht an das Backend gemeldet:', e)
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
/**
|
||||
* Umschaltbares Erscheinungsbild (CI/CD): STADTBOTE (Vorgabe) oder HANSETRANS.
|
||||
*
|
||||
* Beide Vorgaben stammen aus dem HANSETRANS-Brandbook (Stand 2025/10): gleiche
|
||||
* Schriftfamilie (Red Hat Display/Text), aber eigene Farben, Wortmarke und
|
||||
* Bildmarke je Marke. Die Umschaltung läuft über `data-brand` am Wurzelelement –
|
||||
* die Farb-Token dazu stehen in der index.css.
|
||||
*/
|
||||
export type Brand = 'stadtbote' | 'hansetrans'
|
||||
|
||||
const STORAGE_KEY = 'swyxweb.brand'
|
||||
|
||||
export function loadBrand(): Brand {
|
||||
try {
|
||||
return localStorage.getItem(STORAGE_KEY) === 'hansetrans' ? 'hansetrans' : 'stadtbote'
|
||||
} catch {
|
||||
// Privater Modus o. Ä. – dann eben immer die Vorgabe.
|
||||
return 'stadtbote'
|
||||
}
|
||||
}
|
||||
|
||||
const TITLES: Record<Brand, string> = {
|
||||
stadtbote: 'SwyxWeb · STADTBOTE',
|
||||
hansetrans: 'SwyxWeb · HANSETRANS',
|
||||
}
|
||||
|
||||
/**
|
||||
* Favicons als Daten-URI: das STADTBOTE-Signet (roter Ring mit „S",
|
||||
* Markenrot #CA0D38) und die HANSETRANS-Bildmarke (grünes Achteck #68B022
|
||||
* mit „Fahrbahn"-Schwung), jeweils wie in den Signet-Komponenten.
|
||||
*/
|
||||
const FAVICONS: Record<Brand, string> = {
|
||||
stadtbote:
|
||||
"data:image/svg+xml,%3Csvg%20xmlns='http://www.w3.org/2000/svg'%20viewBox='0%200%20100%20100'%3E%3Cpath%20d='M%2055%2014.4%20A%2036%2036%200%201%200%2081.8%2033.1'%20fill='none'%20stroke='%23CA0D38'%20stroke-width='13'/%3E%3Ctext%20x='50'%20y='52'%20text-anchor='middle'%20dominant-baseline='central'%20font-family='Arial,Helvetica,sans-serif'%20font-weight='bold'%20font-size='54'%20fill='%23CA0D38'%3ES%3C/text%3E%3C/svg%3E",
|
||||
hansetrans:
|
||||
"data:image/svg+xml,%3Csvg%20xmlns='http://www.w3.org/2000/svg'%20viewBox='0%200%20100%20100'%3E%3Cpath%20fill='%2368B022'%20fill-rule='evenodd'%20d='M29.3%200%20H70.7%20L100%2029.3%20V70.7%20L70.7%20100%20H29.3%20L0%2070.7%20V29.3%20Z%20M4%2052%20C38%2046%2070%2040%2096%2031%20C66%2044%2036%2054%206%2062%20Z%20M10%2076%20C46%2066%2074%2052%2096%2035%20C76%2056%2048%2072%2016%2084%20Z'/%3E%3C/svg%3E",
|
||||
}
|
||||
|
||||
/** Stellt das Erscheinungsbild um und merkt es sich für den nächsten Start. */
|
||||
export function applyBrand(brand: Brand): void {
|
||||
document.documentElement.dataset.brand = brand
|
||||
document.title = TITLES[brand]
|
||||
const icon = document.querySelector<HTMLLinkElement>("link[rel='icon']")
|
||||
if (icon) icon.href = FAVICONS[brand]
|
||||
try {
|
||||
localStorage.setItem(STORAGE_KEY, brand)
|
||||
} catch {
|
||||
// absichtlich still – dann gilt die Wahl nur für diese Sitzung.
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
import type { CallEvent } from './swyx/protocol'
|
||||
|
||||
/**
|
||||
* Meldet Anruf-Ereignisse an das Backend, das sie in der MongoDB ablegt.
|
||||
*
|
||||
* Die Ereignisse entstehen nur hier im Browser (aus dem Vergleich zweier
|
||||
* SwyxTray-Snapshots); das Backend sieht die WebSocket-Verbindung nicht.
|
||||
* Fire-and-forget: Ist das Backend nicht erreichbar, läuft die Anzeige
|
||||
* unverändert weiter – die Speicherung ist Beiwerk, nicht Voraussetzung.
|
||||
*/
|
||||
export function reportCallEvents(events: CallEvent[]): void {
|
||||
if (events.length === 0) return
|
||||
void fetch('/api/calls', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(events),
|
||||
// Auch beim Schließen des Tabs noch absetzen, z. B. das "ended" beim Auflegen.
|
||||
keepalive: true,
|
||||
}).catch((e: unknown) => {
|
||||
console.warn('Anrufdaten nicht an das Backend gemeldet:', e)
|
||||
})
|
||||
}
|
||||
@@ -185,10 +185,6 @@ export default function BrowserTabsPanel({ active, disabled, busy, onList, onOpe
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<p className="note">
|
||||
Die Tabs gehören zum Firefox auf dem Rechner der SwyxTray-App. Ohne verbundenes Plugin
|
||||
beantwortet die App die drei Kommandos nach etwa fünf Sekunden mit einem Fehler.
|
||||
</p>
|
||||
{hint && <p className="note note--error">{hint}</p>}
|
||||
{outcome && <p className="note note--success">{outcome}</p>}
|
||||
</>
|
||||
|
||||
@@ -1,172 +1,102 @@
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||
import { describeContact, type Contact } from '../swyx/protocol'
|
||||
import {
|
||||
filterContacts,
|
||||
sortContacts,
|
||||
type Directory,
|
||||
type DirectoryProgress,
|
||||
type SweepOptions,
|
||||
} from '../swyx/directory'
|
||||
import LoadingDialog from './LoadingDialog'
|
||||
import { fetchAddresses } from '../addresses'
|
||||
|
||||
interface Props {
|
||||
/** Panel ist sichtbar – erst dann wird geladen. */
|
||||
/** Panel ist sichtbar – erst dann wird abgefragt. */
|
||||
active: boolean
|
||||
/** Keine Verbindung zur SwyxTray-App – Anrufen entfällt. */
|
||||
disabled: boolean
|
||||
/**
|
||||
* Adress-Cache, den die App unaufgefordert schickt. `null` heißt „noch keine
|
||||
* Cache-Nachricht"; eine leere Liste heißt „Cache ist leer".
|
||||
* Adress-Cache, den die App unaufgefordert schickt. Er wird hier nicht
|
||||
* angezeigt, sondern ist das Signal, die Ablage neu abzufragen – das Backend
|
||||
* hat ihn gerade hineingeschrieben.
|
||||
*/
|
||||
cache: Contact[] | null
|
||||
onLoad: (options: SweepOptions) => Promise<Directory | null>
|
||||
onDial: (number: string) => void
|
||||
}
|
||||
|
||||
const STORAGE_FILTER = 'swyxweb.contacts.filter'
|
||||
/** So lange bekommt das Backend Zeit, den Telefonbuch-Push wegzuschreiben. */
|
||||
const ARCHIVE_WRITE_DELAY_MS = 1500
|
||||
|
||||
function stored(key: string): string {
|
||||
try {
|
||||
return localStorage.getItem(key) ?? ''
|
||||
} catch {
|
||||
// Privater Modus o. Ä. – dann eben ohne Gedächtnis.
|
||||
return ''
|
||||
}
|
||||
}
|
||||
|
||||
function remember(key: string, value: string): void {
|
||||
try {
|
||||
localStorage.setItem(key, value)
|
||||
} catch {
|
||||
// absichtlich still
|
||||
}
|
||||
}
|
||||
/** Entprellung der Eingabe – erst dann geht die Abfrage an die Datenbank. */
|
||||
const SEARCH_DEBOUNCE_MS = 300
|
||||
|
||||
/**
|
||||
* Adressdaten des Swyx-Clients – der **gesamte** Bestand des globalen
|
||||
* Telefonbuchs.
|
||||
* Adressdaten aus der **Adress-Ablage des Backends** (MongoDB-Sammlung
|
||||
* `addresses`) – dem zusammengeführten Bestand aus den Webhooks (Kunden,
|
||||
* Kuriere) und dem Telefonbuch des Swyx-Clients.
|
||||
*
|
||||
* Drei Wege, in dieser Reihenfolge:
|
||||
* Es gibt keinen Bestand im Browser: Jede Eingabe im Suchfeld fragt –
|
||||
* entprellt – die Datenbank ab, gesucht wird dort als Teilzeichenkette in
|
||||
* Name, Rufnummer und Beschreibung. Angezeigt wird immer das frische
|
||||
* Abfrageergebnis; der Bereich funktioniert damit auch ohne Verbindung zur
|
||||
* SwyxTray-App.
|
||||
*
|
||||
* 1. Der **Cache**, den die App seit Protokoll 8 beim Verbinden von selbst
|
||||
* schickt. Er liegt dann schon vor, bevor der Bereich geöffnet wird – kein
|
||||
* Laden, kein Wartedialog.
|
||||
* 2. Sonst beim Öffnen das Kommando `addresses`: derselbe Cache auf Anfrage.
|
||||
* 3. Kennt die App das Kommando nicht oder ist der Cache leer, werden rund 110
|
||||
* Einzelabfragen zusammengesetzt. Das dauert einige Sekunden und läuft hinter
|
||||
* einem Wartedialog.
|
||||
*
|
||||
* Danach liegt der Bestand im Browser: gefiltert wird ohne weitere Abfrage.
|
||||
* Das Telefonbuch der App fließt über den **Cache-Push** beim Verbinden
|
||||
* (Protokoll 8) in die Ablage – die Startseite meldet ihn ans Backend; das
|
||||
* Panel fragt die Ablage danach neu ab.
|
||||
*/
|
||||
export default function ContactsPanel({ active, disabled, cache, onLoad, onDial }: Props) {
|
||||
const [directory, setDirectory] = useState<Directory | null>(null)
|
||||
const [progress, setProgress] = useState<DirectoryProgress | null>(null)
|
||||
const [filter, setFilter] = useState(() => stored(STORAGE_FILTER))
|
||||
export default function ContactsPanel({ active, disabled, cache, onDial }: Props) {
|
||||
// Das jeweils letzte Abfrageergebnis – kein Bestand, nur die Anzeige.
|
||||
const [results, setResults] = useState<Contact[] | null>(null)
|
||||
const [loadError, setLoadError] = useState<string | null>(null)
|
||||
// Bewusst nicht gemerkt: Nach einem Neustart beginnt die Suche leer.
|
||||
const [filter, setFilter] = useState('')
|
||||
|
||||
// Nach einem Verbindungsabbruch soll beim nächsten Öffnen neu geladen werden.
|
||||
const loadedRef = useRef(false)
|
||||
const abortRef = useRef<AbortController | null>(null)
|
||||
// Zählt die Ladevorgänge. Trifft der Cache ein, während die Einzelabfragen
|
||||
// noch laufen, darf deren abgebrochenes Ergebnis den Cache nicht überschreiben.
|
||||
const loadIdRef = useRef(0)
|
||||
// Zählt die Abfragen: Eine überholte darf das Ergebnis einer neueren nicht
|
||||
// mehr überschreiben – Antworten kommen nicht zwingend in Reihenfolge.
|
||||
const queryIdRef = useRef(0)
|
||||
// Der aktuelle Suchbegriff für Abfragen außerhalb des Eingabe-Effekts
|
||||
// (Cache-Push) – ohne den Effekt neu anzustoßen.
|
||||
const filterRef = useRef(filter)
|
||||
filterRef.current = filter
|
||||
|
||||
/** Macht einen laufenden Ladevorgang ungültig und beendet ihn. */
|
||||
const supersede = useCallback(() => {
|
||||
loadIdRef.current += 1
|
||||
abortRef.current?.abort()
|
||||
abortRef.current = null
|
||||
setProgress(null)
|
||||
/** Fragt die Ablage mit dem aktuellen Suchbegriff ab. */
|
||||
const search = useCallback(async () => {
|
||||
const ticket = ++queryIdRef.current
|
||||
try {
|
||||
const entries = await fetchAddresses(filterRef.current)
|
||||
if (ticket !== queryIdRef.current) return
|
||||
setResults(entries)
|
||||
setLoadError(null)
|
||||
} catch (e) {
|
||||
if (ticket !== queryIdRef.current) return
|
||||
setLoadError(e instanceof Error ? e.message : String(e))
|
||||
}
|
||||
}, [])
|
||||
|
||||
const load = useCallback(async () => {
|
||||
// Ein zweiter Durchlauf würde nur dieselben Abfragen doppelt stellen.
|
||||
supersede()
|
||||
const ticket = loadIdRef.current
|
||||
const controller = new AbortController()
|
||||
abortRef.current = controller
|
||||
// Jede Eingabe fragt die Datenbank ab – entprellt; die erste Abfrage nach
|
||||
// dem Öffnen läuft sofort. Beim erneuten Öffnen des Bereichs ebenfalls
|
||||
// frisch abfragen, damit nie ein alter Stand stehen bleibt.
|
||||
useEffect(() => {
|
||||
if (!active) return
|
||||
const timer = setTimeout(() => void search(), results === null ? 0 : SEARCH_DEBOUNCE_MS)
|
||||
return () => clearTimeout(timer)
|
||||
// `results` absichtlich nicht in den Abhängigkeiten: Es würde nach jeder
|
||||
// Antwort eine weitere Abfrage anstoßen.
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [active, filter, search])
|
||||
|
||||
setProgress({ done: 0, total: 0, found: 0 })
|
||||
try {
|
||||
const result = await onLoad({
|
||||
signal: controller.signal,
|
||||
onProgress: (next) => {
|
||||
if (ticket === loadIdRef.current) setProgress(next)
|
||||
},
|
||||
})
|
||||
if (ticket !== loadIdRef.current) return
|
||||
// null heißt: Fehler – der steht bereits in der Fehleranzeige der Seite.
|
||||
if (result) setDirectory(result)
|
||||
} finally {
|
||||
if (ticket === loadIdRef.current) {
|
||||
abortRef.current = null
|
||||
setProgress(null)
|
||||
}
|
||||
}
|
||||
}, [onLoad, supersede])
|
||||
|
||||
// Der Cache der App hat Vorrang: Er kommt unaufgefordert und ist damit oft
|
||||
// schon da, bevor der Bereich überhaupt geöffnet wird. Ein leerer Cache ist
|
||||
// dagegen keine Antwort – dann bleibt es beim Laden weiter unten.
|
||||
// Telefonbuch-Push der App: Die Startseite hat ihn ans Backend gemeldet;
|
||||
// nach einer kurzen Schreibfrist die Ablage neu abfragen.
|
||||
useEffect(() => {
|
||||
if (!cache || cache.length === 0) return
|
||||
loadedRef.current = true
|
||||
supersede()
|
||||
setDirectory({
|
||||
contacts: sortContacts(cache),
|
||||
source: 'cache',
|
||||
queries: 0,
|
||||
complete: true,
|
||||
aborted: false,
|
||||
})
|
||||
}, [cache, supersede])
|
||||
|
||||
useEffect(() => {
|
||||
if (disabled) {
|
||||
// Ohne Verbindung ist der Bestand veraltet; er wird beim nächsten Mal neu geholt.
|
||||
supersede()
|
||||
loadedRef.current = false
|
||||
setDirectory(null)
|
||||
return
|
||||
}
|
||||
if (!active || loadedRef.current) return
|
||||
loadedRef.current = true
|
||||
void load()
|
||||
}, [active, disabled, load, supersede])
|
||||
|
||||
// Ein laufender Durchlauf soll nicht weiterfragen, wenn die Seite verschwindet.
|
||||
useEffect(() => () => abortRef.current?.abort(), [])
|
||||
|
||||
const visible = useMemo(
|
||||
() => (directory ? filterContacts(directory.contacts, filter) : []),
|
||||
[directory, filter],
|
||||
)
|
||||
|
||||
const loading = progress !== null
|
||||
const timer = setTimeout(() => void search(), ARCHIVE_WRITE_DELAY_MS)
|
||||
return () => clearTimeout(timer)
|
||||
}, [cache, search])
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="card__header">
|
||||
<h2>Adressdaten</h2>
|
||||
<button
|
||||
type="button"
|
||||
className="button button--ghost"
|
||||
onClick={() => void load()}
|
||||
disabled={disabled || loading}
|
||||
>
|
||||
{loading ? 'Lädt …' : 'Neu laden'}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{disabled ? (
|
||||
<p className="note">Erst mit der SwyxTray-App verbinden.</p>
|
||||
) : (
|
||||
<>
|
||||
{/* Die `.row` ist nicht bloß Zierde: `.field` wächst (`flex: 1 1 260px`)
|
||||
und würde als direktes Kind der Karte – einer Spalte – in die *Höhe*
|
||||
wachsen und den freien Platz aufsaugen. In der Zeile wächst es in
|
||||
die Breite, wie in allen anderen Bereichen auch. */}
|
||||
<div className="row">
|
||||
<label className="field">
|
||||
<span className="field__label">Filtern</span>
|
||||
<span className="field__label">Suchen</span>
|
||||
<input
|
||||
className="field__input"
|
||||
type="search"
|
||||
@@ -174,41 +104,31 @@ export default function ContactsPanel({ active, disabled, cache, onLoad, onDial
|
||||
spellCheck={false}
|
||||
autoComplete="off"
|
||||
placeholder="Name, Rufnummer oder Kürzel"
|
||||
onChange={(e) => {
|
||||
setFilter(e.target.value)
|
||||
remember(STORAGE_FILTER, e.target.value.trim())
|
||||
}}
|
||||
onChange={(e) => setFilter(e.target.value)}
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
{directory === null ? (
|
||||
<p className="note">{loading ? 'Adressdaten werden geladen …' : 'Noch nicht geladen.'}</p>
|
||||
{loadError && <p className="note note--error">{loadError}</p>}
|
||||
|
||||
{results === null ? (
|
||||
!loadError && <p className="note">Adressdaten werden abgefragt …</p>
|
||||
) : (
|
||||
<>
|
||||
<p className="note">
|
||||
{filter.trim()
|
||||
? `${visible.length} von ${directory.contacts.length} Einträgen`
|
||||
: `${directory.contacts.length} Einträge`}
|
||||
{directory.source === 'cache'
|
||||
? ' · aus dem Adress-Cache der App'
|
||||
: ` · aus ${directory.queries} Einzelabfragen zusammengesetzt`}
|
||||
{filter.trim() ? `${results.length} Treffer` : `${results.length} Einträge`}
|
||||
</p>
|
||||
|
||||
{!directory.complete && (
|
||||
<p className="note note--error">
|
||||
{directory.aborted
|
||||
? 'Abgebrochen – der Bestand ist unvollständig.'
|
||||
: 'Die App hat mindestens eine Abfrage gekürzt; es können Einträge fehlen.'}{' '}
|
||||
„Neu laden" versucht es erneut.
|
||||
{results.length === 0 ? (
|
||||
!filter.trim() && (
|
||||
<p className="note">
|
||||
Die Ablage ist noch leer – sie füllt sich über die Webhooks und über das
|
||||
Telefonbuch der SwyxTray-App.
|
||||
</p>
|
||||
)}
|
||||
|
||||
{visible.length === 0 ? (
|
||||
<p className="note">Kein Eintrag zu „{filter.trim()}".</p>
|
||||
)
|
||||
) : (
|
||||
<ul className="contacts">
|
||||
{visible.map((contact, index) => (
|
||||
{results.map((contact, index) => (
|
||||
// Namen sind nicht eindeutig – dieselbe Person kommt mit
|
||||
// mehreren Durchwahlen vor; deshalb die Position mit hinein.
|
||||
<li
|
||||
@@ -222,7 +142,9 @@ export default function ContactsPanel({ active, disabled, cache, onLoad, onDial
|
||||
)}
|
||||
</span>
|
||||
{contact.number && <span className="contacts__number">{contact.number}</span>}
|
||||
{contact.number && (
|
||||
{/* Ohne Verbindung zur Tray-App gibt es nichts zu wählen –
|
||||
der Knopf erscheint dann gar nicht erst. */}
|
||||
{contact.number && !disabled && (
|
||||
<button
|
||||
type="button"
|
||||
className="button button--small"
|
||||
@@ -238,25 +160,5 @@ export default function ContactsPanel({ active, disabled, cache, onLoad, onDial
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
<p className="note">
|
||||
Der Bestand kommt aus dem Telefonbuch des Swyx-Clients auf dem Rechner der SwyxTray-App –
|
||||
aus deren Adress-Cache, den sie beim Verbinden von selbst schickt. Kennt die App den Cache
|
||||
noch nicht oder ist er leer, wird der Bestand ersatzweise aus rund 110 Suchabfragen
|
||||
zusammengesetzt. Danach liegt er vollständig im Browser; das Filtern läuft ohne weitere
|
||||
Abfrage.
|
||||
</p>
|
||||
|
||||
{loading && (
|
||||
<LoadingDialog
|
||||
title="Adressdaten werden geladen"
|
||||
done={progress.done}
|
||||
total={progress.total}
|
||||
detail={progress.found > 0 ? `${progress.found} Einträge bisher` : undefined}
|
||||
onCancel={() => abortRef.current?.abort()}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,115 @@
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import type { StoredAddress } from '../addresses'
|
||||
import { fetchJob } from '../jobs'
|
||||
|
||||
interface Props {
|
||||
/** Der Kunde oder Kurier aus der Adress-Ablage, dessen Rufnummer zum Anruf passt. */
|
||||
customer: StoredAddress
|
||||
/**
|
||||
* Nimmt den Anruf an; solange er klingelt gesetzt, danach `undefined` –
|
||||
* der Knopf „Anruf annehmen" verschwindet dann von selbst.
|
||||
*/
|
||||
onAnswer?: () => void
|
||||
onClose: () => void
|
||||
}
|
||||
|
||||
/**
|
||||
* Popup zu einem eingehenden Anruf: zeigt die Daten des Anrufers aus der
|
||||
* Adress-Ablage (Kunde oder Kurier), dazu je Job-Kennung des Eintrags
|
||||
* (`job_ids`) einen Knopf, der den Job aus der Ablage holt und dessen
|
||||
* Sprungadresse (`url`) in einem neuen Tab öffnet. Gleiches Overlay wie der
|
||||
* Wartedialog (siehe LoadingDialog zur Begründung gegen `<dialog>`).
|
||||
*/
|
||||
export default function CustomerCallPopup({ customer, onAnswer, onClose }: Props) {
|
||||
const closeRef = useRef<HTMLButtonElement>(null)
|
||||
// Meldung, wenn ein Job nicht zu öffnen war (nicht abgelegt, ohne
|
||||
// Sprungadresse, Ablage nicht erreichbar) – das Popup bleibt dann offen.
|
||||
const [jobNote, setJobNote] = useState<string | null>(null)
|
||||
// Kennung des Jobs, der gerade geholt wird; sperrt derweil alle Job-Knöpfe.
|
||||
const [busyJobId, setBusyJobId] = useState<number | null>(null)
|
||||
|
||||
// Fokus in den Dialog holen, damit Escape sofort greift.
|
||||
useEffect(() => {
|
||||
closeRef.current?.focus()
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
function onKeyDown(event: KeyboardEvent) {
|
||||
if (event.key === 'Escape') onClose()
|
||||
}
|
||||
document.addEventListener('keydown', onKeyDown)
|
||||
return () => document.removeEventListener('keydown', onKeyDown)
|
||||
}, [onClose])
|
||||
|
||||
const jobIds = customer.jobIds ?? []
|
||||
|
||||
async function openJob(jobId: number) {
|
||||
setBusyJobId(jobId)
|
||||
setJobNote(null)
|
||||
try {
|
||||
const job = await fetchJob(jobId)
|
||||
if (!job.url) {
|
||||
setJobNote(`Zu Job ${jobId} ist keine Sprungadresse abgelegt.`)
|
||||
return
|
||||
}
|
||||
window.open(job.url, '_blank', 'noopener,noreferrer')
|
||||
// Der neue Tab ist offen; das Popup hat damit seinen Zweck erfüllt.
|
||||
onClose()
|
||||
} catch (e) {
|
||||
setJobNote(e instanceof Error ? e.message : String(e))
|
||||
} finally {
|
||||
setBusyJobId(null)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="overlay">
|
||||
<div className="dialog" role="dialog" aria-modal="true" aria-labelledby="caller-popup-title">
|
||||
<p className="dialog__status">Eingehender Anruf von</p>
|
||||
<h2 className="dialog__title" id="caller-popup-title">
|
||||
{customer.name ?? customer.number}
|
||||
</h2>
|
||||
{customer.number && <p className="note">{customer.number}</p>}
|
||||
{customer.description && <p className="note">{customer.description}</p>}
|
||||
{customer.cscId != null && <p className="note">Kundennummer {customer.cscId}</p>}
|
||||
|
||||
{jobIds.length > 0 && (
|
||||
<>
|
||||
<p className="dialog__status">Zugeordnete Jobs</p>
|
||||
<div className="dialog__jobs">
|
||||
{jobIds.map((jobId) => (
|
||||
<button
|
||||
key={jobId}
|
||||
type="button"
|
||||
className="button"
|
||||
disabled={busyJobId != null}
|
||||
onClick={() => void openJob(jobId)}
|
||||
>
|
||||
{busyJobId === jobId ? `Job ${jobId} …` : `Job ${jobId}`}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
{jobNote && <p className="note note--error">{jobNote}</p>}
|
||||
|
||||
<div className="dialog__actions">
|
||||
{onAnswer && (
|
||||
// Das Popup bleibt nach dem Annehmen offen: Die Job-Knöpfe werden
|
||||
// ja gerade während des Gesprächs gebraucht.
|
||||
<button
|
||||
type="button"
|
||||
className="button button--accept dialog__actions-start"
|
||||
onClick={onAnswer}
|
||||
>
|
||||
Anruf annehmen
|
||||
</button>
|
||||
)}
|
||||
<button ref={closeRef} type="button" className="button" onClick={onClose}>
|
||||
Schließen
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
/**
|
||||
* Die HANSETRANS-Bildmarke aus dem Brandbook: ein geöffnetes Achteck mit
|
||||
* „Fahrbahn"-Schwung. Als Inline-SVG angenähert, weil das Brandbook keine
|
||||
* Vektordaten enthält; die Farbe kommt über currentColor vom Umfeld, der
|
||||
* Schwung ist ein echtes Loch (evenodd) und zeigt den Seitenhintergrund –
|
||||
* so, wie das Brandbook die Marke auf hellen und dunklen Flächen zeigt.
|
||||
*/
|
||||
export default function HansetransSignet({ className }: { className?: string }) {
|
||||
return (
|
||||
<svg className={className} viewBox="0 0 100 100" aria-hidden="true" focusable="false">
|
||||
<path
|
||||
fill="currentColor"
|
||||
fillRule="evenodd"
|
||||
d="M29.3 0 H70.7 L100 29.3 V70.7 L70.7 100 H29.3 L0 70.7 V29.3 Z M4 52 C38 46 70 40 96 31 C66 44 36 54 6 62 Z M10 76 C46 66 74 52 96 35 C76 56 48 72 16 84 Z"
|
||||
/>
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
@@ -1,48 +0,0 @@
|
||||
import { type CallEvent } from '../swyx/protocol'
|
||||
|
||||
interface Props {
|
||||
call: CallEvent
|
||||
busy: boolean
|
||||
onAnswer: (line: number) => void
|
||||
onHangup: (line: number) => void
|
||||
}
|
||||
|
||||
/** Meldung für einen eingehenden, noch klingelnden Anruf. */
|
||||
export default function IncomingCallCard({ call, busy, onAnswer, onHangup }: Props) {
|
||||
// Name und Nummer getrennt anzeigen, sonst die fertige Anzeigeform der App.
|
||||
const title = call.peerName ?? call.peerNumber ?? call.peer ?? `Leitung ${call.line}`
|
||||
const subtitle = call.peerName ? call.peerNumber : undefined
|
||||
|
||||
return (
|
||||
<section className="card card--ringing" role="alert">
|
||||
<div className="ringing">
|
||||
<span className="ringing__icon" aria-hidden="true">
|
||||
☎
|
||||
</span>
|
||||
<div className="ringing__text">
|
||||
<p className="ringing__label">Eingehender Anruf · Leitung {call.line}</p>
|
||||
<p className="ringing__number">{title}</p>
|
||||
{subtitle && <p className="ringing__meta">{subtitle}</p>}
|
||||
</div>
|
||||
<div className="ringing__actions">
|
||||
<button
|
||||
type="button"
|
||||
className="button button--accept"
|
||||
disabled={busy}
|
||||
onClick={() => onAnswer(call.line)}
|
||||
>
|
||||
Annehmen
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="button button--reject"
|
||||
disabled={busy}
|
||||
onClick={() => onHangup(call.line)}
|
||||
>
|
||||
Ablehnen
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,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>
|
||||
)
|
||||
}
|
||||
@@ -65,10 +65,7 @@ export default function WebhookPanel({ events, status, error, onClear }: Props)
|
||||
{error && <p className="note note--error">{error}</p>}
|
||||
|
||||
{events.length === 0 ? (
|
||||
<p className="note">
|
||||
Noch keine Nachricht eingegangen. Ein fremdes System schickt Adressdaten an{' '}
|
||||
<code>POST /api/webhook</code>; sie erscheinen hier ohne Zutun.
|
||||
</p>
|
||||
<p className="note">Noch keine Nachricht eingegangen.</p>
|
||||
) : (
|
||||
<ul className="webhook">
|
||||
{events.map((event) => (
|
||||
@@ -83,12 +80,6 @@ export default function WebhookPanel({ events, status, error, onClear }: Props)
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
|
||||
<p className="note">
|
||||
Der Webhook nimmt beliebiges JSON an – Objekt wie Liste – und zeigt es hier unverändert.
|
||||
Der Verlauf liegt im Speicher des Backends und ist nach dessen Neustart leer; „Leeren"
|
||||
verwirft ihn auch dort.
|
||||
</p>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -6,6 +6,36 @@ export const DEFAULT_WS_PATH = '/ws'
|
||||
export const DEFAULT_WS_URL =
|
||||
import.meta.env.VITE_WS_URL ?? `ws://${DEFAULT_WS_HOST}:${DEFAULT_WS_PORT}${DEFAULT_WS_PATH}`
|
||||
|
||||
const SAVED_WS_URL_KEY = 'swyxweb.wsUrl'
|
||||
|
||||
/**
|
||||
* Zuletzt erfolgreich verbundene Adresse. localStorage kann fehlen oder gesperrt
|
||||
* sein (Privatmodus, Browser-Einstellung) – dann wird schlicht nichts gemerkt.
|
||||
*/
|
||||
export function loadSavedWsUrl(): string | null {
|
||||
try {
|
||||
return localStorage.getItem(SAVED_WS_URL_KEY)
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
export function saveWsUrl(url: string): void {
|
||||
try {
|
||||
localStorage.setItem(SAVED_WS_URL_KEY, url)
|
||||
} catch {
|
||||
// Ohne Speicher kein Wiederverbinden nach Neustart – mehr geht nicht verloren.
|
||||
}
|
||||
}
|
||||
|
||||
export function clearSavedWsUrl(): void {
|
||||
try {
|
||||
localStorage.removeItem(SAVED_WS_URL_KEY)
|
||||
} catch {
|
||||
// Siehe saveWsUrl.
|
||||
}
|
||||
}
|
||||
|
||||
export interface ClientConfig {
|
||||
websocketUrl: string
|
||||
host: string
|
||||
|
||||
@@ -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],
|
||||
)
|
||||
|
||||
|
||||
+239
-138
@@ -1,26 +1,76 @@
|
||||
/*
|
||||
* Farb- und Schriftvorgaben aus dem HANSETRANS Brandbook. Vorgabe ist das
|
||||
* STADTBOTE-Erscheinungsbild: Primär Rot #CA0D38 (Pantone 185 C) und Grau
|
||||
* #7C7F7E (Pantone 424 C), Off-Black #222222 für Fließtext, Grauabstufungen
|
||||
* als Flächen. Schrift: Red Hat Display (Überschriften, Versalien, Laufweite
|
||||
* 4 %) und Red Hat Text (Fließtext). Buttons: primär vollflächig, sekundär mit
|
||||
* Rahmen, Kästen und Linien mit leichter Abrundung.
|
||||
* Das HANSETRANS-Erscheinungsbild wird weiter unten über data-brand
|
||||
* am Wurzelelement zugeschaltet.
|
||||
*/
|
||||
:root {
|
||||
--bg: #0f1116;
|
||||
--surface: #171a21;
|
||||
--surface-2: #1f2530;
|
||||
--border: #2b3341;
|
||||
--text: #e6e9ef;
|
||||
--text-muted: #98a2b3;
|
||||
--accent: #4f8cff;
|
||||
--ok: #35c98a;
|
||||
--warn: #e0b341;
|
||||
--err: #f2585b;
|
||||
color-scheme: dark;
|
||||
--bg: #ffffff;
|
||||
--surface: #ffffff;
|
||||
--surface-2: #f6f6f6;
|
||||
--border: #dcdcdd;
|
||||
--border-strong: #b4b4b5;
|
||||
--text: #222222;
|
||||
--text-muted: #7c7f7e;
|
||||
/* 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%);
|
||||
--accent: #ca0d38;
|
||||
/* Kopfzeile: Bild- und Wortmarke; bei STADTBOTE beide im Markenrot. */
|
||||
--logo-mark: #ca0d38;
|
||||
--logo-word: #ca0d38;
|
||||
--ok: #2f9e6e;
|
||||
--warn: #b98a1c;
|
||||
--err: #d92b1f;
|
||||
color-scheme: light;
|
||||
}
|
||||
|
||||
@media (prefers-color-scheme: light) {
|
||||
@media (prefers-color-scheme: dark) {
|
||||
:root {
|
||||
--bg: #f5f7fa;
|
||||
--surface: #ffffff;
|
||||
--surface-2: #eef1f6;
|
||||
--border: #d8dee9;
|
||||
--text: #1b2230;
|
||||
--text-muted: #5c6779;
|
||||
color-scheme: light;
|
||||
--bg: #17181a;
|
||||
--surface: #1f2023;
|
||||
--surface-2: #28292d;
|
||||
--border: #3a3b40;
|
||||
--border-strong: #55565c;
|
||||
--text: #f1f1f2;
|
||||
--text-muted: #a4a7a8;
|
||||
--brand-soft: rgb(202 13 56 / 14%);
|
||||
--ok: #43c78f;
|
||||
--warn: #e0b341;
|
||||
--err: #f2665b;
|
||||
color-scheme: dark;
|
||||
}
|
||||
}
|
||||
|
||||
/*
|
||||
* HANSETRANS-Erscheinungsbild (HANSETRANS Brandbook 2025/10, Styleguide):
|
||||
* HANSETRANS Grün #68B022 (Pantone 361 C) als zentrale Akzentfarbe,
|
||||
* Dunkelgrün #3D821C, HANSETRANS Grau #4B4A4D als Farbe der Wortmarke,
|
||||
* Off-Black #222222 für Fließtext, Grauabstufungen #B4B4B5/#DCDCDD/#F6F6F6.
|
||||
* Umgeschaltet über data-brand am Wurzelelement (Bereich „Verbindung").
|
||||
*/
|
||||
:root[data-brand='hansetrans'] {
|
||||
--brand: #68b022;
|
||||
--brand-strong: #3d821c;
|
||||
--brand-soft: rgb(104 176 34 / 10%);
|
||||
--accent: #68b022;
|
||||
--text-muted: #4b4a4d;
|
||||
--logo-mark: #68b022;
|
||||
--logo-word: #4b4a4d;
|
||||
}
|
||||
|
||||
@media (prefers-color-scheme: dark) {
|
||||
:root[data-brand='hansetrans'] {
|
||||
--brand-soft: rgb(104 176 34 / 16%);
|
||||
--text-muted: #a4a7a8;
|
||||
/* Auf dunklen Flächen bleibt das Achteck grün, der Schriftzug wird weiß. */
|
||||
--logo-word: #f1f1f2;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -32,9 +82,30 @@ body {
|
||||
margin: 0;
|
||||
background: var(--bg);
|
||||
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;
|
||||
line-height: 1.5;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
h1,
|
||||
h2,
|
||||
h3 {
|
||||
/* Überschriften laut STADTBOTE-Styleguide: Red Hat Display Semibold in
|
||||
Versalien, Laufweite 4 %, Zeilenabstand 100 %. */
|
||||
font-family: 'Red Hat Display', 'Red Hat Text', system-ui, sans-serif;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.04em;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
/* HANSETRANS setzt Überschriften gemischt geschrieben mit leicht negativer
|
||||
Laufweite (Red Hat Display Semibold, Laufweite −20). */
|
||||
:root[data-brand='hansetrans'] h1,
|
||||
:root[data-brand='hansetrans'] h2,
|
||||
:root[data-brand='hansetrans'] h3 {
|
||||
text-transform: none;
|
||||
letter-spacing: -0.02em;
|
||||
}
|
||||
|
||||
code {
|
||||
@@ -59,14 +130,69 @@ code {
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.page__header h1 {
|
||||
/* Abgerundete Linie als Trennlinie unter dem Kopf – Gestaltungselement des CI. */
|
||||
.page__header::after {
|
||||
content: '';
|
||||
flex-basis: 100%;
|
||||
height: 3px;
|
||||
border-radius: 999px;
|
||||
background: var(--brand);
|
||||
}
|
||||
|
||||
.page__header-tools {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
/* Marken-Umschalter im Badge-Stil, damit er neben dem Status nicht aus der
|
||||
Reihe fällt. Die Wahl gilt sofort und bleibt über Neustarts erhalten. */
|
||||
.brand-switch {
|
||||
appearance: none;
|
||||
padding: 6px 12px;
|
||||
border-radius: 999px;
|
||||
border: 1px solid var(--border);
|
||||
background: var(--surface);
|
||||
color: var(--text);
|
||||
font: inherit;
|
||||
font-size: 13px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.brand-switch:hover {
|
||||
border-color: var(--border-strong);
|
||||
}
|
||||
|
||||
.brand {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 14px;
|
||||
color: var(--logo-mark);
|
||||
}
|
||||
|
||||
.brand__signet {
|
||||
width: 46px;
|
||||
height: 46px;
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
|
||||
.brand__name {
|
||||
margin: 0;
|
||||
font-size: 26px;
|
||||
letter-spacing: -0.02em;
|
||||
font-weight: 700;
|
||||
font-size: 24px;
|
||||
letter-spacing: 0.06em;
|
||||
color: var(--logo-word);
|
||||
}
|
||||
|
||||
/* Die HANSETRANS-Wortmarke steht kursiv („Brandon Grotesque Bold Italic" –
|
||||
hier mit der Hausschrift angenähert) in HANSETRANS Grau bzw. Weiß. */
|
||||
:root[data-brand='hansetrans'] .brand__name {
|
||||
font-style: italic;
|
||||
letter-spacing: 0.02em;
|
||||
}
|
||||
|
||||
.page__subtitle {
|
||||
margin: 2px 0 0;
|
||||
margin: 4px 0 0;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
@@ -76,15 +202,10 @@ code {
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.page__footer {
|
||||
color: var(--text-muted);
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.card {
|
||||
background: var(--surface);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 12px;
|
||||
border-radius: 8px;
|
||||
padding: 18px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
@@ -93,8 +214,8 @@ code {
|
||||
|
||||
.card h2 {
|
||||
margin: 0;
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
font-size: 15px;
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.card__header {
|
||||
@@ -134,9 +255,9 @@ code {
|
||||
.field__input {
|
||||
width: 100%;
|
||||
padding: 9px 12px;
|
||||
border-radius: 8px;
|
||||
border: 1px solid var(--border);
|
||||
background: var(--surface-2);
|
||||
border-radius: 6px;
|
||||
border: 1px solid var(--border-strong);
|
||||
background: var(--surface);
|
||||
color: var(--text);
|
||||
font: inherit;
|
||||
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
|
||||
@@ -144,27 +265,37 @@ code {
|
||||
}
|
||||
|
||||
.field__input:focus {
|
||||
outline: 2px solid var(--accent);
|
||||
outline: 2px solid var(--brand);
|
||||
outline-offset: 1px;
|
||||
border-color: var(--brand);
|
||||
}
|
||||
|
||||
.field__input:disabled {
|
||||
opacity: 0.55;
|
||||
background: var(--surface-2);
|
||||
}
|
||||
|
||||
/* Sekundärbutton laut Styleguide: schlichter roter Rahmen, leichte Abrundung. */
|
||||
.button {
|
||||
padding: 9px 16px;
|
||||
border-radius: 8px;
|
||||
border: 1px solid var(--border);
|
||||
background: var(--surface-2);
|
||||
color: var(--text);
|
||||
border-radius: 6px;
|
||||
border: 1px solid var(--accent);
|
||||
background: transparent;
|
||||
color: var(--accent);
|
||||
font: inherit;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
/* Auch Links treten als Knopf auf (z. B. „Auftrag öffnen" im Anruf-Popup). */
|
||||
a.button {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.button:hover:not(:disabled) {
|
||||
border-color: var(--accent);
|
||||
background: var(--brand-soft);
|
||||
}
|
||||
|
||||
.button:disabled {
|
||||
@@ -172,13 +303,20 @@ code {
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
/* Primärbutton laut Styleguide: vollflächig im Markenrot. */
|
||||
.button--primary {
|
||||
background: var(--accent);
|
||||
border-color: var(--accent);
|
||||
background: var(--brand);
|
||||
border-color: var(--brand);
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.button--primary:hover:not(:disabled) {
|
||||
background: var(--brand-strong);
|
||||
border-color: var(--brand-strong);
|
||||
}
|
||||
|
||||
.button--ghost {
|
||||
border-color: transparent;
|
||||
background: transparent;
|
||||
padding: 5px 10px;
|
||||
font-size: 13px;
|
||||
@@ -200,21 +338,25 @@ code {
|
||||
|
||||
.tabs {
|
||||
display: flex;
|
||||
gap: 4px;
|
||||
gap: 18px;
|
||||
border-bottom: 1px solid var(--border);
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.tabs__button {
|
||||
padding: 9px 16px;
|
||||
border: 1px solid transparent;
|
||||
/* Die aktive Kante überdeckt die Linie der Leiste. */
|
||||
border-bottom: none;
|
||||
padding: 10px 2px;
|
||||
border: none;
|
||||
border-bottom: 3px solid transparent;
|
||||
margin-bottom: -1px;
|
||||
border-radius: 10px 10px 0 0;
|
||||
border-radius: 0;
|
||||
background: transparent;
|
||||
color: var(--text-muted);
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -223,9 +365,8 @@ code {
|
||||
}
|
||||
|
||||
.tabs__button--active {
|
||||
background: var(--surface);
|
||||
border-color: var(--border);
|
||||
color: var(--text);
|
||||
color: var(--accent);
|
||||
border-bottom-color: var(--brand);
|
||||
}
|
||||
|
||||
[role='tabpanel'] {
|
||||
@@ -286,75 +427,15 @@ code {
|
||||
}
|
||||
}
|
||||
|
||||
/* --- Eingehender Anruf --- */
|
||||
|
||||
.card--ringing {
|
||||
border-color: var(--ok);
|
||||
box-shadow: 0 0 0 1px var(--ok);
|
||||
}
|
||||
|
||||
.ringing {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.ringing__icon {
|
||||
font-size: 30px;
|
||||
animation: shake 1s ease-in-out infinite;
|
||||
}
|
||||
|
||||
.ringing__text {
|
||||
flex: 1 1 200px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.ringing__label {
|
||||
margin: 0;
|
||||
font-size: 12px;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.06em;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.ringing__number {
|
||||
margin: 2px 0 0;
|
||||
font-size: 22px;
|
||||
font-weight: 600;
|
||||
word-break: break-all;
|
||||
}
|
||||
|
||||
.ringing__meta {
|
||||
margin: 2px 0 0;
|
||||
color: var(--text-muted);
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.ringing__actions {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
@keyframes shake {
|
||||
25% {
|
||||
transform: rotate(-14deg);
|
||||
}
|
||||
75% {
|
||||
transform: rotate(14deg);
|
||||
}
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.ringing__icon {
|
||||
animation: none;
|
||||
}
|
||||
}
|
||||
|
||||
.button--accept {
|
||||
background: var(--ok);
|
||||
border-color: var(--ok);
|
||||
color: #06281c;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.button--accept:hover:not(:disabled) {
|
||||
background: var(--ok);
|
||||
opacity: 0.9;
|
||||
}
|
||||
|
||||
.button--reject {
|
||||
@@ -363,6 +444,11 @@ code {
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.button--reject:hover:not(:disabled) {
|
||||
background: var(--err);
|
||||
opacity: 0.9;
|
||||
}
|
||||
|
||||
.button--small {
|
||||
padding: 5px 12px;
|
||||
font-size: 13px;
|
||||
@@ -387,7 +473,7 @@ code {
|
||||
gap: 12px;
|
||||
flex-wrap: wrap;
|
||||
padding: 10px 12px;
|
||||
border-radius: 8px;
|
||||
border-radius: 6px;
|
||||
border: 1px solid var(--border);
|
||||
background: var(--surface-2);
|
||||
}
|
||||
@@ -400,7 +486,7 @@ code {
|
||||
.history__line {
|
||||
flex: 0 0 auto;
|
||||
padding: 2px 8px;
|
||||
border-radius: 6px;
|
||||
border-radius: 4px;
|
||||
background: var(--border);
|
||||
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
|
||||
font-size: 12px;
|
||||
@@ -463,8 +549,8 @@ code {
|
||||
}
|
||||
|
||||
.history__badge--ended {
|
||||
border-color: var(--err);
|
||||
color: var(--err);
|
||||
border-color: var(--border-strong);
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.history__number {
|
||||
@@ -492,7 +578,7 @@ code {
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
padding: 10px 12px;
|
||||
border-radius: 8px;
|
||||
border-radius: 6px;
|
||||
border: 1px solid var(--border);
|
||||
background: var(--surface-2);
|
||||
}
|
||||
@@ -504,7 +590,7 @@ code {
|
||||
.tablist__id {
|
||||
flex: 0 0 auto;
|
||||
padding: 2px 8px;
|
||||
border-radius: 6px;
|
||||
border-radius: 4px;
|
||||
background: var(--border);
|
||||
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
|
||||
font-size: 12px;
|
||||
@@ -561,15 +647,15 @@ code {
|
||||
.dialog {
|
||||
width: min(420px, 100%);
|
||||
padding: 24px;
|
||||
border-radius: 12px;
|
||||
border-radius: 8px;
|
||||
border: 1px solid var(--border);
|
||||
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 {
|
||||
margin: 0 0 16px;
|
||||
font-size: 18px;
|
||||
font-size: 17px;
|
||||
}
|
||||
|
||||
.dialog__status {
|
||||
@@ -585,20 +671,35 @@ code {
|
||||
.dialog__actions {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 8px;
|
||||
margin-top: 20px;
|
||||
}
|
||||
|
||||
/* Schiebt einen Knopf an den linken Rand der Aktionszeile („Anruf annehmen"). */
|
||||
.dialog__actions-start {
|
||||
margin-right: auto;
|
||||
}
|
||||
|
||||
/* Die Job-Knöpfe des Anruf-Popups: einer je Kennung, umbrechend. */
|
||||
.dialog__jobs {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
.progress {
|
||||
height: 8px;
|
||||
border-radius: 999px;
|
||||
background: var(--border);
|
||||
background: var(--surface-2);
|
||||
border: 1px solid var(--border);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.progress__bar {
|
||||
height: 100%;
|
||||
border-radius: 999px;
|
||||
background: var(--accent);
|
||||
background: var(--brand);
|
||||
transition: width 120ms linear;
|
||||
}
|
||||
|
||||
@@ -634,7 +735,7 @@ code {
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
padding: 10px 12px;
|
||||
border-radius: 8px;
|
||||
border-radius: 6px;
|
||||
border: 1px solid var(--border);
|
||||
background: var(--surface-2);
|
||||
}
|
||||
@@ -672,7 +773,7 @@ code {
|
||||
height: 300px;
|
||||
overflow-y: auto;
|
||||
padding: 12px;
|
||||
border-radius: 8px;
|
||||
border-radius: 6px;
|
||||
border: 1px solid var(--border);
|
||||
background: var(--surface-2);
|
||||
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
|
||||
@@ -732,7 +833,7 @@ code {
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
padding: 10px 12px;
|
||||
border-radius: 8px;
|
||||
border-radius: 6px;
|
||||
border: 1px solid var(--border);
|
||||
background: var(--surface-2);
|
||||
}
|
||||
@@ -753,7 +854,7 @@ code {
|
||||
|
||||
.webhook__id {
|
||||
padding: 1px 8px;
|
||||
border-radius: 6px;
|
||||
border-radius: 4px;
|
||||
background: var(--border);
|
||||
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
|
||||
font-size: 12px;
|
||||
@@ -772,7 +873,7 @@ code {
|
||||
.webhook__json {
|
||||
margin: 0;
|
||||
padding: 10px 12px;
|
||||
border-radius: 6px;
|
||||
border-radius: 4px;
|
||||
background: var(--surface);
|
||||
border: 1px solid var(--border);
|
||||
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
|
||||
@@ -789,8 +890,8 @@ code {
|
||||
margin-left: 8px;
|
||||
padding: 1px 7px;
|
||||
border-radius: 999px;
|
||||
background: var(--accent);
|
||||
color: var(--surface);
|
||||
background: var(--brand);
|
||||
color: #fff;
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
font-variant-numeric: tabular-nums;
|
||||
|
||||
@@ -0,0 +1,154 @@
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import { Job, fetchJob } from './jobs'
|
||||
|
||||
/**
|
||||
* Dieselbe Beispiel-Nutzlast wie in den Backend-Tests (JobEntryTests.java) –
|
||||
* beide Seiten verstehen denselben Webhook.
|
||||
*/
|
||||
const SAMPLE = `{
|
||||
"type": "job",
|
||||
"id": 21891263,
|
||||
"url": "https://test.sb.assecutor.de/admin/jb_detail.php?job_id=21891263",
|
||||
"state": 9,
|
||||
"ordertime": "2026-08-13T13:35:27",
|
||||
"orderdate": "2026-08-13",
|
||||
"modified": "2026-08-13T13:35:27",
|
||||
"finished": null,
|
||||
"vehicle": "Transporter XL",
|
||||
"service": null,
|
||||
"canceled": false,
|
||||
"global": false,
|
||||
"customer": { "csc_id": 100164, "name": "SYSGEN GmbH", "hq": "Bremen",
|
||||
"phone": "+491602107449" },
|
||||
"courier": null,
|
||||
"tours": [
|
||||
{ "id": 22396758, "sort": 1, "state": 0, "mode": "del", "comp": "SYSGEN GmbH",
|
||||
"person": "Frau Hoffmann", "phone": "+49421409660", "com": null,
|
||||
"remark": "~~~\\nHebebühne benötigt.\\n~~~", "street": "Am Hallacker 48",
|
||||
"zip": "28327", "city": "Bremen", "finished": null },
|
||||
{ "id": 22396759, "sort": 2, "state": 0, "mode": "pu",
|
||||
"comp": "Super Micro Computer B.V.", "person": null, "phone": null, "com": null,
|
||||
"remark": "Abholreferenz: 8801420234\\nAnzahl an Paletten: 8",
|
||||
"street": "Het Sterrenbeeld 12-16", "zip": "5215", "city": "'s-Hertogenbosch",
|
||||
"finished": null }
|
||||
]
|
||||
}`
|
||||
|
||||
describe('Job.isJob', () => {
|
||||
it('erkennt die Job-Nutzlast am Kennzeichen "type":"job"', () => {
|
||||
expect(Job.isJob(JSON.parse(SAMPLE))).toBe(true)
|
||||
expect(Job.isJob({ name: 'SYSGEN GmbH' })).toBe(false)
|
||||
expect(Job.isJob({ type: 'addresses', addresses: [] })).toBe(false)
|
||||
expect(Job.isJob(null)).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('Job.fromJson', () => {
|
||||
it('füllt alle Felder aus der Beispiel-Nutzlast', () => {
|
||||
const job = Job.fromJson(JSON.parse(SAMPLE))
|
||||
|
||||
expect(job.id).toBe(21891263)
|
||||
expect(job.url).toBe('https://test.sb.assecutor.de/admin/jb_detail.php?job_id=21891263')
|
||||
expect(job.state).toBe(9)
|
||||
expect(job.ordertime).toBe('2026-08-13T13:35:27')
|
||||
expect(job.orderdate).toBe('2026-08-13')
|
||||
expect(job.finished).toBeUndefined()
|
||||
expect(job.vehicle).toBe('Transporter XL')
|
||||
expect(job.service).toBeUndefined()
|
||||
expect(job.canceled).toBe(false)
|
||||
expect(job.global).toBe(false)
|
||||
|
||||
expect(job.customer?.cscId).toBe(100164)
|
||||
expect(job.customer?.name).toBe('SYSGEN GmbH')
|
||||
expect(job.customer?.hq).toBe('Bremen')
|
||||
expect(job.customer?.phone).toBe('+491602107449')
|
||||
|
||||
// Noch kein Kurier zugeteilt (in der Nutzlast null).
|
||||
expect(job.courier).toBeUndefined()
|
||||
|
||||
expect(job.tours).toHaveLength(2)
|
||||
const first = job.tours[0]
|
||||
expect(first.id).toBe(22396758)
|
||||
expect(first.sort).toBe(1)
|
||||
expect(first.mode).toBe('del')
|
||||
expect(first.person).toBe('Frau Hoffmann')
|
||||
expect(first.phone).toBe('+49421409660')
|
||||
expect(first.remark).toContain('Hebebühne benötigt.')
|
||||
expect(first.street).toBe('Am Hallacker 48')
|
||||
// Fehlende Rufnummer und leere Felder der zweiten Station bleiben undefined.
|
||||
expect(job.tours[1].phone).toBeUndefined()
|
||||
expect(job.tours[1].city).toBe("'s-Hertogenbosch")
|
||||
})
|
||||
|
||||
it('versteht die ältere Form mit "number" statt "phone"', () => {
|
||||
const job = Job.fromJson({
|
||||
type: 'job',
|
||||
id: 21891253,
|
||||
courier: { cr_id: 14116, sid: 'B1006', name: 'CA Kurier B1006', number: '+4917xxxxxxx' },
|
||||
tours: [{ id: 22396738, sort: 1, number: '+4942037010xx' }],
|
||||
})
|
||||
|
||||
expect(job.courier?.phone).toBe('+4917xxxxxxx')
|
||||
expect(job.tours[0].phone).toBe('+4942037010xx')
|
||||
})
|
||||
|
||||
it('kommt mit kargen Nutzlasten aus, verlangt aber die Kennung', () => {
|
||||
const bare = Job.fromJson({ id: 4711 })
|
||||
expect(bare.id).toBe(4711)
|
||||
expect(bare.customer).toBeUndefined()
|
||||
expect(bare.tours).toEqual([])
|
||||
|
||||
expect(() => Job.fromJson({ state: 1 })).toThrowError(/"id" fehlt/)
|
||||
expect(() => Job.fromJson('kein Objekt')).toThrowError(/"id" fehlt/)
|
||||
})
|
||||
})
|
||||
|
||||
describe('Job.toJson', () => {
|
||||
it('liefert wieder die Form des Webhooks', () => {
|
||||
const job = Job.fromJson(JSON.parse(SAMPLE))
|
||||
const json = job.toJson()
|
||||
|
||||
expect(json.type).toBe('job')
|
||||
expect(json.id).toBe(21891263)
|
||||
expect(json.canceled).toBe(false)
|
||||
expect((json.customer as Record<string, unknown>).csc_id).toBe(100164)
|
||||
expect((json.customer as Record<string, unknown>).phone).toBe('+491602107449')
|
||||
expect((json.tours as unknown[]).length).toBe(2)
|
||||
|
||||
// Einmal hin und zurück ändert nichts mehr.
|
||||
expect(Job.fromJson(json)).toEqual(job)
|
||||
})
|
||||
})
|
||||
|
||||
/**
|
||||
* Über diesen Weg öffnet das Anruf-Popup einen Job aus `job_ids` des Kunden:
|
||||
* Kennung → Job aus der Ablage → Sprungadresse (`url`).
|
||||
*/
|
||||
describe('fetchJob', () => {
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals()
|
||||
})
|
||||
|
||||
it('holt den Job zur Kennung vom Backend', async () => {
|
||||
const fetchMock = vi.fn().mockResolvedValue(new Response(SAMPLE, { status: 200 }))
|
||||
vi.stubGlobal('fetch', fetchMock)
|
||||
|
||||
const job = await fetchJob(21891263)
|
||||
|
||||
expect(fetchMock).toHaveBeenCalledWith('/api/jobs/21891263')
|
||||
expect(job.id).toBe(21891263)
|
||||
expect(job.url).toBe('https://test.sb.assecutor.de/admin/jb_detail.php?job_id=21891263')
|
||||
})
|
||||
|
||||
it('meldet eine unbekannte Kennung verständlich', async () => {
|
||||
vi.stubGlobal('fetch', vi.fn().mockResolvedValue(new Response('', { status: 404 })))
|
||||
|
||||
await expect(fetchJob(4711)).rejects.toThrowError(/Kein Job mit der Kennung 4711/)
|
||||
})
|
||||
|
||||
it('meldet eine nicht erreichbare Ablage verständlich', async () => {
|
||||
vi.stubGlobal('fetch', vi.fn().mockResolvedValue(new Response('', { status: 502 })))
|
||||
|
||||
await expect(fetchJob(4711)).rejects.toThrowError(/HTTP 502/)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,273 @@
|
||||
/**
|
||||
* Jobs (Kurieraufträge), wie sie das Fremdsystem über den Webhook schickt
|
||||
* (`POST /api/webhook/jobs` bzw. `"type":"job"` am generischen Webhook) und wie
|
||||
* sie das Backend in der MongoDB-Sammlung `jobs` ablegt (JobEntry.java).
|
||||
*
|
||||
* Die Klasse {@link Job} ist das typisierte Gegenstück im Browser:
|
||||
* {@link Job.fromJson} übernimmt beliebiges JSON und liefert einen geprüften
|
||||
* Job – unbekannte Felder werden übergangen, fehlende bleiben `undefined`.
|
||||
* {@link Job.toJson} liefert wieder die Form des Webhooks.
|
||||
*
|
||||
* Die Zeitangaben bleiben die Zeichenketten der Nutzlast
|
||||
* (z. B. `2026-08-13T13:35:27`, teils auch mit Zeitzonenversatz): ISO-8601
|
||||
* sortiert auch als Text richtig, und die Schreibweise des Fremdsystems geht
|
||||
* nicht verloren.
|
||||
*
|
||||
* Die Rufnummern hießen in einer älteren Form der Nutzlast `number` statt
|
||||
* `phone` – beide Schreibweisen werden angenommen.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Holt einen einzelnen Job über das Backend aus der Sammlung `jobs` – etwa zu
|
||||
* einer Kennung aus `job_ids` eines Kunden, um dessen Sprungadresse (`url`) zu
|
||||
* öffnen.
|
||||
*
|
||||
* @throws wenn die Kennung nicht abgelegt oder die Ablage nicht erreichbar ist
|
||||
*/
|
||||
export async function fetchJob(id: number): Promise<Job> {
|
||||
const response = await fetch(`/api/jobs/${id}`)
|
||||
if (response.status === 404) {
|
||||
throw new Error(`Kein Job mit der Kennung ${id} in der Ablage.`)
|
||||
}
|
||||
if (!response.ok) {
|
||||
throw new Error(`Job-Ablage nicht abrufbar (/api/jobs/${id} antwortete mit HTTP ${response.status}).`)
|
||||
}
|
||||
return Job.fromJson(await response.json())
|
||||
}
|
||||
|
||||
/** Zeichenkette der Nutzlast; leer, `null` oder fehlend wird `undefined`. */
|
||||
function text(value: unknown): string | undefined {
|
||||
return typeof value === 'string' && value !== '' ? value : undefined
|
||||
}
|
||||
|
||||
/** Ganzzahl der Nutzlast; alles andere wird `undefined`. */
|
||||
function integer(value: unknown): number | undefined {
|
||||
return typeof value === 'number' && Number.isFinite(value) ? value : undefined
|
||||
}
|
||||
|
||||
/** Wahrheitswert der Nutzlast; alles andere wird `undefined`. */
|
||||
function bool(value: unknown): boolean | undefined {
|
||||
return typeof value === 'boolean' ? value : undefined
|
||||
}
|
||||
|
||||
function record(value: unknown): Record<string, unknown> {
|
||||
return typeof value === 'object' && value !== null ? (value as Record<string, unknown>) : {}
|
||||
}
|
||||
|
||||
/** Der beauftragende Kunde. */
|
||||
export class JobCustomer {
|
||||
constructor(
|
||||
/** Kundenkennung des Fremdsystems (`csc_id`). */
|
||||
readonly cscId?: number,
|
||||
/** Firmenname. */
|
||||
readonly name?: string,
|
||||
/** Niederlassung, z. B. "Bremen". */
|
||||
readonly hq?: string,
|
||||
/** Rufnummer. */
|
||||
readonly phone?: string,
|
||||
) {}
|
||||
|
||||
static fromJson(value: unknown): JobCustomer {
|
||||
const json = record(value)
|
||||
return new JobCustomer(
|
||||
integer(json.csc_id),
|
||||
text(json.name),
|
||||
text(json.hq),
|
||||
text(json.phone) ?? text(json.number),
|
||||
)
|
||||
}
|
||||
|
||||
toJson(): Record<string, unknown> {
|
||||
return { csc_id: this.cscId, name: this.name, hq: this.hq, phone: this.phone }
|
||||
}
|
||||
}
|
||||
|
||||
/** Der ausführende Kurier. */
|
||||
export class JobCourier {
|
||||
constructor(
|
||||
/** Kurierkennung des Fremdsystems (`cr_id`). */
|
||||
readonly crId?: number,
|
||||
/** Kurzkennung, z. B. "B1006". */
|
||||
readonly sid?: string,
|
||||
/** Anzeigename. */
|
||||
readonly name?: string,
|
||||
/** Rufnummer. */
|
||||
readonly phone?: string,
|
||||
) {}
|
||||
|
||||
static fromJson(value: unknown): JobCourier {
|
||||
const json = record(value)
|
||||
return new JobCourier(
|
||||
integer(json.cr_id),
|
||||
text(json.sid),
|
||||
text(json.name),
|
||||
text(json.phone) ?? text(json.number),
|
||||
)
|
||||
}
|
||||
|
||||
toJson(): Record<string, unknown> {
|
||||
return { cr_id: this.crId, sid: this.sid, name: this.name, phone: this.phone }
|
||||
}
|
||||
}
|
||||
|
||||
/** Eine Station des Jobs. */
|
||||
export class JobTour {
|
||||
constructor(
|
||||
/** Kennung des Fremdsystems. */
|
||||
readonly id?: number,
|
||||
/** Reihenfolge innerhalb des Jobs, 1-basiert. */
|
||||
readonly sort?: number,
|
||||
/** Zustand der Station. */
|
||||
readonly state?: number,
|
||||
/** Art der Station, z. B. "pu" (Abholung) oder "del" (Zustellung). */
|
||||
readonly mode?: string,
|
||||
/** Firma an der Station. */
|
||||
readonly comp?: string,
|
||||
/** Ansprechperson. */
|
||||
readonly person?: string,
|
||||
/** Rufnummer an der Station, kann fehlen. */
|
||||
readonly phone?: string,
|
||||
/** Straße und Hausnummer. */
|
||||
readonly street?: string,
|
||||
/** Postleitzahl. */
|
||||
readonly zip?: string,
|
||||
/** Ort. */
|
||||
readonly city?: string,
|
||||
/** Bemerkung. */
|
||||
readonly com?: string,
|
||||
/** Hinweise des Fremdsystems, mehrzeilig (Referenzen, Maße …). */
|
||||
readonly remark?: string,
|
||||
/** Wann die Station abgeschlossen wurde, sonst leer. */
|
||||
readonly finished?: string,
|
||||
) {}
|
||||
|
||||
static fromJson(value: unknown): JobTour {
|
||||
const json = record(value)
|
||||
return new JobTour(
|
||||
integer(json.id),
|
||||
integer(json.sort),
|
||||
integer(json.state),
|
||||
text(json.mode),
|
||||
text(json.comp),
|
||||
text(json.person),
|
||||
text(json.phone) ?? text(json.number),
|
||||
text(json.street),
|
||||
text(json.zip),
|
||||
text(json.city),
|
||||
text(json.com),
|
||||
text(json.remark),
|
||||
text(json.finished),
|
||||
)
|
||||
}
|
||||
|
||||
toJson(): Record<string, unknown> {
|
||||
return {
|
||||
id: this.id,
|
||||
sort: this.sort,
|
||||
state: this.state,
|
||||
mode: this.mode,
|
||||
comp: this.comp,
|
||||
person: this.person,
|
||||
phone: this.phone,
|
||||
street: this.street,
|
||||
zip: this.zip,
|
||||
city: this.city,
|
||||
com: this.com,
|
||||
remark: this.remark,
|
||||
finished: this.finished,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Ein Job (Kurierauftrag) mit Kunde, Kurier und Stationen. */
|
||||
export class Job {
|
||||
constructor(
|
||||
/** Kennung des Fremdsystems – im Backend zugleich Schlüssel der Sammlung. */
|
||||
readonly id: number,
|
||||
/** Sprungadresse des Fremdsystems zur Auftragsansicht. */
|
||||
readonly url?: string,
|
||||
/** Zustand des Jobs im Fremdsystem. */
|
||||
readonly state?: number,
|
||||
/** Auftragszeit, z. B. "2026-08-13T13:35:27". */
|
||||
readonly ordertime?: string,
|
||||
/** Auftragsdatum, z. B. "2026-08-13". */
|
||||
readonly orderdate?: string,
|
||||
/** Letzte Änderung im Fremdsystem. */
|
||||
readonly modified?: string,
|
||||
/** Wann der Job abgeschlossen wurde, sonst leer. */
|
||||
readonly finished?: string,
|
||||
/** Fahrzeugart, z. B. "Transporter XL". */
|
||||
readonly vehicle?: string,
|
||||
/** Gebuchte Leistung, kann fehlen. */
|
||||
readonly service?: string,
|
||||
/** Ist der Job storniert? */
|
||||
readonly canceled?: boolean,
|
||||
/** Bundesweite Vermittlung? */
|
||||
readonly global?: boolean,
|
||||
/** Der beauftragende Kunde. */
|
||||
readonly customer?: JobCustomer,
|
||||
/** Der ausführende Kurier, solange keiner zugeteilt ist `undefined`. */
|
||||
readonly courier?: JobCourier,
|
||||
/** Die Stationen des Jobs, in der Reihenfolge der Nutzlast (`sort`). */
|
||||
readonly tours: JobTour[] = [],
|
||||
) {}
|
||||
|
||||
/** Trägt die Nutzlast das Kennzeichen `"type":"job"`? */
|
||||
static isJob(value: unknown): boolean {
|
||||
return record(value).type === 'job'
|
||||
}
|
||||
|
||||
/**
|
||||
* Füllt die Klasse aus der Webhook-Nutzlast.
|
||||
*
|
||||
* @throws wenn die Kennung fehlt – ohne sie ist es kein Job
|
||||
*/
|
||||
static fromJson(value: unknown): Job {
|
||||
const json = record(value)
|
||||
const id = integer(json.id)
|
||||
if (id === undefined) {
|
||||
throw new Error('Nutzlast ist kein Job – das Feld "id" fehlt.')
|
||||
}
|
||||
const tours = Array.isArray(json.tours) ? json.tours.map(JobTour.fromJson) : []
|
||||
// `null` heißt: noch kein Kurier zugeteilt – dann bleibt es undefined.
|
||||
const hasCustomer = json.customer !== undefined && json.customer !== null
|
||||
const hasCourier = json.courier !== undefined && json.courier !== null
|
||||
return new Job(
|
||||
id,
|
||||
text(json.url),
|
||||
integer(json.state),
|
||||
text(json.ordertime),
|
||||
text(json.orderdate),
|
||||
text(json.modified),
|
||||
text(json.finished),
|
||||
text(json.vehicle),
|
||||
text(json.service),
|
||||
bool(json.canceled),
|
||||
bool(json.global),
|
||||
hasCustomer ? JobCustomer.fromJson(json.customer) : undefined,
|
||||
hasCourier ? JobCourier.fromJson(json.courier) : undefined,
|
||||
tours,
|
||||
)
|
||||
}
|
||||
|
||||
/** Die Form des Webhooks, z. B. zum Weiterreichen oder Anzeigen. */
|
||||
toJson(): Record<string, unknown> {
|
||||
return {
|
||||
type: 'job',
|
||||
id: this.id,
|
||||
url: this.url,
|
||||
state: this.state,
|
||||
ordertime: this.ordertime,
|
||||
orderdate: this.orderdate,
|
||||
modified: this.modified,
|
||||
finished: this.finished,
|
||||
vehicle: this.vehicle,
|
||||
service: this.service,
|
||||
canceled: this.canceled,
|
||||
global: this.global,
|
||||
customer: this.customer?.toJson(),
|
||||
courier: this.courier?.toJson(),
|
||||
tours: this.tours.map((tour) => tour.toJson()),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,8 +1,12 @@
|
||||
import { StrictMode } from 'react'
|
||||
import { createRoot } from 'react-dom/client'
|
||||
import App from './App.tsx'
|
||||
import { applyBrand, loadBrand } from './brand'
|
||||
import './index.css'
|
||||
|
||||
// Vor dem ersten Rendern, damit die Seite nicht kurz in der falschen Marke aufblitzt.
|
||||
applyBrand(loadBrand())
|
||||
|
||||
const container = document.getElementById('root')
|
||||
if (!container) {
|
||||
throw new Error('Root-Element #root nicht gefunden')
|
||||
|
||||
+107
-52
@@ -1,12 +1,16 @@
|
||||
import { useEffect, useRef, useState, type FormEvent, type ReactNode } from 'react'
|
||||
import { DEFAULT_WS_URL, fetchClientConfig } from '../config'
|
||||
import { DEFAULT_WS_URL, fetchClientConfig, clearSavedWsUrl, loadSavedWsUrl, saveWsUrl } from '../config'
|
||||
import { useSwyxTray } from '../hooks/useSwyxTray'
|
||||
import { useCallNotifications } from '../hooks/useCallNotifications'
|
||||
import { useWebhook } from '../hooks/useWebhook'
|
||||
import { countUnseen } from '../webhook'
|
||||
import { applyBrand, loadBrand, type Brand } from '../brand'
|
||||
import { findCallerByNumber, type StoredAddress } from '../addresses'
|
||||
import StatusBadge from '../components/StatusBadge'
|
||||
import CustomerCallPopup from '../components/CustomerCallPopup'
|
||||
import StadtboteSignet from '../components/StadtboteSignet'
|
||||
import HansetransSignet from '../components/HansetransSignet'
|
||||
import MessageLog from '../components/MessageLog'
|
||||
import IncomingCallCard from '../components/IncomingCallCard'
|
||||
import DialPanel from '../components/DialPanel'
|
||||
import BrowserTabsPanel from '../components/BrowserTabsPanel'
|
||||
import ContactsPanel from '../components/ContactsPanel'
|
||||
@@ -37,7 +41,9 @@ export default function HomePage() {
|
||||
const [url, setUrl] = useState(DEFAULT_WS_URL)
|
||||
const [configNote, setConfigNote] = useState<string | null>(null)
|
||||
const [tab, setTab] = useState<TabName>('calls')
|
||||
const [draft, setDraft] = useState('')
|
||||
// Erscheinungsbild (CI/CD): STADTBOTE oder HANSETRANS, gemerkt über Neustarts.
|
||||
const [brand, setBrand] = useState<Brand>(loadBrand)
|
||||
useEffect(() => applyBrand(brand), [brand])
|
||||
|
||||
const tray = useSwyxTray()
|
||||
const { status, error, log, history, calls, ringingCall, busy, tray: appState } = tray
|
||||
@@ -46,6 +52,38 @@ export default function HomePage() {
|
||||
|
||||
const { permission, requestPermission } = useCallNotifications(ringingCall)
|
||||
|
||||
// Meldet SwyxIt! einen eingehenden Anruf, geht das Popup auf – mit den
|
||||
// Daten aus der Adress-Ablage, wenn die Rufnummer einem Kunden oder Kurier
|
||||
// gehört, sonst nur mit der Rufnummer. Je Anruf nur eine Abfrage – der
|
||||
// Snapshot kommt mehrfach.
|
||||
const [caller, setCaller] = useState<StoredAddress | null>(null)
|
||||
const lookedUpRef = useRef<string | null>(null)
|
||||
useEffect(() => {
|
||||
if (!ringingCall) {
|
||||
// Anruf vorbei: Der nächste – auch von derselben Nummer – zählt neu.
|
||||
lookedUpRef.current = null
|
||||
return
|
||||
}
|
||||
const number = ringingCall.peerNumber?.trim()
|
||||
if (!number) return
|
||||
const key = `${ringingCall.line}:${number}`
|
||||
if (lookedUpRef.current === key) return
|
||||
lookedUpRef.current = key
|
||||
let stale = false
|
||||
findCallerByNumber(number)
|
||||
.then((match) => {
|
||||
if (!stale) setCaller(match ?? { number })
|
||||
})
|
||||
.catch(() => {
|
||||
// Ablage nicht erreichbar – das Popup zeigt dann eben nur die
|
||||
// Rufnummer; annehmen lässt sich der Anruf trotzdem.
|
||||
if (!stale) setCaller({ number })
|
||||
})
|
||||
return () => {
|
||||
stale = true
|
||||
}
|
||||
}, [ringingCall])
|
||||
|
||||
// Läuft unabhängig vom geöffneten Bereich: Der Webhook hängt am Backend, nicht
|
||||
// an der SwyxTray-Verbindung, und soll auch dann mitschreiben, wenn gerade ein
|
||||
// anderer Bereich offen ist.
|
||||
@@ -67,13 +105,25 @@ export default function HomePage() {
|
||||
|
||||
// Der Benutzer soll seine eingetippte Adresse nicht durch die Backend-Antwort verlieren.
|
||||
const urlTouched = useRef(false)
|
||||
// Adresse des laufenden Verbindungsversuchs; gemerkt wird sie erst, wenn die
|
||||
// Verbindung tatsächlich zustande kommt.
|
||||
const pendingUrl = useRef<string | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
// Mit der zuletzt erfolgreich verbundenen Adresse direkt wieder verbinden –
|
||||
// die Backend-Konfiguration muss dann nicht gefragt werden.
|
||||
const saved = loadSavedWsUrl()
|
||||
if (saved) {
|
||||
urlTouched.current = true
|
||||
setUrl(saved)
|
||||
pendingUrl.current = saved
|
||||
tray.connect(saved)
|
||||
return
|
||||
}
|
||||
const controller = new AbortController()
|
||||
fetchClientConfig(controller.signal)
|
||||
.then((backendUrl) => {
|
||||
if (controller.signal.aborted) return
|
||||
setConfigNote(`Adresse vom Backend übernommen: ${backendUrl}`)
|
||||
if (!urlTouched.current) setUrl(backendUrl)
|
||||
})
|
||||
.catch((e: unknown) => {
|
||||
@@ -82,42 +132,63 @@ export default function HomePage() {
|
||||
setConfigNote(`Backend-Konfiguration nicht abrufbar (${message}) – Standardadresse wird verwendet.`)
|
||||
})
|
||||
return () => controller.abort()
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps -- nur beim Laden der Seite
|
||||
}, [])
|
||||
|
||||
// Erst die zustande gekommene Verbindung zählt: dann die Adresse merken, damit
|
||||
// sie nach Verbindungsverlust oder Neustart automatisch wieder aufgebaut wird.
|
||||
useEffect(() => {
|
||||
if (status === 'open' && pendingUrl.current) saveWsUrl(pendingUrl.current)
|
||||
}, [status])
|
||||
|
||||
function handleConnect(event: FormEvent) {
|
||||
event.preventDefault()
|
||||
const target = url.trim()
|
||||
if (target) tray.connect(target)
|
||||
if (!target) return
|
||||
pendingUrl.current = target
|
||||
tray.connect(target)
|
||||
}
|
||||
|
||||
function handleSendRaw(event: FormEvent) {
|
||||
event.preventDefault()
|
||||
const message = draft.trim()
|
||||
if (message && tray.sendRaw(message)) setDraft('')
|
||||
// Manuelles Trennen heißt auch: nach einem Neustart nicht wieder verbinden.
|
||||
function handleDisconnect() {
|
||||
clearSavedWsUrl()
|
||||
pendingUrl.current = null
|
||||
tray.disconnect()
|
||||
}
|
||||
|
||||
|
||||
return (
|
||||
<div className="page">
|
||||
<header className="page__header">
|
||||
<div className="brand">
|
||||
{brand === 'hansetrans' ? (
|
||||
<HansetransSignet className="brand__signet" />
|
||||
) : (
|
||||
<StadtboteSignet className="brand__signet" />
|
||||
)}
|
||||
<div>
|
||||
<h1>SwyxWeb</h1>
|
||||
<p className="page__subtitle">Telefonie über die SwyxTray-App</p>
|
||||
<h1 className="brand__name">{brand === 'hansetrans' ? 'HANSETRANS' : 'Stadtbote'}</h1>
|
||||
<p className="page__subtitle">SwyxWeb</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="page__header-tools">
|
||||
{/* Erscheinungsbild (CI/CD) umschalten – im Badge-Stil, damit es
|
||||
neben dem Verbindungsstatus nicht aus der Reihe fällt. */}
|
||||
<select
|
||||
className="brand-switch"
|
||||
aria-label="Erscheinungsbild"
|
||||
title="Erscheinungsbild"
|
||||
value={brand}
|
||||
onChange={(e) => setBrand(e.target.value as Brand)}
|
||||
>
|
||||
<option value="stadtbote">STADTBOTE</option>
|
||||
<option value="hansetrans">HANSETRANS</option>
|
||||
</select>
|
||||
<StatusBadge status={status} />
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<main className="page__main">
|
||||
{/* Steht bewusst über den Tabs: Ein klingelnder Anruf darf nicht davon
|
||||
abhängen, welcher Bereich gerade offen ist. */}
|
||||
{ringingCall && (
|
||||
<IncomingCallCard
|
||||
call={ringingCall}
|
||||
busy={busy}
|
||||
onAnswer={(line) => void tray.answer(line)}
|
||||
onHangup={(line) => void tray.hangup(line)}
|
||||
/>
|
||||
)}
|
||||
|
||||
<Tabs tabs={tabsWithBadges} active={tab} onChange={setTab} />
|
||||
|
||||
<TabPanel id="calls" active={tab === 'calls'}>
|
||||
@@ -161,9 +232,6 @@ export default function HomePage() {
|
||||
active={tab === 'contacts'}
|
||||
disabled={!isConnected}
|
||||
cache={tray.addressCache}
|
||||
// Unverpackt weitergereicht: der Rückruf ist stabil, damit das
|
||||
// Panel nicht bei jedem Rendern neu lädt.
|
||||
onLoad={tray.loadDirectory}
|
||||
onDial={(number) => void tray.dial(number)}
|
||||
/>
|
||||
{error && <p className="note note--error">{error}</p>}
|
||||
@@ -224,7 +292,7 @@ export default function HomePage() {
|
||||
<button
|
||||
type="button"
|
||||
className="button"
|
||||
onClick={tray.disconnect}
|
||||
onClick={handleDisconnect}
|
||||
disabled={!isBusyConnection}
|
||||
>
|
||||
Trennen
|
||||
@@ -247,26 +315,8 @@ export default function HomePage() {
|
||||
<section className="card">
|
||||
<h2>Diagnose</h2>
|
||||
<MessageLog entries={log} />
|
||||
<form className="row" onSubmit={handleSendRaw}>
|
||||
<label className="field">
|
||||
<span className="field__label">Rohnachricht senden</span>
|
||||
<input
|
||||
className="field__input"
|
||||
type="text"
|
||||
value={draft}
|
||||
disabled={!isConnected}
|
||||
placeholder={'{"id":1,"cmd":"status"}'}
|
||||
onChange={(e) => setDraft(e.target.value)}
|
||||
/>
|
||||
</label>
|
||||
<div className="row">
|
||||
<div className="row__actions">
|
||||
<button
|
||||
type="submit"
|
||||
className="button button--primary"
|
||||
disabled={!isConnected || !draft.trim()}
|
||||
>
|
||||
Senden
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="button"
|
||||
@@ -284,17 +334,22 @@ export default function HomePage() {
|
||||
Log leeren
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</section>
|
||||
</TabPanel>
|
||||
</main>
|
||||
|
||||
<footer className="page__footer">
|
||||
SwyxTray · Kommandos <code>call</code> / <code>answer</code> / <code>hangup</code> /{' '}
|
||||
<code>tabs</code> / <code>opentab</code> / <code>closetab</code> / <code>contacts</code> ·
|
||||
Zustand über{' '}
|
||||
<code>snapshot</code> · Standardziel <code>{DEFAULT_WS_URL}</code>
|
||||
</footer>
|
||||
{caller && (
|
||||
<CustomerCallPopup
|
||||
customer={caller}
|
||||
// Nur solange es klingelt: Danach fällt der Annehmen-Knopf im Popup
|
||||
// von selbst weg, die Kundendaten und Job-Knöpfe bleiben stehen.
|
||||
onAnswer={
|
||||
ringingCall && !busy ? () => void tray.answer(ringingCall.line) : undefined
|
||||
}
|
||||
onClose={() => setCaller(null)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user