Files
FireAdministration/src/server/routers/stammdaten/person.ts
T
florian.wilde 14cc4de9b8
Pruefung / pruefung (push) Canceled after 0s
Pruefung / oberflaeche (push) Canceled after 0s
Phase 11: Freigabereife (#16)
2026-09-06 20:05:49 +00:00

564 lines
19 KiB
TypeScript

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