import { z } from "zod"; import { TRPCError } from "@trpc/server"; import { EinsatzFunktion, Prisma, UebungsArt } from "@prisma/client"; import { router, mitRecht } from "@/server/trpc"; import { AuditAktion, protokolliereIn } from "@/lib/audit"; import { blaettern, idEingabe, listenAntwort, listenEingabe, ohneGeloeschte, } from "@/server/routers/stammdaten/gemeinsam"; import { naechsteNummer } from "@/lib/nummernkreis"; /** * Übungen. * * Aufgebaut wie ein Einsatz, aber mit Ausbildungsthema und Lernzielen statt * Schadenlage. Der Zweck ist ein anderer: eine Übung ist kein Nachweis * gegenüber der Gemeinde, sondern gegenüber der eigenen Mannschaft — wer war * dabei, wie oft, und woran wurde gearbeitet. * * Daraus ergibt sich die Teilnahmequote, nach der in der Jahreshauptversammlung * gefragt wird, und die Grundlage für Beförderungen. * * Ein Übungsbericht wird nicht festgeschrieben. Er geht an niemanden außerhalb * der Wehr, und ein Tippfehler soll ohne Korrekturvorgang zu beheben sein. */ const LISTEN_AUSWAHL = { id: true, nummer: true, thema: true, art: true, datum: true, beginnUm: true, endeUm: true, ort: true, geloeschtAm: true, leiter: { select: { id: true, vorname: true, nachname: true } }, _count: { select: { teilnahmen: true } }, } satisfies Prisma.UebungSelect; const uebungFelder = z.object({ thema: z.string().trim().min(1, "Ohne Thema ist es keine Übung, sondern ein Treffen.").max(200), art: z.nativeEnum(UebungsArt).default(UebungsArt.PRAXIS), datum: z.coerce.date(), beginnUm: z.coerce.date().nullish(), endeUm: z.coerce.date().nullish(), ort: z.string().trim().max(200).nullish(), leiterId: z.string().uuid().nullish(), lernziele: z.string().trim().max(2000).nullish(), beschreibung: z.string().trim().max(4000).nullish(), bericht: z.string().trim().max(50_000).nullish(), }); export const uebungRouter = router({ liste: mitRecht("ausbildung.lesen") .input( listenEingabe(["datum", "thema", "nummer"] as const).extend({ art: z.array(z.nativeEnum(UebungsArt)).optional(), jahr: z.number().int().min(2000).max(2100).optional(), personId: z.string().uuid().optional(), }), ) .query(async ({ ctx, input }) => ctx.db(async (db) => { const wo: Prisma.UebungWhereInput = { ...ohneGeloeschte(input.mitGeloeschten), ...(input.art?.length ? { art: { in: input.art } } : {}), ...(input.jahr ? { datum: { gte: new Date(Date.UTC(input.jahr, 0, 1)), lt: new Date(Date.UTC(input.jahr + 1, 0, 1)), }, } : {}), ...(input.personId ? { teilnahmen: { some: { personId: input.personId } } } : {}), ...(input.suche ? { OR: [ { thema: { contains: input.suche, mode: "insensitive" as const } }, { nummer: { contains: input.suche, mode: "insensitive" as const } }, { ort: { contains: input.suche, mode: "insensitive" as const } }, ], } : {}), }; const [eintraege, gesamt] = await Promise.all([ db.uebung.findMany({ where: wo, select: LISTEN_AUSWAHL, orderBy: input.sortierung ? { [input.sortierung]: input.richtung } : { datum: "desc" }, ...blaettern(input), }), db.uebung.count({ where: wo }), ]); return listenAntwort(eintraege, gesamt, input); }), ), einzeln: mitRecht("ausbildung.lesen") .input(idEingabe) .query(async ({ ctx, input }) => ctx.db(async (db) => { const uebung = await db.uebung.findUnique({ where: { id: input.id }, include: { leiter: { select: { id: true, vorname: true, nachname: true } }, teilnahmen: { include: { person: { select: { id: true, vorname: true, nachname: true, dienstgrad: true }, }, }, orderBy: { erstelltAm: "asc" }, }, }, }); if (!uebung) { throw new TRPCError({ code: "NOT_FOUND", message: "Diese Übung gibt es nicht." }); } const dauer = uebung.beginnUm && uebung.endeUm ? Math.round((uebung.endeUm.getTime() - uebung.beginnUm.getTime()) / 60_000) : null; return { ...uebung, dauerMinuten: dauer }; }), ), anlegen: mitRecht("ausbildung.bearbeiten") .input(uebungFelder.extend({ teilnehmerIds: z.array(z.string().uuid()).max(200).default([]) })) .mutation(async ({ ctx, input }) => ctx.db(async (db) => { const { teilnehmerIds, ...felder } = input; const jahr = felder.datum.getUTCFullYear(); await db.$executeRaw`SELECT pg_advisory_xact_lock(hashtext(${`uebung:${ctx.sitzung.organisationId}:${jahr}`}))`; const letzte = await db.uebung.findFirst({ where: { nummer: { startsWith: `${jahr}-` } }, orderBy: { nummer: "desc" }, select: { nummer: true }, }); const nummer = naechsteNummer(jahr, letzte?.nummer); const uebung = await db.uebung.create({ data: { organisationId: ctx.sitzung.organisationId, nummer, ...felder, teilnahmen: { create: teilnehmerIds.map((personId) => ({ organisationId: ctx.sitzung.organisationId, personId, })), }, }, }); await protokolliereIn(db, { organisationId: ctx.sitzung.organisationId, aktion: AuditAktion.ERSTELLT, entitaet: "Uebung", entitaetId: uebung.id, beschreibung: `Übung ${nummer} — ${uebung.thema} mit ${teilnehmerIds.length} Teilnehmern`, sitzung: ctx.sitzung, nachher: uebung, ipAdresse: ctx.ipAdresse, userAgent: ctx.userAgent, }); return uebung; }), ), aendern: mitRecht("ausbildung.bearbeiten") .input(uebungFelder.partial().extend({ id: z.string().uuid() })) .mutation(async ({ ctx, input }) => ctx.db(async (db) => { const { id, ...aenderungen } = input; const vorher = await db.uebung.findUnique({ where: { id } }); if (!vorher) { throw new TRPCError({ code: "NOT_FOUND", message: "Diese Übung gibt es nicht." }); } const nachher = await db.uebung.update({ where: { id }, data: aenderungen }); await protokolliereIn(db, { organisationId: ctx.sitzung.organisationId, aktion: AuditAktion.GEAENDERT, entitaet: "Uebung", entitaetId: id, beschreibung: `Übung ${nachher.nummer} geändert`, sitzung: ctx.sitzung, vorher, nachher, ipAdresse: ctx.ipAdresse, userAgent: ctx.userAgent, }); return nachher; }), ), /** Teilnehmerliste setzen — Anwesenheit wird am Stück abgehakt. */ teilnahmeSetzen: mitRecht("ausbildung.bearbeiten") .input( z.object({ uebungId: z.string().uuid(), teilnehmer: z .array( z.object({ personId: z.string().uuid(), funktion: z.nativeEnum(EinsatzFunktion).nullish(), vonUm: z.coerce.date().nullish(), bisUm: z.coerce.date().nullish(), bemerkung: z.string().trim().max(500).nullish(), }), ) .max(200), /** Nicht genannte Teilnehmer entfernen — die Liste ist dann vollständig. */ ersetzen: z.boolean().default(false), }), ) .mutation(async ({ ctx, input }) => ctx.db(async (db) => { const uebung = await db.uebung.findUnique({ where: { id: input.uebungId }, select: { id: true, nummer: true }, }); if (!uebung) { throw new TRPCError({ code: "NOT_FOUND", message: "Diese Übung gibt es nicht." }); } if (input.ersetzen) { await db.uebungsteilnahme.deleteMany({ where: { uebungId: input.uebungId, personId: { notIn: input.teilnehmer.map((eintrag) => eintrag.personId) }, }, }); } for (const teilnehmer of input.teilnehmer) { await db.uebungsteilnahme.upsert({ where: { uebungId_personId: { uebungId: input.uebungId, personId: teilnehmer.personId }, }, create: { organisationId: ctx.sitzung.organisationId, uebungId: input.uebungId, ...teilnehmer, }, update: { funktion: teilnehmer.funktion ?? null, vonUm: teilnehmer.vonUm ?? null, bisUm: teilnehmer.bisUm ?? null, bemerkung: teilnehmer.bemerkung ?? null, }, }); } await protokolliereIn(db, { organisationId: ctx.sitzung.organisationId, aktion: AuditAktion.GEAENDERT, entitaet: "Uebung", entitaetId: input.uebungId, beschreibung: `Übung ${uebung.nummer}: ${input.teilnehmer.length} Teilnehmer erfasst`, sitzung: ctx.sitzung, nachher: { anzahl: input.teilnehmer.length }, ipAdresse: ctx.ipAdresse, userAgent: ctx.userAgent, }); return { erfasst: input.teilnehmer.length }; }), ), loeschen: mitRecht("ausbildung.bearbeiten") .input(idEingabe) .mutation(async ({ ctx, input }) => ctx.db(async (db) => { const uebung = await db.uebung.findUnique({ where: { id: input.id } }); if (!uebung || uebung.geloeschtAm) { throw new TRPCError({ code: "NOT_FOUND", message: "Diese Übung gibt es nicht." }); } await db.uebung.update({ where: { id: input.id }, data: { geloeschtAm: new Date() } }); await protokolliereIn(db, { organisationId: ctx.sitzung.organisationId, aktion: AuditAktion.GELOESCHT, entitaet: "Uebung", entitaetId: input.id, beschreibung: `Übung ${uebung.nummer} gelöscht`, sitzung: ctx.sitzung, vorher: uebung, ipAdresse: ctx.ipAdresse, userAgent: ctx.userAgent, }); return { erfolgreich: true }; }), ), });