Files
dogfather-universe/server-internal/lib/webdesign-personendaten.js
T
DogFatherGitandClaude Opus 5 7b09d752f8 Auskunft und Loeschung nach DSGVO (Art. 15/17)
Schreibt jemand "Welche Daten haben Sie ueber mich?" oder "Bitte
loeschen Sie meine Daten", laeuft eine Frist von einem Monat (Art. 12
Abs. 3 DSGVO). Bisher haette man dafuer von Hand durch ein Dutzend
Tabellen suchen muessen -- und uebersieht man eine, ist die Auskunft
unvollstaendig, ohne dass man es ihr ansieht.

⚠️ LOESCHEN IST NICHT EINFACH LOESCHEN

Der naheliegende Weg waere ein "Alles loeschen". Das waere bequem und
doppelt falsch: Rechnungen und Buchungsbelege unterliegen einer
gesetzlichen Aufbewahrungspflicht -- § 147 Abs. 3 AO nennt acht Jahre
fuer Buchungsbelege, zehn fuer Handelsbuecher, gerechnet ab dem Ende
des Kalenderjahres (Abs. 4). Wer sie auf Zuruf loescht, verstoesst
gegen Steuerrecht, um Datenschutzrecht zu erfuellen.

Die DSGVO nimmt diesen Fall selbst aus (Art. 17 Abs. 3 lit. b). Fuer
das, was bleiben muss, sieht sie die Einschraenkung der Verarbeitung
vor (Art. 18) -- genau so wird es ausgewiesen, samt Datum, ab dem
geloescht werden darf.

Dasselbe bei Widerrufserklaerungen: Sie sind der Nachweis, DASS und
WANN widerrufen wurde. Wer sie loescht, vernichtet seinen eigenen Beleg
in genau der Sache, in der es spaeter Streit geben koennte.

EIN FEHLER, DEN DER TEST VOR DEM ERSTEN LAUF GEFUNDEN HAT

Die Tabelle wd_widerrufe fuehrt die Adresse nicht als "email", sondern
als "kontakt". Die erste Fassung suchte nach "email" -- sie haette dort
NIE einen Treffer geliefert, und die Auskunft haette trotzdem sauber
ausgesehen. Aufgefallen nur, weil die Testdaten gegen das echte Schema
angelegt wurden statt gegen die Annahme.

Deshalb steht in der Quellenliste jetzt die Spalte, nicht eine
Vermutung.

WEITERE ENTSCHEIDUNGEN

- Die Suche geht ueber die Adresse UND ueber die Kundenkennung. Vieles
  haengt nicht an der Adresse; ohne diesen Schritt bliebe die halbe
  Auskunft leer.
- Gross- und Kleinschreibung spielt keine Rolle -- sonst bekaeme
  jemand eine leere Auskunft, weil er seine Adresse anders schreibt.
- Die Loeschung verlangt die Adresse ein zweites Mal. Der Unterschied
  zwischen .org und .com ist ein Buchstabe, die Folge unwiederbringlich.
- Sie laeuft in einer Transaktion: Bricht sie ab, bliebe sonst ein
  halbgeloeschter Bestand, und niemand wuesste, welcher Teil weg ist.
- Der Vorgang wird protokolliert, aber OHNE die geloeschten Inhalte --
  sie dabei erneut zu speichern waere das Gegenteil des Zwecks.

15 Pruefungen gruen, mit Gegenprobe (fremde Adresse liefert nichts).

Die Oberflaeche in der Verwaltung folgt. Deploy braucht Filipe:
server-internal liegt unter /home/dogiintern mit Rechten 700.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-08-26 19:55:05 +02:00

235 lines
9.8 KiB
JavaScript

/* =====================================================================
webdesign-personendaten.js — Auskunft und Löschung (DSGVO Art. 15/17)
WORUM ES GEHT
Schreibt jemand "Welche Daten haben Sie über mich?" oder "Bitte
löschen Sie meine Daten", läuft eine Frist von einem Monat
(Art. 12 Abs. 3 DSGVO). Bisher hätte man dafür von Hand durch ein
Dutzend Tabellen suchen müssen — fehleranfällig, und übersieht man
eine, ist die Auskunft unvollständig.
⚠️ WARUM LÖSCHEN NICHT EINFACH LÖSCHEN IST
Der häufigste Fehler an dieser Stelle ist, ein "Alles löschen" zu
bauen. Das wäre bequem und gleich doppelt falsch:
Rechnungen und Buchungsbelege unterliegen einer gesetzlichen
Aufbewahrungspflicht — § 147 Abs. 3 AO nennt acht Jahre für
Buchungsbelege, zehn für Handelsbücher und Jahresabschlüsse,
gerechnet ab dem Ende des Kalenderjahres (§ 147 Abs. 4 AO). Wer sie
auf Zuruf löscht, verstößt gegen Steuerrecht, um Datenschutzrecht zu
erfüllen. Die DSGVO selbst nimmt diesen Fall ausdrücklich aus:
Art. 17 Abs. 3 lit. b gilt, "solange die Verarbeitung zur Erfüllung
einer rechtlichen Verpflichtung erforderlich ist".
Dasselbe bei Widerrufserklärungen: Sie sind der Nachweis, DASS und
WANN widerrufen wurde. Wer sie löscht, vernichtet seinen eigenen
Beleg in genau der Sache, in der es später Streit geben könnte.
Deshalb trennt dieses Modul strikt:
löschbar — geht sofort weg
aufzubewahren — bleibt, mit Grund und Datum der Freigabe
Für das, was bleiben muss, sieht die DSGVO die Einschränkung der
Verarbeitung vor (Art. 18): Die Daten werden nicht mehr benutzt, nur
noch aufbewahrt. Genau das wird hier gekennzeichnet.
NICHTS WIRD AUTOMATISCH GELÖSCHT
Es gibt eine Vorschau, und erst danach eine Entscheidung durch einen
Menschen. Eine Löschung ist endgültig; sie gehört nicht hinter einen
Knopf, der sofort zuschlägt.
===================================================================== */
import { db } from "../db.js";
/* ---------------------------------------------------------------------
WO ÜBERALL ETWAS ZU EINER PERSON STEHT
Bewusst als Liste und nicht im Code verstreut: Eine Auskunft, die
eine Tabelle vergisst, ist falsch — und man sieht ihr das nicht an.
Kommt eine Tabelle dazu, gehört sie hier hinein.
ueber: "email" — die Tabelle führt die Adresse selbst
"kunde_id" — sie hängt über die Kundenkennung daran
--------------------------------------------------------------------- */
const QUELLEN = [
{ tabelle: "wd_anfragen", ueber: "email", was: "Projektanfragen" },
{ tabelle: "wd_kunden", ueber: "email", was: "Kundenkonto" },
/* ⚠️ Hier heißt die Spalte "kontakt", nicht "email".
Mit "email" hätte diese Tabelle NIE einen Treffer geliefert — und
die Auskunft hätte trotzdem sauber ausgesehen. Aufgefallen beim
Anlegen der Testdaten, weil das Schema die Spalte nicht kannte;
im Betrieb wäre es niemandem aufgefallen. Genau deshalb steht in
dieser Liste jetzt die Spalte statt einer Annahme. */
{ tabelle: "wd_widerrufe", ueber: "spalte", spalte: "kontakt",
was: "Widerrufserklärungen",
aufbewahren: "Nachweis des Widerrufs (§ 355 BGB)" },
{ tabelle: "wd_projekte", ueber: "kunde_id", was: "Projekte" },
{ tabelle: "wd_angebote", ueber: "kunde_id", was: "Angebote" },
{ tabelle: "wd_zahlungen", ueber: "kunde_id", was: "Zahlungen und Rechnungen",
aufbewahren: "Buchungsbeleg, 8 Jahre ab Jahresende (§ 147 Abs. 3 Nr. 4 AO)" },
{ tabelle: "wd_postfach", ueber: "kunde_id", was: "Nachrichten" },
{ tabelle: "wd_nachrichten", ueber: "kunde_id", was: "Projektnachrichten" },
{ tabelle: "wd_aenderungen", ueber: "kunde_id", was: "Änderungswünsche" },
{ tabelle: "wd_aufgaben", ueber: "kunde_id", was: "Aufgaben" },
{ tabelle: "wd_dateien", ueber: "kunde_id", was: "Hochgeladene Dateien" },
{ tabelle: "wd_betreuung_abos", ueber: "kunde_id", was: "Betreuungsverträge",
aufbewahren: "Vertragsunterlage (§ 257 HGB)" },
{ tabelle: "wd_kunden_sitzungen", ueber: "kunde_id", was: "Anmeldungen im Portal" },
{ tabelle: "wd_kunden_token", ueber: "kunde_id", was: "Einladungs- und Zugangstoken" },
{ tabelle: "wd_anfrage_notizen", ueber: "anfrage", was: "Interne Notizen zu Anfragen" },
];
/* Ob eine Tabelle existiert. Das Schema wächst über Migrationen; eine
fehlende Tabelle darf die Auskunft nicht abbrechen lassen, sondern
muss übersprungen werden. */
function tabelleDa(name) {
const z = db.prepare(
`SELECT 1 FROM sqlite_master WHERE type='table' AND name = ?`
).get(name);
return !!z;
}
function spaltenVon(tabelle) {
try {
return db.prepare(`PRAGMA table_info(${tabelle})`).all().map((s) => s.name);
} catch {
return [];
}
}
/* ---------------------------------------------------------------------
ALLES ZU EINER PERSON ZUSAMMENTRAGEN
--------------------------------------------------------------------- */
export function datenZuPerson(email) {
const adresse = String(email || "").trim().toLowerCase();
if (!adresse || !adresse.includes("@")) {
throw new Error("Bitte eine E-Mail-Adresse angeben.");
}
/* Erst die Kundenkennungen: Vieles hängt nicht an der Adresse,
sondern an ihnen. Ohne diesen Schritt bliebe die halbe Auskunft
leer, ohne dass es auffiele. */
let kundenIds = [];
if (tabelleDa("wd_kunden")) {
kundenIds = db.prepare(`SELECT id FROM wd_kunden WHERE lower(email) = ?`)
.all(adresse).map((z) => z.id);
}
/* Anfragen können auch ohne Kundenkonto bestehen. */
let anfrageIds = [];
if (tabelleDa("wd_anfragen")) {
anfrageIds = db.prepare(`SELECT id FROM wd_anfragen WHERE lower(email) = ?`)
.all(adresse).map((z) => z.id);
}
const gefunden = [];
for (const q of QUELLEN) {
if (!tabelleDa(q.tabelle)) continue;
const spalten = spaltenVon(q.tabelle);
let zeilen = [];
try {
if (q.ueber === "email" && spalten.includes("email")) {
zeilen = db.prepare(`SELECT * FROM ${q.tabelle} WHERE lower(email) = ?`).all(adresse);
} else if (q.ueber === "spalte" && spalten.includes(q.spalte)) {
/* Für Tabellen, die die Adresse unter anderem Namen führen. */
zeilen = db.prepare(`SELECT * FROM ${q.tabelle} WHERE lower(${q.spalte}) = ?`).all(adresse);
} else if (q.ueber === "kunde_id" && spalten.includes("kunde_id") && kundenIds.length) {
const platz = kundenIds.map(() => "?").join(",");
zeilen = db.prepare(`SELECT * FROM ${q.tabelle} WHERE kunde_id IN (${platz})`).all(...kundenIds);
} else if (q.ueber === "anfrage" && spalten.includes("anfrage_id") && anfrageIds.length) {
const platz = anfrageIds.map(() => "?").join(",");
zeilen = db.prepare(`SELECT * FROM ${q.tabelle} WHERE anfrage_id IN (${platz})`).all(...anfrageIds);
}
} catch (e) {
/* Eine Tabelle, deren Aufbau abweicht, darf die Auskunft nicht
zum Abbruch bringen -- sie wird gemeldet, damit die Lücke
sichtbar ist statt still zu bleiben. */
gefunden.push({
tabelle: q.tabelle, was: q.was, anzahl: null,
fehler: String(e.message).slice(0, 120),
});
continue;
}
if (!zeilen.length) continue;
gefunden.push({
tabelle: q.tabelle,
was: q.was,
anzahl: zeilen.length,
aufbewahren: q.aufbewahren || null,
zeilen,
});
}
return {
email: adresse,
kundenIds,
anfrageIds,
erstellt: new Date().toISOString(),
bereiche: gefunden,
/* Die Zahl, die man zuerst wissen will. */
datensaetze: gefunden.reduce((s, b) => s + (b.anzahl || 0), 0),
};
}
/* ---------------------------------------------------------------------
WAS BEI EINER LÖSCHUNG GESCHÄHE
Nur eine Vorschau. Sie trennt in das, was weg darf, und das, was
bleiben muss -- mit Grund und mit dem Datum, ab dem es weg darf.
--------------------------------------------------------------------- */
export function loeschvorschau(email) {
const daten = datenZuPerson(email);
const loeschbar = [];
const bleibt = [];
for (const b of daten.bereiche) {
if (!b.anzahl) continue;
if (b.aufbewahren) {
/* Frist ab Ende des Kalenderjahres (§ 147 Abs. 4 AO). Genommen
wird der jüngste Eintrag -- solange einer davon noch in der
Frist liegt, bleibt der ganze Bereich. */
const jahre = /10 Jahre|§ 257/.test(b.aufbewahren) ? 10 : 8;
let juengstes = null;
for (const z of b.zeilen) {
/* Die Tabellen benennen ihr Datum unterschiedlich. Fehlt es
hier, faellt die Frist auf das laufende Jahr zurueck -- also
auf die laengere Aufbewahrung. Im Zweifel lieber zu lange
behalten als zu frueh loeschen. */
const d = z.erstellt_am || z.bezahlt_am || z.eingegangen_am
|| z.erklaert_am || z.datum;
if (d && (!juengstes || d > juengstes)) juengstes = d;
}
const jahr = juengstes ? new Date(juengstes).getFullYear() : new Date().getFullYear();
bleibt.push({
was: b.was, anzahl: b.anzahl, grund: b.aufbewahren,
/* 31.12. des Jahres + Frist. */
freiAb: `${jahr + jahre}-12-31`,
});
} else {
loeschbar.push({ was: b.was, anzahl: b.anzahl, tabelle: b.tabelle });
}
}
return {
email: daten.email,
gefunden: daten.datensaetze,
loeschbar,
bleibt,
/* Der Satz, den man in die Antwort an die Person schreiben kann. */
hinweis: bleibt.length
? "Ein Teil der Daten unterliegt einer gesetzlichen Aufbewahrungspflicht und "
+ "kann erst danach gelöscht werden. Bis dahin wird die Verarbeitung "
+ "eingeschränkt (Art. 18 DSGVO): Die Daten werden nur noch aufbewahrt, "
+ "nicht mehr verwendet."
: "Alle gefundenen Daten können gelöscht werden.",
};
}