import { z } from "zod"; import { TRPCError } from "@trpc/server"; import { Dienstgrad, Prisma } from "@prisma/client"; import { router, angemeldet, mitRecht } from "@/server/trpc"; import { AuditAktion, protokolliereIn } from "@/lib/audit"; import { auskunftSammeln, auskunftUmfang } from "@/lib/dsgvo/auskunft"; import { auskunftPdf } from "@/lib/dsgvo/bericht"; import { hochladen, objektSchluessel } from "@/lib/storage"; import { darfPersonSehen, KeinZugriffFehler } from "@/lib/authz"; import { blaettern, idEingabe, listenAntwort, listenEingabe, ohneGeloeschte, sortierenNach, sucheUeber, } from "@/server/routers/stammdaten/gemeinsam"; import { qualifikationGueltigBis, qualifikationZustand } from "@/lib/stammdaten/fristen"; /** * Mitglieder und ihre Qualifikationen. * * Personendaten sind der heikelste Teil der Anwendung. Zwei Regeln greifen * hier durchgängig: * - Die eigenen Daten darf jeder sehen, fremde nur mit dem Recht * person.lesen. Das prüft darfPersonSehen() aus lib/authz. * - Gelöscht wird nie hart. Ein ausgetretenes Mitglied bleibt bestehen, * bis der Aufbewahrungsjob es nach PERSON_ANONYMISIERUNG_TAGE anonymisiert. */ const SORTIERFELDER = [ "nachname", "vorname", "mitgliedsnummer", "dienstgrad", "eintrittsdatum", "abteilung", "erstelltAm", ] as const; const personDaten = z.object({ vorname: z.string().trim().min(1, "Der Vorname fehlt.").max(100), nachname: z.string().trim().min(1, "Der Nachname fehlt.").max(100), mitgliedsnummer: z.string().trim().max(30).nullish(), geburtsdatum: z.coerce.date().nullish(), dienstgrad: z.nativeEnum(Dienstgrad).default(Dienstgrad.KEIN), email: z.string().trim().email("Das ist keine gültige E-Mail-Adresse.").max(200).nullish(), telefon: z.string().trim().max(40).nullish(), mobil: z.string().trim().max(40).nullish(), strasse: z.string().trim().max(200).nullish(), plz: z.string().trim().max(10).nullish(), ort: z.string().trim().max(120).nullish(), eintrittsdatum: z.coerce.date().nullish(), austrittsdatum: z.coerce.date().nullish(), abteilung: z.string().trim().max(60).nullish(), aktiv: z.boolean().default(true), }); export const personRouter = router({ /** * Auskunft nach Art. 15 DSGVO. * * Eine Mutation, obwohl sie im Kern nur liest — und das mit Absicht: das * Erstellen der Auskunft **ist** die Offenlegung, und die gehört ins * Protokoll. Als Abfrage würde sie zwischengespeichert, mehrfach ausgeführt * und wäre entweder nicht oder zwanzigmal vermerkt. * * Ein Durchgang liefert alles auf einmal: die Angaben für die Anzeige, das * PDF für die Akte und die JSON-Fassung nach Art. 20. Wer eine Auskunft * verlangt, bekommt sie vollständig oder gar nicht — nicht in drei Schritten, * von denen zwei vergessen werden können. */ auskunft: mitRecht("person.export") .input(idEingabe) .mutation(async ({ ctx, input }) => { const auskunft = await ctx.db((db) => auskunftSammeln(db, input.id)); const organisation = await ctx.db((db) => db.organisation.findUnique({ where: { id: ctx.sitzung.organisationId }, select: { slug: true }, }), ); const pdf = await auskunftPdf(auskunft); const schluessel = objektSchluessel({ organisationSlug: organisation?.slug ?? ctx.sitzung.organisationId, entitaet: "auskunft", entitaetId: input.id, dateiname: `auskunft-${auskunft.erstelltAm.toISOString().slice(0, 10)}.pdf`, }); const abgelegt = await hochladen(schluessel, pdf, "application/pdf", { kategorie: "dsgvo-auskunft", person: input.id, }); const datei = await ctx.db(async (db) => { const angelegt = await db.datei.create({ data: { organisationId: ctx.sitzung.organisationId, bucket: abgelegt.bucket, objektSchluessel: abgelegt.objektSchluessel, dateiname: `Auskunft ${auskunft.person.name}.pdf`, mimeTyp: "application/pdf", groesseBytes: BigInt(pdf.byteLength), pruefsumme: abgelegt.pruefsumme, hochgeladenVonId: ctx.sitzung.personId, }, }); // Die Offenlegung selbst — mit Umfang, damit später nachvollziehbar // ist, was ausgehändigt wurde, ohne die Daten zu wiederholen. await protokolliereIn(db, { organisationId: ctx.sitzung.organisationId, sitzung: ctx.sitzung, aktion: AuditAktion.EXPORTIERT, entitaet: "Person", entitaetId: input.id, beschreibung: `Datenauskunft nach Art. 15 DSGVO für ${auskunft.person.name} erstellt ` + `(${auskunftUmfang(auskunft)} Angaben in ${auskunft.abschnitte.length} Abschnitten)`, nachher: { abschnitte: auskunft.abschnitte.length, angaben: auskunftUmfang(auskunft), dateiId: angelegt.id, }, }); return angelegt; }); return { auskunft, umfang: auskunftUmfang(auskunft), dateiId: datei.id, }; }), liste: mitRecht("person.lesen") .input( listenEingabe(SORTIERFELDER).extend({ abteilung: z.string().trim().max(60).optional(), nurAktive: z.boolean().default(true), /** Nur Personen mit diesem Qualifikationstyp. */ qualifikationstypId: z.string().uuid().optional(), }), ) .query(async ({ ctx, input }) => ctx.db(async (db) => { const wo: Prisma.PersonWhereInput = { ...ohneGeloeschte(input.mitGeloeschten), ...(input.nurAktive ? { aktiv: true } : {}), ...(input.abteilung ? { abteilung: input.abteilung } : {}), ...(input.qualifikationstypId ? { qualifikationen: { some: { typId: input.qualifikationstypId, geloeschtAm: null }, }, } : {}), ...(sucheUeber( ["nachname", "vorname", "mitgliedsnummer", "email", "ort"], input.suche, ) ?? {}), }; const [eintraege, gesamt] = await Promise.all([ db.person.findMany({ where: wo, select: { id: true, vorname: true, nachname: true, mitgliedsnummer: true, dienstgrad: true, abteilung: true, email: true, mobil: true, eintrittsdatum: true, aktiv: true, geloeschtAm: true, rollen: { select: { rolle: { select: { schluessel: true, name: true } } } }, _count: { select: { qualifikationen: true } }, }, orderBy: sortierenNach(input.sortierung, input.richtung, { nachname: "asc", }) as Prisma.PersonOrderByWithRelationInput, ...blaettern(input), }), db.person.count({ where: wo }), ]); return listenAntwort(eintraege, gesamt, input); }), ), auswahl: mitRecht("person.lesen").query(async ({ ctx }) => ctx.db((db) => db.person.findMany({ where: { geloeschtAm: null, aktiv: true }, select: { id: true, vorname: true, nachname: true, mitgliedsnummer: true }, orderBy: [{ nachname: "asc" }, { vorname: "asc" }], }), ), ), /** Eine Person. Die eigenen Daten darf jeder sehen, fremde nur mit Recht. */ einzeln: angemeldet.input(idEingabe).query(async ({ ctx, input }) => { if (!darfPersonSehen(ctx.sitzung, input.id)) { throw new KeinZugriffFehler("person.lesen", "Diese Personendaten dürfen Sie nicht sehen."); } return ctx.db(async (db) => { const person = await db.person.findUnique({ where: { id: input.id }, include: { rollen: { include: { rolle: true } }, qualifikationen: { where: { geloeschtAm: null }, include: { typ: true }, orderBy: { erworbenAm: "desc" }, }, }, }); if (!person) { throw new TRPCError({ code: "NOT_FOUND", message: "Diese Person gibt es nicht." }); } // Bis Phase 9 wurde hier der Passwort-Hash aus dem Ergebnis geschnitten. // Das ist nicht mehr nötig: die Anmeldedaten liegen seit Phase 10 am // `Benutzer`, und eine Personenabfrage kann sie gar nicht mehr // mitbringen. Die Sorgfaltsmaßnahme ist durch eine Struktur ersetzt. return person; }); }), anlegen: mitRecht("person.bearbeiten") .input(personDaten) .mutation(async ({ ctx, input }) => ctx.db(async (db) => { const person = await db.person.create({ data: { organisationId: ctx.sitzung.organisationId, ...input, email: input.email?.toLowerCase() ?? null, }, }); await protokolliereIn(db, { organisationId: ctx.sitzung.organisationId, aktion: AuditAktion.ERSTELLT, entitaet: "Person", entitaetId: person.id, beschreibung: `Mitglied ${person.nachname}, ${person.vorname} angelegt`, sitzung: ctx.sitzung, nachher: person, ipAdresse: ctx.ipAdresse, userAgent: ctx.userAgent, }); return person; }), ), aendern: mitRecht("person.bearbeiten") .input(personDaten.partial().extend({ id: z.string().uuid() })) .mutation(async ({ ctx, input }) => ctx.db(async (db) => { const { id, ...aenderungen } = input; const vorher = await db.person.findUnique({ where: { id } }); if (!vorher) { throw new TRPCError({ code: "NOT_FOUND", message: "Diese Person gibt es nicht." }); } const nachher = await db.person.update({ where: { id }, data: { ...aenderungen, ...(aenderungen.email !== undefined ? { email: aenderungen.email?.toLowerCase() ?? null } : {}), }, }); await protokolliereIn(db, { organisationId: ctx.sitzung.organisationId, aktion: AuditAktion.GEAENDERT, entitaet: "Person", entitaetId: id, beschreibung: `Mitglied ${nachher.nachname}, ${nachher.vorname} geändert`, sitzung: ctx.sitzung, vorher, nachher, ipAdresse: ctx.ipAdresse, userAgent: ctx.userAgent, }); return nachher; }), ), /** Austragen. Soft-Delete, wie bei allem mit Nachweischarakter. */ austragen: mitRecht("person.loeschen") .input(idEingabe.extend({ austrittsdatum: z.coerce.date().optional() })) .mutation(async ({ ctx, input }) => ctx.db(async (db) => { if (input.id === ctx.sitzung.personId) { throw new TRPCError({ code: "BAD_REQUEST", message: "Sie können sich nicht selbst austragen.", }); } const vorher = await db.person.findUnique({ where: { id: input.id } }); if (!vorher || vorher.geloeschtAm) { throw new TRPCError({ code: "NOT_FOUND", message: "Diese Person gibt es nicht." }); } const nachher = await db.person.update({ where: { id: input.id }, data: { aktiv: false, austrittsdatum: input.austrittsdatum ?? new Date(), geloeschtAm: new Date(), }, }); await protokolliereIn(db, { organisationId: ctx.sitzung.organisationId, aktion: AuditAktion.GELOESCHT, entitaet: "Person", entitaetId: input.id, beschreibung: `Mitglied ${vorher.nachname}, ${vorher.vorname} ausgetragen`, sitzung: ctx.sitzung, vorher, nachher, ipAdresse: ctx.ipAdresse, userAgent: ctx.userAgent, }); return { erfolgreich: true }; }), ), /** Welche Abteilungen es gibt — für die Filterleiste. */ abteilungen: mitRecht("person.lesen").query(async ({ ctx }) => ctx.db(async (db) => { const gruppen = await db.person.groupBy({ by: ["abteilung"], where: { geloeschtAm: null, abteilung: { not: null } }, _count: { _all: true }, }); return gruppen .filter((gruppe) => gruppe.abteilung) .map((gruppe) => ({ name: gruppe.abteilung as string, anzahl: gruppe._count._all })) .sort((a, b) => a.name.localeCompare(b.name, "de")); }), ), }); // ----------------------------------------------------------------------------- const qualifikationDaten = z.object({ personId: z.string().uuid(), typId: z.string().uuid(), erworbenAm: z.coerce.date(), /** Leer lassen: dann wird aus der Gültigkeitsdauer des Typs gerechnet. */ gueltigBis: z.coerce.date().nullish(), ausstellendeStelle: z.string().trim().max(200).nullish(), nachweisnummer: z.string().trim().max(80).nullish(), bemerkung: z.string().trim().max(1000).nullish(), }); export const qualifikationRouter = router({ /** Qualifikationen einer Person. */ zuPerson: angemeldet .input(z.object({ personId: z.string().uuid() })) .query(async ({ ctx, input }) => { if (!darfPersonSehen(ctx.sitzung, input.personId)) { throw new KeinZugriffFehler("person.lesen"); } return ctx.db((db) => db.qualifikation.findMany({ where: { personId: input.personId, geloeschtAm: null }, include: { typ: true }, orderBy: [{ gueltigBis: "asc" }, { erworbenAm: "desc" }], }), ); }), /** * Was demnächst abläuft. Speist die Warnung auf der Übersicht. * Gerechnet wird mit der Vorwarnzeit des jeweiligen Typs, nicht mit einer * pauschalen Frist — eine G26.3-Untersuchung braucht mehr Vorlauf als eine * Unterweisung. */ ablaufwarnungen: mitRecht("qualifikation.lesen") .input(z.object({ tage: z.number().int().min(1).max(730).default(90) }).optional()) .query(async ({ ctx, input }) => ctx.db(async (db) => { const fenster = new Date(Date.now() + (input?.tage ?? 90) * 864e5); const eintraege = await db.qualifikation.findMany({ where: { geloeschtAm: null, gueltigBis: { not: null, lte: fenster }, person: { aktiv: true, geloeschtAm: null }, }, include: { typ: { select: { id: true, name: true, kuerzel: true, vorwarnzeitTage: true, einsatzrelevant: true } }, person: { select: { id: true, vorname: true, nachname: true, mitgliedsnummer: true } }, }, orderBy: { gueltigBis: "asc" }, take: 200, }); return eintraege.map((eintrag) => ({ ...eintrag, zustand: qualifikationZustand(eintrag.gueltigBis, eintrag.typ.vorwarnzeitTage), })); }), ), anlegen: mitRecht("qualifikation.bearbeiten") .input(qualifikationDaten) .mutation(async ({ ctx, input }) => ctx.db(async (db) => { const typ = await db.qualifikationstyp.findUnique({ where: { id: input.typId }, select: { id: true, name: true, gueltigkeitMonate: true }, }); if (!typ) { throw new TRPCError({ code: "NOT_FOUND", message: "Diesen Qualifikationstyp gibt es nicht.", }); } const person = await db.person.findUnique({ where: { id: input.personId }, select: { id: true, vorname: true, nachname: true }, }); if (!person) { throw new TRPCError({ code: "NOT_FOUND", message: "Diese Person gibt es nicht." }); } const qualifikation = await db.qualifikation.create({ data: { organisationId: ctx.sitzung.organisationId, ...input, // Ausdrücklich gesetztes Ablaufdatum gewinnt: auf einer Urkunde // steht manchmal etwas anderes, als die Regel hergäbe. gueltigBis: input.gueltigBis ?? qualifikationGueltigBis(input.erworbenAm, typ.gueltigkeitMonate), bestaetigtVonId: ctx.sitzung.personId, bestaetigtAm: new Date(), }, }); await protokolliereIn(db, { organisationId: ctx.sitzung.organisationId, aktion: AuditAktion.ERSTELLT, entitaet: "Qualifikation", entitaetId: qualifikation.id, beschreibung: `${typ.name} für ${person.nachname}, ${person.vorname} eingetragen`, sitzung: ctx.sitzung, nachher: qualifikation, kontext: { personId: person.id }, ipAdresse: ctx.ipAdresse, userAgent: ctx.userAgent, }); return qualifikation; }), ), aendern: mitRecht("qualifikation.bearbeiten") .input(qualifikationDaten.partial().omit({ personId: true }).extend({ id: z.string().uuid() })) .mutation(async ({ ctx, input }) => ctx.db(async (db) => { const { id, ...aenderungen } = input; const vorher = await db.qualifikation.findUnique({ where: { id }, include: { typ: { select: { name: true, gueltigkeitMonate: true } } }, }); if (!vorher) { throw new TRPCError({ code: "NOT_FOUND", message: "Diese Qualifikation gibt es nicht.", }); } const erworben = aenderungen.erworbenAm ?? vorher.erworbenAm; const nachher = await db.qualifikation.update({ where: { id }, data: { ...aenderungen, gueltigBis: aenderungen.gueltigBis !== undefined ? aenderungen.gueltigBis : qualifikationGueltigBis(erworben, vorher.typ.gueltigkeitMonate), }, }); await protokolliereIn(db, { organisationId: ctx.sitzung.organisationId, aktion: AuditAktion.GEAENDERT, entitaet: "Qualifikation", entitaetId: id, beschreibung: `${vorher.typ.name} geändert`, sitzung: ctx.sitzung, vorher, nachher, ipAdresse: ctx.ipAdresse, userAgent: ctx.userAgent, }); return nachher; }), ), loeschen: mitRecht("qualifikation.bearbeiten") .input(idEingabe) .mutation(async ({ ctx, input }) => ctx.db(async (db) => { const vorher = await db.qualifikation.findUnique({ where: { id: input.id }, include: { typ: { select: { name: true } } }, }); if (!vorher || vorher.geloeschtAm) { throw new TRPCError({ code: "NOT_FOUND", message: "Diese Qualifikation gibt es nicht.", }); } await db.qualifikation.update({ where: { id: input.id }, data: { geloeschtAm: new Date() }, }); await protokolliereIn(db, { organisationId: ctx.sitzung.organisationId, aktion: AuditAktion.GELOESCHT, entitaet: "Qualifikation", entitaetId: input.id, beschreibung: `${vorher.typ.name} entfernt`, sitzung: ctx.sitzung, vorher, ipAdresse: ctx.ipAdresse, userAgent: ctx.userAgent, }); return { erfolgreich: true }; }), ), });