Personen loeschen -- mit Vorschau und einer Sicherung unmittelbar davor

Wunsch Filipe: "ich will auch die moeglichkeit haben die loeschen zu
koennen."

Das ist die einzige Handlung im ganzen Workspace, die sich nicht
rueckgaengig machen laesst. Vier Vorkehrungen:

1. NUR DOGFATHER. Nicht die Leitung, nicht ein Manager -- "nur DogFather
   hat alle endgueltigen Rechte" heisst genau hier etwas. Ein Manager
   kann weiterhin sperren; das reicht fuer den Alltag und ist umkehrbar.
   Alle anderen bekommen 404, auch fuer die Vorschau: Die verraet, wie
   viel an einer Person haengt.

2. VORSCHAU. Vor dem Klick steht da, was MITGEHT (Profil, Start-Check,
   Content-Saeulen, Sitzungen, Zustaendigkeit) und was BLEIBT und nur
   seine Zuordnung verliert (Aufgaben, Termine, Bereichseintraege,
   Dateien, Leads). Eine Aufgabe verschwinden zu lassen, weil jemand
   geht, waere Geschichtsfaelschung.

   Dazu die gefaehrlichste Einzelwarnung: Wer einen Scout loescht, nimmt
   seinen Creators die zustaendige Person weg. Die Vorschau nennt sie
   beim Namen.

3. EINE SICHERUNG DIREKT DAVOR -- und wenn sie scheitert, wird NICHT
   geloescht. Damit ist "geloescht" wiederherstellbar. Das ist der
   eigentliche Gewinn aus der Sicherungsarbeit von heute Nachmittag.

4. Der Name muss getippt werden. Nicht als Schikane: Der Loeschknopf
   sitzt neben dem Sperrknopf, und die beiden sind sehr verschieden. Wer
   den Namen tippt, hat die Zeile gelesen, die er trifft.

ZWEI FEHLER, die erst die Pruefung sichtbar gemacht hat:

a) Alle Loeschungen schrieben in DIESELBE Tagessicherung. Nach der
   dritten kannte sie die erste geloeschte Person nicht mehr -- die
   Sicherung haette genau in dem Fall versagt, fuer den sie da ist.
   Loeschungen bekommen jetzt eine eigene Datei ("vorher-…") mit
   Zeitstempel, die nie ueberschrieben wird. Aufgefallen nur, weil die
   Pruefung die ANZAHL der Dateien zaehlt und nicht bloss, ob eine da ist.

b) Der Zeitstempel hatte Sekundenaufloesung -- drei Loeschungen in
   derselben Sekunde ergaben wieder eine einzige Datei. Jetzt mit
   Millisekunden, plus Zaehler als Notloesung. Und: Eine Sicherung vor
   dem Loeschen setzt NICHT den Vermerk fuer den planmaessigen Lauf,
   sonst faellt die naechtliche Sicherung aus.

Dabei auch ein Fehler in meiner eigenen Pruefung gefunden: Sie griff die
alphabetisch erste Datei und nannte sie "die aelteste" -- "vorher-…-2.db"
sortiert aber VOR "vorher-….db", weil "-" kleiner ist als ".". Sie prueft
jetzt die Eigenschaft selbst: JEDE geloeschte Person muss sich aus
irgendeiner Sicherung zurueckholen lassen.

43 Pruefungen, Schwerpunkt auf dem, was NICHT gehen darf.

Co-Authored-By: Claude Opus 5 <[email protected]>
This commit is contained in:
2026-08-31 23:13:14 +02:00
co-authored by Claude Opus 5
parent 2dbf9fb57a
commit ac81f288c8
23 changed files with 656 additions and 117 deletions
Binary file not shown.

Before

Width:  |  Height:  |  Size: 317 KiB

After

Width:  |  Height:  |  Size: 324 KiB

BIN
View File
Binary file not shown.

Before

Width:  |  Height:  |  Size: 534 KiB

After

Width:  |  Height:  |  Size: 533 KiB

+253
View File
@@ -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);
+155
View File
@@ -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) => {
+50 -12
View File
@@ -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;
}
+12
View File
@@ -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); }
+81
View File
@@ -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;
+6 -6
View File
@@ -7,9 +7,9 @@
<meta name="robots" content="noindex, nofollow" />
<meta name="theme-color" content="#05070d" />
<link rel="icon" href="assets/img/favicon.png" />
<link rel="stylesheet" href="assets/css/gate.css?v=202608312305" />
<link rel="stylesheet" href="assets/css/start.css?v=202608312305" />
<link rel="stylesheet" href="assets/css/aufgaben.css?v=202608312305" />
<link rel="stylesheet" href="assets/css/gate.css?v=202608312313" />
<link rel="stylesheet" href="assets/css/start.css?v=202608312313" />
<link rel="stylesheet" href="assets/css/aufgaben.css?v=202608312313" />
</head>
<body class="start">
@@ -134,8 +134,8 @@
</form>
</dialog>
<script src="assets/js/wahl.js?v=202608312305" defer></script>
<script src="assets/js/kopf.js?v=202608312305" defer></script>
<script src="assets/js/aufgaben.js?v=202608312305" defer></script>
<script src="assets/js/wahl.js?v=202608312313" defer></script>
<script src="assets/js/kopf.js?v=202608312313" defer></script>
<script src="assets/js/aufgaben.js?v=202608312313" defer></script>
</body>
</html>
+7 -7
View File
@@ -7,10 +7,10 @@
<meta name="robots" content="noindex, nofollow" />
<meta name="theme-color" content="#05070d" />
<link rel="icon" href="assets/img/favicon.png" />
<link rel="stylesheet" href="assets/css/gate.css?v=202608312305" />
<link rel="stylesheet" href="assets/css/start.css?v=202608312305" />
<link rel="stylesheet" href="assets/css/aufgaben.css?v=202608312305" />
<link rel="stylesheet" href="assets/css/automation.css?v=202608312305" />
<link rel="stylesheet" href="assets/css/gate.css?v=202608312313" />
<link rel="stylesheet" href="assets/css/start.css?v=202608312313" />
<link rel="stylesheet" href="assets/css/aufgaben.css?v=202608312313" />
<link rel="stylesheet" href="assets/css/automation.css?v=202608312313" />
</head>
<body class="start">
@@ -127,8 +127,8 @@
</main>
<script src="assets/js/wahl.js?v=202608312305" defer></script>
<script src="assets/js/kopf.js?v=202608312305" defer></script>
<script src="assets/js/automation.js?v=202608312305" defer></script>
<script src="assets/js/wahl.js?v=202608312313" defer></script>
<script src="assets/js/kopf.js?v=202608312313" defer></script>
<script src="assets/js/automation.js?v=202608312313" defer></script>
</body>
</html>
+7 -7
View File
@@ -7,10 +7,10 @@
<meta name="robots" content="noindex, nofollow" />
<meta name="theme-color" content="#05070d" />
<link rel="icon" href="assets/img/favicon.png" />
<link rel="stylesheet" href="assets/css/gate.css?v=202608312305" />
<link rel="stylesheet" href="assets/css/start.css?v=202608312305" />
<link rel="stylesheet" href="assets/css/aufgaben.css?v=202608312305" />
<link rel="stylesheet" href="assets/css/bereich.css?v=202608312305" />
<link rel="stylesheet" href="assets/css/gate.css?v=202608312313" />
<link rel="stylesheet" href="assets/css/start.css?v=202608312313" />
<link rel="stylesheet" href="assets/css/aufgaben.css?v=202608312313" />
<link rel="stylesheet" href="assets/css/bereich.css?v=202608312313" />
</head>
<body class="start">
@@ -98,8 +98,8 @@
</main>
<script src="assets/js/wahl.js?v=202608312305" defer></script>
<script src="assets/js/kopf.js?v=202608312305" defer></script>
<script src="assets/js/bereich.js?v=202608312305" defer></script>
<script src="assets/js/wahl.js?v=202608312313" defer></script>
<script src="assets/js/kopf.js?v=202608312313" defer></script>
<script src="assets/js/bereich.js?v=202608312313" defer></script>
</body>
</html>
+8 -8
View File
@@ -7,10 +7,10 @@
<meta name="robots" content="noindex, nofollow" />
<meta name="theme-color" content="#05070d" />
<link rel="icon" href="assets/img/favicon.png" />
<link rel="stylesheet" href="assets/css/gate.css?v=202608312305" />
<link rel="stylesheet" href="assets/css/start.css?v=202608312305" />
<link rel="stylesheet" href="assets/css/aufgaben.css?v=202608312305" />
<link rel="stylesheet" href="assets/css/calls.css?v=202608312305" />
<link rel="stylesheet" href="assets/css/gate.css?v=202608312313" />
<link rel="stylesheet" href="assets/css/start.css?v=202608312313" />
<link rel="stylesheet" href="assets/css/aufgaben.css?v=202608312313" />
<link rel="stylesheet" href="assets/css/calls.css?v=202608312313" />
</head>
<body class="start">
@@ -49,9 +49,9 @@
</main>
<script src="assets/js/ki.js?v=202608312305" defer></script>
<script src="assets/js/wahl.js?v=202608312305" defer></script>
<script src="assets/js/kopf.js?v=202608312305" defer></script>
<script src="assets/js/calls.js?v=202608312305" defer></script>
<script src="assets/js/ki.js?v=202608312313" defer></script>
<script src="assets/js/wahl.js?v=202608312313" defer></script>
<script src="assets/js/kopf.js?v=202608312313" defer></script>
<script src="assets/js/calls.js?v=202608312313" defer></script>
</body>
</html>
+7 -7
View File
@@ -7,10 +7,10 @@
<meta name="robots" content="noindex, nofollow" />
<meta name="theme-color" content="#05070d" />
<link rel="icon" href="assets/img/favicon.png" />
<link rel="stylesheet" href="assets/css/gate.css?v=202608312305" />
<link rel="stylesheet" href="assets/css/start.css?v=202608312305" />
<link rel="stylesheet" href="assets/css/aufgaben.css?v=202608312305" />
<link rel="stylesheet" href="assets/css/content.css?v=202608312305" />
<link rel="stylesheet" href="assets/css/gate.css?v=202608312313" />
<link rel="stylesheet" href="assets/css/start.css?v=202608312313" />
<link rel="stylesheet" href="assets/css/aufgaben.css?v=202608312313" />
<link rel="stylesheet" href="assets/css/content.css?v=202608312313" />
</head>
<body class="start">
@@ -153,8 +153,8 @@
</main>
<script src="assets/js/wahl.js?v=202608312305" defer></script>
<script src="assets/js/kopf.js?v=202608312305" defer></script>
<script src="assets/js/content.js?v=202608312305" defer></script>
<script src="assets/js/wahl.js?v=202608312313" defer></script>
<script src="assets/js/kopf.js?v=202608312313" defer></script>
<script src="assets/js/content.js?v=202608312313" defer></script>
</body>
</html>
+7 -7
View File
@@ -7,10 +7,10 @@
<meta name="robots" content="noindex, nofollow" />
<meta name="theme-color" content="#05070d" />
<link rel="icon" href="assets/img/favicon.png" />
<link rel="stylesheet" href="assets/css/gate.css?v=202608312305" />
<link rel="stylesheet" href="assets/css/start.css?v=202608312305" />
<link rel="stylesheet" href="assets/css/aufgaben.css?v=202608312305" />
<link rel="stylesheet" href="assets/css/dateien.css?v=202608312305" />
<link rel="stylesheet" href="assets/css/gate.css?v=202608312313" />
<link rel="stylesheet" href="assets/css/start.css?v=202608312313" />
<link rel="stylesheet" href="assets/css/aufgaben.css?v=202608312313" />
<link rel="stylesheet" href="assets/css/dateien.css?v=202608312313" />
</head>
<body class="start">
@@ -88,8 +88,8 @@
</main>
<script src="assets/js/wahl.js?v=202608312305" defer></script>
<script src="assets/js/kopf.js?v=202608312305" defer></script>
<script src="assets/js/dateien.js?v=202608312305" defer></script>
<script src="assets/js/wahl.js?v=202608312313" defer></script>
<script src="assets/js/kopf.js?v=202608312313" defer></script>
<script src="assets/js/dateien.js?v=202608312313" defer></script>
</body>
</html>
+2 -2
View File
@@ -25,7 +25,7 @@
type="image/webp" media="(max-width: 899px)" />
<link rel="icon" href="assets/img/favicon.png" />
<link rel="stylesheet" href="assets/css/gate.css?v=202608312305" />
<link rel="stylesheet" href="assets/css/gate.css?v=202608312313" />
</head>
<body class="gate">
@@ -143,6 +143,6 @@
</p>
</main>
<script src="assets/js/gate.js?v=202608312305" defer></script>
<script src="assets/js/gate.js?v=202608312313" defer></script>
</body>
</html>
+7 -7
View File
@@ -7,10 +7,10 @@
<meta name="robots" content="noindex, nofollow" />
<meta name="theme-color" content="#05070d" />
<link rel="icon" href="assets/img/favicon.png" />
<link rel="stylesheet" href="assets/css/gate.css?v=202608312305" />
<link rel="stylesheet" href="assets/css/start.css?v=202608312305" />
<link rel="stylesheet" href="assets/css/aufgaben.css?v=202608312305" />
<link rel="stylesheet" href="assets/css/kalender.css?v=202608312305" />
<link rel="stylesheet" href="assets/css/gate.css?v=202608312313" />
<link rel="stylesheet" href="assets/css/start.css?v=202608312313" />
<link rel="stylesheet" href="assets/css/aufgaben.css?v=202608312313" />
<link rel="stylesheet" href="assets/css/kalender.css?v=202608312313" />
</head>
<body class="start">
@@ -94,8 +94,8 @@
</main>
<script src="assets/js/wahl.js?v=202608312305" defer></script>
<script src="assets/js/kopf.js?v=202608312305" defer></script>
<script src="assets/js/kalender.js?v=202608312305" defer></script>
<script src="assets/js/wahl.js?v=202608312313" defer></script>
<script src="assets/js/kopf.js?v=202608312313" defer></script>
<script src="assets/js/kalender.js?v=202608312313" defer></script>
</body>
</html>
+7 -7
View File
@@ -7,10 +7,10 @@
<meta name="robots" content="noindex, nofollow" />
<meta name="theme-color" content="#05070d" />
<link rel="icon" href="assets/img/favicon.png" />
<link rel="stylesheet" href="assets/css/gate.css?v=202608312305" />
<link rel="stylesheet" href="assets/css/start.css?v=202608312305" />
<link rel="stylesheet" href="assets/css/aufgaben.css?v=202608312305" />
<link rel="stylesheet" href="assets/css/personen.css?v=202608312305" />
<link rel="stylesheet" href="assets/css/gate.css?v=202608312313" />
<link rel="stylesheet" href="assets/css/start.css?v=202608312313" />
<link rel="stylesheet" href="assets/css/aufgaben.css?v=202608312313" />
<link rel="stylesheet" href="assets/css/personen.css?v=202608312313" />
</head>
<body class="start">
@@ -116,8 +116,8 @@
</main>
<script src="assets/js/wahl.js?v=202608312305" defer></script>
<script src="assets/js/kopf.js?v=202608312305" defer></script>
<script src="assets/js/personen.js?v=202608312305" defer></script>
<script src="assets/js/wahl.js?v=202608312313" defer></script>
<script src="assets/js/kopf.js?v=202608312313" defer></script>
<script src="assets/js/personen.js?v=202608312313" defer></script>
</body>
</html>
+7 -7
View File
@@ -7,10 +7,10 @@
<meta name="robots" content="noindex, nofollow" />
<meta name="theme-color" content="#05070d" />
<link rel="icon" href="assets/img/favicon.png" />
<link rel="stylesheet" href="assets/css/gate.css?v=202608312305" />
<link rel="stylesheet" href="assets/css/start.css?v=202608312305" />
<link rel="stylesheet" href="assets/css/aufgaben.css?v=202608312305" />
<link rel="stylesheet" href="assets/css/profil.css?v=202608312305" />
<link rel="stylesheet" href="assets/css/gate.css?v=202608312313" />
<link rel="stylesheet" href="assets/css/start.css?v=202608312313" />
<link rel="stylesheet" href="assets/css/aufgaben.css?v=202608312313" />
<link rel="stylesheet" href="assets/css/profil.css?v=202608312313" />
</head>
<body class="start">
@@ -127,8 +127,8 @@
</main>
<script src="assets/js/wahl.js?v=202608312305" defer></script>
<script src="assets/js/kopf.js?v=202608312305" defer></script>
<script src="assets/js/profil.js?v=202608312305" defer></script>
<script src="assets/js/wahl.js?v=202608312313" defer></script>
<script src="assets/js/kopf.js?v=202608312313" defer></script>
<script src="assets/js/profil.js?v=202608312313" defer></script>
</body>
</html>
+8 -8
View File
@@ -7,10 +7,10 @@
<meta name="robots" content="noindex, nofollow" />
<meta name="theme-color" content="#05070d" />
<link rel="icon" href="assets/img/favicon.png" />
<link rel="stylesheet" href="assets/css/gate.css?v=202608312305" />
<link rel="stylesheet" href="assets/css/start.css?v=202608312305" />
<link rel="stylesheet" href="assets/css/aufgaben.css?v=202608312305" />
<link rel="stylesheet" href="assets/css/report.css?v=202608312305" />
<link rel="stylesheet" href="assets/css/gate.css?v=202608312313" />
<link rel="stylesheet" href="assets/css/start.css?v=202608312313" />
<link rel="stylesheet" href="assets/css/aufgaben.css?v=202608312313" />
<link rel="stylesheet" href="assets/css/report.css?v=202608312313" />
</head>
<body class="start">
@@ -86,9 +86,9 @@
</main>
<script src="assets/js/ki.js?v=202608312305" defer></script>
<script src="assets/js/wahl.js?v=202608312305" defer></script>
<script src="assets/js/kopf.js?v=202608312305" defer></script>
<script src="assets/js/report.js?v=202608312305" defer></script>
<script src="assets/js/ki.js?v=202608312313" defer></script>
<script src="assets/js/wahl.js?v=202608312313" defer></script>
<script src="assets/js/kopf.js?v=202608312313" defer></script>
<script src="assets/js/report.js?v=202608312313" defer></script>
</body>
</html>
+7 -7
View File
@@ -7,10 +7,10 @@
<meta name="robots" content="noindex, nofollow" />
<meta name="theme-color" content="#05070d" />
<link rel="icon" href="assets/img/favicon.png" />
<link rel="stylesheet" href="assets/css/gate.css?v=202608312305" />
<link rel="stylesheet" href="assets/css/start.css?v=202608312305" />
<link rel="stylesheet" href="assets/css/aufgaben.css?v=202608312305" />
<link rel="stylesheet" href="assets/css/scouting.css?v=202608312305" />
<link rel="stylesheet" href="assets/css/gate.css?v=202608312313" />
<link rel="stylesheet" href="assets/css/start.css?v=202608312313" />
<link rel="stylesheet" href="assets/css/aufgaben.css?v=202608312313" />
<link rel="stylesheet" href="assets/css/scouting.css?v=202608312313" />
</head>
<body class="start">
@@ -124,8 +124,8 @@
</div>
</div>
<script src="assets/js/wahl.js?v=202608312305" defer></script>
<script src="assets/js/kopf.js?v=202608312305" defer></script>
<script src="assets/js/scouting.js?v=202608312305" defer></script>
<script src="assets/js/wahl.js?v=202608312313" defer></script>
<script src="assets/js/kopf.js?v=202608312313" defer></script>
<script src="assets/js/scouting.js?v=202608312313" defer></script>
</body>
</html>
+5 -5
View File
@@ -7,8 +7,8 @@
<meta name="robots" content="noindex, nofollow" />
<meta name="theme-color" content="#05070d" />
<link rel="icon" href="assets/img/favicon.png" />
<link rel="stylesheet" href="assets/css/gate.css?v=202608312305" />
<link rel="stylesheet" href="assets/css/start.css?v=202608312305" />
<link rel="stylesheet" href="assets/css/gate.css?v=202608312313" />
<link rel="stylesheet" href="assets/css/start.css?v=202608312313" />
</head>
<body class="start">
@@ -68,8 +68,8 @@
</p>
</main>
<script src="assets/js/wahl.js?v=202608312305" defer></script>
<script src="assets/js/kopf.js?v=202608312305" defer></script>
<script src="assets/js/start.js?v=202608312305" defer></script>
<script src="assets/js/wahl.js?v=202608312313" defer></script>
<script src="assets/js/kopf.js?v=202608312313" defer></script>
<script src="assets/js/start.js?v=202608312313" defer></script>
</body>
</html>
+8 -8
View File
@@ -7,10 +7,10 @@
<meta name="robots" content="noindex, nofollow" />
<meta name="theme-color" content="#05070d" />
<link rel="icon" href="assets/img/favicon.png" />
<link rel="stylesheet" href="assets/css/gate.css?v=202608312305" />
<link rel="stylesheet" href="assets/css/start.css?v=202608312305" />
<link rel="stylesheet" href="assets/css/aufgaben.css?v=202608312305" />
<link rel="stylesheet" href="assets/css/startcheck.css?v=202608312305" />
<link rel="stylesheet" href="assets/css/gate.css?v=202608312313" />
<link rel="stylesheet" href="assets/css/start.css?v=202608312313" />
<link rel="stylesheet" href="assets/css/aufgaben.css?v=202608312313" />
<link rel="stylesheet" href="assets/css/startcheck.css?v=202608312313" />
</head>
<body class="start">
@@ -65,9 +65,9 @@
</main>
<script src="assets/js/ki.js?v=202608312305" defer></script>
<script src="assets/js/wahl.js?v=202608312305" defer></script>
<script src="assets/js/kopf.js?v=202608312305" defer></script>
<script src="assets/js/startcheck.js?v=202608312305" defer></script>
<script src="assets/js/ki.js?v=202608312313" defer></script>
<script src="assets/js/wahl.js?v=202608312313" defer></script>
<script src="assets/js/kopf.js?v=202608312313" defer></script>
<script src="assets/js/startcheck.js?v=202608312313" defer></script>
</body>
</html>
+5 -5
View File
@@ -7,9 +7,9 @@
<meta name="robots" content="noindex, nofollow" />
<meta name="theme-color" content="#05070d" />
<link rel="icon" href="assets/img/favicon.png" />
<link rel="stylesheet" href="assets/css/gate.css?v=202608312305" />
<link rel="stylesheet" href="assets/css/start.css?v=202608312305" />
<link rel="stylesheet" href="assets/css/uebersicht.css?v=202608312305" />
<link rel="stylesheet" href="assets/css/gate.css?v=202608312313" />
<link rel="stylesheet" href="assets/css/start.css?v=202608312313" />
<link rel="stylesheet" href="assets/css/uebersicht.css?v=202608312313" />
</head>
<body class="start">
@@ -52,7 +52,7 @@
</main>
<script src="assets/js/kopf.js?v=202608312305" defer></script>
<script src="assets/js/uebersicht.js?v=202608312305" defer></script>
<script src="assets/js/kopf.js?v=202608312313" defer></script>
<script src="assets/js/uebersicht.js?v=202608312313" defer></script>
</body>
</html>
+7 -7
View File
@@ -7,10 +7,10 @@
<meta name="robots" content="noindex, nofollow" />
<meta name="theme-color" content="#05070d" />
<link rel="icon" href="assets/img/favicon.png" />
<link rel="stylesheet" href="assets/css/gate.css?v=202608312305" />
<link rel="stylesheet" href="assets/css/start.css?v=202608312305" />
<link rel="stylesheet" href="assets/css/aufgaben.css?v=202608312305" />
<link rel="stylesheet" href="assets/css/wissen.css?v=202608312305" />
<link rel="stylesheet" href="assets/css/gate.css?v=202608312313" />
<link rel="stylesheet" href="assets/css/start.css?v=202608312313" />
<link rel="stylesheet" href="assets/css/aufgaben.css?v=202608312313" />
<link rel="stylesheet" href="assets/css/wissen.css?v=202608312313" />
</head>
<body class="start">
@@ -170,8 +170,8 @@
</main>
<script src="assets/js/wahl.js?v=202608312305" defer></script>
<script src="assets/js/kopf.js?v=202608312305" defer></script>
<script src="assets/js/wissen.js?v=202608312305" defer></script>
<script src="assets/js/wahl.js?v=202608312313" defer></script>
<script src="assets/js/kopf.js?v=202608312313" defer></script>
<script src="assets/js/wissen.js?v=202608312313" defer></script>
</body>
</html>