564 lines
19 KiB
TypeScript
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 };
|
|
}),
|
|
),
|
|
});
|