Phase 11: Freigabereife
Pruefung / pruefung (pull_request) Canceled after 0s
Pruefung / oberflaeche (pull_request) Canceled after 0s

Kein Modul, sondern die drei Software-Voraussetzungen fuer 1.0.0:
Auskunft nach Art. 15 DSGVO, ein geprueftes Sicherungskonzept und eine
Pipeline.

Das Auskunftsrecht war ein Versprechen ohne Umsetzung: `person.export`
stand seit Phase 0 im Katalog und war dem Kommandanten zugewiesen,
implementiert war nichts. Ohne Auskunftsseite ist ein Loeschkonzept nicht
abnehmbar. Jetzt sammelt FLORIAN dreizehn Abschnitte ueber eine Person,
mit dem Zweck ueber jedem - Art. 15 verlangt nicht nur die Daten, sondern
auch Zwecke und Rechte, und eine blosse Liste erfuellt die Pflicht nicht.
Ein Durchgang liefert Anzeige, PDF und JSON nach Art. 20.

Die Trennlinie ist "ueber mich" gegen "von mir": was ueber die Person
gespeichert ist, steht vollstaendig drin, auch das Unangenehme. Was sie
getan hat, steht als Zahl drin - die Pruefprotokolle gehoeren zum
Nachweisbestand der Wehr, und vollstaendig ausgeliefert kaeme jeder ueber
eine Selbstauskunft an die Geraeteakten.

Die Rueckspielprobe hat sich sofort bezahlt gemacht: der erste Versuch
lief sauber durch und hatte dabei saemtliche Unveraenderlichkeitsgarantien
der Datenbank entfernt. Die Vorgabeprivilegierung aus dem Init-Skript gibt
jeder von pg_restore neu angelegten Tabelle wieder alle Rechte, und GRANT
kann nur hinzufuegen. Die Anwendungsrolle durfte danach das Audit-Log
aendern, Wiki-Fassungen loeschen, Kiosk-Vorgaenge umschreiben - und nichts
haette darauf hingewiesen. Gefunden haben es vier Datenbanktests
unmittelbar nach der Probe, also genau die Probe, wegen der 1.0.0 sie
verlangt.

Die Pipeline ist geschrieben, aber NICHT erprobt: in Gitea ist kein
act_runner registriert. Das steht so im README und im Devlog, damit sich
niemand auf eine Pruefung verlaesst, die nicht stattfindet.

601 Unit-Tests gruen; Sicherung und Wiederherstellung einmal vollstaendig
durchgefuehrt und die Tests danach erneut gruen.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Florian Wilde
2026-09-05 17:16:08 +02:00
co-authored by Claude Opus 5
parent 91742a0dd7
commit 9156dcc144
21 changed files with 2495 additions and 8 deletions
+15 -1
View File
@@ -2,6 +2,7 @@ import Link from "next/link";
import { notFound, redirect } from "next/navigation";
import { ArrowLeft } from "lucide-react";
import { aktuelleSitzung } from "@/lib/auth";
import { hatRecht } from "@/lib/authz";
import { darfPersonSehen } from "@/lib/authz";
import { fuerOrganisation } from "@/lib/db";
import { alsDatum, personAnrede } from "@/lib/utils";
@@ -10,6 +11,7 @@ import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/com
import { Badge } from "@/components/ui/badge";
import { FristAnzeige, NichtsGefunden } from "@/components/stammdaten/anzeige";
import { DIENSTGRAD_TEXT } from "@/lib/stammdaten/texte";
import { Datenauskunft } from "@/components/stammdaten/auskunft";
export const metadata = { title: "Mitglied" };
@@ -130,7 +132,19 @@ export default async function MitgliedSeite({ params }: { params: Promise<{ id:
Zurück zur Mitgliederliste
</Link>
</Button>
<h1 className="text-2xl font-semibold tracking-tight">{personAnrede(person)}</h1>
<div className="flex flex-wrap items-start justify-between gap-3">
<h1 className="text-2xl font-semibold tracking-tight">{personAnrede(person)}</h1>
{/* Die Auskunft nach Art. 15 DSGVO. Sie steht hier und nicht in
einem Datenschutzmenü: gefragt wird sie zu einer bestimmten
Person, und genau deren Seite ist man gerade. */}
{hatRecht(sitzung, "person.export") && (
<Datenauskunft
personId={person.id}
personName={`${person.vorname} ${person.nachname}`}
/>
)}
</div>
<div className="mt-1 flex flex-wrap items-center gap-2">
{person.rollen.map((zuweisung) => (
<Badge key={zuweisung.rolle.schluessel} variant="secondary">
+190
View File
@@ -0,0 +1,190 @@
"use client";
import * as React from "react";
import { Download, FileText, ShieldCheck } from "lucide-react";
import type { inferRouterOutputs } from "@trpc/server";
import { trpc } from "@/lib/trpc/client";
import type { AppRouter } from "@/server/routers/_app";
import { Button } from "@/components/ui/button";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import { Fehlermeldung } from "@/components/stammdaten/anzeige";
type Ergebnis = inferRouterOutputs<AppRouter>["person"]["auskunft"];
/**
* Die Datenauskunft nach Art. 15 DSGVO.
*
* Ein Knopf, ein Vorgang, drei Ergebnisse: die Anzeige auf dem Schirm, ein PDF
* für die Akte und eine JSON-Datei nach Art. 20. Bewusst nicht drei Knöpfe —
* wer eine Auskunft erteilt, soll sie vollständig erteilen und nicht in
* Schritten, von denen zwei vergessen werden können.
*
* Der Vorgang steht danach im Protokoll. Das ist keine Förmlichkeit: die
* Auskunft legt sämtliche Daten eines Menschen offen, einschließlich seiner
* Gesundheitsdaten aus der G26 — dass das geschehen ist, gehört festgehalten.
*/
export function Datenauskunft({
personId,
personName,
}: {
personId: string;
personName: string;
}) {
const [offen, setOffen] = React.useState(false);
const [ergebnis, setErgebnis] = React.useState<Ergebnis | null>(null);
const [meldung, setMeldung] = React.useState<string | null>(null);
const erstellen = trpc.person.auskunft.useMutation({
onSuccess: (daten) => {
setErgebnis(daten);
setMeldung(null);
},
onError: (fehler) => setMeldung(fehler.message),
});
/** Die maschinenlesbare Fassung — Art. 20, Datenübertragbarkeit. */
function jsonHerunterladen() {
if (!ergebnis) return;
const ziel = URL.createObjectURL(
new Blob([JSON.stringify(ergebnis.auskunft, null, 2)], {
type: "application/json;charset=utf-8",
}),
);
const verweis = document.createElement("a");
verweis.href = ziel;
verweis.download = `auskunft-${personName.replace(/\s+/g, "-").toLowerCase()}.json`;
verweis.click();
URL.revokeObjectURL(ziel);
}
return (
<>
<Button
variant="outline"
size="sm"
onClick={() => {
setOffen(true);
setErgebnis(null);
}}
>
<ShieldCheck aria-hidden />
Datenauskunft
</Button>
<Dialog
open={offen}
onOpenChange={(zustand) => {
if (!zustand) {
setOffen(false);
setErgebnis(null);
setMeldung(null);
}
}}
>
<DialogContent className="max-h-[90vh] overflow-y-auto sm:max-w-2xl">
<DialogHeader>
<DialogTitle>Datenauskunft nach Art. 15 DSGVO</DialogTitle>
<DialogDescription>
Für {personName}. Es entsteht eine vollständige Übersicht aller Daten, die diese
Wehr über die Person verarbeitet — einschließlich der Gesundheitsdaten aus der
G26.
</DialogDescription>
</DialogHeader>
{meldung && <Fehlermeldung meldung={meldung} kennung="auskunftsfehler" />}
{!ergebnis && (
<div className="space-y-3 text-sm">
<p>
Die Auskunft enthält Stammdaten, Rollen, Qualifikationen, Einsatz- und
Übungsbeteiligung, Anwesenheiten, Fahrten, Beiträge, Benachrichtigungswege und
die arbeitsmedizinische Vorsorge.
</p>
<p className="rounded-md bg-[var(--muted)] p-3">
<strong>Der Vorgang wird protokolliert.</strong> Dass eine Auskunft erteilt
wurde, gehört festgehalten — sie legt alles offen, was über einen Menschen
gespeichert ist.
</p>
</div>
)}
{ergebnis && (
<div className="space-y-4">
<div className="rounded-md border border-[var(--einsatzbereit)] bg-[var(--einsatzbereit)]/10 p-3 text-sm">
Auskunft erstellt: <strong>{ergebnis.umfang} Angaben</strong> in{" "}
{ergebnis.auskunft.abschnitte.length} Abschnitten. Der Vorgang steht im
Protokoll.
</div>
<div className="flex flex-wrap gap-2">
<Button size="sm" asChild>
<a href={`/api/dateien/${ergebnis.dateiId}`} target="_blank">
<FileText aria-hidden />
PDF herunterladen
</a>
</Button>
<Button size="sm" variant="outline" onClick={jsonHerunterladen}>
<Download aria-hidden />
Als JSON (Art. 20)
</Button>
</div>
<div className="space-y-3">
{ergebnis.auskunft.abschnitte.map((abschnitt) => (
<Card key={abschnitt.titel}>
<CardHeader className="pb-2">
<CardTitle className="text-sm">
{abschnitt.titel}
{abschnitt.besondereKategorie && (
<span className="ml-2 text-xs font-normal text-[var(--pruefung-faellig)]">
besondere Kategorie (Art. 9)
</span>
)}
</CardTitle>
<CardDescription className="text-xs">{abschnitt.zweck}</CardDescription>
</CardHeader>
<CardContent className="text-sm">
{abschnitt.eintraege.length === 0 ? (
<p className="text-[var(--muted-foreground)]">
Dazu liegen keine Daten vor.
</p>
) : (
<p>
{abschnitt.eintraege.length}{" "}
{abschnitt.eintraege.length === 1 ? "Eintrag" : "Einträge"}
</p>
)}
</CardContent>
</Card>
))}
</div>
</div>
)}
<DialogFooter>
<Button variant="outline" onClick={() => setOffen(false)}>
{ergebnis ? "Schließen" : "Abbrechen"}
</Button>
{!ergebnis && (
<Button
onClick={() => erstellen.mutate({ id: personId })}
disabled={erstellen.isPending}
>
{erstellen.isPending ? "Wird zusammengestellt …" : "Auskunft erstellen"}
</Button>
)}
</DialogFooter>
</DialogContent>
</Dialog>
</>
);
}
+394
View File
@@ -0,0 +1,394 @@
import type { MandantenClient } from "@/lib/db";
/**
* Die Auskunft nach Art. 15 DSGVO.
*
* Jede Person darf verlangen zu erfahren, welche Daten über sie verarbeitet
* werden. In einer Feuerwehr ist das mehr, als die meisten erwarten: neben den
* Stammdaten stehen dort Einsatzbeteiligungen, Anwesenheiten, Fahrten,
* Beiträge — und mit den G26-Untersuchungen auch Gesundheitsdaten, also eine
* besondere Kategorie nach Art. 9.
*
* Zwei Festlegungen bestimmen den Zuschnitt:
*
* **Was über die Person gespeichert ist, steht vollständig drin.** Auch das
* Unangenehme: eine nicht bestandene G26, ein gemahnter Beitrag. Eine
* Auskunft, die das Angenehme auswählt, ist keine.
*
* **Was die Person getan hat, steht als Zusammenfassung drin.** Wer zwölf
* Prüfprotokolle unterschrieben hat, bekommt die Zahl und die Fundstellen —
* nicht zwölf Protokolle. Die gehören zum Nachweisbestand der Wehr, nicht zur
* Person; sie mitzuliefern hieße, dass jeder über den Umweg einer
* Selbstauskunft an den Geräteakten der Wehr käme.
*
* Die Trennlinie ist also nicht „viel oder wenig", sondern „über mich" gegen
* „von mir".
*/
/** Ein Abschnitt der Auskunft, wie er auf Papier und im JSON erscheint. */
export interface Abschnitt {
titel: string;
/** Warum diese Daten überhaupt vorliegen — Art. 15 Abs. 1 lit. a und b. */
zweck: string;
/** Leer heißt: dazu liegt nichts vor. Das ist auch eine Auskunft. */
eintraege: Array<Record<string, string | null>>;
/** Besondere Kategorie nach Art. 9 — wird im Bericht gekennzeichnet. */
besondereKategorie?: boolean;
}
export interface Auskunft {
erstelltAm: Date;
wehr: { name: string; strasse: string | null; plz: string | null; ort: string | null };
person: {
id: string;
name: string;
mitgliedsnummer: string | null;
};
abschnitte: Abschnitt[];
/** Was die Person getan hat — als Zahl, nicht als Aktenauszug. */
taetigkeiten: Array<{ was: string; anzahl: number }>;
}
/** Datum ohne Uhrzeit, wie es in einem Bericht steht. */
function tag(wert: Date | null | undefined): string | null {
return wert ? wert.toISOString().slice(0, 10) : null;
}
/** Zeitpunkt mit Minute — für alles, wo die Uhrzeit zur Sache gehört. */
function zeitpunkt(wert: Date | null | undefined): string | null {
if (!wert) return null;
return wert.toISOString().slice(0, 16).replace("T", " ");
}
/**
* Alles zusammentragen, was über eine Person vorliegt.
*
* Läuft im Mandantenkontext: die Abfragen sehen nur die Wehr, in der die
* Person geführt wird. Eine Person, die in zwei Wehren eine Akte hat, bekommt
* zwei Auskünfte — und das ist richtig so, denn es sind zwei
* Verantwortliche im Sinne der Verordnung.
*/
export async function auskunftSammeln(db: MandantenClient, personId: string): Promise<Auskunft> {
const person = await db.person.findUnique({
where: { id: personId },
include: {
organisation: { select: { name: true, strasse: true, plz: true, ort: true } },
mitgliedschaft: {
include: {
benutzer: {
select: {
email: true,
letzteAnmeldung: true,
aktiv: true,
oidcSubject: true,
},
},
},
},
rollen: { include: { rolle: { select: { name: true } } } },
qualifikationen: {
where: { geloeschtAm: null },
include: { typ: { select: { name: true, kuerzel: true } } },
orderBy: { erworbenAm: "desc" },
},
ausweisTokens: {
where: { geloeschtAm: null },
select: { typ: true, bezeichnung: true, erstelltAm: true, letzteNutzung: true },
},
zustellkanaele: {
select: {
kanal: true,
ziel: true,
bezeichnung: true,
aktiv: true,
letzteZustellung: true,
},
},
},
});
if (!person) {
throw new Error("Diese Person gibt es nicht.");
}
const abschnitte: Abschnitt[] = [];
// --- Stammdaten -----------------------------------------------------------
abschnitte.push({
titel: "Stammdaten",
zweck:
"Führung des Mitgliederverzeichnisses der Feuerwehr. Rechtsgrundlage: "
+ "Art. 6 Abs. 1 lit. e DSGVO in Verbindung mit dem Bayerischen "
+ "Feuerwehrgesetz.",
eintraege: [
{
Vorname: person.vorname,
Nachname: person.nachname,
Mitgliedsnummer: person.mitgliedsnummer,
Dienstgrad: person.dienstgrad,
Abteilung: person.abteilung,
Geburtsdatum: tag(person.geburtsdatum),
Eintritt: tag(person.eintrittsdatum),
Austritt: tag(person.austrittsdatum),
"E-Mail": person.email,
Telefon: person.telefon,
Mobil: person.mobil,
Anschrift: [person.strasse, [person.plz, person.ort].filter(Boolean).join(" ")]
.filter(Boolean)
.join(", "),
"Angelegt am": tag(person.erstelltAm),
},
],
});
// --- Zugang ---------------------------------------------------------------
abschnitte.push({
titel: "Zugang zur Software",
zweck:
"Anmeldung und Nachvollziehbarkeit von Änderungen. Ohne Konto ist die "
+ "Person nur im Verzeichnis geführt und meldet sich nicht an.",
eintraege: person.mitgliedschaft?.benutzer
? [
{
"Anmeldung mit": person.mitgliedschaft.benutzer.email,
"Zuletzt angemeldet": zeitpunkt(person.mitgliedschaft.benutzer.letzteAnmeldung),
Konto: person.mitgliedschaft.benutzer.aktiv ? "aktiv" : "abgeschaltet",
"Anmeldung über authentik": person.mitgliedschaft.benutzer.oidcSubject
? "ja"
: "nein",
},
]
: [],
});
// --- Rollen ---------------------------------------------------------------
abschnitte.push({
titel: "Rollen und Berechtigungen",
zweck: "Steuert, was die Person in der Software sehen und tun darf.",
eintraege: person.rollen.map((zuweisung) => ({
Rolle: zuweisung.rolle.name,
"Zugewiesen am": tag(zuweisung.zugewiesenAm),
"Gültig bis": tag(zuweisung.gueltigBis),
"Aus authentik-Gruppe": zuweisung.ausOidcGruppe ? "ja" : "nein",
})),
});
// --- Qualifikationen ------------------------------------------------------
abschnitte.push({
titel: "Qualifikationen und Lehrgänge",
zweck: "Nachweis der Ausbildung; Grundlage für die Einteilung im Einsatz.",
eintraege: person.qualifikationen.map((eintrag) => ({
Qualifikation: eintrag.typ.name,
Kürzel: eintrag.typ.kuerzel,
Erworben: tag(eintrag.erworbenAm),
"Gültig bis": tag(eintrag.gueltigBis),
Nachweis: eintrag.nachweisnummer,
})),
});
// --- Atemschutz: besondere Kategorie --------------------------------------
const g26 = await db.g26Untersuchung.findMany({
where: { personId, geloeschtAm: null },
orderBy: { untersuchtAm: "desc" },
});
const belastung = await db.belastungsuebung.findMany({
where: { personId },
orderBy: { datum: "desc" },
});
abschnitte.push({
titel: "Arbeitsmedizinische Vorsorge G 26",
zweck:
"Nachweis der Atemschutztauglichkeit nach FwDV 7 und DGUV. "
+ "Gesundheitsdaten — besondere Kategorie nach Art. 9 DSGVO. Gespeichert "
+ "wird das Ergebnis und die Frist, nicht der Befund.",
besondereKategorie: true,
eintraege: g26.map((eintrag) => ({
"Untersucht am": tag(eintrag.untersuchtAm),
Ergebnis: eintrag.ergebnis,
"Gültig bis": tag(eintrag.gueltigBis),
Arzt: eintrag.arzt,
Bemerkung: eintrag.bemerkung,
})),
});
abschnitte.push({
titel: "Belastungsübungen",
zweck: "Nachweis der jährlichen Übung in der Atemschutzstrecke nach FwDV 7.",
besondereKategorie: true,
eintraege: belastung.map((eintrag) => ({
"Absolviert am": tag(eintrag.datum),
Art: eintrag.art,
Ort: eintrag.ort,
Dauer: eintrag.dauerMinuten === null ? null : `${eintrag.dauerMinuten} Minuten`,
Bemerkung: eintrag.bemerkung,
})),
});
// --- Einsatzbeteiligung ---------------------------------------------------
const einsaetze = await db.einsatzkraft.findMany({
where: { personId },
include: {
einsatz: {
select: { einsatznummer: true, stichwort: true, alarmiertUm: true, ort: true },
},
},
orderBy: { einsatz: { alarmiertUm: "desc" } },
take: 500,
});
abschnitte.push({
titel: "Einsatzbeteiligung",
zweck:
"Nachweis der Teilnahme; Grundlage für Aufwandsentschädigung, "
+ "Versicherungsschutz und Auszeichnungen.",
eintraege: einsaetze.map((eintrag) => ({
Einsatz: eintrag.einsatz.einsatznummer,
Stichwort: eintrag.einsatz.stichwort,
Alarmierung: zeitpunkt(eintrag.einsatz.alarmiertUm),
Ort: eintrag.einsatz.ort,
Funktion: eintrag.funktion,
})),
});
// --- Übungen --------------------------------------------------------------
const uebungen = await db.uebungsteilnahme.findMany({
where: { personId },
include: { uebung: { select: { thema: true, beginnUm: true, art: true } } },
orderBy: { uebung: { beginnUm: "desc" } },
take: 500,
});
abschnitte.push({
titel: "Übungsteilnahme",
zweck: "Nachweis der Ausbildung nach FwDV 2.",
eintraege: uebungen.map((eintrag) => ({
Übung: eintrag.uebung.thema,
Art: eintrag.uebung.art,
Beginn: zeitpunkt(eintrag.uebung.beginnUm),
Funktion: eintrag.funktion,
Bemerkung: eintrag.bemerkung,
})),
});
// --- Anwesenheit und Fahrten ---------------------------------------------
const anwesenheiten = await db.anwesenheit.findMany({
where: { personId },
orderBy: { angemeldetUm: "desc" },
take: 500,
});
abschnitte.push({
titel: "Anwesenheit am Gerätehaus",
zweck: "Am Terminal erfasste An- und Abmeldungen.",
eintraege: anwesenheiten.map((eintrag) => ({
Anlass: eintrag.anlass,
Angemeldet: zeitpunkt(eintrag.angemeldetUm),
Abgemeldet: zeitpunkt(eintrag.abgemeldetUm),
})),
});
const fahrten = await db.fahrt.findMany({
where: { fahrerId: personId },
include: { fahrzeug: { select: { funkrufname: true } } },
orderBy: { abfahrtUm: "desc" },
take: 500,
});
abschnitte.push({
titel: "Fahrten als Maschinist",
zweck: "Fahrtenbuch der Fahrzeuge; Nachweis gegenüber Halter und Versicherung.",
eintraege: fahrten.map((eintrag) => ({
Fahrzeug: eintrag.fahrzeug.funkrufname,
Abfahrt: zeitpunkt(eintrag.abfahrtUm),
Rückkehr: zeitpunkt(eintrag.rueckkehrUm),
Zweck: eintrag.zweck,
"Kilometer bei Abfahrt": String(eintrag.kilometerAbfahrt),
"Kilometer bei Rückkehr":
eintrag.kilometerRueckkehr === null ? null : String(eintrag.kilometerRueckkehr),
})),
});
// --- Beiträge -------------------------------------------------------------
const beitraege = await db.beitrag.findMany({
where: { personId },
orderBy: { jahr: "desc" },
});
abschnitte.push({
titel: "Mitgliedsbeiträge",
zweck: "Abrechnung der Beiträge; Buchführung der Wehr.",
eintraege: beitraege.map((eintrag) => ({
Jahr: String(eintrag.jahr),
Betrag: `${eintrag.betrag.toFixed(2)} EUR`,
Stand: eintrag.stand,
"Bezahlt am": tag(eintrag.bezahltAm),
Mahnstufe: eintrag.mahnstufe > 0 ? String(eintrag.mahnstufe) : null,
"Grund des Erlasses": eintrag.erlassGrund,
})),
});
// --- Erreichbarkeit -------------------------------------------------------
abschnitte.push({
titel: "Benachrichtigungswege",
zweck:
"Zustellung von Terminerinnerungen und Meldungen. Jede Person richtet "
+ "sie selbst ein und kann sie jederzeit entfernen.",
eintraege: person.zustellkanaele.map((eintrag) => ({
Kanal: eintrag.kanal,
Ziel: eintrag.ziel,
Bezeichnung: eintrag.bezeichnung,
"Zuletzt zugestellt": zeitpunkt(eintrag.letzteZustellung),
Aktiv: eintrag.aktiv ? "ja" : "nein",
})),
});
abschnitte.push({
titel: "Ausweise und PIN",
zweck:
"Anmeldung am Terminal im Gerätehaus. Die Geheimnisse selbst sind nur "
+ "als Prüfsumme gespeichert und lassen sich nicht zurückrechnen — "
+ "deshalb stehen hier nur Art und Nutzung.",
eintraege: person.ausweisTokens.map((eintrag) => ({
Art: eintrag.typ,
Bezeichnung: eintrag.bezeichnung,
"Angelegt am": tag(eintrag.erstelltAm),
"Zuletzt benutzt": zeitpunkt(eintrag.letzteNutzung),
})),
});
// --- Was die Person getan hat --------------------------------------------
//
// Bewusst nur gezählt. Die Unterlagen dahinter gehören zum Nachweisbestand
// der Wehr; sie hier mitzuliefern hieße, dass jeder über eine
// Selbstauskunft an die Geräteakten käme.
const [pruefungen, maengel, protokolleinträge] = await Promise.all([
db.pruefung.count({ where: { prueferId: personId } }),
db.mangel.count({ where: { melderId: personId } }),
db.auditLog.count({ where: { akteurPersonId: personId } }),
]);
const taetigkeiten = [
{ was: "durchgeführte Prüfungen", anzahl: pruefungen },
{ was: "gemeldete Mängel", anzahl: maengel },
{ was: "protokollierte Änderungen in der Software", anzahl: protokolleinträge },
];
return {
erstelltAm: new Date(),
wehr: person.organisation,
person: {
id: person.id,
name: `${person.vorname} ${person.nachname}`,
mitgliedsnummer: person.mitgliedsnummer,
},
abschnitte,
taetigkeiten,
};
}
/** Wie viele Angaben die Auskunft insgesamt enthält — für die Vorschau. */
export function auskunftUmfang(auskunft: Auskunft): number {
return auskunft.abschnitte.reduce((summe, abschnitt) => summe + abschnitt.eintraege.length, 0);
}
+170
View File
@@ -0,0 +1,170 @@
import { htmlZuPdf } from "@/lib/pdf";
import type { Auskunft } from "@/lib/dsgvo/auskunft";
/**
* Die Auskunft als PDF.
*
* Eine Datenauskunft ist ein Schreiben an einen Menschen, keine
* Datenbankausgabe. Deshalb steht über jedem Abschnitt, **warum** diese Daten
* vorliegen — Art. 15 Abs. 1 verlangt nicht nur die Daten, sondern auch
* Zwecke, Empfänger, Speicherdauer und den Hinweis auf die Betroffenenrechte.
* Eine Liste ohne diese Angaben erfüllt die Auskunftspflicht nicht, sieht aber
* so aus, als täte sie es.
*
* Leere Abschnitte bleiben stehen. „Dazu liegt nichts vor" ist eine Auskunft;
* ein weggelassener Abschnitt wirft die Frage auf, ob da doch etwas ist.
*/
function html(wert: string | null | undefined): string {
return (wert ?? "")
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;");
}
function fusszeile(text: string): string {
return `<!doctype html><html><head><meta charset="utf-8"><style>
body { font-family: Helvetica, Arial, sans-serif; font-size: 7pt; color: #555;
width: 100%; margin: 0; padding: 0 18mm; }
</style></head><body>
<div style="display:flex;justify-content:space-between">
<span>${html(text)}</span>
<span>Seite <span class="pageNumber"></span> von <span class="totalPages"></span></span>
</div>
</body></html>`;
}
function rahmen(titel: string, inhalt: string): string {
return `<!doctype html>
<html lang="de">
<head>
<meta charset="utf-8">
<title>${html(titel)}</title>
<style>
@page { size: A4; margin: 18mm 18mm 22mm 18mm; }
body { font-family: Helvetica, Arial, sans-serif; font-size: 10pt; color: #111; line-height: 1.45; }
h1 { font-size: 17pt; margin: 0 0 2mm; }
h2 { font-size: 12pt; margin: 8mm 0 1mm; border-bottom: 1px solid #ccc; padding-bottom: 1mm; }
.kopf { border-bottom: 2px solid #b00; padding-bottom: 4mm; margin-bottom: 6mm; }
.klein { font-size: 8.5pt; color: #555; }
.zweck { font-size: 8.5pt; color: #555; margin: 0 0 2mm; font-style: italic; }
table { width: 100%; border-collapse: collapse; margin-bottom: 3mm; }
td { padding: 1.2mm 2mm; border-bottom: 0.4pt solid #e5e5e5; vertical-align: top; }
td.feld { width: 38%; color: #555; }
.leer { color: #777; font-size: 9pt; padding: 1mm 0 2mm; }
.kategorie { background: #fff6e5; border-left: 3px solid #d98b00; padding: 2mm 3mm; margin: 0 0 3mm; font-size: 8.5pt; }
.hinweis { background: #f5f5f5; padding: 4mm; margin-top: 6mm; font-size: 9pt; }
.satz { margin-bottom: 3mm; }
</style>
</head>
<body>${inhalt}</body>
</html>`;
}
/** Ein Eintrag als Feld-Wert-Tabelle. Leere Felder werden weggelassen. */
function eintragAlsTabelle(eintrag: Record<string, string | null>): string {
const zeilen = Object.entries(eintrag)
.filter(([, wert]) => wert !== null && wert !== "")
.map(
([feld, wert]) =>
`<tr><td class="feld">${html(feld)}</td><td>${html(wert)}</td></tr>`,
)
.join("");
return zeilen ? `<table>${zeilen}</table>` : "";
}
export async function auskunftPdf(auskunft: Auskunft): Promise<Buffer> {
const anschrift = [
auskunft.wehr.strasse,
[auskunft.wehr.plz, auskunft.wehr.ort].filter(Boolean).join(" "),
]
.filter(Boolean)
.join(", ");
const abschnitte = auskunft.abschnitte
.map((abschnitt) => {
const koerper =
abschnitt.eintraege.length === 0
? `<p class="leer">Dazu liegen keine Daten vor.</p>`
: abschnitt.eintraege.map(eintragAlsTabelle).join("");
const kennzeichen = abschnitt.besondereKategorie
? `<p class="kategorie"><strong>Besondere Kategorie personenbezogener Daten</strong>
nach Art. 9 DSGVO. Diese Angaben unterliegen einem erhöhten Schutz und werden
nur den Personen zugänglich gemacht, die sie für die Atemschutzüberwachung
brauchen.</p>`
: "";
return `
<h2>${html(abschnitt.titel)}</h2>
<p class="zweck">${html(abschnitt.zweck)}</p>
${kennzeichen}
${koerper}`;
})
.join("");
const taetigkeiten = auskunft.taetigkeiten
.map((eintrag) => `<tr><td class="feld">${html(eintrag.was)}</td><td>${eintrag.anzahl}</td></tr>`)
.join("");
const inhalt = `
<div class="kopf">
<h1>Auskunft nach Art. 15 DSGVO</h1>
<p class="klein">
${html(auskunft.wehr.name)}${anschrift ? ` · ${html(anschrift)}` : ""}<br>
Erstellt am ${html(auskunft.erstelltAm.toISOString().slice(0, 10))}
</p>
</div>
<p class="satz">
Sehr geehrte/r <strong>${html(auskunft.person.name)}</strong>,
</p>
<p class="satz">
auf dieser und den folgenden Seiten stehen alle personenbezogenen Daten, die
${html(auskunft.wehr.name)} über Sie verarbeitet — Stand
${html(auskunft.erstelltAm.toISOString().slice(0, 10))}. Über jedem Abschnitt ist
angegeben, wozu diese Daten erhoben wurden.
</p>
${abschnitte}
<h2>Von Ihnen erfasste Vorgänge</h2>
<p class="zweck">
Was Sie in der Software getan haben, ist hier nur gezählt. Die Unterlagen selbst
— Prüfprotokolle, Mängelmeldungen, Einsatzberichte — gehören zum Nachweisbestand
der Feuerwehr und nicht zu Ihren personenbezogenen Daten. Sie können sie über die
jeweilige Ansicht einsehen, soweit Ihre Rolle das vorsieht.
</p>
<table>${taetigkeiten}</table>
<div class="hinweis">
<strong>Ihre weiteren Rechte</strong><br><br>
<strong>Berichtigung (Art. 16).</strong> Stimmt etwas nicht, wenden Sie sich an die
Kommandantin oder den Kommandanten — falsche Angaben werden berichtigt.<br><br>
<strong>Löschung (Art. 17).</strong> Ein Teil dieser Daten unterliegt gesetzlichen
Aufbewahrungsfristen: Einsatzbeteiligungen und Atemschutznachweise sind Nachweise
gegenüber der Unfallkasse und können nicht gelöscht werden, solange die Frist
läuft. Was nicht darunter fällt, wird auf Verlangen gelöscht; nach dem Austritt
geschieht das ohnehin automatisch.<br><br>
<strong>Datenübertragbarkeit (Art. 20).</strong> Dieselben Daten gibt es auf
Wunsch zusätzlich als JSON-Datei zur maschinellen Weiterverarbeitung.<br><br>
<strong>Beschwerde (Art. 77).</strong> Sie können sich beim Bayerischen
Landesbeauftragten für den Datenschutz beschweren.<br><br>
<strong>Empfänger.</strong> Diese Daten werden nicht an Dritte weitergegeben. Sie
liegen auf einem Server, den die Feuerwehr selbst betreibt; es findet keine
Übermittlung in ein Drittland statt.
</div>
<p class="klein" style="margin-top:6mm">
Diese Auskunft wurde maschinell erstellt. Der Abruf ist im Protokoll der Feuerwehr
vermerkt.
</p>
`;
return htmlZuPdf(rahmen(`Auskunft ${auskunft.person.name}`, inhalt), {
hintergrundDrucken: true,
fusszeileHtml: fusszeile(`${auskunft.wehr.name} · Auskunft ${auskunft.person.name}`),
});
}
+84
View File
@@ -3,6 +3,9 @@ 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,
@@ -55,6 +58,87 @@ const personDaten = z.object({
});
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({