Adressdaten-Suche direkt in der MongoDB; Sammlung auf Nummer, Name, Beschreibung verschlankt

Die Sammlung addresses führt für alle Quellen (Webhooks Kunden/Kuriere,
SwyxTray-Telefonbuch) nur noch Rufnummer, Name und Beschreibung; alte
Felder receivedAt/source werden beim Upsert entfernt. GET /api/addresses
sucht mit ?q= als Teilzeichenkette in allen drei Feldern. Der Bereich
"Adressdaten" hält keinen Bestand mehr im Browser: Jede Eingabe fragt
entprellt die Datenbank ab, das Suchfeld startet leer.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-27 17:07:48 +02:00
co-authored by Claude Fable 5
parent 715720fbb3
commit a9b40c7e6a
9 changed files with 275 additions and 230 deletions
@@ -1,6 +1,5 @@
package de.appcreation.swyxweb.storage;
import java.time.Instant;
import java.util.ArrayList;
import java.util.List;
@@ -32,20 +31,15 @@ import org.springframework.data.mongodb.core.mapping.Document;
*
* @param id Kennung des Dokuments, vergibt die MongoDB
* @param number Rufnummer, so wie sie gewählt wird erkennt die Dublette
* @param receivedAt Eingangszeit im Backend, bei bekannten Einträgen die des
* letzten Eingangs
* @param source woher der Eintrag stammt: {@code kunden}, {@code kuriere},
* {@code webhook} (generischer Webhook) oder {@code swyxtray}
* @param name Anzeigename, z. B. "SYSGEN GmbH" oder "Rainer Peters (HH1003)"
* @param description Zusatz, z. B. "Kunde | SYSGEN, SYSTEME UND | NL Bremen | csc 100164"
* oder "Kurier | NL Hamburg | PKW | cr 2024"
* oder "Kurier | NL Hamburg | PKW | cr 2024" die Herkunft
* eines Eintrags steht damit im Eintrag selbst
*/
@Document("addresses")
public record AddressEntry(
@Id String id,
String number,
Instant receivedAt,
String source,
String name,
String description) {
@@ -72,7 +66,7 @@ public record AddressEntry(
if (!node.isObject()) return;
String number = text(node, "number");
if (number == null) return;
entries.add(new AddressEntry(null, number, null, null, text(node, "name"), text(node, "description")));
entries.add(new AddressEntry(null, number, text(node, "name"), text(node, "description")));
}
/** Leere Zeichenketten schickt die SwyxTray-App als {@code ""}; hier werden sie {@code null}. */
@@ -1,8 +1,8 @@
package de.appcreation.swyxweb.storage;
import java.time.Instant;
import java.util.List;
import java.util.concurrent.ExecutorService;
import java.util.regex.Pattern;
import java.util.concurrent.Executors;
import jakarta.annotation.PreDestroy;
@@ -50,32 +50,46 @@ public class ArchiveService {
}
/**
* Legt Adressdaten-Einträge in der Sammlung {@code addresses} ab. Die
* Sammlung ist ein Verzeichnis, kein Protokoll: Ein Eintrag zu einer
* bekannten Rufnummer wird aktualisiert statt verdoppelt die
* SwyxTray-App schickt bei jeder Cache-Änderung das ganze Telefonbuch.
*
* @param receivedAt Eingangszeit im Backend
* @param source {@code kunden}, {@code kuriere}, {@code webhook}
* (generischer Webhook) oder {@code swyxtray}
* 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(Instant receivedAt, String source, List<AddressEntry> entries) {
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("receivedAt", receivedAt)
.set("source", source)
.set("name", entry.name())
.set("description", entry.description()),
.set("description", entry.description())
// 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. Liest direkt aus der MongoDB. */
public List<AddressEntry> addresses() {
return mongo.find(new Query().with(Sort.by("name")), 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);
}
/**
@@ -1,6 +1,5 @@
package de.appcreation.swyxweb.web;
import java.time.Instant;
import java.util.List;
import tools.jackson.databind.JsonNode;
@@ -11,6 +10,7 @@ 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;
@@ -53,13 +53,17 @@ public class AddressController {
if (entries.isEmpty()) {
throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "Keine Adressdaten in der Nutzlast.");
}
archive.saveAddresses(Instant.now(), "swyxtray", entries);
archive.saveAddresses(entries);
return entries.size();
}
/** Die gespeicherten Adressdaten gelesen direkt aus der MongoDB. */
/**
* 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() {
return archive.addresses();
public List<AddressEntry> list(@RequestParam(name = "q", required = false) String query) {
return archive.addresses(query);
}
}
@@ -81,7 +81,7 @@ public class WebhookController {
return accept(payload, token, null);
}
/** Adressdaten der Kunden; abgelegt mit {@code source: "kunden"}. */
/** Adressdaten der Kunden; abgelegt in der Sammlung {@code addresses}. */
@PostMapping(path = "/kunden", consumes = MediaType.APPLICATION_JSON_VALUE)
public Receipt receiveKunden(
@RequestBody JsonNode payload,
@@ -89,7 +89,7 @@ public class WebhookController {
return accept(payload, token, "kunden");
}
/** Adressdaten der Kuriere; abgelegt mit {@code source: "kuriere"}. */
/** Adressdaten der Kuriere; abgelegt in der Sammlung {@code addresses}. */
@PostMapping(path = "/kuriere", consumes = MediaType.APPLICATION_JSON_VALUE)
public Receipt receiveKuriere(
@RequestBody JsonNode payload,
@@ -133,22 +133,21 @@ public class WebhookService {
}
/**
* Die URL bestimmt die Ablage: Kunden und Kuriere sind Adressdaten (die
* Herkunft wird als {@code source} vermerkt), Jobs sind Jobs. Beim
* generischen Webhook ({@code channel == null}) entscheidet die Nutzlast
* selbst: {@code "type":"job"} ist ein Job, alles andere sind Adressdaten.
* 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(event.receivedAt(), channel, AddressEntry.manyFrom(payload));
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(event.receivedAt(), "webhook", AddressEntry.manyFrom(payload));
archive.saveAddresses(AddressEntry.manyFrom(payload));
}
}
}
@@ -1,13 +1,15 @@
package de.appcreation.swyxweb;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.argThat;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.Mockito.verify;
import static org.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;
@@ -16,6 +18,7 @@ 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;
/**
@@ -44,7 +47,21 @@ class AddressControllerTests {
.andExpect(status().isAccepted())
.andExpect(jsonPath("$").value(2));
verify(archive).saveAddresses(any(), eq("swyxtray"), argThat(entries -> entries.size() == 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)));
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
@@ -1,9 +1,7 @@
package de.appcreation.swyxweb;
import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.argThat;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.Mockito.verify;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.delete;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
@@ -118,8 +116,10 @@ class WebhookControllerTests {
.containsExactly("kunden", "kuriere", "jobs");
// Kunden und Kuriere landen als Adressdaten in der Ablage, der Job als Job.
verify(archive).saveAddresses(any(), eq("kunden"), argThat(entries -> entries.size() == 1));
verify(archive).saveAddresses(any(), eq("kuriere"), argThat(entries -> entries.size() == 1));
verify(archive).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));
}
@@ -132,7 +132,7 @@ class WebhookControllerTests {
.andExpect(status().isOk());
assertThat(service.history().getFirst().channel()).isNull();
verify(archive).saveAddresses(any(), eq("webhook"), argThat(entries -> entries.size() == 1));
verify(archive).saveAddresses(argThat(entries -> entries.size() == 1));
}
/** Ohne Token in der Konfiguration darf der Kopf fehlen. */