From 7b3c1879853702e163f5cb234a3041f964d86100 Mon Sep 17 00:00:00 2001 From: Sven Carstensen Date: Mon, 2 Nov 2026 15:14:27 +0100 Subject: [PATCH] =?UTF-8?q?Kartenlinks=20f=C3=BCr=20Jobs=20speichern=20und?= =?UTF-8?q?=20im=20Anruf-Popup=20=C3=B6ffnen?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 15 ++++++ .../appcreation/swyxweb/storage/JobEntry.java | 4 +- .../swyxweb/JobControllerTests.java | 4 +- .../de/appcreation/swyxweb/JobEntryTests.java | 46 +++++++++++++++++++ .../swyxweb/WebhookControllerTests.java | 17 +++++++ frontend/src/components/CustomerCallPopup.tsx | 43 +++++++++++++---- frontend/src/index.css | 8 +++- frontend/src/jobs.test.ts | 10 ++++ frontend/src/jobs.ts | 16 +++++++ 9 files changed, 150 insertions(+), 13 deletions(-) diff --git a/README.md b/README.md index cab6e9a..303504e 100644 --- a/README.md +++ b/README.md @@ -17,6 +17,21 @@ Anrufen, Adressdaten, Webhook, Tabs und Verbindung ein. Er ist beim Laden der Se ausgeschaltet. Beim Umschalten bleiben Eingaben und die SwyxTray-Verbindung erhalten; Anruf-Popups und Benachrichtigungen funktionieren auch im Dashboard. +Job-Datensätze unterstützen zusätzlich zur Auftragsadresse `url` die optionale +Kartenadresse `map_url`, beispielsweise +`"map_url": "https://vhs.assecutor.org/?jb=21891233&cr=13385"`. +Ist eine HTTP(S)-Kartenadresse vorhanden, erscheint im Anruf-Popup neben dem +zugeordneten Job **Karte öffnen**. Ein Klick öffnet diese Adresse in einem neuen +Browser-Tab. Bei fehlender, leerer oder ungültiger Adresse bleibt der Knopf ausgeblendet. + +Das Backend übernimmt `map_url` sowohl über `POST /api/webhook/jobs` als auch über +`POST /api/webhook` mit `"type": "job"` in die MongoDB-Sammlung `jobs`. Dort heißt +das Feld ebenfalls `map_url`; `GET /api/jobs/{id}` gibt den gespeicherten Wert zurück. +Ein erneut gesendeter Job ersetzt den bisherigen Datensatz einschließlich der +Kartenadresse. Bestehende Jobs ohne Kartenadresse bleiben lesbar; zum Ergänzen +die vollständigen Jobdaten mit `map_url` erneut senden. Eine Schema-Migration ist +nicht erforderlich. + ``` SwyxWeb/ ├── backend/ Spring Boot 4.1 (Java 21, Maven Wrapper) diff --git a/backend/src/main/java/de/appcreation/swyxweb/storage/JobEntry.java b/backend/src/main/java/de/appcreation/swyxweb/storage/JobEntry.java index 18f1771..2385534 100644 --- a/backend/src/main/java/de/appcreation/swyxweb/storage/JobEntry.java +++ b/backend/src/main/java/de/appcreation/swyxweb/storage/JobEntry.java @@ -34,6 +34,7 @@ import org.springframework.data.mongodb.core.mapping.Field; * @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 mapUrl optionale Kartenadresse des Jobs ({@code map_url}) * @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" @@ -53,6 +54,7 @@ public record JobEntry( @Id long id, Instant receivedAt, String url, + @JsonProperty("map_url") @Field("map_url") String mapUrl, Integer state, String ordertime, String orderdate, @@ -142,7 +144,7 @@ public record JobEntry( /** 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(), + return new JobEntry(job.id(), receivedAt, job.url(), job.mapUrl(), job.state(), job.ordertime(), job.orderdate(), job.modified(), job.finished(), job.vehicle(), job.service(), job.canceled(), job.global(), job.customer(), job.courier(), job.tours()); } diff --git a/backend/src/test/java/de/appcreation/swyxweb/JobControllerTests.java b/backend/src/test/java/de/appcreation/swyxweb/JobControllerTests.java index ff91f2a..0d892f4 100644 --- a/backend/src/test/java/de/appcreation/swyxweb/JobControllerTests.java +++ b/backend/src/test/java/de/appcreation/swyxweb/JobControllerTests.java @@ -35,12 +35,14 @@ class JobControllerTests { 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", + "https://vhs.assecutor.org/?jb=21891263&cr=13385", 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")); + .andExpect(jsonPath("$.url").value("https://test.sb.assecutor.de/admin/jb_detail.php?job_id=21891263")) + .andExpect(jsonPath("$.map_url").value("https://vhs.assecutor.org/?jb=21891263&cr=13385")); } @Test diff --git a/backend/src/test/java/de/appcreation/swyxweb/JobEntryTests.java b/backend/src/test/java/de/appcreation/swyxweb/JobEntryTests.java index d312982..ae41cd3 100644 --- a/backend/src/test/java/de/appcreation/swyxweb/JobEntryTests.java +++ b/backend/src/test/java/de/appcreation/swyxweb/JobEntryTests.java @@ -7,7 +7,12 @@ import java.time.Instant; import tools.jackson.databind.JsonNode; import tools.jackson.databind.ObjectMapper; +import org.bson.Document; import org.junit.jupiter.api.Test; +import org.springframework.data.mongodb.core.convert.MappingMongoConverter; +import org.springframework.data.mongodb.core.convert.MongoCustomConversions; +import org.springframework.data.mongodb.core.convert.NoOpDbRefResolver; +import org.springframework.data.mongodb.core.mapping.MongoMappingContext; import de.appcreation.swyxweb.storage.JobEntry; @@ -26,6 +31,7 @@ class JobEntryTests { "type": "job", "id": 21891263, "url": "https://test.sb.assecutor.de/admin/jb_detail.php?job_id=21891263", + "map_url": "https://vhs.assecutor.org/?jb=21891263&cr=13385", "state": 9, "ordertime": "2026-08-13T13:35:27", "orderdate": "2026-08-13", @@ -68,6 +74,7 @@ class JobEntryTests { 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.mapUrl()).isEqualTo("https://vhs.assecutor.org/?jb=21891263&cr=13385"); assertThat(job.state()).isEqualTo(9); assertThat(job.ordertime()).isEqualTo("2026-08-13T13:35:27"); assertThat(job.orderdate()).isEqualTo("2026-08-13"); @@ -114,6 +121,45 @@ class JobEntryTests { JobEntry job = JobEntry.from(json, mapper, Instant.parse("2026-08-28T10:00:00Z")); assertThat(job.courier().phone()).isEqualTo("+4917xxxxxxx"); + assertThat(job.mapUrl()).isNull(); assertThat(job.tours().getFirst().phone()).isEqualTo("+4942037010xx"); } + + /** Prüft den echten MongoDB-Konverter, ohne eine externe Datenbank zu verändern. */ + @Test + void storesAndReadsMapUrlUnderItsDatabaseFieldName() throws Exception { + MappingMongoConverter converter = mongoConverter(); + JobEntry job = JobEntry.from(mapper.readTree(SAMPLE), mapper, Instant.parse("2026-08-28T10:00:00Z")); + Document document = new Document(); + + converter.write(job, document); + + assertThat(document).containsEntry("_id", job.id()) + .containsEntry("map_url", "https://vhs.assecutor.org/?jb=21891263&cr=13385") + .doesNotContainKey("mapUrl"); + assertThat(converter.read(JobEntry.class, document)).isEqualTo(job); + } + + @Test + void readsExistingDatabaseJobsWithoutMapUrl() throws Exception { + MappingMongoConverter converter = mongoConverter(); + Document document = new Document("_id", 21891233L).append("state", 1); + + JobEntry job = converter.read(JobEntry.class, document); + + assertThat(job.id()).isEqualTo(21891233L); + assertThat(job.mapUrl()).isNull(); + assertThat(converter.read(JobEntry.class, document.append("map_url", null)).mapUrl()).isNull(); + } + + private MappingMongoConverter mongoConverter() throws Exception { + MongoCustomConversions conversions = MongoCustomConversions.create(adapter -> {}); + MongoMappingContext context = new MongoMappingContext(); + context.setSimpleTypeHolder(conversions.getSimpleTypeHolder()); + context.afterPropertiesSet(); + MappingMongoConverter converter = new MappingMongoConverter(NoOpDbRefResolver.INSTANCE, context); + converter.setCustomConversions(conversions); + converter.afterPropertiesSet(); + return converter; + } } diff --git a/backend/src/test/java/de/appcreation/swyxweb/WebhookControllerTests.java b/backend/src/test/java/de/appcreation/swyxweb/WebhookControllerTests.java index d21c5e5..0a912c2 100644 --- a/backend/src/test/java/de/appcreation/swyxweb/WebhookControllerTests.java +++ b/backend/src/test/java/de/appcreation/swyxweb/WebhookControllerTests.java @@ -14,6 +14,8 @@ import de.appcreation.swyxweb.webhook.WebhookService; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Nested; import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.ValueSource; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.webmvc.test.autoconfigure.AutoConfigureMockMvc; import org.springframework.boot.test.context.SpringBootTest; @@ -134,6 +136,21 @@ class WebhookControllerTests { verify(archive).saveAddresses(argThat(entries -> entries.size() == 1)); } + @ParameterizedTest + @ValueSource(strings = {"/api/webhook", "/api/webhook/jobs"}) + void passesMapUrlToJobStorage(String endpoint) throws Exception { + mvc.perform(post(endpoint) + .contentType(MediaType.APPLICATION_JSON) + .content(""" + {"type":"job","id":21891233, + "map_url":"https://vhs.assecutor.org/?jb=21891233&cr=13385"} + """)) + .andExpect(status().isOk()); + + verify(archive).saveJob(argThat(job -> job.id() == 21891233L + && "https://vhs.assecutor.org/?jb=21891233&cr=13385".equals(job.mapUrl()))); + } + /** Ohne Token in der Konfiguration darf der Kopf fehlen. */ @Test void tokenIsNotRequiredWhenUnset() throws Exception { diff --git a/frontend/src/components/CustomerCallPopup.tsx b/frontend/src/components/CustomerCallPopup.tsx index 7687b2e..91176eb 100644 --- a/frontend/src/components/CustomerCallPopup.tsx +++ b/frontend/src/components/CustomerCallPopup.tsx @@ -18,7 +18,8 @@ interface Props { * 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 ``). + * Wartedialog. Ist `map_url` gesetzt, öffnet „Karte öffnen" die zugehörige Karte. + * Siehe LoadingDialog zur Begründung gegen ``. */ export default function CustomerCallPopup({ customer, onAnswer, onClose }: Props) { const closeRef = useRef(null) @@ -27,6 +28,20 @@ export default function CustomerCallPopup({ customer, onAnswer, onClose }: Props const [jobNote, setJobNote] = useState(null) // Kennung des Jobs, der gerade geholt wird; sperrt derweil alle Job-Knöpfe. const [busyJobId, setBusyJobId] = useState(null) + const [mapUrls, setMapUrls] = useState>({}) + + // Kartenadressen vorab laden, damit der optionale Knopf schon im Popup + // verfügbar ist. Die vorhandene Job-Aktion bleibt auch bei Ladefehlern nutzbar. + useEffect(() => { + let active = true + setMapUrls({}) + for (const jobId of customer.jobIds ?? []) { + void fetchJob(jobId).then((job) => { + if (active && job.mapUrl) setMapUrls((current) => ({ ...current, [jobId]: job.mapUrl! })) + }).catch(() => undefined) + } + return () => { active = false } + }, [customer]) // Fokus in den Dialog holen, damit Escape sofort greift. useEffect(() => { @@ -78,15 +93,23 @@ export default function CustomerCallPopup({ customer, onAnswer, onClose }: Props

Zugeordnete Jobs

{jobIds.map((jobId) => ( - +
+ + {mapUrls[jobId] && ( + + Karte öffnen + + )} +
))}
diff --git a/frontend/src/index.css b/frontend/src/index.css index e0d64e0..a9b2311 100644 --- a/frontend/src/index.css +++ b/frontend/src/index.css @@ -871,7 +871,7 @@ a.button { margin-right: auto; } -/* Die Job-Knöpfe des Anruf-Popups: einer je Kennung, umbrechend. */ +/* Je Job bleiben Auftrags- und optionaler Kartenknopf zusammen. */ .dialog__jobs { display: flex; flex-wrap: wrap; @@ -879,6 +879,12 @@ a.button { margin-top: 8px; } +.dialog__job { + display: flex; + flex-wrap: wrap; + gap: 8px; +} + .progress { height: 8px; border-radius: 999px; diff --git a/frontend/src/jobs.test.ts b/frontend/src/jobs.test.ts index 22d286d..fab1f13 100644 --- a/frontend/src/jobs.test.ts +++ b/frontend/src/jobs.test.ts @@ -9,6 +9,7 @@ const SAMPLE = `{ "type": "job", "id": 21891263, "url": "https://test.sb.assecutor.de/admin/jb_detail.php?job_id=21891263", + "map_url": "https://vhs.assecutor.org/?jb=21891263&cr=13385", "state": 9, "ordertime": "2026-08-13T13:35:27", "orderdate": "2026-08-13", @@ -49,6 +50,7 @@ describe('Job.fromJson', () => { expect(job.id).toBe(21891263) expect(job.url).toBe('https://test.sb.assecutor.de/admin/jb_detail.php?job_id=21891263') + expect(job.mapUrl).toBe('https://vhs.assecutor.org/?jb=21891263&cr=13385') expect(job.state).toBe(9) expect(job.ordertime).toBe('2026-08-13T13:35:27') expect(job.orderdate).toBe('2026-08-13') @@ -95,12 +97,18 @@ describe('Job.fromJson', () => { it('kommt mit kargen Nutzlasten aus, verlangt aber die Kennung', () => { const bare = Job.fromJson({ id: 4711 }) expect(bare.id).toBe(4711) + expect(bare.mapUrl).toBeUndefined() expect(bare.customer).toBeUndefined() expect(bare.tours).toEqual([]) expect(() => Job.fromJson({ state: 1 })).toThrowError(/"id" fehlt/) expect(() => Job.fromJson('kein Objekt')).toThrowError(/"id" fehlt/) }) + + it.each([undefined, null, '', ' ', 123, 'keine URL', 'javascript:alert(1)', 'data:text/html,test']) + ('öffnet keinen Kartenlink für map_url=%s', (map_url) => { + expect(Job.fromJson({ id: 21891233, map_url }).mapUrl).toBeUndefined() + }) }) describe('Job.toJson', () => { @@ -110,6 +118,7 @@ describe('Job.toJson', () => { expect(json.type).toBe('job') expect(json.id).toBe(21891263) + expect(json.map_url).toBe('https://vhs.assecutor.org/?jb=21891263&cr=13385') expect(json.canceled).toBe(false) expect((json.customer as Record).csc_id).toBe(100164) expect((json.customer as Record).phone).toBe('+491602107449') @@ -137,6 +146,7 @@ describe('fetchJob', () => { expect(fetchMock).toHaveBeenCalledWith('/api/jobs/21891263') expect(job.id).toBe(21891263) + expect(job.mapUrl).toBe('https://vhs.assecutor.org/?jb=21891263&cr=13385') expect(job.url).toBe('https://test.sb.assecutor.de/admin/jb_detail.php?job_id=21891263') }) diff --git a/frontend/src/jobs.ts b/frontend/src/jobs.ts index b978324..aa9073f 100644 --- a/frontend/src/jobs.ts +++ b/frontend/src/jobs.ts @@ -40,6 +40,18 @@ function text(value: unknown): string | undefined { return typeof value === 'string' && value !== '' ? value : undefined } +/** Externe Kartenlinks dürfen nur normale HTTP(S)-Adressen öffnen. */ +function mapUrl(value: unknown): string | undefined { + if (typeof value !== 'string' || !value.trim()) return undefined + const target = value.trim() + try { + const url = new URL(target) + return url.protocol === 'https:' || url.protocol === 'http:' ? target : undefined + } catch { + return undefined + } +} + /** Ganzzahl der Nutzlast; alles andere wird `undefined`. */ function integer(value: unknown): number | undefined { return typeof value === 'number' && Number.isFinite(value) ? value : undefined @@ -186,6 +198,8 @@ export class Job { readonly id: number, /** Sprungadresse des Fremdsystems zur Auftragsansicht. */ readonly url?: string, + /** Optionale Kartenadresse des Jobs (`map_url`). */ + readonly mapUrl?: string, /** Zustand des Jobs im Fremdsystem. */ readonly state?: number, /** Auftragszeit, z. B. "2026-08-13T13:35:27". */ @@ -235,6 +249,7 @@ export class Job { return new Job( id, text(json.url), + mapUrl(json.map_url), integer(json.state), text(json.ordertime), text(json.orderdate), @@ -256,6 +271,7 @@ export class Job { type: 'job', id: this.id, url: this.url, + map_url: this.mapUrl, state: this.state, ordertime: this.ordertime, orderdate: this.orderdate,