Files
dogfather-universe/server/workspace.js
T
DogFatherGitandClaude Opus 5 c8a4e7628c Stufe 1+2: Zahlen ueber das Geschaeft, und eine Fruehwarnung
Der grosse Befund aus dem Plan vom 31.08.2026: Der Workspace
organisiert ARBEIT hervorragend, wusste ueber das GESCHAEFT aber
nichts. Keine Diamanten, keine LIVE-Tage, keine Verweildauer. Damit
hing die ganze Betreuung in der Luft -- der Start-Check bewertete ohne
zu messen, der Report fragte "was hat funktioniert" ohne Beleg, das
90-Tage-Ziel war ein Satz statt eines Fortschritts.

STUFE 1 -- LEISTUNG

Eine Zeile je Creator und TAG (nicht Woche: Feineres laesst sich immer
zusammenfassen, Groeberes nie aufteilen). Diamanten, LIVE-Dauer,
gueltiger Tag, Zuschauer im Schnitt und in der Spitze, Verweildauer,
Schenker, neue Follower -- und eine NOTIZ. Ohne sie sieht man in drei
Monaten einen Einbruch und weiss nicht mehr, dass die Person Grippe
hatte.

DIE VERWEILDAUER IST DIE LEITZAHL (Recherche 06.09.2026): 2026 haengt
der Algorithmus alles an der Completion Rate, und sie gehoert als
BETRIEBSkennzahl behandelt -- sie soll die naechste Runde steuern,
nicht die letzte erklaeren.

Jede Zahl kommt mit Vergleich, Verlauf und Einordnung. Eine Zahl ohne
diese drei ist eine Eitelkeitszahl: Sie sieht nach Auskunft aus und ist
keine, weil man nichts entscheiden kann.

Zwei Wege hinein: Schnelleingabe und CSV-Import aus dem offiziellen
Export, mit Vorschau vor dem Uebernehmen.

STUFE 2 -- FRUEHWARNUNG

Sechs benannte Signale (Zahlen fallen, LIVE-Tage brechen weg, lange
nicht angemeldet, kein Gespraech, Aufgabenstau, Start-Check haengt) und
daraus EIN ruhiger Satz: "Bei Nora wuerde ich diese Woche nachfassen."

BEWUSST KEIN SCORE. Eine Note von 1 bis 100 wirkt objektiv und ist es
nicht; sie verfuehrt dazu, Menschen nach einer Zahl zu behandeln. Am
Score kann man nichts tun, am Signal schon. Deshalb Saetze statt
Punkte, und jedes Signal einzeln nachvollziehbar.

Ein CREATOR sieht die Fruehwarnung nicht: Das ist eine
Arbeitsgrundlage fuer die Betreuung, keine Mitteilung an die
betroffene Person.

ZWEI ECHTE FEHLER, VON DER PRUEFUNG GEFUNDEN

1. ZAHLENFORMAT, Faktor tausend daneben. "1.250" wurde als 1,25
   gelesen und "1,234" als 1,234. Die Regel "das letzte Trennzeichen
   ist das Dezimalzeichen" liefert bei nur EINEM Trennzeichen fuer
   beide Faelle das Falsche -- und die Zahl sieht danach trotzdem
   plausibel aus. Jetzt entscheidet, wie viele Ziffern dahinter stehen:
   genau drei heisst Tausendertrenner.

2. DIE ZIELE-ROUTE WURDE VERSCHLUCKT. Express nimmt die erste passende
   Route, und `:tag` passt auch auf "ziele" -- ein PUT auf .../ziele
   landete in der Tages-Route und scheiterte am Datumsmuster. Die
   Meldung sagte "ungueltig", was auf die Zahlen deutet, nicht auf den
   Weg. Reihenfolge getauscht.

Beides waere still gewesen: falsche Zahlen sehen richtig aus, und ein
Ziel, das sich nicht setzen laesst, probiert man zweimal und laesst es
dann.

pruef-leistung.mjs: 47 Punkte, darunter jede Rechnung einzeln
nachgerechnet (Durchschnitte ohne Tage ohne Wert, Prozent von null,
Wochengrenzen), Import zweimal eingelesen, deutsche und englische
Zahlenformate, Rechte, und die Gegenprobe, dass die Fruehwarnung bei
Unauffaelligkeit SCHWEIGT.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-09-06 18:12:16 +02:00

2212 lines
103 KiB
JavaScript

/* =====================================================================
workspace.js — Anmeldung und Sitzungen für den Creator Workspace
(/workspace). Eigenständiger Router, nach dem Muster von gate.js und
webdesign-gate.js.
BEWUSST OHNE NEUE ABHÄNGIGKEITEN. Node 24 bringt `node:sqlite` mit, das
Hashen und die Zufallswerte kommen aus `node:crypto`. Damit gibt es
nichts zu kompilieren, nichts zu aktualisieren und keine fremde
Lieferkette in einem Bereich, in dem es um Zugangsdaten geht.
WICHTIG — dieses Modul darf die Website niemals mitreißen. Es läuft im
selben Prozess wie dogfather-universe.com. Deshalb:
* beim Laden wird KEINE Datenbank geöffnet (siehe `db()` weiter unten),
* jede Route fängt ihre Fehler selbst ab,
* schlägt die Datenbank fehl, antwortet nur /workspace/api/* mit 503 —
die restliche Seite merkt davon nichts.
Sicherheitsentscheidungen und ihre Gründe stehen jeweils direkt an der
betreffenden Stelle.
===================================================================== */
import express from "express";
import {
randomBytes, scryptSync, timingSafeEqual, createHash,
} from "node:crypto";
import { dirname, join } from "node:path";
import { fileURLToPath, pathToFileURL } from "node:url";
import { mkdirSync } from "node:fs";
import { createRequire } from "node:module";
const __dirname = dirname(fileURLToPath(import.meta.url));
/* `node:sqlite` wird bewusst NICHT oben importiert, sondern erst in db()
nachgeladen. Ein fehlgeschlagener Import an dieser Stelle würde den
gesamten Website-Prozess beim Start abbrechen -- also auch
dogfather-universe.com. So bleibt der Schaden auf /workspace/api/
beschränkt. (Vorhanden ab Node 22; auf dem Server läuft Node 24.) */
const require = createRequire(pathToFileURL(__dirname + "/"));
/* Die Datenbank liegt bewusst AUSSERHALB des Repo-Ordners. Zwei Gründe:
express.static liefert den Repo-Ordner aus (eine .db darin wäre über das
Netz erreichbar), und ein `git pull` darf Nutzdaten nie anfassen. */
/* Wird ausgegeben, weil die Sicherung sowohl die Datei selbst als auch
ihr WAL nachschlagen muss. Aus DATEN_ORDNER + "workspace.db" laesst
sich das NICHT ableiten: Steht WORKSPACE_DB auf einem anderen Namen
(so laufen alle Pruefungen), zeigte die Sicherung auf eine Datei, die
es gar nicht gibt -- und meldete brav 0 Bytes. */
export const DB_PFAD = process.env.WORKSPACE_DB
|| join(__dirname, "..", "..", "workspace-daten", "workspace.db");
/* Ordner fuer hochgeladene Dateien -- neben der Datenbank, also ebenfalls
ausserhalb des Repos. Waeren sie im Repo, wuerde express.static sie
ungeschuetzt ausliefern. */
export const DATEN_ORDNER = dirname(DB_PFAD);
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"]);
/* 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"];
/* Als SQL-Ausdruck fuer ORDER BY. "ORDER BY rolle" waere alphabetisch
(admin, creator, manager, scout) -- 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";
/* LEITUNG = DogFather und Manager. Ein Manager darf alles, was
DogFather darf -- mit genau zwei Ausnahmen, die in
workspace-personen.js stehen: Er kann keine Leitung anlegen und keine
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"]);
export const istLeitung = (person) => !!person && LEITUNG.has(person.rolle);
export const istDogFather = (person) => !!person && person.rolle === "admin";
/* 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:
Stand der Anzeigename in elf Dateien, waere er beim naechsten Mal in
zehn davon geaendert.
Hinweis fuer spaeter: In den Kommentaren der Fachmodule heisst die
Rolle "admin" weiterhin "das Management". Gemeint ist dieselbe Rolle,
die in der Oberflaeche "DogFather" heisst. */
export const ROLLEN_NAME = {
admin: "DogFather",
manager: "Manager",
scout: "Scout",
creator: "Creator",
};
/* scrypt-Parameter. N=2^15 braucht auf diesem Server rund 150 ms — spürbar
genug, um Rateversuche auszubremsen, und unauffällig für einen echten
Anmeldevorgang. Die Werte werden je Datensatz mitgespeichert, damit sie
später erhöht werden können, ohne alte Codes ungültig zu machen. */
const SCRYPT = { N: 32768, r: 8, p: 1, keylen: 64 };
let _db = null;
let _dbFehler = null;
/* =====================================================================
Umstellungen an bestehenden Datenbanken.
CREATE TABLE IF NOT EXISTS legt eine Tabelle nur an, wenn sie fehlt --
eine vorhandene wird NICHT angefasst. Die CHECK-Regel fuer die Rolle
stand also weiterhin auf den alten drei Werten, und ein Manager waere
an der Datenbank gescheitert, obwohl im Code alles stimmt.
SQLite kann eine CHECK-Regel nicht aendern. Der einzige saubere Weg
ist: neue Tabelle, Daten hinueber, alte weg, neue umbenennen. Das ist
der Moment, in dem eine Datenbank kaputtgehen kann -- deshalb wird
vorher eine vollstaendige Sicherung geschrieben (VACUUM INTO, von
SQLite selbst und in sich konsistent, anders als ein Dateikopie
waehrend laufender Schreibvorgaenge).
===================================================================== */
function umstellungen(d) {
const jetztStempel = new Date().toISOString().slice(0, 16).replace(/[-:T]/g, "");
/* ---- Zeitpunkt des Hochladens in der Bibliothek ----
Eine neue Spalte laesst SQLite anstandslos anhaengen -- anders als
eine CHECK-Regel. Alte Eintraege bleiben leer und fallen auf das
Veroeffentlichungsdatum zurueck. */
try {
const spalten = d.prepare("PRAGMA table_info(wissen)").all().map((s) => s.name);
if (spalten.length && !spalten.includes("hochgeladen")) {
d.exec("ALTER TABLE wissen ADD COLUMN hochgeladen TEXT");
console.log("[workspace] Spalte 'hochgeladen' in der Bibliothek ergaenzt.");
}
} catch (fehler) {
console.error("[workspace] Spalte 'hochgeladen':", fehler?.message);
}
/* ---- Content-Planung: aus der Liste wird eine Strecke ----------------
Die Content-Planung war eine flache Liste mit drei Arten. Die
Recherche vom 31.08.2026 ist an dieser Stelle eindeutig: *"A content
calendar for creators is a pipeline, not a datebook."* Und der
wichtigste Einzelwert eines Kurzvideos ist der Aufhaenger -- die
ersten ein bis drei Sekunden entscheiden ueber die Verbreitung. Der
gehoert in ein eigenes Feld und nicht irgendwo in den Fliesstext.
Vier neue Spalten. ALTER TABLE ADD COLUMN laesst SQLite anstandslos
zu; alte Eintraege bleiben leer und stoeren nicht. */
/* ---- Der eigene Steckbrief (01.09.2026) ------------------------------
Jede Person -- Creator, Scout, Manager, DogFather -- bekommt ein
eigenes Profil: ein Bild, ein Satz ueber sich, und die Kanaele, auf
denen sie unterwegs ist.
Warum der TikTok-Name als eigene Spalte und nicht als freier Text:
Er wird zu einem Verweis, taucht in der Personenliste auf und soll
eindeutig sein. Gespeichert wird NUR der oeffentliche Name (das
Handle ohne @) -- kein Zugriffstoken, kein Passwort. Eine echte
Anmeldung bei TikTok braucht ein Entwicklerkonto mit Freischaltung
und laufender Pruefung durch TikTok; das waere eine eigene Baustelle
mit Kosten und Abhaengigkeit. Der Verweis leistet das, was hier
gebraucht wird: Man kommt mit einem Klick auf den Kanal, und in der
Uebersicht steht, wer wo unterwegs ist.
Das Bild liegt als Datei auf der Platte, in der Datenbank steht nur
der Dateiname. Bilder in eine SQLite-Datei zu legen macht jede
Sicherung um ein Vielfaches groesser -- und die Sicherung laeuft
jede Nacht. */
for (const [tabelle, spalte, typ] of [
["eintraege", "hook", "TEXT"], // die ersten Sekunden
["eintraege", "format", "TEXT"], // Talking Head, Tutorial, Story …
["eintraege", "saeule_id", "INTEGER"],// zu welcher Content-Saeule
["eintraege", "geplant", "TEXT"], // wann es raus soll
["personen", "bild", "TEXT"], // Dateiname des Profilbildes
["personen", "ueber_mich", "TEXT"], // ein Satz zur Person
["personen", "tiktok", "TEXT"], // öffentlicher Name, ohne @
["personen", "instagram", "TEXT"],
["personen", "youtube", "TEXT"],
["personen", "twitch", "TEXT"],
/* ---- Wiederkehrende Termine (02.09.2026) ----
Drei Spalten an `termine`, damit eine Ausprägung weiss, woher sie
stammt:
serie_id die Regel, aus der sie entstanden ist
serie_tag der geplante Tag -- daran erkennt der Nachfüller,
dass es diese Ausprägung schon gibt, und legt sie
kein zweites Mal an
serie_beruehrt jemand hat sie von Hand angefasst (verschoben,
umbenannt, abgehakt). Solche Termine bleiben beim
Abstellen der Serie stehen -- was jemand
bearbeitet hat, wird ihm nicht weggeräumt. */
["termine", "serie_id", "INTEGER REFERENCES termin_serien(id) ON DELETE SET NULL"],
["termine", "serie_tag", "TEXT"],
["termine", "serie_beruehrt", "INTEGER NOT NULL DEFAULT 0"],
/* ---- Frei eingetragene Namen (02.09.2026) ----
Wunsch: "ich will da auch sachen selber noch eintragen können,
also aussuchen und selbst eintragen."
Bis hierher konnte an einem Eintrag nur stehen, wer auch ein Konto
im Workspace hat. Eine Agentur, eine Marke, ein Gast liess sich
nicht eintragen -- man haette dafuer ein Konto anlegen muessen,
das nie jemand benutzt.
Eine EIGENE Spalte je Feld, kein Missbrauch der id-Spalte: Ein
Fremdschluessel zeigt auf eine Person oder auf nichts. Einen
Namen dort hineinzuschreiben haette jede Verknuepfung zerstoert.
Es gilt entweder/oder: Steht eine Person drin, ist das Feld leer,
und umgekehrt. Durchgesetzt wird das beim Speichern (siehe
externPruefen), nicht durch eine CHECK-Regel -- die liesse sich
an einer bestehenden Tabelle nicht mehr nachtragen.
WICHTIG, damit nichts still verschwindet: Ueberall, wo bisher der
Name der verknuepften Person gelesen wurde, faellt die Abfrage
jetzt auf diesen Text zurueck (siehe externSql). Ein frei
eingetragener Name steht dadurch in jeder Liste, jeder Suche und
jeder Uebersicht mit da -- gekennzeichnet als "(extern)", damit
niemand ihn fuer ein Konto haelt. */
["termine", "teilnehmer_extern", "TEXT"],
["termin_serien", "teilnehmer_extern", "TEXT"],
["aufgaben", "creator_extern", "TEXT"],
["aufgaben", "verantwortlich_extern", "TEXT"],
["eintraege", "creator_extern", "TEXT"],
["dateien", "creator_extern", "TEXT"],
/* ABBRECHEN (05.09.2026). Wunsch: *"ich will dass man in jedem
status die aufgaben auch abbrechen kann."*
Drei Spalten, weil "abgebrochen" allein zu wenig sagt:
abbruch_grund WARUM. Pflichtfeld in der Oberflaeche -- ohne
Grund weiss in vier Wochen niemand mehr, warum
etwas wegfiel, und es sieht aus wie Loeschen.
abgebrochen_am WANN.
abbruch_von WER. Nur Leitung und Scouts duerfen es, und
wer es war, gehoert dazu.
status_vorher IN WELCHEM ZUSTAND. Diese Spalte ist der Grund,
warum "abgebrochen" ein eigener Status wurde
und nicht bloss ein Haken: Ohne sie ginge beim
Wechsel verloren, ob die Arbeit schon lief.
"Im Review abgebrochen" ist eine ganz andere
Aussage als "nie angefangen". */
["aufgaben", "abbruch_grund", "TEXT"],
["aufgaben", "abgebrochen_am", "TEXT"],
["aufgaben", "abbruch_von", "INTEGER REFERENCES personen(id) ON DELETE SET NULL"],
["aufgaben", "status_vorher", "TEXT"],
]) {
try {
const vorhanden = d.prepare(`PRAGMA table_info(${tabelle})`).all().map((s) => s.name);
if (vorhanden.length && !vorhanden.includes(spalte)) {
d.exec(`ALTER TABLE ${tabelle} ADD COLUMN ${spalte} ${typ}`);
console.log(`[workspace] Spalte '${spalte}' in ${tabelle} ergaenzt.`);
}
} catch (fehler) {
console.error(`[workspace] Spalte '${spalte}':`, fehler?.message);
}
}
/* Der Nachfüller fragt bei jedem Lauf "welche Ausprägungen dieser
Serie gibt es schon?". Ohne diesen Verbund-Index liest SQLite dafür
die ganze Termintabelle. Er steht hier unten und nicht oben im
Bauplan, weil die beiden Spalten erst durch die Schleife darüber
entstehen -- oben gäbe es sie beim ersten Start noch nicht. */
try {
d.exec("CREATE INDEX IF NOT EXISTS idx_termine_serie ON termine (serie_id, serie_tag)");
} catch (fehler) {
console.error("[workspace] Index idx_termine_serie:", fehler?.message);
}
/* ---- Die vorhandenen Teilnehmer in die neue Tabelle uebernehmen ----
Ohne diesen Schritt haette jeder Termin, der vor dem 05.09.2026
angelegt wurde, eine LEERE Teilnehmerliste -- und weil an dieser
Liste die Sichtbarkeit haengt, faende die eingetragene Person ihren
eigenen Termin nicht mehr. Ein stiller Datenverlust, der erst
auffaellt, wenn jemand einen Call sucht, den es noch gibt.
`INSERT OR IGNORE` macht den Lauf wiederholbar: Beim zweiten Start
ist alles schon da und nichts passiert. Deshalb steht das hier bei
den Umstellungen und nicht in einem einmaligen Skript, das jemand
vergessen koennte. */
for (const [tabelle, quelle, ziel, schluessel] of [
["termin_teilnehmer", "termine", "termin_id", "id"],
["serie_teilnehmer", "termin_serien", "serie_id", "id"],
]) {
try {
const spalten = d.prepare(`PRAGMA table_info(${quelle})`).all().map((s) => s.name);
if (!spalten.includes("teilnehmer_id")) continue;
const vorher = d.prepare(`SELECT COUNT(*) AS n FROM ${tabelle}`).get().n;
d.exec(`INSERT OR IGNORE INTO ${tabelle} (${ziel}, person_id)
SELECT ${schluessel}, teilnehmer_id FROM ${quelle}
WHERE teilnehmer_id IS NOT NULL`);
const nachher = d.prepare(`SELECT COUNT(*) AS n FROM ${tabelle}`).get().n;
if (nachher > vorher) {
console.log(`[workspace] ${nachher - vorher} vorhandene Teilnehmer nach ${tabelle} uebernommen.`);
}
} catch (fehler) {
console.error(`[workspace] Uebernahme nach ${tabelle}:`, fehler?.message);
}
}
/* Content-Saeulen: die drei bis fuenf Themen, aus denen der Kanal
besteht. Aus der Recherche: 3-5 Saeulen nach der 70/20/10-Regel
(70 % Wert, 20 % Community, 10 % Eigenwerbung); eine Saeule wird
erst brauchbar, wenn Formate an ihr haengen, und einmal im Monat
schaut man nach, welche ihr Gewicht traegt.
Je Creator eigene Saeulen -- ein Gaming-Kanal und ein Bastelkanal
haben nichts gemeinsam, eine gemeinsame Liste waere fuer beide
falsch. */
try {
d.exec(`
CREATE TABLE IF NOT EXISTS content_saeulen (
id INTEGER PRIMARY KEY AUTOINCREMENT,
creator_id INTEGER NOT NULL REFERENCES personen(id) ON DELETE CASCADE,
name TEXT NOT NULL,
ziel INTEGER, -- gewuenschter Anteil in Prozent
slot INTEGER NOT NULL DEFAULT 1, -- Farbplatz 1..5, siehe content.css
erstellt TEXT NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_saeulen_creator ON content_saeulen (creator_id);
`);
} catch (fehler) {
console.error("[workspace] content_saeulen:", fehler?.message);
}
/* Die alte Art "produktion" wird zu "gedreht". Vorher gab es drei
Stufen (Idee, Produktion, Veroeffentlicht) -- dazwischen lag alles,
was in Wahrheit den Unterschied macht: Aufhaenger geschrieben,
abgedreht, geschnitten, terminiert. Ohne diese Umbenennung fielen
bestehende Eintraege aus jeder Spalte heraus und waeren unsichtbar. */
try {
const betroffen = d.prepare(
"SELECT COUNT(*) AS n FROM eintraege WHERE bereich = 'content' AND art = 'produktion'").get()?.n ?? 0;
if (betroffen) {
d.prepare("UPDATE eintraege SET art = 'gedreht' WHERE bereich = 'content' AND art = 'produktion'").run();
console.log(`[workspace] ${betroffen} Content-Eintraege von 'produktion' auf 'gedreht' umgestellt.`);
}
} catch (fehler) {
console.error("[workspace] Content-Arten:", fehler?.message);
}
/* ---- Status "abgebrochen" erlauben (05.09.2026) ----
Der CHECK-Constraint einer Tabelle laesst sich in SQLite nicht
aendern -- kein ALTER TABLE der Welt hilft, die Tabelle muss neu
gebaut werden. Deshalb derselbe Weg wie bei der Rolle "manager"
darunter: erst sichern, dann tauschen, danach die Verweise pruefen.
WARUM UEBERHAUPT EIN NEUER STATUS und nicht bloss ein Haken
"abgebrochen": Weil die vier Spalten des Bretts nach Status
gruppieren. Eine abgebrochene Aufgabe faellt damit von selbst aus
allen vieren heraus -- ohne dass irgendeine Abfrage angefasst
werden muss. Ein zusaetzlicher Haken haette bedeutet, JEDE Abfrage
um "AND nicht abgebrochen" zu ergaenzen, und die eine vergessene
waere ein stiller Fehler gewesen: Die Aufgabe stuende weiter im
Brett, und niemand wuesste warum. */
const aufgabenPlan = d.prepare(
"SELECT sql FROM sqlite_master WHERE type = 'table' AND name = 'aufgaben'").get()?.sql || "";
if (aufgabenPlan && !aufgabenPlan.includes("'abgebrochen'")) {
const sicherung = `${DB_PFAD}.vor-abbruch-${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;
}
d.exec("PRAGMA foreign_keys = OFF");
try {
/* Die Spalten stehen hier vollstaendig, weil die Schleife oben
sie zu diesem Zeitpunkt schon ergaenzt hat. Wer hier eine
vergisst, verliert ihren Inhalt still -- deshalb wird nach dem
Tausch die Zeilenzahl verglichen. */
const vorher = d.prepare("SELECT COUNT(*) AS n FROM aufgaben").get().n;
d.exec("BEGIN");
d.exec(`
CREATE TABLE aufgaben_neu (
id INTEGER PRIMARY KEY AUTOINCREMENT,
titel TEXT NOT NULL,
beschreibung TEXT,
status TEXT NOT NULL DEFAULT 'offen'
CHECK (status IN ('offen','arbeit','review','erledigt','abgebrochen')),
prioritaet TEXT NOT NULL DEFAULT 'mittel'
CHECK (prioritaet IN ('hoch','mittel','niedrig')),
creator_id INTEGER REFERENCES personen(id) ON DELETE SET NULL,
verantwortlich_id INTEGER REFERENCES personen(id) ON DELETE SET NULL,
frist TEXT,
erstellt TEXT NOT NULL,
erstellt_von INTEGER REFERENCES personen(id) ON DELETE SET NULL,
geaendert TEXT,
erledigt_am TEXT,
creator_extern TEXT,
verantwortlich_extern TEXT,
abbruch_grund TEXT,
abgebrochen_am TEXT,
abbruch_von INTEGER REFERENCES personen(id) ON DELETE SET NULL,
status_vorher TEXT
);
INSERT INTO aufgaben_neu
(id, titel, beschreibung, status, prioritaet, creator_id, verantwortlich_id,
frist, erstellt, erstellt_von, geaendert, erledigt_am,
creator_extern, verantwortlich_extern,
abbruch_grund, abgebrochen_am, abbruch_von, status_vorher)
SELECT id, titel, beschreibung, status, prioritaet, creator_id, verantwortlich_id,
frist, erstellt, erstellt_von, geaendert, erledigt_am,
creator_extern, verantwortlich_extern,
abbruch_grund, abgebrochen_am, abbruch_von, status_vorher
FROM aufgaben;
DROP TABLE aufgaben;
ALTER TABLE aufgaben_neu RENAME TO aufgaben;
CREATE INDEX IF NOT EXISTS idx_aufgaben_status ON aufgaben (status);
CREATE INDEX IF NOT EXISTS idx_aufgaben_creator ON aufgaben (creator_id);
`);
/* Nach dem RENAME heisst die neue Tabelle wieder "aufgaben" --
gezaehlt wird also unter dem alten Namen, und der Vergleich
laeuft noch INNERHALB der Transaktion. Stimmt er nicht, ist
ein ROLLBACK noch moeglich. */
const nachher = d.prepare("SELECT COUNT(*) AS n FROM aufgaben").get().n;
/* Stimmt die Zahl nicht, wird NICHT bestaetigt. Lieber laeuft das
Abbrechen noch nicht, als dass eine Aufgabe verschwindet. */
if (nachher !== vorher) {
d.exec("ROLLBACK");
console.error(`[workspace] Umstellung abgebrochen: ${vorher} Aufgaben vorher, `
+ `${nachher} nachher. Sicherung: ${sicherung}`);
} else {
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] Status 'abgebrochen' freigeschaltet, ${vorher} Aufgaben, Verweise geprueft.`);
}
}
} catch (fehler) {
try { d.exec("ROLLBACK"); } catch { /* schon zurueckgerollt */ }
console.error("[workspace] Umstellung 'abgebrochen' fehlgeschlagen:", fehler?.message);
} finally {
d.exec("PRAGMA foreign_keys = ON");
}
}
/* ---- Rolle "manager" erlauben ---- */
const bauplan = d.prepare(
"SELECT sql FROM sqlite_master WHERE type = 'table' AND name = 'personen'").get()?.sql || "";
if (!bauplan.includes("'manager'")) {
const sicherung = `${DB_PFAD}.vor-manager-${jetztStempel}`;
try {
d.exec(`VACUUM INTO '${sicherung.replace(/'/g, "''")}'`);
console.log("[workspace] Sicherung vor der Umstellung:", sicherung);
} catch (fehler) {
/* Ohne Sicherung wird NICHT umgestellt. Lieber laeuft der Manager
noch nicht, als dass Daten ohne Netz angefasst werden. */
console.error("[workspace] Sicherung fehlgeschlagen, Umstellung abgebrochen:", fehler?.message);
return;
}
/* Fremdschluessel muessen aus sein, weil andere Tabellen auf
personen(id) zeigen -- und das laesst sich nicht innerhalb einer
Transaktion umschalten. */
d.exec("PRAGMA foreign_keys = OFF");
try {
d.exec("BEGIN");
d.exec(`
CREATE TABLE personen_neu (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL,
rolle TEXT NOT NULL CHECK (rolle IN ('admin','manager','scout','creator')),
code_hash TEXT NOT NULL,
code_salt TEXT NOT NULL,
code_n INTEGER NOT NULL,
aktiv INTEGER NOT NULL DEFAULT 1,
erstellt TEXT NOT NULL,
letzter_login TEXT
);
INSERT INTO personen_neu
(id, name, rolle, code_hash, code_salt, code_n, aktiv, erstellt, letzter_login)
SELECT id, name, rolle, code_hash, code_salt, code_n, aktiv, erstellt, letzter_login
FROM personen;
DROP TABLE personen;
ALTER TABLE personen_neu RENAME TO personen;
`);
d.exec("COMMIT");
/* Nach dem Tausch pruefen, ob die Verweise noch stimmen. Findet
sich etwas, wird das laut gemeldet -- stillschweigend kaputte
Verweise waeren das Schlimmste an dieser Stelle. */
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 'manager' freigeschaltet, Verweise geprueft.");
}
} catch (fehler) {
try { d.exec("ROLLBACK"); } catch { /* schon zurueckgerollt */ }
console.error("[workspace] Umstellung fehlgeschlagen:", fehler?.message);
} finally {
d.exec("PRAGMA foreign_keys = ON");
}
}
}
export function db() {
if (_db) return _db;
if (_dbFehler) throw _dbFehler;
try {
const { DatabaseSync } = require("node:sqlite");
mkdirSync(dirname(DB_PFAD), { recursive: true });
const d = new DatabaseSync(DB_PFAD);
d.exec(`
PRAGMA journal_mode = WAL;
PRAGMA foreign_keys = ON;
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')),
code_hash TEXT NOT NULL,
code_salt TEXT NOT NULL,
code_n INTEGER NOT NULL,
aktiv INTEGER NOT NULL DEFAULT 1,
erstellt TEXT NOT NULL,
letzter_login TEXT
);
CREATE TABLE IF NOT EXISTS sitzungen (
token_hash TEXT PRIMARY KEY,
person_id INTEGER NOT NULL REFERENCES personen(id) ON DELETE CASCADE,
erstellt TEXT NOT NULL,
gueltig_bis TEXT NOT NULL,
ip TEXT,
browser TEXT
);
/* Audit-Log. Im Konzept ausdrücklich gefordert ("Login-Historie,
kritische Aktionen protokollieren"). Nur anhängen, nie ändern. */
CREATE TABLE IF NOT EXISTS protokoll (
id INTEGER PRIMARY KEY AUTOINCREMENT,
zeitpunkt TEXT NOT NULL,
person_id INTEGER,
rolle TEXT,
aktion TEXT NOT NULL,
detail TEXT,
ip TEXT
);
/* Aufgaben. creator_id sagt, ZU WEM die Aufgabe gehört (wessen
Bereich), verantwortlich_id, WER sie erledigt. Beides getrennt,
weil im Konzept auch Aufgaben vorkommen, die das Management für
einen Creator anlegt. */
CREATE TABLE IF NOT EXISTS aufgaben (
id INTEGER PRIMARY KEY AUTOINCREMENT,
titel TEXT NOT NULL,
beschreibung TEXT,
status TEXT NOT NULL DEFAULT 'offen'
CHECK (status IN ('offen','arbeit','review','erledigt','abgebrochen')),
prioritaet TEXT NOT NULL DEFAULT 'mittel'
CHECK (prioritaet IN ('hoch','mittel','niedrig')),
creator_id INTEGER REFERENCES personen(id) ON DELETE SET NULL,
verantwortlich_id INTEGER REFERENCES personen(id) ON DELETE SET NULL,
frist TEXT,
erstellt TEXT NOT NULL,
erstellt_von INTEGER REFERENCES personen(id) ON DELETE SET NULL,
geaendert TEXT,
erledigt_am TEXT
);
CREATE INDEX IF NOT EXISTS idx_aufgaben_status ON aufgaben (status);
CREATE INDEX IF NOT EXISTS idx_aufgaben_creator ON aufgaben (creator_id);
/* Einstellungen, die sich zur Laufzeit aendern lassen. Bisher nur
eine: ob die KI-Vorschlaege angeboten werden. Als Tabelle statt
als Datei, damit sie dieselbe Sicherung bekommt wie alles
andere -- eine Einstellung, die beim Wiederherstellen fehlt,
faellt erst auf, wenn etwas nicht mehr geht. */
CREATE TABLE IF NOT EXISTS einstellungen (
schluessel TEXT PRIMARY KEY,
wert TEXT NOT NULL,
geaendert TEXT,
von INTEGER REFERENCES personen(id) ON DELETE SET NULL
);
/* BENACHRICHTIGUNGEN (05.09.2026) ---------------------------------
Wunsch Filipe, mit dem Bild eines "Benachrichtigungen aus"-
Knopfes: *"ich will auch sowas und dass es perfekt funktioniert
auf der seite fuer jeden."*
Eine Anmeldung ist die Adresse, unter der ein bestimmter
BROWSER auf einem bestimmten GERAET erreichbar ist -- nicht die
Person. Wer den Workspace auf dem Rechner und auf dem Handy
benutzt, hat zwei; beide sollen klingeln, und beide muessen
einzeln abschaltbar sein.
Der Endpunkt ist der Schluessel, nicht eine eigene Nummer: Meldet
sich derselbe Browser erneut an (nach dem Leeren der
Website-Daten etwa), soll daraus KEIN zweiter Eintrag werden,
sonst kaeme jede Nachricht doppelt.
p256dh und auth sind die Schluessel des Browsers. Ohne sie
laesst sich nichts verschluesseln -- und ohne Verschluesselung
nimmt kein Push-Dienst etwas an. */
CREATE TABLE IF NOT EXISTS push_anmeldungen (
endpunkt TEXT PRIMARY KEY,
person_id INTEGER NOT NULL REFERENCES personen(id) ON DELETE CASCADE,
p256dh TEXT NOT NULL,
auth TEXT NOT NULL,
geraet TEXT,
erstellt TEXT NOT NULL,
zuletzt_ok TEXT,
fehler INTEGER NOT NULL DEFAULT 0
);
CREATE INDEX IF NOT EXISTS idx_push_person ON push_anmeldungen (person_id);
/* Was jemand bekommen WILL -- je Art einzeln.
Der Plan ist an dieser Stelle deutlich: *"je Person abschaltbar
-- pro Art, nicht alles oder nichts. Eine Benachrichtigung, die
nervt, wird abgeschaltet und dann fehlt auch die wichtige."*
Fehlt eine Zeile, gilt die Voreinstellung aus workspace-push.js
(an). Damit muss niemand erst etwas einstellen, um etwas zu
bekommen -- und wer etwas abstellt, bekommt genau das nicht
mehr. */
CREATE TABLE IF NOT EXISTS push_einstellungen (
person_id INTEGER NOT NULL REFERENCES personen(id) ON DELETE CASCADE,
art TEXT NOT NULL,
an INTEGER NOT NULL DEFAULT 1,
PRIMARY KEY (person_id, art)
);
/* Was schon verschickt wurde. Ohne dieses Gedaechtnis bekaeme
jemand dieselbe Erinnerung bei jedem Lauf erneut -- viermal am
Tag "Aufgabe ist ueberfaellig" ist der schnellste Weg, dass
jemand Benachrichtigungen komplett abschaltet.
Das Merkmal ist die Sache selbst (z. B. "aufgabe-faellig:42"),
nicht der Zeitpunkt: Dieselbe Aufgabe erinnert einmal, nicht
einmal je Stunde. */
CREATE TABLE IF NOT EXISTS push_verschickt (
person_id INTEGER NOT NULL REFERENCES personen(id) ON DELETE CASCADE,
merkmal TEXT NOT NULL,
zeit TEXT NOT NULL,
PRIMARY KEY (person_id, merkmal)
);
CREATE INDEX IF NOT EXISTS idx_push_verschickt_zeit ON push_verschickt (zeit);
/* Rueckmeldungen zu einer Aufgabe (Konzept: "Aufgaben & Feedback").
Ohne sie endet jede Rueckfrage ausserhalb des Systems -- in
WhatsApp, und damit ausserhalb dessen, was spaeter noch
nachvollziehbar ist.
Wer die Aufgabe sehen darf, darf auch die Rueckmeldungen sehen
und schreiben. Eine eigene Sichtbarkeitsregel gibt es bewusst
NICHT: Zwei Regeln fuer dieselbe Sache laufen irgendwann
auseinander. */
CREATE TABLE IF NOT EXISTS aufgaben_notizen (
id INTEGER PRIMARY KEY AUTOINCREMENT,
aufgabe_id INTEGER NOT NULL REFERENCES aufgaben(id) ON DELETE CASCADE,
person_id INTEGER REFERENCES personen(id) ON DELETE SET NULL,
text TEXT NOT NULL,
erstellt TEXT NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_aufgaben_notizen ON aufgaben_notizen (aufgabe_id);
/* =================================================================
CHAT (06.09.2026)
Wunsch Filipe: *"eine kategorie chat, wo die creator mit ihren
manager nachrichten austauschen können, wie erinnerungen, fragen
und noch vieles mehr, wo jeder immer mit denen schreiben kann
wie verbindung auch läuft."* Auf Nachfrage: Zweier-Gespraeche
UND Gruppen, und Manager/Scouts duerfen sich auch ohne
gemeinsamen Creator schreiben.
DREI TABELLEN, und die Aufteilung hat einen Grund:
chat_raeume das Gespraech selbst
chat_teilnehmer wer darin ist -- und bis wohin er gelesen hat
chat_nachrichten was gesagt wurde
WARUM DIE TEILNEHMER EINE EIGENE TABELLE SIND und nicht zwei
Spalten am Raum: Ein Zweier-Gespraech kaeme mit "person_a,
person_b" aus, eine Gruppe nicht. Zwei verschiedene Bauweisen
fuer dieselbe Sache waeren der sichere Weg dazu, dass eine
Abfrage die eine Haelfte vergisst. So ist ein Zweier-Gespraech
schlicht eine Gruppe mit zwei Leuten.
WARUM "gelesen_bis" AM TEILNEHMER haengt und nicht an der
Nachricht: Sonst braeuchte es je Person und Nachricht eine
Zeile -- bei drei Leuten und tausend Nachrichten dreitausend
Eintraege, nur um "gelesen" zu wissen. Eine Nummer je Person
und Raum genuegt: Alles darunter ist gelesen. */
CREATE TABLE IF NOT EXISTS chat_raeume (
id INTEGER PRIMARY KEY AUTOINCREMENT,
art TEXT NOT NULL DEFAULT 'direkt'
CHECK (art IN ('direkt','gruppe')),
/* Nur Gruppen haben einen Namen. Ein Zweier-Gespraech heisst
immer nach dem Gegenueber -- und zwar fuer jeden anders. Ein
gespeicherter Name waere fuer eine der beiden Seiten falsch. */
name TEXT,
erstellt TEXT NOT NULL,
erstellt_von INTEGER REFERENCES personen(id) ON DELETE SET NULL,
/* Der Zeitpunkt der letzten Nachricht, mitgefuehrt. Ohne ihn
braeuchte die Gespraechsliste je Raum eine Unterabfrage --
bei zwanzig Raeumen zwanzig Abfragen fuer eine Liste. */
letzte_am TEXT
);
CREATE INDEX IF NOT EXISTS idx_chat_raeume_letzte ON chat_raeume (letzte_am DESC);
CREATE TABLE IF NOT EXISTS chat_teilnehmer (
raum_id INTEGER NOT NULL REFERENCES chat_raeume(id) ON DELETE CASCADE,
person_id INTEGER NOT NULL REFERENCES personen(id) ON DELETE CASCADE,
seit TEXT NOT NULL,
/* Bis zu welcher Nachricht diese Person gelesen hat. */
gelesen_bis INTEGER NOT NULL DEFAULT 0,
/* Wer eine Gruppe angelegt hat, darf Leute hinzufuegen und
entfernen. In Zweier-Gespraechen bedeutungslos. */
leitung INTEGER NOT NULL DEFAULT 0,
/* Verlassene Gruppen: Die Zeile bleibt mit einem Datum stehen,
damit der Verlauf bis dahin lesbar bleibt und niemand
rueckwirkend aus der Geschichte verschwindet. */
raus_am TEXT,
PRIMARY KEY (raum_id, person_id)
);
CREATE INDEX IF NOT EXISTS idx_chat_teilnehmer_person ON chat_teilnehmer (person_id);
CREATE TABLE IF NOT EXISTS chat_nachrichten (
id INTEGER PRIMARY KEY AUTOINCREMENT,
raum_id INTEGER NOT NULL REFERENCES chat_raeume(id) ON DELETE CASCADE,
person_id INTEGER REFERENCES personen(id) ON DELETE SET NULL,
text TEXT NOT NULL,
erstellt TEXT NOT NULL,
/* Zurueckgenommen: Der Text wird geleert, die Zeile bleibt.
Ein Loch im Verlauf wirft mehr Fragen auf als der Hinweis,
dass hier etwas zurueckgenommen wurde. */
weg_am TEXT
);
CREATE INDEX IF NOT EXISTS idx_chat_nachrichten_raum
ON chat_nachrichten (raum_id, id);
/* Creator-Profil (Onboarding aus dem Konzept). Eine Zeile je
Creator, entsteht erst beim ersten Speichern.
admin_notiz ist bewusst Teil dieser Tabelle, wird aber nur an
das Management ausgeliefert -- siehe workspace-profil.js. */
CREATE TABLE IF NOT EXISTS profile (
person_id INTEGER PRIMARY KEY REFERENCES personen(id) ON DELETE CASCADE,
handles TEXT,
nische TEXT,
live_zeiten TEXT,
technik TEXT,
ziel_live TEXT,
ziel_content TEXT,
ziel_community TEXT,
ziel_technik TEXT,
plan_start TEXT,
plan_prio1 TEXT,
plan_prio2 TEXT,
plan_prio3 TEXT,
naechster_review TEXT,
admin_notiz TEXT,
geaendert TEXT,
geaendert_von INTEGER REFERENCES personen(id) ON DELETE SET NULL
);
/* Termine, Calls und Reviews. Der Beginn steht als lokale Zeit
(JJJJ-MM-TTThh:mm) -- genau so, wie ein datetime-local-Feld sie
liefert. Bewusst ohne Zeitzone: Alle Beteiligten sitzen in
derselben, und eine falsch umgerechnete Uhrzeit waere schlimmer
als gar keine Umrechnung. */
CREATE TABLE IF NOT EXISTS termine (
id INTEGER PRIMARY KEY AUTOINCREMENT,
titel TEXT NOT NULL,
beschreibung TEXT,
art TEXT NOT NULL DEFAULT 'termin'
CHECK (art IN ('termin','call','review')),
beginn TEXT NOT NULL,
dauer_min INTEGER NOT NULL DEFAULT 30,
ort TEXT,
creator_id INTEGER REFERENCES personen(id) ON DELETE SET NULL,
teilnehmer_id INTEGER REFERENCES personen(id) ON DELETE SET NULL,
erledigt INTEGER NOT NULL DEFAULT 0,
erstellt TEXT NOT NULL,
erstellt_von INTEGER REFERENCES personen(id) ON DELETE SET NULL
);
CREATE INDEX IF NOT EXISTS idx_termine_beginn ON termine (beginn);
/* MEHRERE TEILNEHMER AN EINEM TERMIN (05.09.2026) ------------------
Wunsch Filipe: *"wenn ich im kalender was eintrage will ich dass
ich auch 2 leute markieren kann mit denen der call ist."*
Bis hierher hatte ein Termin GENAU EIN Gegenueber
(termine.teilnehmer_id). Fuer ein Gespraech zu dritt musste man
zwei Termine anlegen -- und hatte damit zwei Wahrheiten ueber
dieselbe halbe Stunde.
Gebaut nach demselben Muster wie datei_personen weiter unten:
eine eigene kleine Tabelle statt weiterer Spalten. Zwei Spalten
"teilnehmer2_id", "teilnehmer3_id" waeren beim vierten Menschen
wieder am Ende, und jede Abfrage muesste alle einzeln
aufzaehlen.
WICHTIG -- teilnehmer_id BLEIBT und behaelt seine Bedeutung:
Es ist das HAUPT-Gegenueber, mit dem der Termin vereinbart
wurde. Diese Tabelle sagt, WER SONST NOCH dabei ist. Beim
Umstellen wird der vorhandene Wert mit uebernommen, damit die
Teilnehmerliste von Anfang an vollstaendig ist und keine
Abfrage zwei Stellen zusammensuchen muss.
An dieser Tabelle haengt die SICHTBARKEIT (siehe
termineSichtbar): Wer hier steht, sieht den Termin. Ein
vergessener Eintrag ist deshalb kein Schoenheitsfehler,
sondern ein Termin, den jemand nicht findet. */
CREATE TABLE IF NOT EXISTS termin_teilnehmer (
termin_id INTEGER NOT NULL REFERENCES termine(id) ON DELETE CASCADE,
person_id INTEGER NOT NULL REFERENCES personen(id) ON DELETE CASCADE,
PRIMARY KEY (termin_id, person_id)
);
CREATE INDEX IF NOT EXISTS idx_termin_teilnehmer ON termin_teilnehmer (person_id);
/* Dasselbe fuer die Wiederholungen. Eine Serie ist eine Regel --
die Teilnehmer gehoeren zur Regel, nicht zur einzelnen
Auspraegung, sonst muesste man sie jede Woche neu eintragen.
Der Nachfueller kopiert sie beim Anlegen jedes Termins hierher
hinueber (siehe workspace-serien.js). */
CREATE TABLE IF NOT EXISTS serie_teilnehmer (
serie_id INTEGER NOT NULL REFERENCES termin_serien(id) ON DELETE CASCADE,
person_id INTEGER NOT NULL REFERENCES personen(id) ON DELETE CASCADE,
PRIMARY KEY (serie_id, person_id)
);
CREATE INDEX IF NOT EXISTS idx_serie_teilnehmer ON serie_teilnehmer (person_id);
/* Wiederkehrende Termine (02.09.2026) --------------------------------
Eine Serie ist eine REGEL, kein Termin: "jeden Dienstag um 18:00
Uhr, bis ich es abstelle". Aus ihr entstehen echte Zeilen in
der Tabelle termine.
Warum echte Zeilen und nicht bloss gerechnete Ausprägungen:
ACHT andere Stellen lesen termine direkt -- Calls, Protokolle,
Berichte, Hinweise/Ampel, Suche, Aufgaben ("nächster Termin"),
Übersicht, Startseite. Eine nur im Kalender gerechnete
Wiederholung wäre in all diesen Ansichten unsichtbar gewesen,
ohne dass es jemandem auffällt, und beim geplanten ICS-Abo
ebenfalls. Der Nachfüller hält stattdessen einen Horizont echt
gefüllt -- damit sieht jedes Modul die Wiederholung, ohne dass
dort eine einzige Zeile geändert werden musste.
uhrzeit statt beginn: Eine Regel kennt keinen Zeitpunkt, nur
eine Uhrzeit und einen Takt. Gerechnet wird auf reinen
Datumstexten -- deshalb bleibt 18:00 auch über die
Zeitumstellung hinweg 18:00 und wandert nicht auf 17:00. */
CREATE TABLE IF NOT EXISTS termin_serien (
id INTEGER PRIMARY KEY AUTOINCREMENT,
titel TEXT NOT NULL,
beschreibung TEXT,
art TEXT NOT NULL DEFAULT 'termin'
CHECK (art IN ('termin','call','review')),
takt TEXT NOT NULL
CHECK (takt IN ('taeglich','werktags','woechentlich',
'zweiwoechentlich','monatlich_datum',
'monatlich_letzter','monatlich_wochentag',
'jaehrlich')),
start_tag TEXT NOT NULL,
uhrzeit TEXT NOT NULL,
ende_tag TEXT,
dauer_min INTEGER NOT NULL DEFAULT 30,
ort TEXT,
creator_id INTEGER REFERENCES personen(id) ON DELETE SET NULL,
teilnehmer_id INTEGER REFERENCES personen(id) ON DELETE SET NULL,
aktiv INTEGER NOT NULL DEFAULT 1,
erstellt TEXT NOT NULL,
erstellt_von INTEGER REFERENCES personen(id) ON DELETE SET NULL,
geaendert TEXT
);
CREATE INDEX IF NOT EXISTS idx_serien_aktiv ON termin_serien (aktiv);
/* Ausgelassene Tage einer Serie. Wer eine einzelne Ausprägung
löscht, meint "dieses eine Mal nicht" -- ohne diese Tabelle
legte der Nachfüller sie beim nächsten Aufruf wieder an, und der
gelöschte Termin wäre kommentarlos zurück. */
CREATE TABLE IF NOT EXISTS termin_serien_aus (
serie_id INTEGER NOT NULL REFERENCES termin_serien(id) ON DELETE CASCADE,
tag TEXT NOT NULL,
PRIMARY KEY (serie_id, tag)
);
/* Dateiablage. Auf der Platte traegt jede Datei einen erzeugten
Zufallsnamen (name_datei), der Originalname steht nur hier.
Dadurch kann ein Dateiname weder Pfade verlassen noch etwas
ueberschreiben. Der Freigabe-Ablauf aus dem Konzept steckt in
status: entwurf -> review -> freigegeben. */
CREATE TABLE IF NOT EXISTS dateien (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name_original TEXT NOT NULL,
name_datei TEXT NOT NULL UNIQUE,
groesse INTEGER NOT NULL,
typ TEXT,
status TEXT NOT NULL DEFAULT 'entwurf'
CHECK (status IN ('entwurf','review','freigegeben')),
notiz TEXT,
creator_id INTEGER REFERENCES personen(id) ON DELETE SET NULL,
hochgeladen_von INTEGER REFERENCES personen(id) ON DELETE SET NULL,
erstellt TEXT NOT NULL,
geaendert TEXT
);
CREATE INDEX IF NOT EXISTS idx_dateien_creator ON dateien (creator_id);
/* Eintraege der Phase-2-Bereiche (LIVE, Content, Technik,
Community, Schutz). Eine gemeinsame Tabelle statt fuenf fast
gleicher: Die Bereiche unterscheiden sich nur in den erlaubten
Arten und darin, ob Bewertung oder Dringlichkeit dazugehoeren --
das steht in workspace-bereiche.js, nicht in der Datenbank. */
CREATE TABLE IF NOT EXISTS eintraege (
id INTEGER PRIMARY KEY AUTOINCREMENT,
bereich TEXT NOT NULL
CHECK (bereich IN ('live','content','technik','community','schutz')),
art TEXT NOT NULL,
titel TEXT NOT NULL,
text TEXT,
datum TEXT NOT NULL,
bewertung INTEGER CHECK (bewertung IS NULL OR (bewertung BETWEEN 1 AND 5)),
dringlichkeit TEXT NOT NULL DEFAULT 'mittel'
CHECK (dringlichkeit IN ('hoch','mittel','niedrig')),
status TEXT NOT NULL DEFAULT 'offen'
CHECK (status IN ('offen','erledigt')),
creator_id INTEGER REFERENCES personen(id) ON DELETE SET NULL,
erstellt TEXT NOT NULL,
erstellt_von INTEGER REFERENCES personen(id) ON DELETE SET NULL,
geaendert TEXT
);
CREATE INDEX IF NOT EXISTS idx_eintraege_bereich ON eintraege (bereich, creator_id);
/* Wer darf welche Datei sehen. Bisher hatte eine Datei genau
EINEN Bereich (dateien.creator_id) -- damit liess sich eine
Datei nicht zweien geben, ohne sie zweimal hochzuladen.
Diese Tabelle erlaubt beliebig viele Empfaenger, Creator wie
Scouts. creator_id bleibt bestehen: Es sagt weiterhin, zu
wessen BEREICH eine Datei gehoert, waehrend hier steht, WER sie
sehen darf. Zwei verschiedene Fragen. */
CREATE TABLE IF NOT EXISTS datei_personen (
datei_id INTEGER NOT NULL REFERENCES dateien(id) ON DELETE CASCADE,
person_id INTEGER NOT NULL REFERENCES personen(id) ON DELETE CASCADE,
PRIMARY KEY (datei_id, person_id)
);
CREATE INDEX IF NOT EXISTS idx_datei_personen ON datei_personen (person_id);
/* Wissens-Bibliothek: Anleitungen als PDF, hinter dem Login.
Jede PDF hat GENAU EINE Hauptkategorie -- so gibt es sie nur
einmal. Zusaetzliche Themen laufen ueber Tags, damit dieselbe
Anleitung trotzdem ueber mehrere Suchbegriffe auffindbar ist.
Die Kategorien selbst stehen im Code (workspace-wissen.js),
nicht hier: Sie sind eine bewusste Gliederung, keine Nutzdaten. */
CREATE TABLE IF NOT EXISTS wissen (
id INTEGER PRIMARY KEY AUTOINCREMENT,
titel TEXT NOT NULL,
beschreibung TEXT,
kategorie TEXT NOT NULL,
stufe TEXT NOT NULL DEFAULT 'einsteiger'
CHECK (stufe IN ('einsteiger','fortgeschritten','profi')),
geraet TEXT NOT NULL DEFAULT 'egal'
CHECK (geraet IN ('pc','handy','beide','egal')),
tags TEXT,
name_original TEXT NOT NULL,
name_datei TEXT NOT NULL,
groesse INTEGER NOT NULL,
wichtig INTEGER NOT NULL DEFAULT 0,
veroeffentlicht TEXT NOT NULL,
aktualisiert TEXT,
erstellt_von INTEGER REFERENCES personen(id) ON DELETE SET NULL
);
CREATE INDEX IF NOT EXISTS idx_wissen_kat ON wissen (kategorie);
/* =================================================================
LEISTUNG — die Zahlen, an denen die Betreuung haengt (06.09.2026)
DER GROSSE BEFUND aus dem Plan vom 31.08.2026: Der Workspace
organisiert ARBEIT sehr gut, aber es gibt keine einzige Zahl
ueber das GESCHAEFT. Damit haengt die ganze Betreuung in der
Luft -- der Start-Check bewertet ohne zu messen, der Report
fragt "was hat funktioniert" ohne Beleg, das 90-Tage-Ziel ist
ein Satz statt eines Fortschritts.
EINE ZEILE JE CREATOR UND TAG. Tag und nicht Woche: Feineres
laesst sich immer zusammenfassen, Groeberes nie aufteilen. Wer
mit Wochenwerten anfaengt, kann spaeter nie mehr sagen, ob der
Einbruch am Dienstag oder am Wochenende lag.
WELCHE FELDER -- und warum genau diese (Recherche 31.08. und
06.09.2026, TikTok LIVE Backstage):
diamanten die Zahl, an der TikTok und die Agentur den
Erfolg messen
dauer_min Grundlage fuer "gueltige Tage"/"aktive Stunden"
gueltiger_tag der Zaehler, an dem Ranghochstufungen haengen
zuschauer_avg sagt, ob es TRAEGT
zuschauer_max sagt, was MOEGLICH waere
verweildauer_s DIE LEITZAHL (Recherche 06.09.2026): 2026
haengt der Algorithmus alles daran, und sie
gehoert als Betriebskennzahl behandelt --
sie soll die naechste Runde steuern, nicht
die letzte erklaeren
schenker wie BREIT die Unterstuetzung steht, nicht nur
wie hoch. Ein Grossspender ist ein Risiko,
zwanzig kleine sind ein Fundament
follower_neu waechst der Kanal oder dreht er sich im Kreis
notiz ein Satz Kontext ("PK gegen X", "krank")
WARUM DIE NOTIZ DAZUGEHOERT: Ohne sie sieht man in drei Monaten
einen Einbruch und weiss nicht mehr, dass die Person Grippe
hatte. Zahlen ohne Umstaende fuehren in die Irre.
PRIMAERSCHLUESSEL (creator_id, tag): Derselbe Creator am
selben Tag kann nur EINE Zeile haben. Das ist die halbe Miete
beim Import -- wer eine Datei zweimal einliest, ueberschreibt
dann, statt zu verdoppeln. Verdoppelte Zahlen sind schlimmer
als fehlende: Sie sehen richtig aus. */
CREATE TABLE IF NOT EXISTS leistung (
creator_id INTEGER NOT NULL REFERENCES personen(id) ON DELETE CASCADE,
tag TEXT NOT NULL, /* JJJJ-MM-TT, Ortszeit */
diamanten INTEGER,
dauer_min INTEGER,
gueltiger_tag INTEGER NOT NULL DEFAULT 0,
zuschauer_avg INTEGER,
zuschauer_max INTEGER,
verweildauer_s INTEGER, /* Sekunden, die Leitzahl */
schenker INTEGER,
follower_neu INTEGER,
notiz TEXT,
erfasst TEXT NOT NULL,
erfasst_von INTEGER REFERENCES personen(id) ON DELETE SET NULL,
/* Woher der Wert kommt. Wichtig, wenn eine Zahl seltsam
aussieht: von Hand getippt oder aus dem Export? */
quelle TEXT NOT NULL DEFAULT 'hand'
CHECK (quelle IN ('hand','import')),
PRIMARY KEY (creator_id, tag)
);
CREATE INDEX IF NOT EXISTS idx_leistung_tag ON leistung (tag DESC);
/* ZIELE je Creator. Aus dem 90-Tage-Plan, den es im Profil schon
als FREITEXT gibt -- daraus wird hier eine pruefbare Groesse.
"Mehr Reichweite" ist kein Ziel, "4 LIVE-Tage pro Woche" ist
eines.
Eine Zeile je Creator, nicht je Ziel und Zeitraum: Ein Ziel,
das sich woechentlich aendert, ist keines. Wer es anpasst,
ueberschreibt -- der Verlauf steht ohnehin in der Tabelle
"leistung". */
CREATE TABLE IF NOT EXISTS leistung_ziele (
creator_id INTEGER PRIMARY KEY REFERENCES personen(id) ON DELETE CASCADE,
diamanten_woche INTEGER,
tage_woche INTEGER,
verweildauer_s INTEGER,
gesetzt TEXT NOT NULL,
gesetzt_von INTEGER REFERENCES personen(id) ON DELETE SET NULL
);
/* Start-Check / Erstanalyse (Konzept, Seite 5). Eine Zeile je
geprueftem Punkt -- ungeprueft heisst: gar keine Zeile. So sieht
man am Bestand sofort, wie weit die Analyse ist, ohne leere
Platzhalter mitschleppen zu muessen.
Die Punkte selbst stehen im Code, nicht hier: Waeren sie
Datensaetze, koennte jeder eigene anlegen -- und zwei Creator
waeren nicht mehr vergleichbar, was der ganze Zweck ist. */
CREATE TABLE IF NOT EXISTS startcheck (
creator_id INTEGER NOT NULL REFERENCES personen(id) ON DELETE CASCADE,
feld TEXT NOT NULL,
punkt TEXT NOT NULL,
bewertung TEXT CHECK (bewertung IS NULL OR bewertung IN ('ok','mittel','handlung')),
notiz TEXT,
geaendert TEXT NOT NULL,
geaendert_von INTEGER REFERENCES personen(id) ON DELETE SET NULL,
PRIMARY KEY (creator_id, feld, punkt)
);
/* Betreuung: wer kuemmert sich um welchen Creator.
Scouts betreuen Creator wie das Management, aber nur die ihnen
zugeteilten -- deshalb braucht es eine ausdrueckliche Zuordnung
und keine Rollenregel. Ein Creator hat genau eine zustaendige
Person (PRIMARY KEY), damit nie unklar ist, wer gefragt ist.
Das Management sieht ohnehin alle und braucht keinen Eintrag. */
CREATE TABLE IF NOT EXISTS betreuung (
creator_id INTEGER PRIMARY KEY REFERENCES personen(id) ON DELETE CASCADE,
betreuer_id INTEGER NOT NULL REFERENCES personen(id) ON DELETE CASCADE,
seit TEXT NOT NULL,
gesetzt_von INTEGER REFERENCES personen(id) ON DELETE SET NULL
);
CREATE INDEX IF NOT EXISTS idx_betreuung_betreuer ON betreuung (betreuer_id);
/* Welcher Scout gehoert zu welchem Manager (01.09.2026).
"er soll nur die Scouts sehen, die ihm zugeteilt sind."
WARUM EINE EIGENE TABELLE und nicht ein zweiter Eintrag in
betreuung: Dort heisst die Spalte creator_id, und der ganze
uebrige Code liest sie als "das ist ein Creator". Ein Scout in
dieser Spalte waere technisch moeglich und fachlich eine Luege --
betreuteIds() gaebe dann Scout-Nummern zurueck, die anderswo als
Creator behandelt wuerden. Solche Abkuerzungen raechen sich
genau dann, wenn niemand mehr weiss, dass sie getroffen wurden.
Ein Scout gehoert zu hoechstens EINEM Manager -- deshalb ist
scout_id der Schluessel. Eine Zuteilung an mehrere waere die
Frage "wer ist zustaendig?" ohne Antwort.
ON DELETE CASCADE auf beiden Seiten: Verschwindet der Scout oder
der Manager, ist die Zuteilung gegenstandslos. Sie stehenzulassen
hiesse, dass eine geloeschte Person weiter Sichtbarkeit steuert. */
CREATE TABLE IF NOT EXISTS scout_zuteilung (
scout_id INTEGER PRIMARY KEY REFERENCES personen(id) ON DELETE CASCADE,
manager_id INTEGER NOT NULL REFERENCES personen(id) ON DELETE CASCADE,
seit TEXT NOT NULL,
gesetzt_von INTEGER REFERENCES personen(id) ON DELETE SET NULL
);
CREATE INDEX IF NOT EXISTS idx_scout_zuteilung_manager
ON scout_zuteilung (manager_id);
/* Meeting-Protokoll zu einem Termin (Konzept, Seite 13).
Bewusst eine eigene Tabelle statt neuer Spalten in termine: Es
gibt hier keine Migrationen, und ein Protokoll gehoert ohnehin
nur zu einem Bruchteil der Termine. Ein Termin hat hoechstens
ein Protokoll -- daher UNIQUE. */
CREATE TABLE IF NOT EXISTS protokolle (
id INTEGER PRIMARY KEY AUTOINCREMENT,
termin_id INTEGER NOT NULL UNIQUE REFERENCES termine(id) ON DELETE CASCADE,
punkte TEXT,
entscheidungen TEXT,
naechster_termin_id INTEGER REFERENCES termine(id) ON DELETE SET NULL,
erstellt TEXT NOT NULL,
erstellt_von INTEGER REFERENCES personen(id) ON DELETE SET NULL,
geaendert TEXT
);
/* "Jeder Call endet mit klaren To-dos, die direkt ins Board
uebernommen werden." Die To-dos leben deshalb NICHT im Protokoll,
sondern sind echte Aufgaben. Diese Tabelle merkt sich nur, aus
welchem Gespraech eine Aufgabe entstanden ist. */
CREATE TABLE IF NOT EXISTS protokoll_aufgaben (
protokoll_id INTEGER NOT NULL REFERENCES protokolle(id) ON DELETE CASCADE,
aufgabe_id INTEGER NOT NULL REFERENCES aufgaben(id) ON DELETE CASCADE,
PRIMARY KEY (protokoll_id, aufgabe_id)
);
/* Scout-CRM: die Pipeline vom ersten Fund bis zur Uebergabe.
creator_id ist gesetzt, sobald aus einem uebergebenen Lead eine
echte Person angelegt wurde -- damit bleibt nachvollziehbar, wer
einen Creator gefunden hat, auch Jahre spaeter. */
CREATE TABLE IF NOT EXISTS leads (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL,
plattform TEXT,
handle TEXT,
status TEXT NOT NULL DEFAULT 'neu'
CHECK (status IN ('neu','angesprochen','gespraech','interessiert','uebergeben','abgelehnt')),
prioritaet TEXT NOT NULL DEFAULT 'mittel'
CHECK (prioritaet IN ('hoch','mittel','niedrig')),
aktivitaet TEXT,
potenzial TEXT,
notizen TEXT,
letzte_nachricht TEXT,
naechster_followup TEXT,
scout_id INTEGER REFERENCES personen(id) ON DELETE SET NULL,
creator_id INTEGER REFERENCES personen(id) ON DELETE SET NULL,
erstellt TEXT NOT NULL,
erstellt_von INTEGER REFERENCES personen(id) ON DELETE SET NULL,
geaendert TEXT,
uebergeben_am TEXT
);
CREATE INDEX IF NOT EXISTS idx_leads_scout ON leads (scout_id, status);
CREATE TABLE IF NOT EXISTS versuche (
ip TEXT NOT NULL,
zeitpunkt TEXT NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_versuche_ip ON versuche (ip, zeitpunkt);
CREATE INDEX IF NOT EXISTS idx_sitzungen_gueltig ON sitzungen (gueltig_bis);
`);
umstellungen(d);
_db = d;
return d;
} catch (fehler) {
_dbFehler = fehler;
console.error("[workspace] Datenbank nicht verfügbar:", fehler?.message);
throw fehler;
}
}
/* ---------- Hilfsmittel ---------------------------------------------- */
const jetzt = () => new Date().toISOString();
/* Die echte Besucher-IP.
Warum das nötig ist: Die Kette lautet Besucher -> Cloudflare -> Caddy ->
Express. `trust proxy` steht auf 1, Express nimmt daher den letzten
Eintrag aus X-Forwarded-For -- und das ist die Adresse von Cloudflare,
nicht die des Besuchers. Folge: Im Protokoll stand bei jedem dieselbe
IP, und viel schlimmer -- ALLE Nutzer teilten sich einen einzigen
Sperr-Zähler. Ein paar Tippfehler von drei Leuten hätten den Zugang für
alle gesperrt.
Cloudflare setzt die echte Adresse in CF-Connecting-IP und überschreibt
den Kopf bei jeder Anfrage, ein Besucher kann ihn also nicht fälschen.
Einschränkung: Wer die Adresse des Servers kennt und ihn unter Umgehung
von Cloudflare direkt anspricht, könnte den Kopf frei setzen. Deshalb
wird auf die Peer-Adresse zurückgefallen, sobald der Kopf fehlt -- und
beide Werte landen im Protokoll, damit so etwas auffällt. */
export function echteIp(req) {
const cf = req.get("cf-connecting-ip");
if (cf && cf.length <= 45) return cf.trim();
return req.ip || "?";
}
function hashe(code, salt, N) {
return scryptSync(code, salt, SCRYPT.keylen,
{ N, r: SCRYPT.r, p: SCRYPT.p, maxmem: 256 * 1024 * 1024 }).toString("hex");
}
/* Vergleich in konstanter Zeit. Ein normaler ===-Vergleich bricht beim
ersten abweichenden Zeichen ab; aus den Laufzeitunterschieden lässt sich
ein Geheimnis Zeichen für Zeichen erraten. */
function gleich(a, b) {
const x = Buffer.from(String(a), "utf8");
const y = Buffer.from(String(b), "utf8");
if (x.length !== y.length) return false;
return timingSafeEqual(x, y);
}
const tokenHash = (t) => createHash("sha256").update(t).digest("hex");
export function protokolliere(aktion, { personId = null, rolle = null, detail = null, ip = null } = {}) {
try {
db().prepare(
"INSERT INTO protokoll (zeitpunkt, person_id, rolle, aktion, detail, ip) VALUES (?,?,?,?,?,?)"
).run(jetzt(), personId, rolle, aktion, detail, ip);
} catch { /* Ein fehlgeschlagener Protokolleintrag darf nichts blockieren. */ }
}
function zuVieleVersuche(ip) {
const grenze = new Date(Date.now() - VERSUCHE_FENSTER_MIN * 60_000).toISOString();
db().prepare("DELETE FROM versuche WHERE zeitpunkt < ?").run(grenze);
const { anzahl } = db()
.prepare("SELECT COUNT(*) AS anzahl FROM versuche WHERE ip = ? AND zeitpunkt >= ?")
.get(ip, grenze);
return anzahl >= VERSUCHE_MAX;
}
/* ---------- Sitzung ---------------------------------------------------- */
export function sitzungLesen(req) {
const token = req.cookies?.[COOKIE];
if (!token) return null;
try {
const reihe = db().prepare(`
SELECT s.gueltig_bis, p.id, p.name, p.rolle, p.aktiv
FROM sitzungen s JOIN personen p ON p.id = s.person_id
WHERE s.token_hash = ?`).get(tokenHash(token));
if (!reihe || !reihe.aktiv) return null;
if (reihe.gueltig_bis < jetzt()) {
db().prepare("DELETE FROM sitzungen WHERE token_hash = ?").run(tokenHash(token));
return null;
}
return { id: reihe.id, name: reihe.name, rolle: reihe.rolle };
} catch { return null; }
}
function sitzungSetzen(res, person, req) {
const token = randomBytes(32).toString("hex");
const bis = new Date(Date.now() + SITZUNG_STUNDEN * 3600_000).toISOString();
db().prepare(
"INSERT INTO sitzungen (token_hash, person_id, erstellt, gueltig_bis, ip, browser) VALUES (?,?,?,?,?,?)"
).run(tokenHash(token), person.id, jetzt(), bis, echteIp(req),
(req.get("user-agent") || "").slice(0, 200));
res.cookie(COOKIE, token, {
httpOnly: true, // kein Zugriff aus JavaScript -> kein Diebstahl per XSS
/* Live immer true: Hinter Caddy ist req.secure dank `trust proxy` das
Ergebnis von X-Forwarded-Proto. Nur beim lokalen Test über http
fällt es weg -- sonst wäre der Ablauf gar nicht prüfbar, weil
Browser und curl ein Secure-Cookie über http verwerfen. */
secure: req.secure,
sameSite: "lax", // schützt vor fremden Formularen (CSRF)
path: "/workspace", // gilt nur hier, nicht auf der ganzen Domain
maxAge: SITZUNG_STUNDEN * 3600_000,
});
}
/* ---------- Router ----------------------------------------------------- */
export const workspaceRouter = express.Router();
/* Schutz der angemeldeten Seiten. Serverseitig, nicht nur im Browser --
sonst könnte man die Seite einfach direkt aufrufen. */
/* Pfad -> erlaubte Rollen. `null` heisst: jede angemeldete Rolle.
Die Rolle wird hier mitgeprueft und nicht nur in der Schnittstelle.
Sonst bekommt z. B. ein Creator die Verwaltungsseite zwar ausgeliefert
(HTTP 200) und sieht ihr Geruest, auch wenn sie danach leer bleibt und
das Skript ihn wegschickt. Sichtbar sein soll sie gar nicht. */
const GESCHUETZT = {
"/workspace/start.html": null,
"/workspace/uebersicht.html": null,
"/workspace/content.html": null,
"/workspace/aufgaben.html": null,
/* Chat (06.09.2026): jede Rolle. WEN man erreicht, entscheidet
schreibbareIds() -- nicht der Zugang zur Seite. Ein Creator, der
die Seite nicht öffnen dürfte, könnte auch nicht antworten, und
genau das Antworten ist der Zweck. */
"/workspace/chat.html": null,
/* 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"],
/* 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/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/calls.html": null,
"/workspace/startcheck.html": null,
"/workspace/wissen.html": null,
/* Ebenfalls nur DogFather: Was von allein passiert, bestimmt, was
allen anderen zugeschoben wird. */
"/workspace/automation.html": ["admin"],
};
/* Die einzige Seite unter /workspace, die offen sein MUSS -- man kann
sich schlecht anmelden, wenn die Anmeldeseite eine Anmeldung verlangt. */
const OFFEN = new Set(["/workspace/index.html"]);
/** Der Pfad, wie ihn die Schranke ansehen muss.
*
* BEFUND VOM 04.09.2026 (Audit). Der erste Entwurf verglich `req.path`
* direkt gegen die Liste -- ein EXAKTER Vergleich. Express raeumt
* Punkt-Segmente selbst weg (`/workspace/./start.html` und
* `/workspace/x/../start.html` kommen bereits zurechtgerueckt an), aber
* MEHRFACHE SCHRAEGSTRICHE nicht. Gemessen:
*
* /workspace/start.html -> req.path /workspace/start.html
* //workspace/start.html -> req.path //workspace/start.html
* /workspace//start.html -> req.path /workspace//start.html
*
* Der Listenvergleich traf damit nicht, die Anfrage lief ungeprueft an
* express.static weiter -- und das zieht die Schraegstriche seinerseits
* zusammen und liefert die Datei aus. Ergebnis: `//workspace/start.html`
* kam OHNE ANMELDUNG mit HTTP 200, und ein Creator bekam ueber
* `/workspace//personen.html` die Verwaltungsseite. Das ist derselbe
* Befund wie am 27.08.2026 ("sichtbar sein soll sie gar nicht"), nur
* eine Schreibweise weiter -- der Fix von damals deckte genau eine ab.
*
* Nicht betroffen waren die DATEN: Bei doppeltem Schraegstrich trifft
* keine der /workspace/api-Routen mehr (404), und wo sie trifft, greift
* ihre eigene Schranke (401). Nachgemessen, nicht angenommen.
*
* Kleinschreibung kommt dazu, weil ein case-unempfindliches Dateisystem
* (Windows, macOS) `PERSONEN.HTML` an dieselbe Datei weiterreicht. Auf
* dem Linux-Server greift das nicht, hier im Test schon -- und eine
* Schranke, die nur auf einem Dateisystem haelt, ist keine.
*
* Abschliessende Punkte und Leerzeichen fallen weg: Windows oeffnet
* `start.html.` als `start.html`. */
function schrankenPfad(roh) {
return String(roh || "")
.replace(/\/{2,}/g, "/") // // -> /
.replace(/[.\s]+$/, (ende) => // abschliessende Punkte/Leerzeichen
ende.includes(".") && !/^\.[a-z]/i.test(ende) ? "" : ende)
.toLowerCase();
}
/* GESCHUETZT IST JETZT DIE AUSNAHME, NICHT DIE REGEL.
Vorher galt: Was in der Liste steht, ist geschuetzt -- alles andere
nicht. Wer eine neue Seite anlegt und den Eintrag vergisst, stellt sie
damit offen ins Netz, ohne dass irgendwo etwas rot wird. Genau diese
Sorte Fehler faellt erst auf, wenn jemand danach sucht.
Jetzt gilt: JEDE .html unter /workspace ist geschuetzt, ausser den
ausdruecklich offenen. Die Liste oben sagt nur noch, WELCHE ROLLEN
zusaetzlich eingeschraenkt sind. Eine neue Seite ist dadurch von
selbst mindestens "nur fuer Angemeldete" -- der sichere Rueckfall. */
workspaceRouter.use((req, res, next) => {
const pfad = schrankenPfad(req.path);
if (!pfad.startsWith("/workspace/") || !pfad.endsWith(".html")) return next();
if (OFFEN.has(pfad)) return next();
const person = sitzungLesen(req);
if (!person) return res.redirect(302, "/workspace/");
const erlaubt = GESCHUETZT[pfad];
if (erlaubt && !erlaubt.includes(person.rolle)) {
return res.redirect(302, "/workspace/start.html");
}
return next();
});
workspaceRouter.post("/workspace/api/anmelden", (req, res) => {
const ip = echteIp(req);
try {
if (zuVieleVersuche(ip)) {
protokolliere("anmeldung_gesperrt", { ip });
return res.status(429).json({ fehler: "zu_viele_versuche" });
}
const rolle = String(req.body?.rolle || "");
const code = String(req.body?.code || "");
if (!ROLLEN.has(rolle) || code.length < 4 || code.length > 200) {
db().prepare("INSERT INTO versuche (ip, zeitpunkt) VALUES (?,?)").run(ip, jetzt());
return res.status(401).json({ fehler: "ungueltig" });
}
/* Alle aktiven Personen dieser Rolle durchgehen. Es gibt bewusst KEIN
Benutzerfeld: Der Code allein identifiziert die Person. Bei der
erwarteten Größenordnung (eine Handvoll Personen je Rolle) ist das
unkritisch; ab etwa 50 Codes je Rolle sollte hier ein Präfix im Code
die Vorauswahl übernehmen, sonst wird die Anmeldung spürbar langsam. */
const kandidaten = db()
.prepare("SELECT id, name, rolle, code_hash, code_salt, code_n FROM personen WHERE rolle = ? AND aktiv = 1")
.all(rolle);
/* EIN KAPUTTER DATENSATZ DARF NICHT ALLE AUSSPERREN (05.09.2026).
Hier stand die Schleife ohne Absicherung. Wirft `hashe()` bei
EINER Person -- etwa weil ihr code_n keine Zweierpotenz ist und
scrypt "Invalid scrypt params" meldet --, dann flog die ganze
Anmeldung in den catch am Ende: 503 "nicht_verfuegbar", für
JEDEN mit dieser Rolle, auch für die, deren Daten in Ordnung
sind.
Aufgefallen ist es beim Bau der Abbruchpruefung, wo ich zum
Testen versehentlich eine Person mit code_n = 1 angelegt hatte.
Ab da kam kein einziger DogFather mehr herein -- und die Meldung
sagte "nicht verfügbar", nicht "ein Datensatz ist defekt".
Danach hätte man lange gesucht.
Ein Datenfehler bei einer Person ist jetzt ein Problem DIESER
Person: Sie wird übersprungen, der Rest der Anmeldung läuft
normal weiter. Und sie wird laut protokolliert, denn sie kann
sich selbst nicht mehr anmelden -- das muss auffallen. */
let gefunden = null;
for (const k of kandidaten) {
try {
if (gleich(hashe(code, k.code_salt, k.code_n), k.code_hash)) { gefunden = k; break; }
} catch (f) {
console.error(`[workspace] Zugangsdaten von Person #${k.id} (${k.rolle}) sind defekt `
+ `-- sie kann sich nicht anmelden. Grund: ${f?.message}`);
protokolliere("zugangsdaten_defekt", {
personId: k.id, rolle: k.rolle, ip,
detail: String(f?.message || "").slice(0, 80),
});
}
}
if (!gefunden) {
db().prepare("INSERT INTO versuche (ip, zeitpunkt) VALUES (?,?)").run(ip, jetzt());
protokolliere("anmeldung_fehlgeschlagen", { rolle, ip });
/* Bewusst dieselbe Antwort wie bei falscher Rolle: Wer raten will,
soll nicht erfahren, ob wenigstens die Rolle gestimmt hat. */
return res.status(401).json({ fehler: "ungueltig" });
}
db().prepare("DELETE FROM versuche WHERE ip = ?").run(ip);
db().prepare("UPDATE personen SET letzter_login = ? WHERE id = ?").run(jetzt(), gefunden.id);
sitzungSetzen(res, gefunden, req);
protokolliere("anmeldung", { personId: gefunden.id, rolle, ip, detail: gefunden.name });
return res.json({ weiter: "/workspace/start.html", name: gefunden.name, rolle });
} catch (fehler) {
console.error("[workspace] Anmeldung fehlgeschlagen:", fehler?.message);
return res.status(503).json({ fehler: "nicht_verfuegbar" });
}
});
workspaceRouter.post("/workspace/api/abmelden", (req, res) => {
try {
const token = req.cookies?.[COOKIE];
if (token) {
const person = sitzungLesen(req);
db().prepare("DELETE FROM sitzungen WHERE token_hash = ?").run(tokenHash(token));
if (person) protokolliere("abmeldung", { personId: person.id, rolle: person.rolle, ip: echteIp(req) });
}
} catch { /* Abmelden darf nie scheitern. */ }
res.clearCookie(COOKIE, { path: "/workspace" });
res.json({ ok: true });
});
workspaceRouter.get("/workspace/api/ich", (req, res) => {
const person = sitzungLesen(req);
if (!person) return res.status(401).json({ fehler: "nicht_angemeldet" });
/* Die eigene Nummer gehört mit dazu: Ohne sie kann die Oberfläche nicht
erkennen, welcher Eintrag der eigene ist (etwa "das bin ich" in der
Personenliste), und das eigene Profil liesse sich gar nicht aufrufen.
Ein Geheimnis ist sie nicht -- sie beschreibt nur den Angemeldeten. */
/* Das Profilbild kommt mit: Es steht in der Kopfleiste JEDER Seite und
in der Begrüßung. Es hier mitzuliefern spart auf jeder Seite eine
zweite Abfrage -- und verhindert, dass die Plakette erst als
Buchstabe erscheint und einen Wimpernschlag später zum Bild
umspringt. */
let bild = null;
try {
const z = db().prepare("SELECT bild FROM personen WHERE id = ?").get(person.id);
if (z?.bild) bild = `/workspace/api/steckbrief/bild/${z.bild}`;
} catch { /* ohne Bild ist die Anmeldung trotzdem gültig */ }
/* WESSEN ARBEITSPLATZ WIRD GERADE ANGESEHEN (02.09.2026).
Ohne diese Angabe war der Umschalter nur halb gebaut: Die LISTEN
folgten der gewaehlten Sicht, die Seite drumherum nicht. DogFather
waehlte einen Creator -- und sah weiterhin seine eigene Begruessung,
seine Rolle und seine Kacheln. Gemeldet mit den Worten "ich seh
immer noch die Seite genau wie meine", und das stimmte.
`sicht` steht NEBEN den eigenen Angaben, nicht an ihrer Stelle. Die
Oberflaeche braucht beides: WER BIN ICH (fuer die Kopfleiste, fuer
"das bin ich" in Listen, fuer alles, was schreibt) und WESSEN
ARBEITSPLATZ SEHE ICH (fuer das, was gezeigt wird). Die beiden zu
vermischen waere der sichere Weg dazu, dass irgendwann etwas unter
fremdem Namen gespeichert wird.
Null, solange die eigene Sicht laeuft -- dann gibt es nichts zu
unterscheiden.
sichtPerson() wird hier direkt gerufen und nicht ueber req.sicht:
Die Middleware haengt erst NACH diesem Router (siehe index.js). */
const angesehen = sichtPerson(req);
const sicht = angesehen && angesehen.id !== person.id
? {
id: angesehen.id,
name: angesehen.name,
rolle: angesehen.rolle,
rolle_name: ROLLEN_NAME[angesehen.rolle] ?? angesehen.rolle,
}
: null;
res.json({
id: person.id, name: person.name, rolle: person.rolle,
rolle_name: ROLLEN_NAME[person.rolle] ?? person.rolle,
sicht,
bild,
});
});
/* ---------- Verwaltung (nur über die Kommandozeile) --------------------
Wird von workspace-code.js benutzt. Bewusst nicht über das Netz
erreichbar: Codes werden auf dem Server erzeugt, einmal angezeigt und
nie gespeichert -- weder in Git noch in einer Notiz. */
/* Alphabet ohne 0/O und 1/I/l: Diese Codes werden abgetippt und
weitergegeben, Verwechslungen kosten sonst unnötig Nerven. */
const ALPHABET = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789";
export function codeErzeugen(gruppen = 4, laenge = 4) {
const roh = randomBytes(gruppen * laenge);
let aus = "";
for (let i = 0; i < gruppen * laenge; i++) {
if (i && i % laenge === 0) aus += "-";
aus += ALPHABET[roh[i] % ALPHABET.length];
}
return aus;
}
/* `akteur` ist WER die Aktion ausloest -- nicht, wen sie betrifft. Das
muss getrennt bleiben: Stand im Protokoll die neu angelegte Person als
person_id, las sich der Eintrag so, als haette sie sich selbst angelegt.
Wer betroffen ist, steht im Text. Ohne Akteur (Kommandozeile) bleibt
das Feld leer. */
/* =====================================================================
Betreuung — wer darf sich um welchen Creator kuemmern.
Scouts betreuen Creator wie das Management, aber nur die ihnen
zugeteilten. Diese Regel steht bewusst NUR hier: Sie wird von sechs
Modulen gebraucht (Profile, Bereiche, Reports, Aufgaben, Kalender,
Dateien), und eine Rechteregel, die an sechs Stellen steht, ist eine
Rechteregel, die irgendwann an fuenf Stellen stimmt.
===================================================================== */
/** Welcher Kalendertag ist heute -- nach der ORTSZEIT, nicht nach UTC.
*
* Steht hier und nicht in jedem Modul: Sie wurde an vier Stellen
* gebraucht und an dreien falsch gerechnet (toISOString liefert UTC).
* Server und Benutzer stehen beide auf Europe/Berlin; zwischen
* Mitternacht und 2 Uhr lieferte die UTC-Rechnung den Vortag.
*
* NICHT verwenden, um mit Datumsangaben zu RECHNEN -- dafuer bleibt es
* bei UTC-Mittag (siehe kalender.js): Wer mit lokalen Zeiten rechnet,
* verliert bei der Zeitumstellung einen Tag. */
export function heuteLokal() {
const d = new Date();
const p = (n) => String(n).padStart(2, "0");
return `${d.getFullYear()}-${p(d.getMonth() + 1)}-${p(d.getDate())}`;
}
/** Derselbe Tag, um n Tage verschoben. */
export function tagLokal(versatz) {
const d = new Date(Date.now() + versatz * 86400_000);
const p = (n) => String(n).padStart(2, "0");
return `${d.getFullYear()}-${p(d.getMonth() + 1)}-${p(d.getDate())}`;
}
/* Ids der Creator, die diese Person betreut.
GILT SEIT DEM 01.09.2026 AUCH FUER MANAGER, nicht mehr nur fuer
Scouts. Vorher stand hier "das Management sieht ohnehin alles" -- und
genau das soll nicht mehr sein:
"NUR DIE ROLLE DOGFATHER SOLL WIRKLICH WEITERHIN ALLEINE ALLES
SEHEN KOENNEN. DIE ANDEREN SOLLEN NUR IHRE ZUGETEILTEN AUFGABEN
VON IHREN CREATOR SEHEN."
Die Datenbank konnte das laengst: In `betreuung` steht eine beliebige
Person als Betreuer, auch ein Manager oder DogFather. Nur diese
Funktion hat alle ausser Scouts abgewiesen -- ein Manager bekam
deshalb eine leere Liste und faellt in den Regeln unten auf "sieht
alles" oder (beim Aufgabenbrett) auf "sieht nichts" zurueck.
Ein Creator bleibt aussen vor: Er betreut niemanden, er wird betreut. */
export function betreuteIds(person) {
if (!person || (person.rolle !== "scout" && person.rolle !== "manager")) return [];
try {
const eigene = db().prepare("SELECT creator_id FROM betreuung WHERE betreuer_id = ?")
.all(person.id).map((z) => z.creator_id);
if (person.rolle !== "manager") return eigene;
/* DIE KETTE: Manager -> seine Scouts -> deren Creator (01.09.2026).
Entschieden auf die Frage "sieht er dann auch die Creator dieser
Scouts?" -- "ja, alles seiner Scouts". Das ist die uebliche
Ordnung: Wer einen Scout fuehrt, muss sehen, woran der arbeitet.
Ohne das muesste jeder Creator einem Manager EINZELN zugewiesen
werden, und beim ersten vergessenen faende er ein Loch in seiner
Uebersicht, ohne zu merken, dass es eines ist.
Ein Set, weil ein Creator auf beiden Wegen kommen kann: direkt
zugeteilt UND ueber seinen Scout. Doppelte Nummern wuerden in den
IN-Listen zu doppelten Fragezeichen -- fachlich harmlos, aber
jede Abfrage unnoetig laenger. */
const scouts = scoutsVon(person.id);
if (!scouts.length) return eigene;
const ueberScouts = db().prepare(
`SELECT creator_id FROM betreuung
WHERE betreuer_id IN (${scouts.map(() => "?").join(",")})`)
.all(...scouts).map((z) => z.creator_id);
return [...new Set([...eigene, ...ueberScouts])];
} catch {
return []; // im Zweifel nichts sehen, nie mehr
}
}
/** Die Scouts, die diesem Manager zugeteilt sind. Fuer alle anderen
* leer -- ein Scout fuehrt keine Scouts, ein Creator schon gar nicht.
* DogFather braucht sie nicht: Er sieht ohnehin alles. */
export function scoutsVon(managerId) {
if (!managerId) return [];
try {
return db().prepare("SELECT scout_id FROM scout_zuteilung WHERE manager_id = ?")
.all(managerId).map((z) => z.scout_id);
} catch {
return [];
}
}
/** Wessen Leads darf diese Person sehen? Gibt die Personennummern
* zurueck, deren Pipeline sichtbar ist -- der eigene immer dabei.
* Ein Scout sieht nur sich, ein Manager sich und seine Scouts. */
export function pipelineIds(person) {
if (!person) return [];
if (person.rolle !== "manager") return [person.id];
return [...new Set([person.id, ...scoutsVon(person.id)])];
}
/** Zuteilung setzen oder loesen (null loest sie).
* Wer das DARF, entscheidet die Route -- hier steht nur, WIE. */
export function scoutZuteilungSetzen(scoutId, managerId, akteur = null) {
const d = db();
if (managerId === null) {
d.prepare("DELETE FROM scout_zuteilung WHERE scout_id = ?").run(scoutId);
return;
}
d.prepare(`
INSERT INTO scout_zuteilung (scout_id, manager_id, seit, gesetzt_von)
VALUES (?,?,?,?)
ON CONFLICT(scout_id) DO UPDATE SET
manager_id = excluded.manager_id,
seit = excluded.seit,
gesetzt_von = excluded.gesetzt_von`)
.run(scoutId, managerId, new Date().toISOString(), akteur);
}
/* =====================================================================
WEN DARF DIESE PERSON ÜBERHAUPT SEHEN? (03.09.2026)
Wunsch: "jeder manager soll auch immer nur seine und die seiner
scouts zugeteilten creator und creator daten sehen. und nicht die der
anderen."
Die Kette Manager -> Scout -> Creator gab es schon (betreuteIds). Was
fehlte, war ihre ANWENDUNG: Über zwanzig Stellen prüften die ROLLE
statt der ZUTEILUNG -- "ist Leitung? dann alles". Eine Lecksuche über
alle Leseschnittstellen (server/pruef-manager-sicht.mjs) fand am
03.09.2026 dreizehn davon: der Kalender zeigte fremde Fristen, die
Personenlisten fremde Namen, Report, Steckbrief, Profil, Start-Check,
Schulung und die Suche jeweils alles.
Deshalb stehen die beiden Antworten jetzt HIER, an einer Stelle, und
werden überall geholt statt jedes Mal neu formuliert.
RÜCKGABE null HEISST "ALLE" -- und zwar nur für DogFather. Das ist
bewusst kein leeres Feld: Eine leere Liste bedeutet "niemand", und
die Verwechslung der beiden ist genau der Fehler, der aus einer
Sperre eine Freigabe macht. Wer null bekommt, lässt die Einschränkung
ganz weg; wer ein Feld bekommt, schränkt darauf ein -- auch wenn es
leer ist.
===================================================================== */
/** Die Creator, deren Daten diese Person sehen darf.
* null = alle (nur DogFather). */
export function sichtbareCreatorIds(person) {
if (!person) return [];
if (istDogFather(person)) return null;
if (person.rolle === "creator") return [person.id];
return betreuteIds(person); // Manager: eigene + die seiner Scouts
}
/** Die PERSONEN, die in Listen und Auswahlfeldern auftauchen dürfen --
* Namen sind auch Daten. null = alle (nur DogFather).
*
* Enthält immer die Person selbst: Wer sich in einer Auswahl nicht
* findet, kann sich nichts selbst zuweisen. Bei einem Manager kommen
* 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;
const creator = sichtbareCreatorIds(person) || [];
const scouts = person.rolle === "manager" ? scoutsVon(person.id) : [];
return [...new Set([person.id, ...creator, ...scouts])];
}
/** WER BETREUT MICH -- die Blickrichtung nach oben.
*
* betreuteIds() fragt "wen führe ich?". Diese Funktion fragt das
* Gegenteil: "wer führt mich?" -- also der eigene Scout, dessen
* Manager, und bei einem Scout sein Manager.
*
* Es gab die Richtung bisher nicht, weil sie für Sichtbarkeit nie
* gebraucht wurde: Ein Creator soll die Daten seines Scouts nicht
* sehen. Für eine EINLADUNG ist die Frage aber eine andere -- siehe
* einladbareIds(). */
export function betreuerIds(person) {
if (!person) return [];
try {
const d = db();
if (person.rolle === "creator") {
const betreuer = d.prepare("SELECT betreuer_id FROM betreuung WHERE creator_id = ?")
.all(person.id).map((z) => z.betreuer_id);
if (!betreuer.length) return [];
/* Über den Scout auch dessen Manager: Wer mit seinem Scout einen
Call hat, hat ihn oft mit dessen Manager zusammen. */
const manager = d.prepare(
`SELECT manager_id FROM scout_zuteilung
WHERE scout_id IN (${betreuer.map(() => "?").join(",")})`)
.all(...betreuer).map((z) => z.manager_id);
return [...new Set([...betreuer, ...manager])];
}
if (person.rolle === "scout") {
return d.prepare("SELECT manager_id FROM scout_zuteilung WHERE scout_id = ?")
.all(person.id).map((z) => z.manager_id);
}
return [];
} catch {
return []; // im Zweifel niemand -- nie mehr, als sicher ist
}
}
/** WEN DARF ICH ZU EINEM TERMIN DAZUSTELLEN?
*
* Eingeführt am 05.09.2026 auf den Wunsch, die Teilnehmerwahl solle
* *"für jeden verfügbar sein"*.
*
* WARUM NICHT EINFACH sichtbarePersonenIds: Die Frage ist eine
* andere. Sichtbarkeit heißt "wessen Daten darf ich sehen" und zeigt
* bewusst nach unten -- ein Creator sieht dort nur sich selbst.
* Einladen heißt "mit wem arbeite ich zusammen" und geht in BEIDE
* Richtungen: Ein Creator lädt seinen Scout ein, ein Scout seinen
* Manager.
*
* Und warum nicht die eine Funktion erweitern: sichtbarePersonenIds
* hängt auch an der AUFGABENZUWEISUNG. Wer dort einen Betreuer
* hinzufügt, gibt einem Creator nebenbei die Möglichkeit, seinem
* Scout Aufgaben zu erteilen. Zwei Fragen, zwei Funktionen.
*
* Was ein Einladen bewirkt, ist eng: Die Person sieht diesen einen
* Termin in ihrem Kalender. Keine Daten, keine Rechte, kein Zugriff.
* Deshalb ist die weitere Menge hier vertretbar.
*
* null = alle (nur DogFather), wie überall in dieser Datei. */
export function einladbareIds(person) {
if (!person) return [];
if (istDogFather(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
Fall, der auffällt. */
const chefs = db().prepare("SELECT id FROM personen WHERE rolle = 'admin' AND aktiv = 1")
.all().map((z) => z.id);
return [...new Set([...sichtbar, ...betreuerIds(person), ...chefs])];
}
/** MIT WEM DARF ICH SCHREIBEN? (06.09.2026, für den Chat)
*
* Entschieden von Filipe: entlang der Betreuung -- PLUS Manager und
* Scouts untereinander, auch ohne gemeinsamen Creator, "für Absprachen
* im Team".
*
* WARUM NICHT einladbareIds() WIEDERVERWENDEN, obwohl es fast passt:
* Dort geht es darum, wen man zu einem TERMIN dazustellen darf. Das
* ist harmlos -- die Person sieht einen Eintrag im Kalender. Ein Chat
* ist etwas anderes: Er legt ein dauerhaftes Gespräch an, schickt eine
* Benachrichtigung und macht den eigenen Namen sichtbar. Zwei Fragen,
* zwei Funktionen -- sonst verschiebt eine spätere Änderung an der
* einen stillschweigend die andere.
*
* WAS SICH DADURCH ÄNDERT gegenüber der Terminwahl: Ein Manager
* erreicht jetzt auch Scouts, die nicht seine sind, und umgekehrt.
* Ein Creator NICHT -- er bleibt bei seinen Betreuern und DogFather.
* Creator untereinander bleiben getrennt; sie sollen die Namen der
* anderen Creator weiterhin nicht sehen.
*
* null = alle (nur DogFather). */
export function schreibbareIds(person) {
if (!person) return [];
if (istDogFather(person)) return null;
const basis = new Set(einladbareIds(person) || []);
/* Das Team untereinander. Ein Scout, der eine Frage zu einem
fremden Creator hat, muss den zuständigen Manager erreichen können
-- sonst läuft alles über DogFather, und das ist genau der
Flaschenhals, den ein Chat auflösen soll. */
if (person.rolle === "manager" || person.rolle === "scout") {
try {
for (const z of db().prepare(
"SELECT id FROM personen WHERE rolle IN ('manager','scout') AND aktiv = 1").all()) {
basis.add(z.id);
}
} catch { /* im Zweifel nur die Betreuungskette */ }
}
/* Sich selbst nicht -- ein Gespräch mit sich allein ist keines. */
basis.delete(person.id);
return [...basis];
}
/** Darf diese Person mit jener schreiben? Eine Frage, eine Antwort --
* damit die Regel nicht an fünf Stellen einzeln nachgebaut wird. */
export function darfSchreibenMit(person, andereId) {
const ids = schreibbareIds(person);
if (ids === null) return Number(andereId) !== Number(person.id);
return ids.includes(Number(andereId));
}
/** SQL-Baustein daraus: "diese Spalte ist eine Person, die ich sehen
* darf". Gibt null zurück, wenn nicht eingeschränkt werden muss. */
export function personenWo(person, spalte) {
const ids = sichtbarePersonenIds(person);
if (ids === null) return null;
if (!ids.length) return { wo: "0=1", werte: [] };
return { wo: `${spalte} IN (${ids.map(() => "?").join(",")})`, werte: ids };
}
/* SQL-Baustein "diese Spalte gehoert zu einem meiner Creator".
Gibt null zurueck, wenn es nichts zu ergaenzen gibt -- eine leere
IN-Liste waere ungueltiges SQL. */
export function betreutWo(person, spalte) {
const ids = betreuteIds(person);
if (!ids.length) return null;
return { wo: `${spalte} IN (${ids.map(() => "?").join(",")})`, werte: ids };
}
/* =====================================================================
FREI EINGETRAGENE NAMEN (02.09.2026)
Ein Feld wie "Mit wem" oder "Creator" nimmt entweder eine Person aus
dem Workspace ODER einen frei getippten Namen. Beides zugleich waere
eine Aussage, die niemand aufloesen kann.
WAS EIN FREIER NAME NICHT KANN, und warum das hier steht:
Er ist eine Beschriftung, kein Konto. Er sieht nichts, bekommt nichts
angezeigt, hat kein Aufgabenbrett. Bei "Creator" heisst das: Der
Eintrag gehoert zu niemandem im System und taucht deshalb in keiner
personenbezogenen Auswertung auf. Filipe hat das am 02.09.2026
ausdruecklich so gewaehlt, nachdem der Nachteil benannt war; das
Formular sagt es an der Stelle noch einmal.
Damit daraus kein STILLER Ausfall wird, ist die Gegenmassnahme
eingebaut: externSql. Jede Abfrage, die bisher den Namen der
verknuepften Person las, faellt jetzt auf den freien Text zurueck.
Der Eintrag verschwindet dadurch aus keiner Liste, keiner Suche und
keiner Uebersicht -- er ist nur eben als "(extern)" gekennzeichnet.
===================================================================== */
export const EXTERN_MAX = 80;
/** Prueft einen frei eingetragenen Namen und schreibt ihn nach `aus`.
* `feld` ist die Spalte ohne Endung, also "creator" oder "teilnehmer".
*
* Ist eine Person gewaehlt, wird der freie Name verworfen statt
* abgelehnt: Wer erst jemanden auswaehlt und dann tippt, meint das
* Getippte nicht mehr -- eine Fehlermeldung waere hier nur im Weg. */
export function externPruefen(körper, aus, feld, fehler) {
const schluessel = `${feld}_extern`;
if (körper[schluessel] === undefined) return;
const t = String(körper[schluessel] ?? "").trim();
if (!t) { aus[schluessel] = null; return; }
if (t.length > EXTERN_MAX) { fehler.push("Der eingetragene Name ist zu lang."); return; }
/* Steuerzeichen raus. Sie waeren unsichtbar und koennten eine Zeile
in Listen und Ausgaben zerreissen. */
aus[schluessel] = t.replace(/[\u0000-\u001f\u007f]/g, "");
aus[`${feld}_id`] = null;
}
/** SQL-Baustein: der Name der Person -- und wenn keine verknuepft ist,
* der frei eingetragene Text, gekennzeichnet.
*
* In SQLite ergibt NULL || 'x' wieder NULL. Ist die Spalte leer, faellt
* COALESCE deshalb korrekt auf NULL durch und nicht auf " (extern)". */
export const externSql = (personSpalte, externSpalte) =>
`COALESCE(${personSpalte}, ${externSpalte} || ' (extern)')`;
/* Wer sieht welchen Termin -- und damit auch: welche Wiederholung.
NUR DogFather sieht alles (01.09.2026). Vorher stand hier istLeitung()
-- damit sah auch jeder Manager jeden Creator. Ein Manager faellt
jetzt in dieselbe Regel wie ein Scout: nur die Creator, die ihm
zugeteilt sind. Ausdruecklicher Wunsch: "NUR DIE ROLLE DOGFATHER SOLL
WIRKLICH ALLEINE ALLES SEHEN."
Geaendert wird ausschliesslich, wer was SIEHT. Was ein Manager DARF
(freigeben, aendern, Personen verwalten), haengt weiterhin an
istLeitung und bleibt unveraendert -- sonst haette dieser eine Wunsch
stillschweigend seine halben Rechte mitgenommen.
Der Praefix ist der Tabellenname im jeweiligen SQL: "t" fuer termine,
"s" fuer termin_serien. Diese Regel steht bewusst nur EINMAL: Eine
zweite, fast gleiche Fassung fuer die Serien waere frueher oder
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: [] };
const p = praefix;
/* MITGEZAEHLT WIRD AUCH DIE TEILNEHMERLISTE (05.09.2026).
Seit ein Termin mehrere Teilnehmer haben kann, reicht
`teilnehmer_id` nicht mehr: Das ist nur das Haupt-Gegenueber. Wer
als zweiter oder dritter dabei ist, steht in termin_teilnehmer --
und ohne diese Zeile saehe er den Termin nicht, an dem er
teilnimmt.
Die beiden Aufrufer arbeiten auf verschiedenen Tabellen: der
Kalender auf `termine` (praefix t), die Wiederholungen auf
`termin_serien` (praefix s). Deshalb wird hier die passende
Nebentabelle gewaehlt statt einer festen -- eine falsche Zuordnung
waere kein Fehler, den man sieht, sondern eine Liste, in der
jemandem etwas fehlt. */
const nebenTabelle = p === "s"
? { tabelle: "serie_teilnehmer", spalte: "serie_id" }
: { tabelle: "termin_teilnehmer", spalte: "termin_id" };
const dabei = `EXISTS (SELECT 1 FROM ${nebenTabelle.tabelle} tn`
+ ` WHERE tn.${nebenTabelle.spalte} = ${p}.id AND tn.person_id = ?)`;
const eigen = `(${p}.creator_id = ? OR ${p}.teilnehmer_id = ? OR ${p}.erstellt_von = ?`
+ ` OR ${dabei})`;
const werte = [person.id, person.id, person.id, person.id];
const b = betreutWo(person, `${p}.creator_id`);
if (!b) return { wo: eigen, werte };
return { wo: `(${eigen} OR ${b.wo})`, werte: [...werte, ...b.werte] };
}
/* =====================================================================
DIE SICHT EINES ANDEREN — nur fuer DogFather.
Wunsch vom 01.09.2026: "ich will, dass ich alles von den Scouts und
Managern einsehen kann, dass ich auswaehlen kann, was und von wem ich
sehen will. Und das soll ich als DogFather-Rolle ueberall haben, auf
jeder Seite. NUR WIR BEIDE SOLLEN DIESE OPTION HABEN."
WIE ES GEBAUT IST, und warum genau so.
Die naheliegende Loesung waere ein neuer Filter je Seite gewesen:
"zeige mir Aufgaben von Person X". Das haette bedeutet, in zwoelf
Modulen eine zweite Rechteregel zu schreiben -- und eine Rechteregel,
die an zwoelf Stellen steht, stimmt irgendwann an elf.
Stattdessen wird die PERSON ausgetauscht, nicht die Regel: Waehlt
DogFather einen Scout, laeuft jede vorhandene sichtbar()-Funktion
unveraendert mit diesem Scout als Person. Er sieht dann exakt das,
was der Scout sieht -- nicht mehr und nicht weniger. Keine einzige
neue Rechteregel, und was heute richtig ist, bleibt es auch, wenn
sich eine Regel spaeter aendert.
DREI SICHERUNGEN, die nicht verhandelbar sind:
1. NUR ADMIN. Die Rolle wird HIER geprueft, nicht in der Oberflaeche.
Wer kein DogFather ist, kann den Wert anhaengen, so oft er will --
er wird stillschweigend ignoriert. Ein Manager duerfte sonst durch
Anhaengen von ?sicht=<Nummer> in fremde Bereiche sehen.
2. NUR LESEN. Ersetzt wird ausschliesslich req.sicht, niemals
req.person. Alles, was schreibt oder Rechte prueft, arbeitet
weiterhin mit dem echten Angemeldeten. Sonst haette ein
Schreibvorgang plotzlich im Namen eines anderen stattgefunden --
und im Protokoll staende der falsche Name.
3. NUR AKTIVE, ECHTE PERSONEN. Eine geloeschte oder abgeschaltete
Nummer faellt auf die eigene Sicht zurueck, statt eine leere Seite
zu zeigen, die aussieht wie ein Fehler.
===================================================================== */
/** Durch wessen Augen wird gerade gesehen? Gibt IMMER eine Person
* zurueck -- im Normalfall den Angemeldeten selbst. */
export function sichtPerson(req) {
/* req.person setzt jedes Modul in seiner eigenen angemeldet()-Huerde.
Diese Funktion laeuft aber schon davor (global, siehe index.js) --
deshalb liest sie die Sitzung notfalls selbst. */
const ich = req?.person || sitzungLesen(req);
if (!ich) return null;
if (ich.rolle !== "admin") return ich;
const id = Number(req.query?.sicht || 0);
if (!Number.isInteger(id) || id <= 0 || id === ich.id) return ich;
try {
const z = db().prepare("SELECT id, name, rolle FROM personen WHERE id = ? AND aktiv = 1").get(id);
/* Nicht gefunden oder abgeschaltet: zurueck auf die eigene Sicht.
Eine leere Seite waere hier die schlechtere Antwort -- sie sieht
aus wie ein Fehler, obwohl nur die Auswahl veraltet ist. */
return z || ich;
} catch {
return ich; /* im Zweifel die eigene Sicht, nie eine fremde */
}
}
/** Middleware: setzt req.sicht. Steht danach jedem Modul zur Verfuegung.
* Module, die sie nicht benutzen, arbeiten unveraendert weiter -- req.sicht
* ist dort schlicht dasselbe wie req.person. */
export function sichtSetzen(req, res, next) {
const s = sichtPerson(req);
if (s) req.sicht = s;
next();
}
/* Darf diese Person den Bereich dieses Creators sehen und bearbeiten? */
export function darfCreator(person, creatorId) {
if (!person || !creatorId) return false;
/* NUR DogFather pauschal (03.09.2026). Hier stand istLeitung -- und
damit war jede Managerin fuer JEDEN Creator zustaendig, auch fuer
die einer fremden Managerin.
Diese eine Zeile hing an mehreren Wegen gleichzeitig: Profil,
Start-Check und die Uebersicht je Creator liessen sich damit ueber
die blosse Kenntnis einer Nummer abfragen. Man musste nichts
umgehen, es reichte, eine Zahl in die Adresse zu schreiben.
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 (person.rolle === "creator") return person.id === Number(creatorId);
return betreuteIds(person).includes(Number(creatorId));
}
/* Zustaendige Person setzen oder entfernen (null loest die Zuordnung). */
export function betreuungSetzen(creatorId, betreuerId, akteur = null) {
const d = db();
if (betreuerId === null) {
d.prepare("DELETE FROM betreuung WHERE creator_id = ?").run(creatorId);
} else {
d.prepare(`
INSERT INTO betreuung (creator_id, betreuer_id, seit, gesetzt_von)
VALUES (?,?,?,?)
ON CONFLICT(creator_id) DO UPDATE SET
betreuer_id = excluded.betreuer_id,
seit = excluded.seit,
gesetzt_von = excluded.gesetzt_von`).run(
creatorId, betreuerId, new Date().toISOString(), akteur?.id ?? null);
}
protokolliere("betreuung_gesetzt", {
personId: akteur?.id ?? null, rolle: akteur?.rolle ?? null, ip: akteur?.ip ?? null,
detail: `Creator #${creatorId} -> ${betreuerId === null ? "niemand" : "#" + betreuerId}`,
});
}
/* ---------- Einstellungen -------------------------------------------------
Bewusst mit Vorgabewert und ohne Fehlerfall: Faellt die Datenbank aus,
soll eine fehlende Einstellung nicht die Seite mitreissen. */
export function einstellung(schluessel, vorgabe = null) {
try {
return db().prepare("SELECT wert FROM einstellungen WHERE schluessel = ?")
.get(schluessel)?.wert ?? vorgabe;
} catch {
return vorgabe;
}
}
export function einstellungSetzen(schluessel, wert, akteur = null) {
db().prepare(`
INSERT INTO einstellungen (schluessel, wert, geaendert, von) VALUES (?,?,?,?)
ON CONFLICT(schluessel) DO UPDATE SET
wert = excluded.wert, geaendert = excluded.geaendert, von = excluded.von`).run(
schluessel, String(wert), new Date().toISOString(), akteur?.id ?? null);
protokolliere("einstellung_geaendert", {
personId: akteur?.id ?? null, rolle: akteur?.rolle ?? null, ip: akteur?.ip ?? null,
detail: `${schluessel} = ${wert}`.slice(0, 120),
});
}
export function personAnlegen(name, rolle, akteur = null) {
if (!ROLLEN.has(rolle)) throw new Error(`Unbekannte Rolle: ${rolle}`);
const code = codeErzeugen();
const salt = randomBytes(16).toString("hex");
const hash = hashe(code, salt, SCRYPT.N);
const { lastInsertRowid } = db().prepare(
"INSERT INTO personen (name, rolle, code_hash, code_salt, code_n, aktiv, erstellt) VALUES (?,?,?,?,?,1,?)"
).run(name, rolle, hash, salt, SCRYPT.N, jetzt());
protokolliere("person_angelegt", {
personId: akteur?.id ?? null, rolle: akteur?.rolle ?? null,
ip: akteur?.ip ?? null, detail: `${name} (${rolle})`,
});
return { id: Number(lastInsertRowid), name, rolle, code };
}
/** Das Sitzungs-Merkmal aus der Anfrage -- roh, wie es im Keks steht.
* Wird gebraucht, um GENAU DIESE eine Sitzung am Leben zu lassen. */
export const sitzungToken = (req) => req?.cookies?.[COOKIE] || null;
/**
* Neuen Code setzen.
*
* @param behalteToken Die eine Sitzung, die NICHT beendet wird.
* Gedacht für den Fall "ich erneuere meinen eigenen Code": Wer das tut,
* ist gerade angemeldet und hält diese Sitzung in der Hand.
*/
export function codeNeu(id, akteur = null, behalteToken = null) {
const person = db().prepare("SELECT id, name, rolle FROM personen WHERE id = ?").get(id);
if (!person) throw new Error(`Keine Person mit Nummer ${id}`);
const code = codeErzeugen();
const salt = randomBytes(16).toString("hex");
db().prepare("UPDATE personen SET code_hash = ?, code_salt = ?, code_n = ? WHERE id = ?")
.run(hashe(code, salt, SCRYPT.N), salt, SCRYPT.N, id);
/* Offene Sitzungen beenden -- ein neuer Code soll den alten Zugang
wirklich beenden, nicht nur die nächste Anmeldung betreffen.
MIT EINER AUSNAHME, seit dem 02.09.2026: die Sitzung, aus der heraus
der Code gerade erneuert wird.
Vorher flog man beim eigenen Code sofort hinaus -- und zwar in
derselben Sekunde, in der der neue Code auf dem Bildschirm erschien.
Der nächste Aufruf der Seite lief in ein "nicht angemeldet" und
leitete zur Anmeldung um; der Code war weg, bevor man ihn kopieren
konnte. Genau so ist es Filipe am 02.09.2026 passiert, und danach kam
er nur noch über einen Eingriff auf dem Server wieder hinein.
Sicherheitlich kostet die Ausnahme nichts: Wer den Code erneuert, hat
sich mit genau dieser Sitzung soeben ausgewiesen und hält sie in
Händen. Alle ANDEREN Geräte fliegen weiterhin hinaus -- das ist der
Sinn der Übung. */
const behalten = behalteToken ? tokenHash(behalteToken) : null;
if (behalten) {
db().prepare("DELETE FROM sitzungen WHERE person_id = ? AND token_hash <> ?")
.run(id, behalten);
} else {
db().prepare("DELETE FROM sitzungen WHERE person_id = ?").run(id);
}
protokolliere("code_erneuert", {
personId: akteur?.id ?? null, rolle: akteur?.rolle ?? null,
ip: akteur?.ip ?? null, detail: `für ${person.name}`,
});
return { ...person, code };
}
export function personSperren(id, aktiv = 0, akteur = null) {
const person = db().prepare("SELECT name FROM personen WHERE id = ?").get(id);
db().prepare("UPDATE personen SET aktiv = ? WHERE id = ?").run(aktiv ? 1 : 0, id);
if (!aktiv) db().prepare("DELETE FROM sitzungen WHERE person_id = ?").run(id);
protokolliere(aktiv ? "person_entsperrt" : "person_gesperrt", {
personId: akteur?.id ?? null, rolle: akteur?.rolle ?? null,
ip: akteur?.ip ?? null, detail: person?.name ?? `#${id}`,
});
}
export function personenListe() {
return db().prepare(`
SELECT p.id, p.name, p.rolle, p.aktiv, p.erstellt, p.letzter_login,
(SELECT COUNT(*) FROM sitzungen s WHERE s.person_id = p.id) AS sitzungen
FROM personen p ORDER BY ${ROLLEN_SORTIERUNG.replace("rolle", "p.rolle")}, p.name`).all();
}
export function protokollLesen(anzahl = 20) {
return db().prepare(
"SELECT zeitpunkt, rolle, aktion, detail, ip FROM protokoll ORDER BY id DESC LIMIT ?"
).all(anzahl);
}