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:
@@ -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. */
|
||||
|
||||
Reference in New Issue
Block a user