Kartenlinks für Jobs speichern und im Anruf-Popup öffnen
This commit is contained in:
1 parent
55e5d34e83
commit
7b3c187985
9 files changed
+150
-13
No files matched your search
@@ -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;
|
ausgeschaltet. Beim Umschalten bleiben Eingaben und die SwyxTray-Verbindung erhalten;
|
||||||
Anruf-Popups und Benachrichtigungen funktionieren auch im Dashboard.
|
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/
|
SwyxWeb/
|
||||||
├── backend/ Spring Boot 4.1 (Java 21, Maven Wrapper)
|
├── backend/ Spring Boot 4.1 (Java 21, Maven Wrapper)
|
||||||
|
|||||||
@@ -34,6 +34,7 @@ import org.springframework.data.mongodb.core.mapping.Field;
|
|||||||
* @param id Kennung des Fremdsystems, zugleich Schlüssel der Sammlung
|
* @param id Kennung des Fremdsystems, zugleich Schlüssel der Sammlung
|
||||||
* @param receivedAt Eingangszeit im Backend, beim letzten Eingang dieses Jobs
|
* @param receivedAt Eingangszeit im Backend, beim letzten Eingang dieses Jobs
|
||||||
* @param url Sprungadresse des Fremdsystems zur Auftragsansicht
|
* @param url Sprungadresse des Fremdsystems zur Auftragsansicht
|
||||||
|
* @param mapUrl optionale Kartenadresse des Jobs ({@code map_url})
|
||||||
* @param state Zustand des Jobs im Fremdsystem
|
* @param state Zustand des Jobs im Fremdsystem
|
||||||
* @param ordertime Auftragszeit, z. B. "2026-08-13T13:35:27"
|
* @param ordertime Auftragszeit, z. B. "2026-08-13T13:35:27"
|
||||||
* @param orderdate Auftragsdatum, z. B. "2026-08-13"
|
* @param orderdate Auftragsdatum, z. B. "2026-08-13"
|
||||||
@@ -53,6 +54,7 @@ public record JobEntry(
|
|||||||
@Id long id,
|
@Id long id,
|
||||||
Instant receivedAt,
|
Instant receivedAt,
|
||||||
String url,
|
String url,
|
||||||
|
@JsonProperty("map_url") @Field("map_url") String mapUrl,
|
||||||
Integer state,
|
Integer state,
|
||||||
String ordertime,
|
String ordertime,
|
||||||
String orderdate,
|
String orderdate,
|
||||||
@@ -142,7 +144,7 @@ public record JobEntry(
|
|||||||
/** Füllt die Klasse aus der Webhook-Nutzlast und stempelt die Eingangszeit. */
|
/** Füllt die Klasse aus der Webhook-Nutzlast und stempelt die Eingangszeit. */
|
||||||
public static JobEntry from(JsonNode json, ObjectMapper mapper, Instant receivedAt) {
|
public static JobEntry from(JsonNode json, ObjectMapper mapper, Instant receivedAt) {
|
||||||
JobEntry job = mapper.convertValue(json, JobEntry.class);
|
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.orderdate(), job.modified(), job.finished(), job.vehicle(), job.service(),
|
||||||
job.canceled(), job.global(), job.customer(), job.courier(), job.tours());
|
job.canceled(), job.global(), job.customer(), job.courier(), job.tours());
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -35,12 +35,14 @@ class JobControllerTests {
|
|||||||
void returnsStoredJob() throws Exception {
|
void returnsStoredJob() throws Exception {
|
||||||
when(archive.job(21891263L)).thenReturn(new JobEntry(21891263L, null,
|
when(archive.job(21891263L)).thenReturn(new JobEntry(21891263L, null,
|
||||||
"https://test.sb.assecutor.de/admin/jb_detail.php?job_id=21891263",
|
"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));
|
1, "2026-08-13T13:35:27", null, null, null, null, null, null, null, null, null, null));
|
||||||
|
|
||||||
mvc.perform(get("/api/jobs/21891263"))
|
mvc.perform(get("/api/jobs/21891263"))
|
||||||
.andExpect(status().isOk())
|
.andExpect(status().isOk())
|
||||||
.andExpect(jsonPath("$.id").value(21891263))
|
.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
|
@Test
|
||||||
|
|||||||
@@ -7,7 +7,12 @@ import java.time.Instant;
|
|||||||
import tools.jackson.databind.JsonNode;
|
import tools.jackson.databind.JsonNode;
|
||||||
import tools.jackson.databind.ObjectMapper;
|
import tools.jackson.databind.ObjectMapper;
|
||||||
|
|
||||||
|
import org.bson.Document;
|
||||||
import org.junit.jupiter.api.Test;
|
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;
|
import de.appcreation.swyxweb.storage.JobEntry;
|
||||||
|
|
||||||
@@ -26,6 +31,7 @@ class JobEntryTests {
|
|||||||
"type": "job",
|
"type": "job",
|
||||||
"id": 21891263,
|
"id": 21891263,
|
||||||
"url": "https://test.sb.assecutor.de/admin/jb_detail.php?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,
|
"state": 9,
|
||||||
"ordertime": "2026-08-13T13:35:27",
|
"ordertime": "2026-08-13T13:35:27",
|
||||||
"orderdate": "2026-08-13",
|
"orderdate": "2026-08-13",
|
||||||
@@ -68,6 +74,7 @@ class JobEntryTests {
|
|||||||
assertThat(job.id()).isEqualTo(21891263L);
|
assertThat(job.id()).isEqualTo(21891263L);
|
||||||
assertThat(job.receivedAt()).isEqualTo(receivedAt);
|
assertThat(job.receivedAt()).isEqualTo(receivedAt);
|
||||||
assertThat(job.url()).isEqualTo("https://test.sb.assecutor.de/admin/jb_detail.php?job_id=21891263");
|
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.state()).isEqualTo(9);
|
||||||
assertThat(job.ordertime()).isEqualTo("2026-08-13T13:35:27");
|
assertThat(job.ordertime()).isEqualTo("2026-08-13T13:35:27");
|
||||||
assertThat(job.orderdate()).isEqualTo("2026-08-13");
|
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"));
|
JobEntry job = JobEntry.from(json, mapper, Instant.parse("2026-08-28T10:00:00Z"));
|
||||||
|
|
||||||
assertThat(job.courier().phone()).isEqualTo("+4917xxxxxxx");
|
assertThat(job.courier().phone()).isEqualTo("+4917xxxxxxx");
|
||||||
|
assertThat(job.mapUrl()).isNull();
|
||||||
assertThat(job.tours().getFirst().phone()).isEqualTo("+4942037010xx");
|
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;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -14,6 +14,8 @@ import de.appcreation.swyxweb.webhook.WebhookService;
|
|||||||
import org.junit.jupiter.api.BeforeEach;
|
import org.junit.jupiter.api.BeforeEach;
|
||||||
import org.junit.jupiter.api.Nested;
|
import org.junit.jupiter.api.Nested;
|
||||||
import org.junit.jupiter.api.Test;
|
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.beans.factory.annotation.Autowired;
|
||||||
import org.springframework.boot.webmvc.test.autoconfigure.AutoConfigureMockMvc;
|
import org.springframework.boot.webmvc.test.autoconfigure.AutoConfigureMockMvc;
|
||||||
import org.springframework.boot.test.context.SpringBootTest;
|
import org.springframework.boot.test.context.SpringBootTest;
|
||||||
@@ -134,6 +136,21 @@ class WebhookControllerTests {
|
|||||||
verify(archive).saveAddresses(argThat(entries -> entries.size() == 1));
|
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. */
|
/** Ohne Token in der Konfiguration darf der Kopf fehlen. */
|
||||||
@Test
|
@Test
|
||||||
void tokenIsNotRequiredWhenUnset() throws Exception {
|
void tokenIsNotRequiredWhenUnset() throws Exception {
|
||||||
|
|||||||
@@ -18,7 +18,8 @@ interface Props {
|
|||||||
* Adress-Ablage (Kunde oder Kurier), dazu je Job-Kennung des Eintrags
|
* Adress-Ablage (Kunde oder Kurier), dazu je Job-Kennung des Eintrags
|
||||||
* (`job_ids`) einen Knopf, der den Job aus der Ablage holt und dessen
|
* (`job_ids`) einen Knopf, der den Job aus der Ablage holt und dessen
|
||||||
* Sprungadresse (`url`) in einem neuen Tab öffnet. Gleiches Overlay wie der
|
* Sprungadresse (`url`) in einem neuen Tab öffnet. Gleiches Overlay wie der
|
||||||
* Wartedialog (siehe LoadingDialog zur Begründung gegen `<dialog>`).
|
* Wartedialog. Ist `map_url` gesetzt, öffnet „Karte öffnen" die zugehörige Karte.
|
||||||
|
* Siehe LoadingDialog zur Begründung gegen `<dialog>`.
|
||||||
*/
|
*/
|
||||||
export default function CustomerCallPopup({ customer, onAnswer, onClose }: Props) {
|
export default function CustomerCallPopup({ customer, onAnswer, onClose }: Props) {
|
||||||
const closeRef = useRef<HTMLButtonElement>(null)
|
const closeRef = useRef<HTMLButtonElement>(null)
|
||||||
@@ -27,6 +28,20 @@ export default function CustomerCallPopup({ customer, onAnswer, onClose }: Props
|
|||||||
const [jobNote, setJobNote] = useState<string | null>(null)
|
const [jobNote, setJobNote] = useState<string | null>(null)
|
||||||
// Kennung des Jobs, der gerade geholt wird; sperrt derweil alle Job-Knöpfe.
|
// Kennung des Jobs, der gerade geholt wird; sperrt derweil alle Job-Knöpfe.
|
||||||
const [busyJobId, setBusyJobId] = useState<number | null>(null)
|
const [busyJobId, setBusyJobId] = useState<number | null>(null)
|
||||||
|
const [mapUrls, setMapUrls] = useState<Record<number, string>>({})
|
||||||
|
|
||||||
|
// 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.
|
// Fokus in den Dialog holen, damit Escape sofort greift.
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -78,15 +93,23 @@ export default function CustomerCallPopup({ customer, onAnswer, onClose }: Props
|
|||||||
<p className="dialog__status">Zugeordnete Jobs</p>
|
<p className="dialog__status">Zugeordnete Jobs</p>
|
||||||
<div className="dialog__jobs">
|
<div className="dialog__jobs">
|
||||||
{jobIds.map((jobId) => (
|
{jobIds.map((jobId) => (
|
||||||
<button
|
<div key={jobId} className="dialog__job">
|
||||||
key={jobId}
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className="button"
|
className="button"
|
||||||
disabled={busyJobId != null}
|
disabled={busyJobId != null}
|
||||||
onClick={() => void openJob(jobId)}
|
onClick={() => void openJob(jobId)}
|
||||||
>
|
>
|
||||||
{busyJobId === jobId ? `Job ${jobId} …` : `Job ${jobId}`}
|
{busyJobId === jobId ? `Job ${jobId} …` : `Job ${jobId}`}
|
||||||
</button>
|
</button>
|
||||||
|
{mapUrls[jobId] && (
|
||||||
|
<a className="button" href={mapUrls[jobId]} target="_blank" rel="noopener noreferrer"
|
||||||
|
aria-label={`Karte für Job ${jobId} öffnen`}
|
||||||
|
title="Karte in einem neuen Tab öffnen">
|
||||||
|
Karte öffnen
|
||||||
|
</a>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
|
|||||||
@@ -871,7 +871,7 @@ a.button {
|
|||||||
margin-right: auto;
|
margin-right: auto;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Die Job-Knöpfe des Anruf-Popups: einer je Kennung, umbrechend. */
|
/* Je Job bleiben Auftrags- und optionaler Kartenknopf zusammen. */
|
||||||
.dialog__jobs {
|
.dialog__jobs {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-wrap: wrap;
|
flex-wrap: wrap;
|
||||||
@@ -879,6 +879,12 @@ a.button {
|
|||||||
margin-top: 8px;
|
margin-top: 8px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.dialog__job {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
.progress {
|
.progress {
|
||||||
height: 8px;
|
height: 8px;
|
||||||
border-radius: 999px;
|
border-radius: 999px;
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ const SAMPLE = `{
|
|||||||
"type": "job",
|
"type": "job",
|
||||||
"id": 21891263,
|
"id": 21891263,
|
||||||
"url": "https://test.sb.assecutor.de/admin/jb_detail.php?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,
|
"state": 9,
|
||||||
"ordertime": "2026-08-13T13:35:27",
|
"ordertime": "2026-08-13T13:35:27",
|
||||||
"orderdate": "2026-08-13",
|
"orderdate": "2026-08-13",
|
||||||
@@ -49,6 +50,7 @@ describe('Job.fromJson', () => {
|
|||||||
|
|
||||||
expect(job.id).toBe(21891263)
|
expect(job.id).toBe(21891263)
|
||||||
expect(job.url).toBe('https://test.sb.assecutor.de/admin/jb_detail.php?job_id=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.state).toBe(9)
|
||||||
expect(job.ordertime).toBe('2026-08-13T13:35:27')
|
expect(job.ordertime).toBe('2026-08-13T13:35:27')
|
||||||
expect(job.orderdate).toBe('2026-08-13')
|
expect(job.orderdate).toBe('2026-08-13')
|
||||||
@@ -95,12 +97,18 @@ describe('Job.fromJson', () => {
|
|||||||
it('kommt mit kargen Nutzlasten aus, verlangt aber die Kennung', () => {
|
it('kommt mit kargen Nutzlasten aus, verlangt aber die Kennung', () => {
|
||||||
const bare = Job.fromJson({ id: 4711 })
|
const bare = Job.fromJson({ id: 4711 })
|
||||||
expect(bare.id).toBe(4711)
|
expect(bare.id).toBe(4711)
|
||||||
|
expect(bare.mapUrl).toBeUndefined()
|
||||||
expect(bare.customer).toBeUndefined()
|
expect(bare.customer).toBeUndefined()
|
||||||
expect(bare.tours).toEqual([])
|
expect(bare.tours).toEqual([])
|
||||||
|
|
||||||
expect(() => Job.fromJson({ state: 1 })).toThrowError(/"id" fehlt/)
|
expect(() => Job.fromJson({ state: 1 })).toThrowError(/"id" fehlt/)
|
||||||
expect(() => Job.fromJson('kein Objekt')).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', () => {
|
describe('Job.toJson', () => {
|
||||||
@@ -110,6 +118,7 @@ describe('Job.toJson', () => {
|
|||||||
|
|
||||||
expect(json.type).toBe('job')
|
expect(json.type).toBe('job')
|
||||||
expect(json.id).toBe(21891263)
|
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.canceled).toBe(false)
|
||||||
expect((json.customer as Record<string, unknown>).csc_id).toBe(100164)
|
expect((json.customer as Record<string, unknown>).csc_id).toBe(100164)
|
||||||
expect((json.customer as Record<string, unknown>).phone).toBe('+491602107449')
|
expect((json.customer as Record<string, unknown>).phone).toBe('+491602107449')
|
||||||
@@ -137,6 +146,7 @@ describe('fetchJob', () => {
|
|||||||
|
|
||||||
expect(fetchMock).toHaveBeenCalledWith('/api/jobs/21891263')
|
expect(fetchMock).toHaveBeenCalledWith('/api/jobs/21891263')
|
||||||
expect(job.id).toBe(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')
|
expect(job.url).toBe('https://test.sb.assecutor.de/admin/jb_detail.php?job_id=21891263')
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
@@ -40,6 +40,18 @@ function text(value: unknown): string | undefined {
|
|||||||
return typeof value === 'string' && value !== '' ? value : 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`. */
|
/** Ganzzahl der Nutzlast; alles andere wird `undefined`. */
|
||||||
function integer(value: unknown): number | undefined {
|
function integer(value: unknown): number | undefined {
|
||||||
return typeof value === 'number' && Number.isFinite(value) ? value : undefined
|
return typeof value === 'number' && Number.isFinite(value) ? value : undefined
|
||||||
@@ -186,6 +198,8 @@ export class Job {
|
|||||||
readonly id: number,
|
readonly id: number,
|
||||||
/** Sprungadresse des Fremdsystems zur Auftragsansicht. */
|
/** Sprungadresse des Fremdsystems zur Auftragsansicht. */
|
||||||
readonly url?: string,
|
readonly url?: string,
|
||||||
|
/** Optionale Kartenadresse des Jobs (`map_url`). */
|
||||||
|
readonly mapUrl?: string,
|
||||||
/** Zustand des Jobs im Fremdsystem. */
|
/** Zustand des Jobs im Fremdsystem. */
|
||||||
readonly state?: number,
|
readonly state?: number,
|
||||||
/** Auftragszeit, z. B. "2026-08-13T13:35:27". */
|
/** Auftragszeit, z. B. "2026-08-13T13:35:27". */
|
||||||
@@ -235,6 +249,7 @@ export class Job {
|
|||||||
return new Job(
|
return new Job(
|
||||||
id,
|
id,
|
||||||
text(json.url),
|
text(json.url),
|
||||||
|
mapUrl(json.map_url),
|
||||||
integer(json.state),
|
integer(json.state),
|
||||||
text(json.ordertime),
|
text(json.ordertime),
|
||||||
text(json.orderdate),
|
text(json.orderdate),
|
||||||
@@ -256,6 +271,7 @@ export class Job {
|
|||||||
type: 'job',
|
type: 'job',
|
||||||
id: this.id,
|
id: this.id,
|
||||||
url: this.url,
|
url: this.url,
|
||||||
|
map_url: this.mapUrl,
|
||||||
state: this.state,
|
state: this.state,
|
||||||
ordertime: this.ordertime,
|
ordertime: this.ordertime,
|
||||||
orderdate: this.orderdate,
|
orderdate: this.orderdate,
|
||||||
|
|||||||
Reference in new issue
Block a user