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 }; }), ), });