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:
Binary file not shown.
|
Before Width: | Height: | Size: 317 KiB After Width: | Height: | Size: 324 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 534 KiB After Width: | Height: | Size: 533 KiB |
@@ -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);
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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); }
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,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,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>
|
||||
|
||||
@@ -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,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,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>
|
||||
|
||||
@@ -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,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,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,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>
|
||||
|
||||
@@ -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,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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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,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>
|
||||
|
||||
Reference in New Issue
Block a user