diff --git a/pruef-personen.png b/pruef-personen.png index b85ac91c..6d36a19c 100644 Binary files a/pruef-personen.png and b/pruef-personen.png differ diff --git a/pruef-zustand.png b/pruef-zustand.png index 2e47e6ac..6bbd97d3 100644 Binary files a/pruef-zustand.png and b/pruef-zustand.png differ diff --git a/server/pruef-personen-loeschen.mjs b/server/pruef-personen-loeschen.mjs new file mode 100644 index 00000000..1c486858 --- /dev/null +++ b/server/pruef-personen-loeschen.mjs @@ -0,0 +1,253 @@ +/* =================================================================== + Pruefung: Personen loeschen. + + Die einzige unumkehrbare Handlung im Workspace. Geprueft wird + entsprechend vor allem, was NICHT gehen darf -- und dass die Sicherung + davor wirklich geschrieben wird. Ohne sie waere "geloescht" endgueltig. + =================================================================== */ +import { mkdtempSync, rmSync, readdirSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; + +const ordner = mkdtempSync(join(tmpdir(), "ws-loeschen-")); +process.env.WORKSPACE_DB = join(ordner, "workspace.db"); +process.env.PORT = "4188"; +process.env.SITE_ACCESS_SECRET = "lokaler-test"; +process.env.SITE_ACCESS_CODE_DOGI = "DOGI-TEST-1"; +process.env.SITE_ACCESS_CODE_VANVAN = "VANVAN-TEST-1"; +process.env.SITE_PUBLIC_LAUNCH_AT = "2020-01-01T00:00:00+01:00"; + +const express = (await import("express")).default; +const ec = express.response.cookie; +express.response.cookie = function (n, w, o) { return ec.call(this, n, w, { ...(o || {}), secure: false }); }; +await import("./index.js"); +await new Promise((r) => setTimeout(r, 700)); +const BASIS = "http://127.0.0.1:4188"; +setTimeout(() => { console.log("ABBRUCH"); process.exit(1); }, 200_000).unref?.(); + +let fehler = 0; +const ok = (b, t) => { console.log((b ? " ok " : " FEHL ") + t); if (!b) fehler++; }; + +await fetch(BASIS + "/workspace/api/anmelden", { + method: "POST", headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ rolle: "admin", code: "x" }) }).catch(() => {}); + +const { DatabaseSync } = await import("node:sqlite"); +const { scryptSync, randomBytes } = await import("node:crypto"); +const d = new DatabaseSync(process.env.WORKSPACE_DB); +const jetzt = new Date().toISOString(); +const heute = jetzt.slice(0, 10); +function anlegen(name, rolle, code) { + const salt = randomBytes(16).toString("hex"); + const hash = scryptSync(code, salt, 64, { N: 32768, r: 8, p: 1, maxmem: 96 * 1024 * 1024 }).toString("hex"); + d.prepare("INSERT INTO personen (name, rolle, code_hash, code_salt, code_n, aktiv, erstellt) VALUES (?,?,?,?,?,1,?)") + .run(name, rolle, hash, salt, 32768, jetzt); + return d.prepare("SELECT last_insert_rowid() AS id").get().id; +} +const idDogi = anlegen("Filipe", "admin", "CODE-DOGI-0001"); +const idDogi2 = anlegen("VanVan", "admin", "CODE-VANV-0001"); +const idManager = anlegen("Ein Manager", "manager", "CODE-MANA-0001"); +const idScout = anlegen("Patrick", "scout", "CODE-SCOU-0001"); +const idAnna = anlegen("Anna", "creator", "CODE-ANNA-0001"); +const idLeer = anlegen("Niemand", "creator", "CODE-LEER-0001"); + +/* Anna haengt voller Daten -- daran muss sich zeigen, was mitgeht und + was bleibt. */ +d.prepare("INSERT INTO betreuung (creator_id, betreuer_id, seit) VALUES (?,?,?)").run(idAnna, idScout, jetzt); +d.prepare("INSERT INTO profile (person_id, nische) VALUES (?, 'Basteln')").run(idAnna); +for (const f of ["a", "b", "c"]) { + d.prepare("INSERT INTO startcheck (creator_id, feld, punkt, bewertung, geaendert) VALUES (?,?,'p','ok',?)") + .run(idAnna, f, jetzt); +} +d.prepare("INSERT INTO content_saeulen (creator_id, name, slot, erstellt) VALUES (?, 'Tutorials', 1, ?)") + .run(idAnna, jetzt); +for (let i = 0; i < 4; i++) { + d.prepare("INSERT INTO aufgaben (titel, status, creator_id, erstellt, erstellt_von) VALUES ('Test','offen',?,?,?)") + .run(idAnna, jetzt, idDogi); +} +d.prepare("INSERT INTO eintraege (bereich, art, titel, datum, status, creator_id, erstellt, erstellt_von) VALUES ('live','auswertung','Test',?,'offen',?,?,?)") + .run(heute, idAnna, jetzt, idDogi); +d.prepare("INSERT INTO termine (titel, beginn, creator_id, erledigt, erstellt, erstellt_von) VALUES ('Test',?,?,0,?,?)") + .run(heute + "T10:00", idAnna, jetzt, idDogi); +d.close(); + +const anmelden = async (rolle, code) => { + const a = await fetch(BASIS + "/workspace/api/anmelden", { + method: "POST", headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ rolle, code }), redirect: "manual" }); + return (a.headers.getSetCookie?.() || []).map((c) => c.split(";")[0]).join("; "); +}; +const ruf = (pfad, keks, opt = {}) => fetch(BASIS + pfad, { + ...opt, headers: { cookie: keks, "Content-Type": "application/json", ...(opt.headers || {}) } }); +const jsn = async (a) => { try { return await a.json(); } catch { return {}; } }; +const zaehle = (sql, ...w) => { + const v = new DatabaseSync(process.env.WORKSPACE_DB, { readOnly: true }); + try { return v.prepare(sql).get(...w).n; } finally { v.close(); } +}; +const sicherungen = () => { + try { return readdirSync(join(ordner, "sicherungen")).filter((n) => n.endsWith(".db")).length; } + catch { return 0; } +}; + +const kDogi = await anmelden("admin", "CODE-DOGI-0001"); +const kManager = await anmelden("manager", "CODE-MANA-0001"); +const kScout = await anmelden("scout", "CODE-SCOU-0001"); + +console.log("\n1) Die Vorschau sagt, was daran hängt"); +{ + const v = await jsn(await ruf(`/workspace/api/verwaltung/personen/${idAnna}/loeschbar`, kDogi)); + console.log(" mit: ", JSON.stringify(v.mit)); + console.log(" bleibt:", JSON.stringify(v.bleibt)); + ok(v.mit["Profil"] === 1, `Profil geht mit (${v.mit?.["Profil"]})`); + ok(v.mit["Start-Check"] === 3, `Start-Check geht mit (${v.mit?.["Start-Check"]})`); + ok(v.mit["Content-Säulen"] === 1, `Säulen gehen mit (${v.mit?.["Content-Säulen"]})`); + ok(v.bleibt["Aufgaben"] === 4, `Aufgaben bleiben (${v.bleibt?.["Aufgaben"]})`); + ok(v.bleibt["Termine"] === 1, `Termine bleiben (${v.bleibt?.["Termine"]})`); + ok(v.hindernis === null, `kein Hindernis (${v.hindernis})`); +} + +console.log("\n2) Beim Scout wird gewarnt, wer ohne Betreuung dasteht"); +{ + const v = await jsn(await ruf(`/workspace/api/verwaltung/personen/${idScout}/loeschbar`, kDogi)); + ok(v.verliertBetreuung.length === 1 && v.verliertBetreuung[0] === "Anna", + `Anna verliert ihre Betreuung (${v.verliertBetreuung.join(", ")})`); +} + +console.log("\n3) Was NICHT gehen darf"); +{ + const nurName = (id) => ({ method: "DELETE", body: JSON.stringify({ name: "egal" }) }); + + /* Ein Manager darf gar nichts loeschen -- und soll nicht einmal + erfahren, dass es den Weg gibt. */ + const m1 = await ruf(`/workspace/api/verwaltung/personen/${idLeer}`, kManager, nurName()); + ok(m1.status === 404, `Manager bekommt 404 beim Löschen (${m1.status})`); + const m2 = await ruf(`/workspace/api/verwaltung/personen/${idLeer}/loeschbar`, kManager); + ok(m2.status === 404, `und sieht nicht einmal die Vorschau (${m2.status})`); + + /* Ein Scout kommt gar nicht erst in die Verwaltung. */ + const s1 = await ruf(`/workspace/api/verwaltung/personen/${idLeer}`, kScout, nurName()); + ok(s1.status === 404, `Scout ebenfalls 404 (${s1.status})`); + + /* Sich selbst nicht. */ + const selbst = await ruf(`/workspace/api/verwaltung/personen/${idDogi}`, kDogi, { + method: "DELETE", body: JSON.stringify({ name: "Filipe" }) }); + ok(selbst.status === 400, `sich selbst nicht (${selbst.status})`); + console.log(" ->", (await jsn(selbst)).fehler); + + /* Falscher Name -- der haeufigste Fall beim Verklicken. */ + const falsch = await ruf(`/workspace/api/verwaltung/personen/${idLeer}`, kDogi, { + method: "DELETE", body: JSON.stringify({ name: "Niemand " + "x" }) }); + ok(falsch.status === 400, `falscher Name wird abgelehnt (${falsch.status})`); + ok(zaehle("SELECT COUNT(*) AS n FROM personen WHERE id = ?", idLeer) === 1, + "und die Person ist noch da"); + + /* Ohne Namen ueberhaupt. */ + const ohne = await ruf(`/workspace/api/verwaltung/personen/${idLeer}`, kDogi, { + method: "DELETE", body: JSON.stringify({}) }); + ok(ohne.status === 400, `ohne Namen abgelehnt (${ohne.status})`); +} + +console.log("\n4) Löschen schreibt vorher eine Sicherung"); +{ + const vorher = sicherungen(); + const a = await ruf(`/workspace/api/verwaltung/personen/${idLeer}`, kDogi, { + method: "DELETE", body: JSON.stringify({ name: "Niemand" }) }); + const j = await jsn(a); + ok(a.ok, `gelöscht (${a.status})`); + ok(!!j.sicherung, `die Sicherung wird genannt (${j.sicherung})`); + ok(sicherungen() > vorher || sicherungen() >= 1, `es liegt eine Sicherung (${sicherungen()})`); + ok(zaehle("SELECT COUNT(*) AS n FROM personen WHERE id = ?", idLeer) === 0, "die Person ist weg"); + + /* Und die Sicherung enthaelt sie noch -- sonst waere sie wertlos. */ + const s = new DatabaseSync(join(ordner, "sicherungen", j.sicherung), { readOnly: true }); + const drin = s.prepare("SELECT COUNT(*) AS n FROM personen WHERE name = 'Niemand'").get().n; + s.close(); + ok(drin === 1, `und in der Sicherung steht sie noch drin (${drin})`); +} + +console.log("\n5) Anhängendes verhält sich wie angekündigt"); +{ + const a = await ruf(`/workspace/api/verwaltung/personen/${idAnna}`, kDogi, { + method: "DELETE", body: JSON.stringify({ name: "Anna" }) }); + ok(a.ok, `Anna gelöscht (${a.status})`); + + ok(zaehle("SELECT COUNT(*) AS n FROM profile WHERE person_id = ?", idAnna) === 0, "Profil ist mit weg"); + ok(zaehle("SELECT COUNT(*) AS n FROM startcheck WHERE creator_id = ?", idAnna) === 0, "Start-Check ist mit weg"); + ok(zaehle("SELECT COUNT(*) AS n FROM content_saeulen WHERE creator_id = ?", idAnna) === 0, "Säulen sind mit weg"); + ok(zaehle("SELECT COUNT(*) AS n FROM betreuung WHERE creator_id = ?", idAnna) === 0, "Zuständigkeit ist mit weg"); + + /* Und das Gegenstueck: Die Arbeit bleibt. Eine Aufgabe verschwinden + zu lassen, weil jemand geht, waere Geschichtsfaelschung. */ + ok(zaehle("SELECT COUNT(*) AS n FROM aufgaben") === 4, "die 4 Aufgaben sind noch da"); + ok(zaehle("SELECT COUNT(*) AS n FROM aufgaben WHERE creator_id IS NULL") === 4, + "sie haben nur ihre Zuordnung verloren"); + ok(zaehle("SELECT COUNT(*) AS n FROM eintraege") === 1, "der Bereichseintrag ist noch da"); + ok(zaehle("SELECT COUNT(*) AS n FROM termine") === 1, "der Termin ist noch da"); +} + +console.log("\n6) Der letzte DogFather lässt sich nicht löschen"); +{ + /* VanVan zuerst weg -- danach ist Filipe der letzte, und ein zweiter + DogFather muss ihn dann ablehnen. */ + const weg = await ruf(`/workspace/api/verwaltung/personen/${idDogi2}`, kDogi, { + method: "DELETE", body: JSON.stringify({ name: "VanVan" }) }); + ok(weg.ok, `der zweite DogFather geht (${weg.status})`); + + const v = await jsn(await ruf(`/workspace/api/verwaltung/personen/${idDogi}/loeschbar`, kDogi)); + ok(/selbst/.test(v.hindernis || ""), `die Vorschau nennt das Hindernis ("${v.hindernis}")`); + ok(v.person.rolle === "admin", "und es ist der letzte DogFather-Zugang"); + ok(zaehle("SELECT COUNT(*) AS n FROM personen WHERE rolle = 'admin'") === 1, + "es bleibt genau ein DogFather"); +} + +console.log("\n6b) Jede Löschung bekommt ihre EIGENE Sicherung"); +{ + /* Der Mangel, den die erste Fassung dieser Prüfung sichtbar gemacht + hat: Alle drei Löschungen schrieben in dieselbe Tagesdatei. Nach der + dritten kannte die Sicherung die ERSTE gelöschte Person nicht mehr -- + die Sicherung hätte genau in dem Fall versagt, für den sie da ist. */ + const dateien = readdirSync(join(ordner, "sicherungen")).filter((n) => n.startsWith("vorher-")); + console.log(" ", dateien.join(" · ")); + ok(dateien.length === 3, `drei eigene Sicherungen, eine je Löschung (${dateien.length})`); + ok(new Set(dateien).size === dateien.length, "alle mit verschiedenen Namen"); + + /* Die eigentlich zugesicherte Eigenschaft: JEDE gelöschte Person muss + sich aus IRGENDEINER dieser Sicherungen zurückholen lassen. Genau + das war vorher nicht der Fall. + + (Die erste Fassung dieser Prüfung griff die alphabetisch erste Datei + und nannte sie "die älteste" -- "vorher-…-2.db" sortiert aber VOR + "vorher-….db", weil "-" kleiner ist als ".". Sie prüfte damit die + falsche Datei. Deshalb jetzt die Eigenschaft selbst statt eines + Stellvertreters.) */ + for (const wer of ["Niemand", "Anna", "VanVan"]) { + const gefunden = dateien.some((datei) => { + const s = new DatabaseSync(join(ordner, "sicherungen", datei), { readOnly: true }); + try { return s.prepare("SELECT COUNT(*) AS n FROM personen WHERE name = ?").get(wer).n > 0; } + finally { s.close(); } + }); + ok(gefunden, `„${wer}" lässt sich aus einer der Sicherungen zurückholen`); + } + + /* Und: Eine Sicherung vor dem Löschen darf NICHT als der planmäßige + Nachtlauf gelten -- sonst liefe in der Nacht keiner mehr. */ + const v = new DatabaseSync(process.env.WORKSPACE_DB, { readOnly: true }); + const vermerk = v.prepare( + "SELECT wert FROM einstellungen WHERE schluessel = 'sicherung_letzte'").get()?.wert; + v.close(); + ok(!vermerk || !/vorher-/.test(vermerk), + `der planmäßige Vermerk bleibt unberührt (${vermerk ? JSON.parse(vermerk).datei : "keiner"})`); +} + +console.log("\n7) Das Protokoll hält es fest"); +{ + const { eintraege } = await jsn(await ruf("/workspace/api/verwaltung/protokoll?anzahl=40", kDogi)); + const geloescht = (eintraege || []).filter((e) => e.aktion === "person_geloescht"); + ok(geloescht.length === 3, `drei Löschungen im Protokoll (${geloescht.length})`); + ok(/Sicherung:/.test(geloescht[0]?.detail || ""), + `mit der Sicherung im Eintrag ("${geloescht[0]?.detail}")`); +} + +console.log(`\n${fehler === 0 ? "Alles in Ordnung." : fehler + " Prüfung(en) fehlgeschlagen."}`); +try { rmSync(ordner, { recursive: true, force: true }); } catch { /* egal */ } +process.exit(fehler === 0 ? 0 : 1); diff --git a/server/workspace-personen.js b/server/workspace-personen.js index 40580840..6ea1d056 100644 --- a/server/workspace-personen.js +++ b/server/workspace-personen.js @@ -17,6 +17,7 @@ import express from "express"; import { db, protokolliere, echteIp, sitzungLesen, personAnlegen, codeNeu, personSperren, betreuungSetzen, istLeitung, istDogFather, ROLLEN_SORTIERUNG, ROLLEN_REIHE, } from "./workspace.js"; +import { sicherungJetzt } from "./workspace-sicherung.js"; export const personenRouter = express.Router(); @@ -259,6 +260,160 @@ personenRouter.patch("/workspace/api/verwaltung/personen/:id", gleicheHerkunft, } }); +/* ===================================================================== + Löschen. + + Wunsch Filipe, 31.08.2026: "ich will auch die möglichkeit haben die + löschen zu können." + + Das ist die einzige Handlung im ganzen Workspace, die sich nicht + rückgängig machen lässt. Deshalb vier Vorkehrungen: + + 1. NUR DOGFATHER. Nicht die Leitung, nicht ein Manager -- "nur + DogFather hat alle endgültigen Rechte" heisst genau hier etwas. + Ein Manager kann weiterhin sperren; das reicht für den Alltag und + ist umkehrbar. + + 2. VORSCHAU, was daran hängt. Man muss VOR dem Klick sehen, was + mitgeht und was nur seine Zuordnung verliert. Ohne diese Liste + löscht man ein Creator-Profil und merkt erst Wochen später, dass + damit auch der Start-Check und die Content-Säulen weg sind. + + 3. EINE SICHERUNG DIREKT DAVOR. Das ist der eigentliche Gewinn aus der + Sicherungsarbeit von heute: Vor jedem Löschen schreibt der Server + eine vollständige, sofort geprüfte Sicherung. Geht sie schief, wird + NICHT gelöscht. Damit ist "gelöscht" wiederherstellbar -- aus einer + unumkehrbaren Handlung wird eine, die man zurückholen kann. + + 4. Sich selbst und den letzten DogFather nie. Dieselbe Regel wie beim + Sperren, aus demselben Grund: Sonst sperrt man sich aus. + + Was BLEIBT: Aufgaben, Termine, Bereichseinträge, Dateien und + Protokollzeilen. Sie verlieren nur den Verweis auf die Person + (ON DELETE SET NULL). Das ist Absicht -- eine Aufgabe verschwinden zu + lassen, weil jemand geht, wäre Geschichtsfälschung. + + Was MITGEHT (ON DELETE CASCADE): Profil, Start-Check, Content-Säulen, + Sitzungen, Zuständigkeit. Dinge, die es ohne die Person nicht gibt. + ===================================================================== */ + +/* Was hängt an dieser Person? Wird von der Vorschau UND vom Löschen + benutzt -- damit die Anzeige nicht etwas anderes behaupten kann, als + das Löschen dann tut. */ +function anhang(id) { + const zahl = (sql, ...w) => db().prepare(sql).get(id, ...w)?.n ?? 0; + return { + mit: { + "Profil": zahl("SELECT COUNT(*) AS n FROM profile WHERE person_id = ?"), + "Start-Check": zahl("SELECT COUNT(*) AS n FROM startcheck WHERE creator_id = ?"), + "Content-Säulen": zahl("SELECT COUNT(*) AS n FROM content_saeulen WHERE creator_id = ?"), + "offene Sitzungen": zahl("SELECT COUNT(*) AS n FROM sitzungen WHERE person_id = ?"), + }, + bleibt: { + "Aufgaben": zahl("SELECT COUNT(*) AS n FROM aufgaben WHERE creator_id = ? OR verantwortlich_id = ?", id), + "Einträge in den Bereichen": zahl("SELECT COUNT(*) AS n FROM eintraege WHERE creator_id = ?"), + "Termine": zahl("SELECT COUNT(*) AS n FROM termine WHERE creator_id = ? OR teilnehmer_id = ?", id), + "Dateien": zahl("SELECT COUNT(*) AS n FROM dateien WHERE creator_id = ?"), + "Leads in der Pipeline": zahl("SELECT COUNT(*) AS n FROM leads WHERE scout_id = ? OR creator_id = ?", id), + }, + /* Der gefährlichste Einzelfall: Wer einen Scout löscht, nimmt seinen + Creators die zuständige Person weg -- und die stehen danach ohne + Betreuung da, ohne dass jemand etwas davon mitbekommt. */ + verliertBetreuung: db().prepare( + "SELECT p.name FROM betreuung b JOIN personen p ON p.id = b.creator_id WHERE b.betreuer_id = ?") + .all(id).map((r) => r.name), + }; +} + +/* Alles, was gegen ein Löschen spricht -- als Fehlertext oder null. + Steht getrennt, damit Vorschau und Löschen GARANTIERT dieselbe Antwort + geben. Zwei Stellen mit derselben Regel laufen irgendwann auseinander. */ +function darfGeloeschtWerden(person, akteur) { + if (!istDogFather(akteur)) return "Löschen darf nur DogFather."; + if (person.id === akteur.id) return "Dich selbst kannst du nicht löschen."; + if (istDogFather(person)) { + const { n } = db().prepare("SELECT COUNT(*) AS n FROM personen WHERE rolle = 'admin'").get(); + if (n <= 1) return "Das ist der letzte DogFather-Zugang."; + } + return null; +} + +personenRouter.get("/workspace/api/verwaltung/personen/:id/loeschbar", (req, res) => { + try { + const id = Number(req.params.id); + if (!Number.isInteger(id)) return res.status(400).json({ fehler: "ungueltig" }); + /* Wer gar nicht löschen darf, sieht auch die Vorschau nicht -- sie + verrät, wie viel an einer Person hängt. */ + if (!istDogFather(req.person)) return res.status(404).json({ fehler: "nicht_gefunden" }); + + const person = db().prepare("SELECT id, name, rolle, aktiv FROM personen WHERE id = ?").get(id); + if (!person) return res.status(404).json({ fehler: "nicht_gefunden" }); + + res.json({ + person: { id: person.id, name: person.name, rolle: person.rolle, aktiv: person.aktiv }, + hindernis: darfGeloeschtWerden(person, req.person), + ...anhang(id), + }); + } catch (fehler) { + console.error("[workspace] Löschvorschau:", fehler?.message); + res.status(503).json({ fehler: "nicht_verfuegbar" }); + } +}); + +personenRouter.delete("/workspace/api/verwaltung/personen/:id", gleicheHerkunft, (req, res) => { + try { + const id = Number(req.params.id); + if (!Number.isInteger(id)) return res.status(400).json({ fehler: "ungueltig" }); + if (!istDogFather(req.person)) return res.status(404).json({ fehler: "nicht_gefunden" }); + + const person = db().prepare("SELECT id, name, rolle, aktiv FROM personen WHERE id = ?").get(id); + if (!person) return res.status(404).json({ fehler: "nicht_gefunden" }); + + const hindernis = darfGeloeschtWerden(person, req.person); + if (hindernis) return res.status(400).json({ fehler: hindernis }); + + /* Der Name muss wortwörtlich mitgeschickt werden. Nicht als Schikane: + Der Löschknopf sitzt neben dem Sperrknopf, und die beiden sind sehr + verschieden. Wer den Namen tippt, hat die Zeile gelesen, die er + trifft. */ + if (String(req.body?.name ?? "").trim() !== person.name) { + return res.status(400).json({ fehler: "Zum Löschen bitte den Namen genau eingeben." }); + } + + const vorher = anhang(id); + + /* Sicherung DIREKT davor -- scheitert sie, wird nicht gelöscht. Das + ist der Punkt, an dem aus "unumkehrbar" "wiederherstellbar" wird. */ + let sicherung = null; + try { + /* Eigene Datei mit Uhrzeit -- drei Loeschungen hintereinander + wuerden sonst dreimal dieselbe Tagessicherung ueberschreiben, + und die erste geloeschte Person waere trotzdem weg. */ + sicherung = sicherungJetzt("vor dem Löschen von " + person.name, true); + } catch (fehler) { + console.error("[workspace] Sicherung vor dem Löschen:", fehler?.message); + return res.status(503).json({ + fehler: "Die Sicherung vor dem Löschen ist fehlgeschlagen – es wurde nichts gelöscht.", + }); + } + + /* Fremdschlüssel müssen an sein, sonst greifen CASCADE und SET NULL + nicht und es bleiben Reste zurück, die auf niemanden mehr zeigen. */ + db().exec("PRAGMA foreign_keys = ON"); + db().prepare("DELETE FROM personen WHERE id = ?").run(id); + + protokolliere("person_geloescht", { + personId: req.person.id, rolle: req.person.rolle, ip: echteIp(req), + detail: `${person.name} (${person.rolle}) · Sicherung: ${sicherung.datei}`.slice(0, 160), + }); + + res.json({ ok: true, sicherung: sicherung.datei, war: vorher }); + } catch (fehler) { + console.error("[workspace] Person löschen:", fehler?.message); + res.status(503).json({ fehler: "nicht_verfuegbar" }); + } +}); + /* ---------- Protokoll ---------------------------------------------------- */ personenRouter.get("/workspace/api/verwaltung/protokoll", (req, res) => { diff --git a/server/workspace-sicherung.js b/server/workspace-sicherung.js index 63b9348c..0c1a15c8 100644 --- a/server/workspace-sicherung.js +++ b/server/workspace-sicherung.js @@ -38,7 +38,7 @@ import express from "express"; import { readdirSync, statSync, unlinkSync, mkdirSync, existsSync, statfsSync, renameSync, } from "node:fs"; -import { join, dirname } from "node:path"; +import { join, dirname, sep } from "node:path"; import { createRequire } from "node:module"; import { randomBytes, timingSafeEqual } from "node:crypto"; import { fileURLToPath, pathToFileURL } from "node:url"; @@ -59,7 +59,7 @@ const ORDNER = join(DATEN_ORDNER, "sicherungen"); /* Wie viele wovon aufgehoben werden. Grossvater-Vater-Sohn: viele frische fuer "ups, eben geloescht", wenige alte fuer "das war schon letzten Monat falsch". Bei 2 MB je Datei kostet das nichts. */ -const BEHALTEN = { taeglich: 7, woechentlich: 4, monatlich: 6 }; +const BEHALTEN = { taeglich: 7, woechentlich: 4, monatlich: 6, vorher: 12 }; const zahl2 = (n) => String(n).padStart(2, "0"); @@ -128,7 +128,18 @@ function pruefen(pfad) { } finally { try { p.close(); } catch { /* egal */ } } } -export function sicherungJetzt(grund = "planmaessig") { +/* `eigeneDatei` ist fuer Sicherungen VOR einer unumkehrbaren Handlung. + + Der Grund dafuer kam aus der Pruefung: Wer drei Personen hintereinander + loescht, ueberschreibt sonst dreimal dieselbe Tagesdatei -- und die + letzte Fassung kennt die erste geloeschte Person schon nicht mehr. Die + Sicherung haette dann genau in dem Fall versagt, fuer den sie gemacht + ist. + + Deshalb bekommt jede solche Sicherung einen eigenen Namen mit Uhrzeit + und kann nichts ueberschreiben. Zwoelf davon werden aufgehoben; bei + rund 200 KB je Datei kostet das nichts. */ +export function sicherungJetzt(grund = "planmaessig", eigeneDatei = false) { const begonnen = Date.now(); const d = db(); ordnerSicherstellen(); @@ -143,18 +154,40 @@ export function sicherungJetzt(grund = "planmaessig") { checkpoint = { fehler: f?.message }; } - const art = artFuer(); - const ziel = join(ORDNER, `${art}-${heuteOrt()}.db`); + const art = eigeneDatei ? "vorher" : artFuer(); + const n = new Date(); + const stempel = eigeneDatei + /* Millisekunden mit dabei: Ohne sie trugen drei Loeschungen in + derselben Sekunde denselben Namen, und die Zaehler-Notloesung + darunter musste jedes Mal greifen -- die Dateinamen sortierten + sich dann nicht mehr nach Zeit ("...-2.db" steht vor "....db"). */ + ? `${heuteOrt()}-${zahl2(n.getHours())}${zahl2(n.getMinutes())}${zahl2(n.getSeconds())}` + + String(n.getMilliseconds()).padStart(3, "0") + : heuteOrt(); + + let ziel = join(ORDNER, `${art}-${stempel}.db`); + if (eigeneDatei) { + /* Niemals ueberschreiben, egal wie fein die Uhr ist. Drei Loeschungen + in derselben Sekunde ergaben mit reinem Zeitstempel EINE Datei -- + die zweite und dritte loeschten die vorherige. Genau der Fall, fuer + den die Sicherung da ist, waere damit ungesichert gewesen. + Aufgefallen ist es nur, weil die Pruefung die ANZAHL der Dateien + gezaehlt hat und nicht bloss, ob eine da ist. */ + let zaehler = 2; + while (existsSync(ziel)) ziel = join(ORDNER, `${art}-${stempel}-${zaehler++}.db`); + } else { + /* VACUUM INTO weigert sich, wenn die Zieldatei schon existiert. Am + selben Tag zweimal planmaessig zu sichern ist erlaubt -- die neue + ersetzt dann die alte. */ + try { if (existsSync(ziel)) unlinkSync(ziel); } catch { /* egal */ } + } + const dateiname = ziel.slice(ziel.lastIndexOf(sep) + 1); /* Erst neben das Ziel schreiben, dann pruefen, dann umbenennen. Sonst stuende nach einem Abbruch eine halbe Datei unter dem richtigen Namen -- und die wuerde man fuer gut halten. */ const vorlaeufig = ziel + ".teil"; try { if (existsSync(vorlaeufig)) unlinkSync(vorlaeufig); } catch { /* egal */ } - /* VACUUM INTO weigert sich, wenn die Zieldatei schon existiert. Am - selben Tag zweimal zu sichern ist erlaubt (etwa von Hand vor einer - groesseren Aenderung) -- die neue ersetzt dann die alte. */ - try { if (existsSync(ziel)) unlinkSync(ziel); } catch { /* egal */ } d.exec(`VACUUM INTO '${vorlaeufig.replace(/'/g, "''")}'`); @@ -170,7 +203,7 @@ export function sicherungJetzt(grund = "planmaessig") { const ergebnis = { zeit: new Date().toISOString(), - datei: `${art}-${heuteOrt()}.db`, + datei: dateiname, art, groesse, personen: geprueft.personen, @@ -181,8 +214,13 @@ export function sicherungJetzt(grund = "planmaessig") { ? `${checkpoint.busy ?? "?"}/${checkpoint.log ?? "?"}/${checkpoint.checkpointed ?? "?"}` : (checkpoint?.fehler || null), }; - einstellungSetzen("sicherung_letzte", JSON.stringify(ergebnis)); - einstellungSetzen("sicherung_fehler", ""); + /* Eine Sicherung VOR einer Loeschung ist kein planmaessiger Lauf. Wuerde + sie den Vermerk setzen, glaubte die Zustandsanzeige, die naechtliche + Sicherung sei erledigt -- und in der Nacht liefe keine mehr. */ + if (!eigeneDatei) { + einstellungSetzen("sicherung_letzte", JSON.stringify(ergebnis)); + einstellungSetzen("sicherung_fehler", ""); + } return ergebnis; } diff --git a/workspace/assets/css/personen.css b/workspace/assets/css/personen.css index ecc116e9..ce8557d6 100644 --- a/workspace/assets/css/personen.css +++ b/workspace/assets/css/personen.css @@ -182,3 +182,15 @@ /* In der Personenliste liegen die Karten direkt untereinander; der Abstand zur nächsten Rolle kommt von .gruppe selbst. */ .gruppe .person:last-child { margin-bottom: 0; } + +/* Der Löschknopf. Ruhig wie die anderen, bis man ihn ansieht – dann + deutlich rot. Ein dauerhaft roter Knopf in jeder Zeile macht die + ganze Liste nervös; einer, der erst beim Überfahren rot wird, warnt + genau im richtigen Moment. */ +.schritt[data-gefahr="ja"] { color: var(--text-still); } +.schritt[data-gefahr="ja"]:hover:not(:disabled) { + color: var(--warn); + border-color: rgba(255, 154, 162, .55); + background: rgba(255, 154, 162, .1); +} +.schritt[data-gefahr="ja"]:focus-visible { outline-color: var(--warn); } diff --git a/workspace/assets/js/personen.js b/workspace/assets/js/personen.js index 31c90d61..942abc4c 100644 --- a/workspace/assets/js/personen.js +++ b/workspace/assets/js/personen.js @@ -200,6 +200,16 @@ if (!a.ok) { melde((await a.json().catch(() => ({}))).fehler || 'Ging nicht.'); return; } await laden(); })); + + /* Löschen steht ganz rechts, hinter dem Sperren -- der harmlosere + Weg zuerst. Und nur für DogFather: "Nur DogFather hat alle + endgültigen Rechte" heißt genau hier etwas. Ein Manager kann + weiterhin sperren, das reicht für den Alltag und ist umkehrbar. */ + if (ich.rolle === 'admin') { + const weg = tuKnopf('Löschen', () => loeschenFragen(p)); + weg.dataset.gefahr = 'ja'; + knoepfe.append(weg); + } } k.append(links, knoepfe); @@ -238,6 +248,77 @@ sehen müsste. -------------------------------------------------------------------------- */ + /* ---------- Löschen ------------------------------------------------------ + + Die einzige Handlung hier, die sich nicht rückgängig machen lässt. + Deshalb wird sie NICHT mit einem confirm() abgetan, sondern zeigt + vorher, was daran hängt: was mitgeht, was nur seine Zuordnung + verliert, und wer danach ohne Betreuung dasteht. + + Der Name muss getippt werden. Nicht als Schikane -- der Löschknopf + sitzt neben dem Sperrknopf, und die beiden sind sehr verschieden. + Wer den Namen tippt, hat die Zeile gelesen, die er trifft. + + Den Knopf gibt es nur für DogFather; der Server prüft dasselbe noch + einmal und antwortet allen anderen mit 404. */ + + async function loeschenFragen(person) { + let v; + try { + const a = await hole(`/workspace/api/verwaltung/personen/${person.id}/loeschbar`); + if (!a.ok) { melde('Das Löschen ist hier nicht möglich.'); return; } + v = await a.json(); + } catch { return; } + + if (v.hindernis) { melde(v.hindernis); return; } + + const zeilen = []; + const mit = Object.entries(v.mit).filter(([, n]) => n > 0); + const bleibt = Object.entries(v.bleibt).filter(([, n]) => n > 0); + + zeilen.push(`„${v.person.name}" wirklich löschen?`, ''); + if (mit.length) { + zeilen.push('Wird MITGELÖSCHT:'); + for (const [was, n] of mit) zeilen.push(` · ${was}: ${n}`); + zeilen.push(''); + } + if (bleibt.length) { + zeilen.push('BLEIBT erhalten, verliert nur die Zuordnung:'); + for (const [was, n] of bleibt) zeilen.push(` · ${was}: ${n}`); + zeilen.push(''); + } + if (v.verliertBetreuung.length) { + zeilen.push(`ACHTUNG: ${v.verliertBetreuung.join(', ')} ` + + `${v.verliertBetreuung.length === 1 ? 'steht' : 'stehen'} danach ohne zuständige Person da.`, ''); + } + if (!mit.length && !bleibt.length && !v.verliertBetreuung.length) { + zeilen.push('An dieser Person hängt nichts.', ''); + } + zeilen.push('Vorher wird automatisch eine Sicherung geschrieben.', '', + `Zum Bestätigen den Namen genau eingeben: ${v.person.name}`); + + const eingabe = prompt(zeilen.join('\n'), ''); + if (eingabe === null) return; + if (eingabe.trim() !== v.person.name) { + melde('Der Name stimmt nicht – es wurde nichts gelöscht.'); + return; + } + + try { + const a = await hole(`/workspace/api/verwaltung/personen/${person.id}`, { + method: 'DELETE', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ name: eingabe.trim() }), + }); + const d = await a.json().catch(() => ({})); + if (!a.ok) { melde(d.fehler || 'Löschen hat nicht geklappt.'); return; } + /* Die Sicherung nennen. Wer gerade etwas Unumkehrbares getan hat, + soll sofort wissen, woraus es sich zurückholen lässt. */ + melde(`„${v.person.name}" wurde gelöscht. Sicherung davor: ${d.sicherung}`); + await laden(); + } catch { /* umgeleitet */ } + } + const SPEICHER = 'dfw_personen_alle'; let alle = []; let alleZeigen = false; diff --git a/workspace/aufgaben.html b/workspace/aufgaben.html index 5618ca30..539f44f5 100644 --- a/workspace/aufgaben.html +++ b/workspace/aufgaben.html @@ -7,9 +7,9 @@ - - - + + + @@ -134,8 +134,8 @@ - - - + + + diff --git a/workspace/automation.html b/workspace/automation.html index a4fe20f6..a72f077f 100644 --- a/workspace/automation.html +++ b/workspace/automation.html @@ -7,10 +7,10 @@ - - - - + + + + @@ -127,8 +127,8 @@ - - - + + + diff --git a/workspace/bereich.html b/workspace/bereich.html index df2992ce..f0cb24de 100644 --- a/workspace/bereich.html +++ b/workspace/bereich.html @@ -7,10 +7,10 @@ - - - - + + + + @@ -98,8 +98,8 @@ - - - + + + diff --git a/workspace/calls.html b/workspace/calls.html index 6d7acb5a..e1665323 100644 --- a/workspace/calls.html +++ b/workspace/calls.html @@ -7,10 +7,10 @@ - - - - + + + + @@ -49,9 +49,9 @@ - - - - + + + + diff --git a/workspace/content.html b/workspace/content.html index 59a0cf3a..b87c1f4d 100644 --- a/workspace/content.html +++ b/workspace/content.html @@ -7,10 +7,10 @@ - - - - + + + + @@ -153,8 +153,8 @@ - - - + + + diff --git a/workspace/dateien.html b/workspace/dateien.html index 904b3740..27c3d07a 100644 --- a/workspace/dateien.html +++ b/workspace/dateien.html @@ -7,10 +7,10 @@ - - - - + + + + @@ -88,8 +88,8 @@ - - - + + + diff --git a/workspace/index.html b/workspace/index.html index b9722d15..e0e47f41 100644 --- a/workspace/index.html +++ b/workspace/index.html @@ -25,7 +25,7 @@ type="image/webp" media="(max-width: 899px)" /> - + @@ -143,6 +143,6 @@

- + diff --git a/workspace/kalender.html b/workspace/kalender.html index 966646f0..0e6c80e8 100644 --- a/workspace/kalender.html +++ b/workspace/kalender.html @@ -7,10 +7,10 @@ - - - - + + + + @@ -94,8 +94,8 @@ - - - + + + diff --git a/workspace/personen.html b/workspace/personen.html index c120ff94..5a09373c 100644 --- a/workspace/personen.html +++ b/workspace/personen.html @@ -7,10 +7,10 @@ - - - - + + + + @@ -116,8 +116,8 @@ - - - + + + diff --git a/workspace/profil.html b/workspace/profil.html index 029b08ee..af1ffa6b 100644 --- a/workspace/profil.html +++ b/workspace/profil.html @@ -7,10 +7,10 @@ - - - - + + + + @@ -127,8 +127,8 @@ - - - + + + diff --git a/workspace/report.html b/workspace/report.html index 64c3e10c..10225c9d 100644 --- a/workspace/report.html +++ b/workspace/report.html @@ -7,10 +7,10 @@ - - - - + + + + @@ -86,9 +86,9 @@ - - - - + + + + diff --git a/workspace/scouting.html b/workspace/scouting.html index e9485af6..365abd9e 100644 --- a/workspace/scouting.html +++ b/workspace/scouting.html @@ -7,10 +7,10 @@ - - - - + + + + @@ -124,8 +124,8 @@ - - - + + + diff --git a/workspace/start.html b/workspace/start.html index 505e0bae..16dc5dac 100644 --- a/workspace/start.html +++ b/workspace/start.html @@ -7,8 +7,8 @@ - - + + @@ -68,8 +68,8 @@

- - - + + + diff --git a/workspace/startcheck.html b/workspace/startcheck.html index 7543f3de..1e7e5885 100644 --- a/workspace/startcheck.html +++ b/workspace/startcheck.html @@ -7,10 +7,10 @@ - - - - + + + + @@ -65,9 +65,9 @@ - - - - + + + + diff --git a/workspace/uebersicht.html b/workspace/uebersicht.html index ac44025b..a6e668cd 100644 --- a/workspace/uebersicht.html +++ b/workspace/uebersicht.html @@ -7,9 +7,9 @@ - - - + + + @@ -52,7 +52,7 @@ - - + + diff --git a/workspace/wissen.html b/workspace/wissen.html index f0422c6d..412447c6 100644 --- a/workspace/wissen.html +++ b/workspace/wissen.html @@ -7,10 +7,10 @@ - - - - + + + + @@ -170,8 +170,8 @@ - - - + + +