Files
FireAdministration/src/server/routers/karte/objekt.ts
T
Florian WildeandClaude Opus 5 1c1e141666 Kartenmodule ergaenzen
Phase 6: alles, was einen Ort hat — Loeschwasser, Objekte mit Feuerwehrplan,
die Lagekarte eines Einsatzes und die geplante Foerderstrecke.

Keine Kacheln aus dem Netz. Wo eine Wehr ausrueckt und wo ihre Hydranten
liegen, ist niemandes Geschaeft; der Kartenserver laeuft selbst
(tileserver-gl im Compose-Profil "karte"). Ohne ihn zeigt die Karte einen
leeren Grund und sagt das auch — Punkte, Entfernungen und Umkreissuche
funktionieren trotzdem. Dasselbe gilt fuer einen eingetragenen, aber nicht
erreichbaren Server.

Die Umkreissuche laeuft ueber PostGIS: ST_DWithin auf der Geografie mit
GiST-Index rechnet in Metern und nutzt den Index. Gefragt wird waehrend der
Fahrt, und bei tausend Hydranten waere alles andere ein vollstaendiger
Tabellendurchlauf.

Der Import aus GeoJSON und Stadtwerke-CSV ist nachsichtig, aber ehrlich: er
erkennt "DN 100", deutsche Kommazahlen, Datumsangaben und vertauschte Achsen
— raet aber nichts. Fehlt die Position, wird die Zeile uebersprungen; ohne
Statusangabe steht "ungeprueft", nicht "einsatzbereit".

22 taktische Zeichen nach FwDV 102 selbst gezeichnet, die Lagekarte gleicht
ueber denselben Ereignisstrom ab wie die Atemschutzueberwachung.

Die Wasserfoerderung rechnet Reibung, Hoehe und Pumpenzahl; das Ergebnis wird
nie gespeichert, sondern bei jeder Anzeige neu gerechnet. Geht eine Strecke
nicht auf, sagt die Ausgabe das.

Behoben: die Reibungsbeiwerte lagen im ersten Wurf um den Faktor zehn daneben.
Aufgefallen ist es nur, weil die Tests gegen die Tabellenwerte der Ausbildung
pruefen und nicht gegen die eigene Implementierung.

Dazu ein Abruf fuer abgelegte Dateien (/api/dateien/[id]) — bis hierher gab es
nur den Upload.

59 neue Tests (48 Unit, 11 E2E je Browser).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-04 01:41:24 +02:00

299 lines
9.4 KiB
TypeScript

import { z } from "zod";
import { TRPCError } from "@trpc/server";
import { ObjektKategorie, PlanArt } from "@prisma/client";
import { router, mitRecht } from "@/server/trpc";
import { AuditAktion, protokolliereIn } from "@/lib/audit";
import { idEingabe } from "@/server/routers/stammdaten/gemeinsam";
/**
* Objekte und Feuerwehrpläne.
*
* Was hier gepflegt wird, wird auf der Anfahrt gelesen — oft vom
* Beifahrersitz, oft in zwanzig Sekunden. Daraus folgt die Reihenfolge, in der
* die Ansicht die Felder zeigt: **Zufahrt, Gefahren, Schlüsseldepot.** Alles
* andere kann warten.
*
* Die Suche ist bewusst breit: gesucht wird nach dem Namen, aber genauso nach
* der Straße, nach einem Gefahrenstichwort („Photovoltaik") oder nach dem
* Ansprechpartner. Wer im Einsatz sucht, weiß selten, wie das Objekt im
* System heißt.
*/
const objektFelder = z.object({
name: z.string().trim().min(1).max(200),
kategorie: z.nativeEnum(ObjektKategorie).default(ObjektKategorie.SONSTIGES),
strasse: z.string().trim().max(200).nullish(),
hausnummer: z.string().trim().max(20).nullish(),
plz: z.string().trim().max(10).nullish(),
ort: z.string().trim().max(120).nullish(),
breitengrad: z.number().min(-90).max(90).nullish(),
laengengrad: z.number().min(-180).max(180).nullish(),
zufahrt: z.string().trim().max(2000).nullish(),
gefahren: z.string().trim().max(2000).nullish(),
schluesseldepot: z.string().trim().max(500).nullish(),
loeschwasser: z.string().trim().max(1000).nullish(),
bemerkung: z.string().trim().max(2000).nullish(),
personenzahl: z.number().int().min(0).max(100_000).nullish(),
});
const listenauswahl = {
id: true,
name: true,
kategorie: true,
strasse: true,
hausnummer: true,
ort: true,
breitengrad: true,
laengengrad: true,
gefahren: true,
personenzahl: true,
_count: { select: { plaene: true } },
} as const;
export const objektRouter = router({
liste: mitRecht("geraet.lesen")
.input(
z
.object({
suche: z.string().trim().max(120).optional(),
kategorie: z.nativeEnum(ObjektKategorie).optional(),
})
.optional(),
)
.query(async ({ ctx, input }) =>
ctx.db((db) =>
db.objekt.findMany({
where: {
geloeschtAm: null,
...(input?.kategorie ? { kategorie: input.kategorie } : {}),
...(input?.suche
? {
OR: [
{ name: { contains: input.suche, mode: "insensitive" } },
{ strasse: { contains: input.suche, mode: "insensitive" } },
{ ort: { contains: input.suche, mode: "insensitive" } },
{ gefahren: { contains: input.suche, mode: "insensitive" } },
{ zufahrt: { contains: input.suche, mode: "insensitive" } },
{
kontakte: {
some: { name: { contains: input.suche, mode: "insensitive" } },
},
},
],
}
: {}),
},
orderBy: { name: "asc" },
select: listenauswahl,
}),
),
),
einzeln: mitRecht("geraet.lesen")
.input(idEingabe)
.query(async ({ ctx, input }) => {
const objekt = await ctx.db((db) =>
db.objekt.findUnique({
where: { id: input.id },
include: {
kontakte: { orderBy: [{ reihenfolge: "asc" }, { name: "asc" }] },
plaene: {
orderBy: [{ reihenfolge: "asc" }, { erstelltAm: "asc" }],
include: { datei: { select: { id: true, dateiname: true, mimeTyp: true } } },
},
},
}),
);
if (!objekt) {
throw new TRPCError({ code: "NOT_FOUND", message: "Dieses Objekt gibt es nicht." });
}
return objekt;
}),
/**
* Objekte in der Nähe einer Einsatzstelle.
*
* Der Weg vom Einsatz zum Feuerwehrplan: Wer einen Einsatz erfasst, soll
* nicht suchen müssen, ob es für die Adresse einen Plan gibt.
*/
umkreis: mitRecht("geraet.lesen")
.input(
z.object({
breitengrad: z.number().min(-90).max(90),
laengengrad: z.number().min(-180).max(180),
umkreisMeter: z.number().int().min(50).max(20_000).default(300),
}),
)
.query(async ({ ctx, input }) =>
ctx.db((db) =>
db.$queryRaw<
Array<{
id: string;
name: string;
kategorie: ObjektKategorie;
strasse: string | null;
gefahren: string | null;
entfernung: number;
}>
>`
SELECT
"id", "name", "kategorie", "strasse", "gefahren",
ROUND(ST_Distance(
"koordinaten",
ST_SetSRID(ST_MakePoint(${input.laengengrad}, ${input.breitengrad}), 4326)::geography
))::int AS "entfernung"
FROM "objekt"
WHERE "geloeschtAm" IS NULL
AND "koordinaten" IS NOT NULL
AND ST_DWithin(
"koordinaten",
ST_SetSRID(ST_MakePoint(${input.laengengrad}, ${input.breitengrad}), 4326)::geography,
${input.umkreisMeter}
)
ORDER BY "entfernung" ASC
LIMIT 10
`,
),
),
anlegen: mitRecht("geraet.bearbeiten")
.input(objektFelder)
.mutation(async ({ ctx, input }) =>
ctx.db(async (db) => {
const objekt = await db.objekt.create({
data: { organisationId: ctx.sitzung.organisationId, ...input },
});
await protokolliereIn(db, {
organisationId: ctx.sitzung.organisationId,
aktion: AuditAktion.ERSTELLT,
entitaet: "Objekt",
entitaetId: objekt.id,
sitzung: ctx.sitzung,
nachher: { name: objekt.name, kategorie: objekt.kategorie },
});
return objekt;
}),
),
bearbeiten: mitRecht("geraet.bearbeiten")
.input(objektFelder.partial().extend({ id: z.string().uuid() }))
.mutation(async ({ ctx, input }) =>
ctx.db(async (db) => {
const { id, ...felder } = input;
const vorher = await db.objekt.findUniqueOrThrow({ where: { id } });
const nachher = await db.objekt.update({ where: { id }, data: felder });
await protokolliereIn(db, {
organisationId: ctx.sitzung.organisationId,
aktion: AuditAktion.GEAENDERT,
entitaet: "Objekt",
entitaetId: id,
sitzung: ctx.sitzung,
vorher,
nachher,
});
return nachher;
}),
),
entfernen: mitRecht("geraet.bearbeiten")
.input(idEingabe)
.mutation(async ({ ctx, input }) =>
ctx.db(async (db) => {
await db.objekt.update({ where: { id: input.id }, data: { geloeschtAm: new Date() } });
await protokolliereIn(db, {
organisationId: ctx.sitzung.organisationId,
aktion: AuditAktion.GELOESCHT,
entitaet: "Objekt",
entitaetId: input.id,
sitzung: ctx.sitzung,
});
return { entfernt: true };
}),
),
/** Einen Ansprechpartner hinterlegen. */
kontaktAnlegen: mitRecht("geraet.bearbeiten")
.input(
z.object({
objektId: z.string().uuid(),
name: z.string().trim().min(1).max(200),
funktion: z.string().trim().max(120).nullish(),
telefon: z.string().trim().max(60).nullish(),
mobil: z.string().trim().max(60).nullish(),
jederzeit: z.boolean().default(false),
bemerkung: z.string().trim().max(500).nullish(),
}),
)
.mutation(async ({ ctx, input }) =>
ctx.db((db) =>
db.objektkontakt.create({
data: { organisationId: ctx.sitzung.organisationId, ...input },
}),
),
),
kontaktEntfernen: mitRecht("geraet.bearbeiten")
.input(idEingabe)
.mutation(async ({ ctx, input }) =>
ctx.db(async (db) => {
await db.objektkontakt.delete({ where: { id: input.id } });
return { entfernt: true };
}),
),
/**
* Ein Planblatt anhängen.
*
* Die Datei liegt schon in MinIO — hochgeladen wie überall über
* /api/dateien. Hier wird nur verknüpft.
*/
planAnhaengen: mitRecht("geraet.bearbeiten")
.input(
z.object({
objektId: z.string().uuid(),
dateiId: z.string().uuid(),
art: z.nativeEnum(PlanArt).default(PlanArt.UEBERSICHTSPLAN),
bezeichnung: z.string().trim().min(1).max(200),
standAm: z.coerce.date().nullish(),
}),
)
.mutation(async ({ ctx, input }) =>
ctx.db(async (db) => {
const plan = await db.objektplan.create({
data: { organisationId: ctx.sitzung.organisationId, ...input },
});
await protokolliereIn(db, {
organisationId: ctx.sitzung.organisationId,
aktion: AuditAktion.DATEI_HOCHGELADEN,
entitaet: "Objekt",
entitaetId: input.objektId,
sitzung: ctx.sitzung,
beschreibung: `Plan „${input.bezeichnung}" angehängt`,
});
return plan;
}),
),
planEntfernen: mitRecht("geraet.bearbeiten")
.input(idEingabe)
.mutation(async ({ ctx, input }) =>
ctx.db(async (db) => {
// Nur die Verknüpfung — die Datei selbst bleibt, wie überall im
// Projekt: gelöscht wird sie erst im Aufbewahrungslauf.
await db.objektplan.delete({ where: { id: input.id } });
return { entfernt: true };
}),
),
});