Die Rolle "Spicy Media" -- und drei Fehler, die nur ihre Pruefung fand

DIE PERSONENTABELLE WURDE NEU GEBAUT. Eine Rolle ist ein erlaubter Wert
in einer Spalte, und der steckt in einem CHECK -- den kann SQLite nicht
aendern. Auf `personen` zeigen ZWEIUNDFUENFZIG Fremdschluessel.

Deshalb wird der Bauplan NICHT abgeschrieben, sondern gelesen: Der
CREATE-Text kommt aus sqlite_master, darin wird ausschliesslich die
Rollenliste ersetzt, und die Kopierliste kommt aus PRAGMA table_info.
Die beiden aelteren Umstellungen schreiben ihre Spalten von Hand ab --
`personen` hat seit damals SIEBEN dazubekommen (bild, ueber_mich,
tiktok ...). Wer hier abschreibt, verliert alle Profilbilder.

ZWEI FRAGEN, DIE MAN AUSEINANDERHALTEN MUSS:
  siehtAlles()   = DogFather ODER Spicy Media -> Listen, Uebersichten
  istDogFather() = nur DogFather              -> loeschen, Rollen, Codes
Es waere weniger Arbeit gewesen, istDogFather() um "spicy" zu erweitern
-- und genau das waere der Fehler: Spicy Media koennte dann DogFather
loeschen.

DER CHAT BRAUCHTE NICHTS. Er haengt allein an der Teilnehmerliste und
kennt kein "das Management sieht alles". Spicy Media sieht fremde
Gespraeche nicht, weil es dafuer keinen Weg gibt -- nicht, weil eine
Abfrage es verbietet.

DREI ECHTE FEHLER, alle von pruef-spicy gefunden, keiner vorher sichtbar:

1. MEIN EIGENER KOMMENTAR STAND IM BAUPLAN. SQLite hebt den CREATE-Text
   woertlich auf, samt Kommentaren. Ich hatte "'spicy' steht HIER mit
   drin" hineingeschrieben -- und die Erkennung suchte genau dieses Wort
   im ganzen Text. Ergebnis: Die Umstellung hielt die Tabelle fuer
   erledigt, obwohl die CHECK-Regel noch die alte war. Jetzt wird die
   Regel herausgeschnitten und NUR darin gesucht; der Kommentar steht
   ausserhalb des SQL.

2. DER SICHERUNGSNAME HATTE NUR MINUTEN. `VACUUM INTO` weigert sich, eine
   vorhandene Datei zu ueberschreiben -- zu Recht. Zwei Umstellungen in
   derselben Minute wollten in dieselbe Datei, die zweite scheiterte, und
   weil ohne Sicherung nicht umgestellt wird, blieb sie aus. Es sah nach
   "lief" aus (die Datei lag ja da) und war keine. Jetzt mit Sekunden.

3. EINE FRISCHE DATENBANK LEGTE DIE ALTE ROLLENLISTE AN und stellte beim
   allerersten Start sofort um -- Tabelle neu bauen, Sicherung schreiben,
   fuer nichts. Ein Bauplan, der sofort umgebaut werden muss, ist der
   falsche.

Und ein vierter in der Pruefung selbst: Der Chat-Aufbau benutzte einen
falschen Weg, das Gespraech entstand gar nicht -- "Spicy Media sieht 0
Gespraeche" war trotzdem gruen, weil es keine gab. Jetzt ist das Anlegen
selbst eine Pruefung, und eine Gegenprobe zeigt, dass Max es sehr wohl
sieht.

DAZU: Manager sehen die Kachel "Personen & Zugaenge" -- die SEITE geht
auf, die Schnittstellen nicht. Alles unter /verwaltung haengt weiter an
`nurAdmin` und antwortet 404; sie sehen genau den Teil, fuer den es
einen Weg gibt. Spicy Media legt Creator UND Manager an (zweite enge
Tuer, Rolle steht auch dort nicht im Aufruf; ein Manager kommt durch
sie nicht). Der Rollentext des Managers stimmte nicht mehr -- er legt
jetzt Creator an.

pruef-spicy: 36 Pruefungen, alle gruen. Ausserdem gruen: personen-liste
(33), creator-anlegen (29), sicht (48), css-klassen (15), alle-wege (19),
haerte (20), manager-sicht (43).

Co-Authored-By: Claude Opus 5 <[email protected]>
This commit is contained in:
2026-09-07 04:56:07 +02:00
co-authored by Claude Opus 5
parent 4bb5afc942
commit 68bc116c36
39 changed files with 983 additions and 247 deletions
+365
View File
@@ -0,0 +1,365 @@
/* =====================================================================
DIE ROLLE "SPICY MEDIA"
Wunsch Filipe (07.09.2026): "eine neue rolle ... soll die gleichen
rechte haben wie dogfather ausser, bei automationen sollen die nicht
sehen und die kategorie personen und zugaenge sollen die nur leute
hinzufuegen koennen ... aber die sollen meine privaten chats und so
nicht sehen."
---------------------------------------------------------------------
ZWEI DINGE WERDEN HIER GEPRUEFT, UND DAS ZWEITE IST DAS WICHTIGERE
1. DER UMBAU DER PERSONENTABELLE. Eine Rolle ist ein erlaubter Wert in
einer Spalte, und der steckt in einem CHECK. Ein CHECK laesst sich
nicht aendern -- die Tabelle muss neu gebaut werden. Auf `personen`
zeigen ZWEIUNDFUENFZIG Fremdschluessel; jede Sitzung, jede Aufgabe,
jeder Termin haengt daran. Deshalb wird der Weg hier nicht "im
Prinzip" geprueft, sondern WIRKLICH GEGANGEN: Die Pruefung baut
eine Datenbank im alten Stand nach, fuellt sie mit Personen UND
abhaengigen Zeilen, laesst die echte Anwendung darueberlaufen und
zaehlt danach jede Zeile und jedes Feld nach.
2. DIE GRENZEN DER ROLLE. Zu jedem "darf" steht das "darf nicht"
daneben. Eine Rolle, von der nur geprueft ist, was sie kann, ist
keine Rolle, sondern eine Vermutung.
Aufruf: node server/pruef-spicy.mjs
===================================================================== */
import { mkdtempSync, rmSync, existsSync, readdirSync } from "node:fs";
import { tmpdir } from "node:os";
import { join, dirname, basename } from "node:path";
import { fileURLToPath } from "node:url";
import { spawn } from "node:child_process";
import { DatabaseSync } from "node:sqlite";
import { scryptSync, randomBytes } from "node:crypto";
import { notbremse } from "./helfer-notbremse.mjs";
notbremse(300_000, "die Spicy-Pruefung");
const HIER = dirname(fileURLToPath(import.meta.url));
const { portMussFreiSein } = await import("./helfer-port.mjs");
const PORT = 4351;
await portMussFreiSein(PORT, "die Spicy-Pruefung");
const BASIS = `http://127.0.0.1:${PORT}`;
const ordner = mkdtempSync(join(tmpdir(), "ws-spicy-"));
const DB = join(ordner, "workspace.db");
let fehler = 0, gemacht = 0;
const ok = (b, t) => { gemacht++; console.log((b ? " ok " : " FEHL ") + t); if (!b) fehler++; };
let kind = null;
async function starten() {
kind = spawn(process.execPath, [join(HIER, "index.js")], {
env: { ...process.env, WORKSPACE_DB: DB, PORT: String(PORT),
SITE_ACCESS_SECRET: "lokaler-test",
SITE_PUBLIC_LAUNCH_AT: "2020-01-01T00:00:00+01:00" },
stdio: ["ignore", "pipe", "pipe"] });
let aus = "";
kind.stdout.on("data", (d) => { aus += d; });
kind.stderr.on("data", (d) => { aus += d; });
for (let i = 0; i < 100; i++) {
await new Promise((r) => setTimeout(r, 250));
if (kind.exitCode !== null) break;
try { const a = await fetch(BASIS + "/workspace/", { redirect: "manual" });
if (a.status < 500) return () => aus; } catch { /* noch nicht oben */ }
}
throw new Error("Server startete nicht:\n" + aus.slice(-1500));
}
async function stoppen() {
if (!kind) return;
kind.kill(); kind = null;
await new Promise((r) => setTimeout(r, 700));
}
/* =======================================================================
1. Eine Datenbank im ALTEN Stand -- mit Personen und allem, was daran
haengt. Ohne die abhaengigen Zeilen pruefte der Umbau nur eine
Tabelle statt eines Netzes.
======================================================================= */
console.log("=== Der alte Stand ===");
await starten();
await stoppen();
const jetzt = new Date().toISOString();
const anlegen = (d, name, rolle, code) => {
const salz = randomBytes(16).toString("hex");
const hash = scryptSync(code, salz, 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, salz, 32768, jetzt);
return d.prepare("SELECT last_insert_rowid() AS id").get().id;
};
let idDogi, idMax, idLuna, spaltenVorher, personenVorher;
{
const d = new DatabaseSync(DB);
/* Die Tabelle auf den alten CHECK zuruecksetzen -- WOERTLICH so, wie
sie vor heute aussah, aber mit allen Spalten, die inzwischen
dazugekommen sind. Genau das ist der Fall, der gefaehrlich ist. */
d.exec("PRAGMA foreign_keys = OFF");
const alt = d.prepare(
"SELECT sql FROM sqlite_master WHERE type='table' AND name='personen'").get().sql;
const zurueck = alt
/* Auch hier muss `IF NOT EXISTS` ins Muster -- sonst waere der
"alte Stand" gar keiner: Die Ersetzung liefe ins Leere, `CREATE
TABLE IF NOT EXISTS personen` waere wegen des vorhandenen Tisches
ein Leerlauf, und die Pruefung testete den heutigen Stand gegen
sich selbst. */
.replace(/CREATE TABLE\s+(?:IF\s+NOT\s+EXISTS\s+)?["'`]?personen["'`]?/i,
"CREATE TABLE personen_alt")
.replace(/rolle\s+IN\s*\([^)]*\)/i, "rolle IN ('admin','manager','scout','creator')");
/* Wenn der Rueckbau nicht greift, ist alles danach wertlos -- dann
prueft diese Datei den heutigen Stand gegen sich selbst. Deshalb
hier abbrechen statt weitermachen. */
const regelDanach = zurueck.match(/rolle\s+IN\s*\(([^)]*)\)/i)?.[1] || "";
if (!zurueck.includes("personen_alt") || regelDanach.includes("'spicy'")) {
console.log(" FEHL der alte Stand liess sich nicht nachbauen -- Muster passt nicht.");
process.exit(1);
}
d.exec("BEGIN");
d.exec(zurueck);
const sp = d.prepare("PRAGMA table_info(personen)").all().map((z) => `"${z.name}"`).join(", ");
d.exec(`INSERT INTO personen_alt (${sp}) SELECT ${sp} FROM personen;`);
d.exec("DROP TABLE personen;");
d.exec("ALTER TABLE personen_alt RENAME TO personen;");
d.exec("COMMIT");
d.exec("PRAGMA foreign_keys = ON");
idDogi = anlegen(d, "Filipe", "admin", "CODE-DOGI-0001");
idMax = anlegen(d, "Max", "manager", "CODE-MAX-00001");
idLuna = anlegen(d, "Luna", "creator", "CODE-LUNA-0001");
/* Ein Profilbild und ein Feld aus einer SPAETER ergaenzten Spalte --
genau die verliert man beim Abschreiben einer Spaltenliste. */
d.prepare("UPDATE personen SET bild = ?, ueber_mich = ?, tiktok = ? WHERE id = ?")
.run("abcdef0123456789abcdef0123456789.jpg", "Ich streame seit 2024.", "luna_live", idLuna);
/* Abhaengige Zeilen aus verschiedenen Tabellen. */
d.prepare("INSERT INTO betreuung (creator_id, betreuer_id, seit) VALUES (?,?,?)")
.run(idLuna, idMax, jetzt);
d.prepare(`INSERT INTO eintraege (bereich, art, titel, datum, creator_id, erstellt, erstellt_von)
VALUES ('technik','problem','Ton bricht ab',?,?,?,?)`)
.run(jetzt.slice(0, 10), idLuna, jetzt, idMax);
spaltenVorher = d.prepare("PRAGMA table_info(personen)").all().map((z) => z.name);
personenVorher = d.prepare("SELECT COUNT(*) AS n FROM personen").get().n;
/* GEGENPROBE: Der ALTE Stand muss 'spicy' wirklich ablehnen -- sonst
gaebe es nichts umzustellen, und alles danach bewiese nichts. */
let abgelehnt = false;
try {
d.prepare("INSERT INTO personen (name, rolle, code_hash, code_salt, code_n, aktiv, erstellt) VALUES (?,'spicy','x','y',1,1,?)")
.run("Darf noch nicht", jetzt);
} catch { abgelehnt = true; }
d.close();
ok(abgelehnt, "der alte Stand lehnt die Rolle 'spicy' wirklich ab");
ok(personenVorher === 3, `${personenVorher} Personen und abhaengige Zeilen angelegt`);
ok(spaltenVorher.includes("bild") && spaltenVorher.includes("tiktok"),
`die Tabelle hat ${spaltenVorher.length} Spalten, auch die spaeter ergaenzten`);
}
/* =======================================================================
2. Die Anwendung laeuft darueber
======================================================================= */
console.log("\n=== Die Umstellung ===");
const ausgabe = await starten();
/* Die Umstellung laeuft beim ersten Datenbankzugriff, nicht beim
Zuhoeren am Anschluss -- ihre Meldung kann also erst kurz nach dem
Start in der Ausgabe stehen. Eine Anfrage stellen und kurz warten. */
await fetch(BASIS + '/workspace/', { redirect: 'manual' }).catch(() => {});
await new Promise((r) => setTimeout(r, 900));
console.log("--- Serverausgabe ---");
for (const z of ausgabe().split(String.fromCharCode(10))) if (z.includes("workspace]")) console.log(" " + z);
{
ok(/Rolle 'spicy' freigeschaltet/.test(ausgabe()),
"die Umstellung meldet sich im Protokoll");
ok(/Sicherung vor der Umstellung/.test(ausgabe()),
"und hat vorher eine Sicherung geschrieben");
const sicherungen = readdirSync(ordner).filter((n) => n.includes("vor-spicy"));
ok(sicherungen.length === 1, `die Sicherungsdatei liegt da (${sicherungen[0] || "keine"})`);
}
{
const d = new DatabaseSync(DB, { readOnly: true });
const spalten = d.prepare("PRAGMA table_info(personen)").all().map((z) => z.name);
const fehlend = spaltenVorher.filter((n) => !spalten.includes(n));
ok(fehlend.length === 0,
fehlend.length ? `nach der Umstellung fehlen Spalten: ${fehlend.join(", ")}`
: `alle ${spalten.length} Spalten sind noch da`);
const n = d.prepare("SELECT COUNT(*) AS n FROM personen").get().n;
ok(n === personenVorher, `alle ${n} Personen sind noch da (vorher ${personenVorher})`);
const luna = d.prepare("SELECT * FROM personen WHERE id = ?").get(idLuna);
ok(luna?.bild === "abcdef0123456789abcdef0123456789.jpg",
`das Profilbild hat den Umbau ueberlebt (${luna?.bild ? "ja" : "WEG"})`);
ok(luna?.ueber_mich === "Ich streame seit 2024." && luna?.tiktok === "luna_live",
"auch die spaeter ergaenzten Textspalten");
const b = d.prepare("SELECT betreuer_id FROM betreuung WHERE creator_id = ?").get(idLuna);
ok(b?.betreuer_id === idMax, `die Betreuung zeigt noch auf Max (${b?.betreuer_id})`);
const e = d.prepare("SELECT creator_id FROM eintraege WHERE titel = 'Ton bricht ab'").get();
ok(e?.creator_id === idLuna, `der Technik-Eintrag noch auf Luna (${e?.creator_id})`);
const kaputt = d.prepare("PRAGMA foreign_key_check").all();
ok(kaputt.length === 0, `keine verwaisten Verweise (${kaputt.length})`);
/* GEGENPROBE, DIE WICHTIGSTE: Die Schranke muss noch BEISSEN. Eine
Umstellung, die nebenbei den CHECK entfernt, sieht aus wie ein
Erfolg und ist der schlimmere Ausgang. */
d.close();
const s = new DatabaseSync(DB);
let erfunden = false;
try {
s.prepare("INSERT INTO personen (name, rolle, code_hash, code_salt, code_n, aktiv, erstellt) VALUES (?,'quatsch','x','y',1,1,?)")
.run("Erfundene Rolle", jetzt);
} catch { erfunden = true; }
ok(erfunden, "die Schranke beisst noch -- eine erfundene Rolle wird abgelehnt");
const idSpicy = anlegen(s, "Agentur", "spicy", "CODE-SPICY-001");
ok(Number.isInteger(idSpicy) && idSpicy > 0, "'spicy' laesst sich jetzt anlegen");
s.close();
}
/* =======================================================================
3. Was die Rolle darf -- und was nicht
======================================================================= */
console.log("\n=== Rechte ===");
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 }) });
const keks = (a.headers.getSetCookie?.() || []).map((k) => k.split(";")[0]).join("; ");
if (!keks) throw new Error(`Anmeldung ${rolle} fehlgeschlagen (${a.status})`);
return keks;
};
const keksSpicy = await anmelden("spicy", "CODE-SPICY-001");
const keksDogi = await anmelden("admin", "CODE-DOGI-0001");
const keksMax = await anmelden("manager", "CODE-MAX-00001");
const ruf = async (art, weg, keks, koerper) => {
const a = await fetch(BASIS + weg, { method: art,
headers: { Cookie: keks, "Content-Type": "application/json", Origin: BASIS },
body: koerper ? JSON.stringify(koerper) : undefined });
let daten = null; try { daten = await a.json(); } catch { /* leer erlaubt */ }
return { status: a.status, daten };
};
{
const { status, daten } = await ruf("GET", "/workspace/api/ich", keksSpicy);
ok(status === 200 && daten?.rolle === "spicy", `die Anmeldung klappt (${daten?.rolle})`);
ok(daten?.rolle_name === "Spicy Media", `und heisst in der Oberflaeche "${daten?.rolle_name}"`);
}
{
/* SIEHT ALLES: alle Personen, alle Bereiche. */
const { daten } = await ruf("GET", "/workspace/api/personen", keksSpicy);
const namen = (daten?.personen || []).map((p) => p.name).sort();
ok(namen.includes("Luna") && namen.includes("Max") && namen.includes("Filipe"),
`sie sieht alle: ${namen.join(", ")}`);
}
{
const { status } = await ruf("GET", "/workspace/api/uebersicht/creator", keksSpicy);
ok(status === 200, `das Dashboard steht ihr offen (HTTP ${status})`);
}
{
/* NICHT: Automationen. */
const a = await fetch(BASIS + "/workspace/automation.html", {
headers: { Cookie: keksSpicy }, redirect: "manual" });
ok(a.status === 302 || a.status === 403 || a.status === 404,
`die Automationen-Seite bleibt zu (HTTP ${a.status})`);
const { status } = await ruf("GET", "/workspace/api/sicherung/zustand", keksSpicy);
ok(status === 404, `und die Sicherungs-Schnittstelle auch (HTTP ${status})`);
}
{
/* NICHT: die Personenverwaltung. Anlegen ja, alles andere nein. */
const { status } = await ruf("GET", "/workspace/api/verwaltung/personen", keksSpicy);
ok(status === 404, `die Personenverwaltung bleibt zu (HTTP ${status})`);
const loeschen = await ruf("DELETE", `/workspace/api/verwaltung/personen/${idLuna}`, keksSpicy);
ok(loeschen.status === 404, `loeschen geht nicht (HTTP ${loeschen.status})`);
const code = await ruf("POST", `/workspace/api/verwaltung/personen/${idLuna}/code`, keksSpicy);
ok(code.status === 404, `einen Code neu setzen auch nicht (HTTP ${code.status})`);
}
{
/* ABER: Leute anlegen -- Creator und Manager. */
const c = await ruf("POST", "/workspace/api/creator-anlegen", keksSpicy, { name: "NeuerCreator" });
ok(c.status === 201 && c.daten?.rolle === "creator",
`sie legt einen Creator an (HTTP ${c.status}, ${c.daten?.rolle})`);
const m = await ruf("POST", "/workspace/api/manager-anlegen", keksSpicy, { name: "NeuerManager" });
ok(m.status === 201 && m.daten?.rolle === "manager",
`und einen Manager (HTTP ${m.status}, ${m.daten?.rolle})`);
}
{
/* GEGENPROBE: Ein Manager kann KEINEN Manager anlegen -- sonst waere
die zweite Tuer nur Zierde. */
const { status } = await ruf("POST", "/workspace/api/manager-anlegen", keksMax, { name: "VonMax" });
ok(status === 404, `ein Manager kommt durch diese Tuer NICHT (HTTP ${status})`);
}
{
/* NICHT: fremde Chats.
Der Chat haengt ausschliesslich an der Teilnehmerliste -- Spicy
Media ist nirgends Teilnehmerin, also sieht sie nichts. Geprueft
wird mit einem ECHTEN Gespraech zwischen DogFather und Max.
DER AUFBAU MUSS GELINGEN, SONST BEWEIST DER REST NICHTS. Beim
ersten Anlauf stand hier ein falscher Weg (/chat/raum statt
/chat/direkt). Das Gespraech entstand gar nicht -- und "Spicy Media
sieht 0 Gespraeche" war trotzdem gruen, weil es ueberhaupt keine
gab. Genau die Sorte gruener Haken, die nichts geprueft hat.
Deshalb ist das Anlegen jetzt selbst eine Pruefung, und die
Raumnummer wird nachgehalten. */
const raum = await ruf("POST", "/workspace/api/chat/direkt", keksDogi, { mit: idMax });
const raumId = raum.daten?.raum_id;
ok(raum.status === 200 && Number.isInteger(raumId),
`DogFather und Max haben ein Gespraech (HTTP ${raum.status}, Raum ${raumId})`);
const nachricht = await ruf("POST", `/workspace/api/chat/raeume/${raumId}/nachrichten`,
keksDogi, { text: "Streng geheim." });
ok(nachricht.status === 200 || nachricht.status === 201,
`und eine Nachricht darin (HTTP ${nachricht.status})`);
/* GEGENPROBE: Max SIEHT das Gespraech -- sonst pruefte die Zeile
darunter nur, dass die Liste immer leer ist. */
const beiMax = await ruf("GET", "/workspace/api/chat/raeume", keksMax);
ok((beiMax.daten?.raeume || []).length === 1,
`Max sieht es (${(beiMax.daten?.raeume || []).length})`);
const meine = await ruf("GET", "/workspace/api/chat/raeume", keksSpicy);
const raeume = meine.daten?.raeume || [];
ok(raeume.length === 0, `Spicy Media sieht 0 Gespraeche (${raeume.length})`);
const rein = await ruf("GET", `/workspace/api/chat/raeume/${raumId}/nachrichten`, keksSpicy);
ok(rein.status === 403 || rein.status === 404,
`und kommt auch mit der Raumnummer nicht hinein (HTTP ${rein.status})`);
}
{
/* Und DogFather bleibt der Einzige mit dem letzten Wort. */
const { status } = await ruf("GET", "/workspace/api/verwaltung/personen", keksDogi);
ok(status === 200, `DogFather kommt weiterhin in die Verwaltung (HTTP ${status})`);
}
/* =======================================================================
4. Die Anmeldeseite kennt die Rolle
======================================================================= */
console.log("\n=== Die Anmeldeseite ===");
{
const pw = await import("file:///C:/Users/qciga/Documents/Obelix/Analyse/node_modules/playwright/index.mjs");
const browser = await pw.chromium.launch();
const ctx = await browser.newContext({ viewport: { width: 1440, height: 950 } });
const seite = await ctx.newPage();
await seite.goto(BASIS + "/workspace/", { waitUntil: "networkidle" });
const rollen = await seite.$$eval(".rolle", (k) => k.map((x) => x.dataset.rolle));
ok(rollen[0] === "spicy", `sie steht ganz oben (${rollen.join(", ")})`);
await seite.click('.rolle[data-rolle="spicy"]');
await seite.fill("#code", "CODE-SPICY-001");
await seite.click("button[type=submit]");
await seite.waitForURL("**/start.html", { timeout: 25000 });
await seite.waitForTimeout(1500);
const kacheln = await seite.$$eval(".kachel__name", (k) => k.map((x) => x.textContent.trim()));
ok(kacheln.includes("Personen & Zugänge"),
`sie sieht die Personen-Kachel (${kacheln.length} Kacheln)`);
ok(!kacheln.includes("Automationen"), "und die Automationen-Kachel NICHT");
await ctx.close();
await browser.close();
}
await stoppen();
try { rmSync(ordner, { recursive: true, force: true }); } catch { /* Zugabe */ }
console.log(`\n${gemacht} Pruefungen`);
console.log(fehler === 0 ? "ALLES IN ORDNUNG" : `${fehler} FEHLER`);
process.exit(fehler ? 1 : 0);
+2 -2
View File
@@ -16,7 +16,7 @@
import express from "express";
import {
db, protokolliere, echteIp, sitzungLesen, betreutWo, darfCreator, betreuteIds, istLeitung, istDogFather,
db, protokolliere, echteIp, sitzungLesen, betreutWo, darfCreator, betreuteIds, istLeitung, istDogFather, siehtAlles,
externPruefen, externSql,
} from "./workspace.js";
@@ -262,7 +262,7 @@ export function sichtbar(person) {
darf (freigeben, aendern, Personen verwalten), haengt weiterhin an
istLeitung und bleibt unveraendert -- sonst haette dieser eine
Wunsch stillschweigend seine halben Rechte mitgenommen. */
if (istDogFather(person)) return { wo: "1=1", werte: [] };
if (siehtAlles(person)) return { wo: "1=1", werte: [] };
/* "ODER ich habe ihn selbst angelegt" kam am 02.09.2026 dazu -- aus
demselben Grund wie bei den Aufgaben: Ein Eintrag ohne creator_id
+2 -2
View File
@@ -18,7 +18,7 @@
import express from "express";
import {
db, protokolliere, echteIp, sitzungLesen, einstellung, einstellungSetzen, istLeitung, istDogFather,
db, protokolliere, echteIp, sitzungLesen, einstellung, einstellungSetzen, istLeitung, istDogFather, siehtAlles,
} from "./workspace.js";
import { sichtbar } from "./workspace-kalender.js";
@@ -203,7 +203,7 @@ callRouter.put("/workspace/api/call-link", gleicheHerkunft, (req, res) => {
CASCADE auf der Zwischentabelle), die Aufgaben selbst nicht. */
callRouter.delete("/workspace/api/calls/:id/protokoll", gleicheHerkunft, (req, res) => {
try {
if (!istDogFather(req.person)) {
if (!siehtAlles(req.person)) {
return res.status(403).json({ fehler: "Nur DogFather darf ein Protokoll löschen." });
}
const id = Number(req.params.id);
+2 -2
View File
@@ -34,7 +34,7 @@
import express from "express";
import {
db, sitzungLesen, istLeitung, istDogFather, darfCreator, betreuteIds,
db, sitzungLesen, istLeitung, istDogFather, siehtAlles, darfCreator, betreuteIds,
protokolliere, echteIp,
} from "./workspace.js";
import {
@@ -207,7 +207,7 @@ checklisteRouter.get("/workspace/api/checkliste/:bereich", (req, res) => {
if (req.person.rolle !== "creator") {
/* Nur DogFather waehlt aus allen Creatorn; ein Manager aus
denen, die ihm zugeteilt sind (01.09.2026). */
const ids = istDogFather(req.person)
const ids = siehtAlles(req.person)
? db().prepare("SELECT id FROM personen WHERE rolle = 'creator' AND aktiv = 1")
.all().map((z) => z.id)
: betreuteIds(req.person);
+2 -2
View File
@@ -26,7 +26,7 @@
import express from "express";
import {
db, protokolliere, echteIp, sitzungLesen, betreutWo, darfCreator, betreuteIds,
istLeitung, istDogFather, tagLokal,
istLeitung, istDogFather, siehtAlles, tagLokal,
} from "./workspace.js";
export const contentRouter = express.Router();
@@ -85,7 +85,7 @@ function sichtbareCreator(person) {
darf (freigeben, aendern, Personen verwalten), haengt weiterhin an
istLeitung und bleibt unveraendert -- sonst haette dieser eine
Wunsch stillschweigend seine halben Rechte mitgenommen. */
if (istDogFather(person)) return null;
if (siehtAlles(person)) return null;
if (person.rolle === "creator") return [person.id];
return betreuteIds(person);
}
+2 -2
View File
@@ -29,7 +29,7 @@ import { randomBytes } from "node:crypto";
import { join, extname, basename } from "node:path";
import { mkdirSync, createReadStream, writeFileSync, unlinkSync, statSync } from "node:fs";
import {
db, protokolliere, echteIp, sitzungLesen, DATEN_ORDNER, betreutWo, darfCreator, betreuteIds, istLeitung, istDogFather, ROLLEN_SORTIERUNG,
db, protokolliere, echteIp, sitzungLesen, DATEN_ORDNER, betreutWo, darfCreator, betreuteIds, istLeitung, istDogFather, siehtAlles, ROLLEN_SORTIERUNG,
externSql, EXTERN_MAX, sichtbarePersonenIds,
} from "./workspace.js";
@@ -79,7 +79,7 @@ export function sichtbar(person) {
darf (freigeben, aendern, Personen verwalten), haengt weiterhin an
istLeitung und bleibt unveraendert -- sonst haette dieser eine
Wunsch stillschweigend seine halben Rechte mitgenommen. */
if (istDogFather(person)) return { wo: "1=1", werte: [] };
if (siehtAlles(person)) return { wo: "1=1", werte: [] };
/* Ausdrueckliche Freigabe -- gilt fuer jede Rolle gleich. Wer in der
Liste steht, sieht die Datei, egal ob Creator oder Scout. */
+3 -3
View File
@@ -24,7 +24,7 @@
import express from "express";
import {
db, sitzungLesen, betreuteIds, istLeitung, istDogFather, pipelineIds,
db, sitzungLesen, betreuteIds, istLeitung, istDogFather, siehtAlles, pipelineIds,
} from "./workspace.js";
import { sichtbar as sichtbarAufgaben } from "./workspace-aufgaben.js";
import { sichtbar as sichtbarTermine } from "./workspace-kalender.js";
@@ -222,8 +222,8 @@ hinweisRouter.get("/workspace/api/hinweise", (req, res) => {
Scouts -- dieselbe Regel wie in der Pipeline selbst, aus
derselben Quelle (pipelineIds in workspace.js). Ein Hinweis, der
mehr verraet als die Seite dahinter, waere ein Leck. */
if (istDogFather(person) || person.rolle === "scout" || person.rolle === "manager") {
const ids = istDogFather(person) ? null : pipelineIds(person);
if (siehtAlles(person) || person.rolle === "scout" || person.rolle === "manager") {
const ids = siehtAlles(person) ? null : pipelineIds(person);
const nur = ids === null ? "1=1"
: ids.length ? `l.scout_id IN (${ids.map(() => "?").join(",")})` : "0=1";
const werte = ids === null ? [] : ids;
+2 -2
View File
@@ -13,7 +13,7 @@
import express from "express";
import {
db, protokolliere, echteIp, sitzungLesen, istLeitung, istDogFather, betreutWo, termineSichtbar, heuteLokal,
db, protokolliere, echteIp, sitzungLesen, istLeitung, istDogFather, siehtAlles, betreutWo, termineSichtbar, heuteLokal,
externPruefen, externSql, betreuteIds, einladbareIds, ROLLEN_SORTIERUNG,
} from "./workspace.js";
import {
@@ -282,7 +282,7 @@ kalenderRouter.get("/workspace/api/termine", (req, res) => {
const eigenA = "(a.creator_id = ? OR a.verantwortlich_id = ? OR a.erstellt_von = ?)";
const werteA = [sicht.id, sicht.id, sicht.id];
const bA = betreutWo(sicht, "a.creator_id");
const aufgabenWo = istDogFather(sicht)
const aufgabenWo = siehtAlles(sicht)
? { wo: "1=1", werte: [] }
: bA
? { wo: `(${eigenA} OR ${bA.wo})`, werte: [...werteA, ...bA.werte] }
+40 -1
View File
@@ -15,7 +15,7 @@
import express from "express";
import {
db, protokolliere, echteIp, sitzungLesen, personAnlegen, codeNeu, sitzungToken, personSperren, betreuungSetzen, scoutZuteilungSetzen, istLeitung, istDogFather, ROLLEN_SORTIERUNG, ROLLEN_REIHE,
db, protokolliere, echteIp, sitzungLesen, personAnlegen, codeNeu, sitzungToken, personSperren, betreuungSetzen, scoutZuteilungSetzen, istLeitung, istDogFather, siehtAlles, ROLLEN_SORTIERUNG, ROLLEN_REIHE,
} from "./workspace.js";
import { sicherungJetzt } from "./workspace-sicherung.js";
@@ -368,6 +368,45 @@ personenRouter.post("/workspace/api/creator-anlegen", gleicheHerkunft, nurLeitun
}
});
/* ZWEITE TUER: EINEN MANAGER ANLEGEN -- nur Spicy Media und DogFather
(07.09.2026).
Filipe: "die agentur manager und creator und die dan auch einteilen."
Warum nicht einfach ein Feld `rolle` an der Tuer daneben? Weil damit
genau die Eigenschaft verloren ginge, die sie sicher macht: Sie KANN
nichts anderes, als einen Creator anzulegen. Sobald eine Abfrage
entscheidet, welche Rolle erlaubt ist, ist eine vergessene Abfrage ein
zweiter Zugang mit vollen Rechten.
Also zwei Tueren, jede mit genau einer Aufgabe. Die Rolle steht auch
hier nicht im Aufruf.
EINEN MANAGER LEGT MAN NICHT NEBENBEI AN: Er sieht danach jeden
Creator, den er betreut, und legt selbst welche an. Deshalb bleibt
diese Tuer Spicy Media und DogFather vorbehalten -- ein Manager kann
keinen zweiten Manager schaffen. */
personenRouter.post("/workspace/api/manager-anlegen", gleicheHerkunft, (req, res) => {
try {
const person = sitzungLesen(req);
if (!person) return res.status(401).json({ fehler: "nicht_angemeldet" });
if (!siehtAlles(person)) return res.status(404).json({ fehler: "nicht_gefunden" });
const name = String(req.body?.name ?? "").trim();
if (name.length < 2) return res.status(400).json({ fehler: "Name fehlt." });
if (name.length > NAME_MAX) return res.status(400).json({ fehler: "Name ist zu lang." });
const vorhanden = db().prepare(
"SELECT 1 FROM personen WHERE lower(name) = lower(?) AND aktiv = 1").get(name);
if (vorhanden) return res.status(409).json({ fehler: "Diesen Namen gibt es schon." });
const neu = personAnlegen(name, "manager", { ...person, ip: echteIp(req) });
res.status(201).json({ id: neu.id, name: neu.name, rolle: neu.rolle, code: neu.code });
} catch (fehler) {
console.error("[workspace] Manager anlegen:", fehler?.message);
res.status(503).json({ fehler: "nicht_verfuegbar" });
}
});
/* Welche Scouts kann ich einem neuen Creator gleich mitgeben?
Dieselbe Regel wie oben, nur lesend -- damit die Oberflaeche genau
die Liste anbietet, die der Server auch annimmt. Zwei getrennte
+2 -2
View File
@@ -14,7 +14,7 @@ import express from "express";
import {
sichtbareCreatorIds,
db, protokolliere, echteIp, sitzungLesen, darfCreator, betreuteIds, istLeitung,
istDogFather,
istDogFather, siehtAlles,
} from "./workspace.js";
export const profilRouter = express.Router();
@@ -71,7 +71,7 @@ function darf(person, zielId) {
Ein Manager behaelt volle Rechte an SEINEN Creators: darfCreator
prueft die Zuteilung, und "voll" bleibt es dort auch -- er soll die
Verwaltungsfelder seiner eigenen Leute pflegen koennen. */
if (istDogFather(person)) return "voll";
if (siehtAlles(person)) return "voll";
if (person.rolle === "creator") return person.id === zielId ? "eigen" : null;
if (person.rolle === "manager") return darfCreator(person, zielId) ? "voll" : null;
if (person.rolle === "scout") return darfCreator(person, zielId) ? "betreuend" : null;
+2 -2
View File
@@ -20,7 +20,7 @@
import express from "express";
import {
db, protokolliere, echteIp, sitzungLesen, betreuteIds, darfCreator, istLeitung, istDogFather, sichtbareCreatorIds,
db, protokolliere, echteIp, sitzungLesen, betreuteIds, darfCreator, istLeitung, istDogFather, siehtAlles, sichtbareCreatorIds,
} from "./workspace.js";
import { BEREICHE } from "./workspace-bereiche.js";
@@ -69,7 +69,7 @@ function zielCreator(req) {
bei ihm "alle MEINE". Vorher lieferte gewuenscht=null eine
Auswertung ueber den gesamten Bestand -- die Zahlen darin stammten
zum Teil von Creators, die ihm nie zugeteilt waren. (03.09.2026) */
if (istDogFather(req.person)) return gewuenscht;
if (siehtAlles(req.person)) return gewuenscht;
/* Scout: darf nur ueber die eigenen Creator einen Report ziehen. Ein
fremder Wunsch wird nicht abgelehnt, sondern still auf den ersten
+2 -2
View File
@@ -44,7 +44,7 @@ import {
import { join } from "node:path";
import { readFileSync, existsSync } from "node:fs";
import {
db, sitzungLesen, istLeitung, istDogFather, darfCreator, betreuteIds,
db, sitzungLesen, istLeitung, istDogFather, siehtAlles, darfCreator, betreuteIds,
protokolliere, echteIp, DATEN_ORDNER,
sichtbareCreatorIds,
} from "./workspace.js";
@@ -406,7 +406,7 @@ schulungRouter.delete("/workspace/api/schulung/:id(\\d+)/bestaetigen", gleicheHe
anzutasten. */
schulungRouter.delete("/workspace/api/schulung/:id(\\d+)", gleicheHerkunft, (req, res) => {
if (!istDogFather(req.person)) return res.status(403).json({ fehler: "nicht_erlaubt" });
if (!siehtAlles(req.person)) return res.status(403).json({ fehler: "nicht_erlaubt" });
try {
db().prepare("UPDATE schulungen SET aktiv = 0 WHERE id = ?").run(Number(req.params.id));
protokolliere("schulung_abgeschaltet", req.person, echteIp(req), `Schulung ${req.params.id}`);
+2 -2
View File
@@ -17,7 +17,7 @@
import express from "express";
import {
db, protokolliere, echteIp, sitzungLesen, personAnlegen, betreuungSetzen, istLeitung, istDogFather, pipelineIds,
db, protokolliere, echteIp, sitzungLesen, personAnlegen, betreuungSetzen, istLeitung, istDogFather, siehtAlles, pipelineIds,
} from "./workspace.js";
export const scoutRouter = express.Router();
@@ -70,7 +70,7 @@ scoutRouter.use("/workspace/api/leads", angemeldet);
dreimal abgeschrieben waere dreimal Gelegenheit, sie einmal falsch
zu aendern. */
function sichtbar(person) {
if (istDogFather(person)) return { wo: "1=1", werte: [] };
if (siehtAlles(person)) return { wo: "1=1", werte: [] };
const ids = pipelineIds(person);
if (!ids.length) return { wo: "0=1", werte: [] };
return { wo: `l.scout_id IN (${ids.map(() => "?").join(",")})`, werte: ids };
+3 -3
View File
@@ -24,7 +24,7 @@
import express from "express";
import {
db, sitzungLesen, betreuteIds, istLeitung, istDogFather, pipelineIds,
db, sitzungLesen, betreuteIds, istLeitung, istDogFather, siehtAlles, pipelineIds,
sichtbareCreatorIds,
} from "./workspace.js";
import { sichtbar as sichtbarAufgaben } from "./workspace-aufgaben.js";
@@ -198,8 +198,8 @@ sucheRouter.get("/workspace/api/suche", (req, res) => {
Scouts -- dieselbe Regel wie in der Pipeline selbst, aus
derselben Quelle (pipelineIds in workspace.js). Ein Hinweis, der
mehr verraet als die Seite dahinter, waere ein Leck. */
if (istDogFather(person) || person.rolle === "scout" || person.rolle === "manager") {
const ids = istDogFather(person) ? null : pipelineIds(person);
if (siehtAlles(person) || person.rolle === "scout" || person.rolle === "manager") {
const ids = siehtAlles(person) ? null : pipelineIds(person);
const nur = ids === null ? "1=1"
: ids.length ? `l.scout_id IN (${ids.map(() => "?").join(",")})` : "0=1";
const werte = ids === null ? [] : ids;
+234 -23
View File
@@ -57,17 +57,19 @@ const COOKIE = "dfw_sitzung";
const SITZUNG_STUNDEN = 12;
const VERSUCHE_MAX = 8; // pro IP
const VERSUCHE_FENSTER_MIN = 10;
const ROLLEN = new Set(["admin", "manager", "scout", "creator"]);
const ROLLEN = new Set(["spicy", "admin", "manager", "scout", "creator"]);
/* Die Reihenfolge, in der Rollen ueberall erscheinen: DogFather zuerst,
dann Manager, dann Scout, dann Creator. Steht hier einmal, damit keine
Liste eine eigene Reihenfolge erfindet. */
export const ROLLEN_REIHE = ["admin", "manager", "scout", "creator"];
/* Die Reihenfolge, in der Rollen ueberall erscheinen: Spicy Media
zuerst, dann DogFather, Manager, Scout, Creator. Steht hier einmal,
damit keine Liste eine eigene Reihenfolge erfindet. */
export const ROLLEN_REIHE = ["spicy", "admin", "manager", "scout", "creator"];
/* Als SQL-Ausdruck fuer ORDER BY. "ORDER BY rolle" waere alphabetisch
(admin, creator, manager, scout) -- also fast genau falsch herum. */
(admin, creator, manager, scout, spicy) -- also fast genau falsch
herum. */
export const ROLLEN_SORTIERUNG =
"CASE rolle WHEN 'admin' THEN 0 WHEN 'manager' THEN 1 WHEN 'scout' THEN 2 ELSE 3 END";
"CASE rolle WHEN 'spicy' THEN 0 WHEN 'admin' THEN 1 WHEN 'manager' THEN 2"
+ " WHEN 'scout' THEN 3 ELSE 4 END";
/* LEITUNG = DogFather und Manager. Ein Manager darf alles, was
DogFather darf -- mit genau zwei Ausnahmen, die in
@@ -75,10 +77,44 @@ export const ROLLEN_SORTIERUNG =
Leitung veraendern. Sonst koennte er sich selbst zum DogFather machen
oder den echten aussperren. "Nur DogFather hat alle endgueltigen
Rechte" heisst genau das. */
const LEITUNG = new Set(["admin", "manager"]);
const LEITUNG = new Set(["spicy", "admin", "manager"]);
export const istLeitung = (person) => !!person && LEITUNG.has(person.rolle);
export const istDogFather = (person) => !!person && person.rolle === "admin";
/* =======================================================================
SPICY MEDIA (07.09.2026)
Wunsch Filipe: "eine neue rolle ... die den namen traegt, spicy media,
und soll die gleichen rechte haben wie dogfather ausser, bei
automationen sollen die nicht sehen und die kategorie personen und
zugaenge sollen die nur leute hinzufuegen koennen ... aber die sollen
meine privaten chats und so nicht sehen."
ZWEI FRAGEN, DIE MAN AUSEINANDERHALTEN MUSS -- und genau daran haette
man diesen Umbau kaputtmachen koennen:
WER SIEHT ALLES? -> siehtAlles() = DogFather ODER Spicy Media
WER ENTSCHEIDET? -> istDogFather() = nur DogFather
Die erste Frage stellt sich bei Listen, Uebersichten und Auswertungen:
Spicy Media soll alle Manager, Scouts und Creator sehen. Die zweite
bei allem Endgueltigen: loeschen, Rollen aendern, Codes neu setzen,
Sicherungen, die KI abschalten. Da bleibt DogFather allein.
Es waere viel weniger Arbeit gewesen, istDogFather() einfach um
"spicy" zu erweitern -- und genau das waere der Fehler: Spicy Media
koennte dann DogFather loeschen. Zwei Namen, zwei Bedeutungen, und an
jeder Stelle steht sichtbar, welche gemeint ist.
DER CHAT BRAUCHTE NICHTS. Er haengt ausschliesslich an der
Teilnehmerliste (chat_teilnehmer) und kennt kein "das Management sieht
alles". Spicy Media sieht fremde Gespraeche also nicht, weil es dafuer
gar keinen Weg gibt -- nicht, weil eine Abfrage es verbietet.
======================================================================= */
export const istSpicy = (person) => !!person && person.rolle === "spicy";
export const siehtAlles = (person) => !!person
&& (person.rolle === "admin" || person.rolle === "spicy");
/* Der Rollenschluessel bleibt "admin" -- er steckt in der CHECK-Regel der
Datenbank, in jeder Sitzung und in jeder Rechteabfrage. Umbenannt wird
nur, was man LIEST. Diese Zuordnung ist die einzige Stelle dafuer:
@@ -89,6 +125,7 @@ export const istDogFather = (person) => !!person && person.rolle === "admin";
Rolle "admin" weiterhin "das Management". Gemeint ist dieselbe Rolle,
die in der Oberflaeche "DogFather" heisst. */
export const ROLLEN_NAME = {
spicy: "Spicy Media",
admin: "DogFather",
manager: "Manager",
scout: "Scout",
@@ -120,7 +157,21 @@ let _dbFehler = null;
waehrend laufender Schreibvorgaenge).
===================================================================== */
function umstellungen(d) {
const jetztStempel = new Date().toISOString().slice(0, 16).replace(/[-:T]/g, "");
/* MIT SEKUNDEN, nicht nur mit Minuten (07.09.2026).
`VACUUM INTO` weigert sich, eine vorhandene Datei zu ueberschreiben
("output file already exists") -- zu Recht, eine Sicherung darf
nichts wegwerfen. Der Stempel hatte aber nur Minutenaufloesung:
Zwei Umstellungen innerhalb derselben Minute wollten in dieselbe
Datei, die zweite scheiterte, und weil ohne Sicherung nicht
umgestellt wird, blieb sie einfach aus.
Das ist genau die unangenehme Sorte Fehler: Es sieht nach "die
Umstellung lief" aus (die Datei liegt ja da) und war doch keine.
Gefunden von pruef-spicy, das den Server zweimal in derselben
Minute startet -- im Betrieb genuegt dafuer ein Neustart im
falschen Moment. */
const jetztStempel = new Date().toISOString().slice(0, 19).replace(/[-:T]/g, "");
/* ---- Zeitpunkt des Hochladens in der Bibliothek ----
Eine neue Spalte laesst SQLite anstandslos anhaengen -- anders als
@@ -635,6 +686,131 @@ function umstellungen(d) {
d.exec("PRAGMA foreign_keys = ON");
}
}
/* =====================================================================
ROLLE "spicy" (Spicy Media) FREISCHALTEN — 07.09.2026
Wunsch Filipe: "ich will dass ueber dogfather auch eine kategorie,
eine neue rolle entsteht die den namen traegt, spicy media."
---------------------------------------------------------------------
DAS IST DER RISKANTESTE UMBAU IM GANZEN HAUS
Eine Rolle ist ein erlaubter Wert in einer Spalte, und der steckt in
SQLite in einem CHECK. Ein CHECK laesst sich nicht aendern -- die
Tabelle muss neu gebaut werden. Bei `eintraege` war das schon
unangenehm; hier geht es um `personen`, und auf die zeigen
ZWEIUNDFUENFZIG Fremdschluessel aus einem Dutzend Tabellen. Jede
Sitzung, jede Aufgabe, jeder Termin, jede Datei haengt daran.
DESHALB WIRD DER BAUPLAN NICHT ABGESCHRIEBEN, SONDERN GELESEN.
Die beiden Umstellungen darueber schreiben ihre Spaltenliste von
Hand ab. Das ging gut, solange die Liste stimmte -- aber `personen`
hat seit damals SIEBEN Spalten dazubekommen (bild, ueber_mich,
tiktok, instagram, youtube, twitch und weitere, siehe den
Spalten-Nachtrag oben). Wer hier eine Liste abschreibt, verliert
beim naechsten Mal genau die Spalte, die jemand letzte Woche
ergaenzt hat -- samt allen Profilbildern.
Also: Der vorhandene CREATE-Text wird aus sqlite_master geholt und
darin AUSSCHLIESSLICH die Rollenliste ersetzt. Alles andere -- jede
Spalte, jeder Typ, jede Vorgabe -- bleibt woertlich stehen. Und die
Kopierliste kommt aus PRAGMA table_info, also aus der Tabelle
selbst.
WENN DIE ERSETZUNG NICHT GREIFT, WIRD NICHT UMGESTELLT. Ein
`replace`, das nichts findet, gibt den Text unveraendert zurueck --
die Umstellung liefe dann durch, baute dieselbe Tabelle noch einmal
und meldete Erfolg. Genau die Sorte gruener Haken, die nichts
geprueft hat. Deshalb wird danach nachgesehen, ob 'spicy' wirklich
drinsteht.
===================================================================== */
const rollenPlan = d.prepare(
"SELECT sql FROM sqlite_master WHERE type = 'table' AND name = 'personen'").get()?.sql || "";
/* GEPRUEFT WIRD DIE REGEL, NICHT DER TEXT (07.09.2026).
Hier stand `rollenPlan.includes("'spicy'")`. Das ist eine Suche im
ganzen gespeicherten Bauplan -- und SQLite hebt den woertlich auf,
mitsamt allen Kommentaren darin. Ein erklaerender Satz mit dem Wort
'spicy' genuegte, und die Umstellung hielt die Tabelle fuer schon
umgestellt, obwohl die CHECK-Regel noch die alte war. Beim Bauen
genau so passiert.
Jetzt wird die Regel selbst herausgeschnitten und NUR darin gesucht.
Findet sich keine, gibt es nichts umzustellen. */
const regel = rollenPlan.match(/rolle\s+IN\s*\(([^)]*)\)/i)?.[1] || "";
if (rollenPlan && regel && !regel.includes("'spicy'")) {
const sicherung = `${DB_PFAD}.vor-spicy-${jetztStempel}`;
try {
d.exec(`VACUUM INTO '${sicherung.replace(/'/g, "''")}'`);
console.log("[workspace] Sicherung vor der Umstellung:", sicherung);
} catch (fehler) {
console.error("[workspace] Sicherung fehlgeschlagen, Umstellung abgebrochen:", fehler?.message);
return;
}
/* Nur die Rollenliste ersetzen -- und pruefen, dass es geklappt hat. */
const neuerPlan = rollenPlan
/* `IF NOT EXISTS` MUSS MIT INS MUSTER (07.09.2026, gefunden von
pruef-spicy). SQLite hebt den CREATE-Text WOERTLICH auf -- die
Tabelle wurde mit `CREATE TABLE IF NOT EXISTS personen` angelegt,
also steht das auch in sqlite_master. Ohne diese drei Woerter im
Muster griff die Ersetzung nicht, der Bauplan blieb unveraendert,
die Schutzabfrage darunter schlug an -- und die Umstellung waere
auf dem echten Server NIE gelaufen. Sie haette es gesagt (das ist
der Wert der Abfrage), aber sie waere nie gelaufen. */
.replace(/CREATE TABLE\s+(?:IF\s+NOT\s+EXISTS\s+)?["'`]?personen["'`]?/i,
"CREATE TABLE personen_neu")
.replace(/rolle\s+IN\s*\([^)]*\)/i, "rolle IN ('spicy','admin','manager','scout','creator')");
if (!neuerPlan.includes("'spicy'") || !neuerPlan.includes("personen_neu")) {
console.error("[workspace] Umstellung abgebrochen: Der Bauplan liess sich nicht "
+ "umschreiben. Steht die CHECK-Regel noch so da wie erwartet?");
return;
}
/* Die Spaltenliste kommt aus der Tabelle, nicht aus dem Gedaechtnis. */
const spalten = d.prepare("PRAGMA table_info(personen)").all().map((z) => z.name);
if (!spalten.length) {
console.error("[workspace] Umstellung abgebrochen: keine Spalten gefunden.");
return;
}
const liste = spalten.map((n) => `"${n}"`).join(", ");
d.exec("PRAGMA foreign_keys = OFF");
try {
const vorher = d.prepare("SELECT COUNT(*) AS n FROM personen").get().n;
d.exec("BEGIN");
d.exec(neuerPlan);
d.exec(`INSERT INTO personen_neu (${liste}) SELECT ${liste} FROM personen;`);
const nachher = d.prepare("SELECT COUNT(*) AS n FROM personen_neu").get().n;
/* Die Zaehlung steht INNERHALB der Transaktion -- stimmt sie nicht,
wird zurueckgerollt und die alte Tabelle bleibt unberuehrt. */
if (nachher !== vorher) {
d.exec("ROLLBACK");
console.error(`[workspace] Umstellung abgebrochen: ${vorher} Personen vorher, `
+ `${nachher} nachher. Sicherung: ${sicherung}`);
} else {
d.exec("DROP TABLE personen;");
d.exec("ALTER TABLE personen_neu RENAME TO personen;");
d.exec("COMMIT");
const kaputt = d.prepare("PRAGMA foreign_key_check").all();
if (kaputt.length) {
console.error("[workspace] ACHTUNG: nach der Umstellung", kaputt.length,
"verwaiste Verweise. Sicherung liegt unter", sicherung);
} else {
console.log(`[workspace] Rolle 'spicy' freigeschaltet, ${nachher} Personen, `
+ `${spalten.length} Spalten uebernommen, Verweise geprueft.`);
}
}
} catch (fehler) {
try { d.exec("ROLLBACK"); } catch { /* schon zurueckgerollt */ }
console.error("[workspace] Umstellung fehlgeschlagen:", fehler?.message,
"-- Sicherung:", sicherung);
} finally {
d.exec("PRAGMA foreign_keys = ON");
}
}
}
export function db() {
@@ -648,10 +824,23 @@ export function db() {
PRAGMA journal_mode = WAL;
PRAGMA foreign_keys = ON;
/* ROLLENLISTE: 'spicy' steht hier mit drin, nicht nur in der
Umstellung (07.09.2026, gefunden von pruef-spicy). Vorher legte
eine frische Datenbank die alte Liste an, und die Umstellung
lief beim allerersten Start sofort hinterher -- Tabelle neu
bauen, Sicherung schreiben, umbenennen, fuer nichts. Ein
Bauplan, der sofort umgebaut werden muss, ist der falsche.
KEIN KOMMENTAR INNERHALB DIESES CREATE-TEXTES. SQLite hebt ihn
woertlich in sqlite_master auf -- samt Kommentaren. Ein Satz mit
dem Wort 'spicy' darin haette bedeutet, dass die Umstellung die
Tabelle fuer bereits umgestellt haelt, obwohl die CHECK-Regel
noch die alte ist. Genau das ist beim Bauen passiert: Der
Bauplan enthielt das Wort, die Regel nicht. */
CREATE TABLE IF NOT EXISTS personen (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL,
rolle TEXT NOT NULL CHECK (rolle IN ('admin','manager','scout','creator')),
rolle TEXT NOT NULL CHECK (rolle IN ('spicy','admin','manager','scout','creator')),
code_hash TEXT NOT NULL,
code_salt TEXT NOT NULL,
code_n INTEGER NOT NULL,
@@ -1456,27 +1645,49 @@ const GESCHUETZT = {
Lesezeichen hat, waere weiterhin hineingekommen. Beides gehoert
zusammen: bereiche.js zeigt sie nur noch DogFather, diese Zeile
laesst nur ihn hinein. */
"/workspace/leistung.html": ["admin"],
"/workspace/leistung.html": ["spicy", "admin"],
/* NUR DogFather (02.09.2026). Die Rollenauswahl verspricht das seit
jeher ("Manager -- dieselben Rechte, ausser der
Personenverwaltung"), die Schranke hielt sich nur nicht daran.
Wunsch: "die manager sollen diese kategorien garnicht sehen". */
"/workspace/personen.html": ["admin"],
"/workspace/profil.html": ["admin", "manager", "scout", "creator"],
/* PERSONEN & ZUGAENGE: jetzt auch fuer Manager und Spicy Media
(07.09.2026).
Filipe: "die manager sehen immer noch nicht die kachel personen &
zugangscode, wo sie dan die neuen creator hinzufuegen koennen ...
und die sollen in der kategorie wo die jetzt sehen werden auch noch
das mit den hinzufuegen sehen, alles andere auf der seite sollen die
weiterhin nicht sehen."
DIE SEITE OEFFNET SICH, DIE SCHNITTSTELLEN NICHT. Das ist der ganze
Trick und der Grund, warum das hier ungefaehrlich ist: Alles unter
/workspace/api/verwaltung haengt weiterhin an `nurAdmin` -- Rollen
aendern, Codes neu setzen, sperren, loeschen, Protokoll lesen
bleiben zu und antworten mit 404. Wer die Seite oeffnet, bekommt
also genau die Teile zu sehen, fuer die es auch einen Weg gibt.
Eine Seite ist kein Schutz, sie ist ein Weg. Der Schutz steht in den
Schnittstellen, und der ist unveraendert. */
"/workspace/personen.html": ["spicy", "admin", "manager"],
"/workspace/profil.html": ["spicy", "admin", "manager", "scout", "creator"],
/* Der eigene Steckbrief -- jede Rolle hat einen. Ein Creator wird
dort nicht hingeschickt (er sieht seinen auf profil.html), darf die
Seite aber aufrufen: Sie zeigt ihm dasselbe, nur ohne die Akte. */
"/workspace/steckbrief.html": ["admin", "manager", "scout", "creator"],
"/workspace/steckbrief.html": ["spicy", "admin", "manager", "scout", "creator"],
"/workspace/kalender.html": null,
"/workspace/dateien.html": null,
"/workspace/bereich.html": ["admin", "manager", "scout", "creator"],
"/workspace/report.html": ["admin", "manager", "scout", "creator"],
"/workspace/scouting.html": ["admin", "manager", "scout"],
"/workspace/bereich.html": ["spicy", "admin", "manager", "scout", "creator"],
"/workspace/report.html": ["spicy", "admin", "manager", "scout", "creator"],
"/workspace/scouting.html": ["spicy", "admin", "manager", "scout"],
"/workspace/calls.html": null,
"/workspace/startcheck.html": null,
"/workspace/wissen.html": null,
/* Ebenfalls nur DogFather: Was von allein passiert, bestimmt, was
allen anderen zugeschoben wird. */
/* AUTOMATIONEN BLEIBEN BEI DogFather (07.09.2026). Ausdruecklich:
"bei automationen sollen die nicht sehen." Dahinter liegen
Sicherungen, die KI und der Zustand des Servers -- das ist Betrieb,
nicht Betreuung. */
"/workspace/automation.html": ["admin"],
};
@@ -1870,7 +2081,7 @@ export function scoutZuteilungSetzen(scoutId, managerId, akteur = null) {
* null = alle (nur DogFather). */
export function sichtbareCreatorIds(person) {
if (!person) return [];
if (istDogFather(person)) return null;
if (siehtAlles(person)) return null;
if (person.rolle === "creator") return [person.id];
return betreuteIds(person); // Manager: eigene + die seiner Scouts
}
@@ -1883,7 +2094,7 @@ export function sichtbareCreatorIds(person) {
* seine Scouts dazu -- er führt sie, er muss sie eintragen können. */
export function sichtbarePersonenIds(person) {
if (!person) return [];
if (istDogFather(person)) return null;
if (siehtAlles(person)) return null;
const creator = sichtbareCreatorIds(person) || [];
const scouts = person.rolle === "manager" ? scoutsVon(person.id) : [];
/* DOGFATHER IST FUER ALLE SICHTBAR (07.09.2026, Wunsch Filipe:
@@ -1968,7 +2179,7 @@ export function betreuerIds(person) {
* null = alle (nur DogFather), wie überall in dieser Datei. */
export function einladbareIds(person) {
if (!person) return [];
if (istDogFather(person)) return null;
if (siehtAlles(person)) return null;
const sichtbar = sichtbarePersonenIds(person) || [];
/* DogFather gehört immer dazu: Er ist der Einzige, mit dem jede
Rolle zu tun hat, und ihn nicht einladen zu können wäre der erste
@@ -2001,7 +2212,7 @@ export function einladbareIds(person) {
* null = alle (nur DogFather). */
export function schreibbareIds(person) {
if (!person) return [];
if (istDogFather(person)) return null;
if (siehtAlles(person)) return null;
const basis = new Set(einladbareIds(person) || []);
@@ -2118,7 +2329,7 @@ export const externSql = (personSpalte, externSpalte) =>
spaeter auseinandergelaufen -- und dann haette eine Wiederholung
jemandem etwas gezeigt, was der einzelne Termin ihm verbirgt. */
export function termineSichtbar(person, praefix = "t") {
if (istDogFather(person)) return { wo: "1=1", werte: [] };
if (siehtAlles(person)) return { wo: "1=1", werte: [] };
const p = praefix;
/* MITGEZAEHLT WIRD AUCH DIE TEILNEHMERLISTE (05.09.2026).
@@ -2236,7 +2447,7 @@ export function darfCreator(person, creatorId) {
Ein Manager faellt jetzt in dieselbe Zeile wie ein Scout -- die
Kette "eigene plus die meiner Scouts" steckt in betreuteIds. */
if (istDogFather(person)) return true;
if (siehtAlles(person)) return true;
if (person.rolle === "creator") return person.id === Number(creatorId);
return betreuteIds(person).includes(Number(creatorId));
}