Files
dogfather-universe/server/workspace.js
T
DogFatherGitandClaude Opus 5 43ce6551e2 Bereiche nur lesend fuer Creator + Content-Planung als echte Strecke
ZWEI SACHEN, beide auf Wunsch vom 31.08.2026.

1) "die creator sollen da nur die sehen die wir ihnen eintragen, also
   dogfather manager und scout."

Vorher durfte ein Creator in LIVE, Content, Technik, Community und Schutz
selbst anlegen, aendern und eigene Eintraege loeschen. Das hebelt den
Zweck dieser Bereiche aus: Sie sind die Betreuungsakte. Wer eine
LIVE-Auswertung umschreiben oder eine unbequeme Notiz verschwinden lassen
kann, macht die Akte wertlos.

Der Creator SIEHT weiterhin alles, was zu ihm gehoert -- vollstaendig.
Er kann es nur nicht veraendern. Umgesetzt als EINE Schranke vor allen
schreibenden Wegen, nicht als Pruefung an drei Stellen: Genau so ist hier
schon einmal ein Loch entstanden (zwei von drei Stellen abgesichert, die
dritte vergessen). 403 mit klarem Text statt 404 -- der Bereich existiert
fuer ihn ja, er steht davor.

Vorsicht Express 4: Der Platzhalter heisst dort `*`, nicht `*name` wie in
Fassung 5. Mit der falschen Schreibweise haette die Sperre stumm nichts
getan -- kein Fehler, keine Warnung.

37 Pruefungen, Schwerpunkt auf den verneinenden Faellen: alle fuenf
Bereiche, alle drei Wege, dazu der eigene Alteintrag (der frueher
ausdruecklich erlaubt war) und der Nachweis, dass hinterher wirklich
nichts veraendert wurde.

2) Content-Planung: aus der Liste wird eine Strecke.

Recherchiert statt geraten. Die Quellen sind an drei Punkten eindeutig:

  * "A content calendar for creators is a pipeline, not a datebook" --
    eine Wand aus Terminen verbirgt genau die Stellen, an denen es hakt.
  * Die ersten ein bis drei Sekunden entscheiden ueber die Verbreitung.
    Der Hook ist deshalb ein eigenes Feld auf jeder Karte, kein Fliesstext.
  * Regelmaessigkeit schlaegt Menge. Wer regelmaessig senden will, braucht
    VORRAT -- und Vorrat ist die eine Zahl, die eine Ideenliste
    verschweigt.

Daraus:
  * Fuenf Stufen statt drei (Idee, Hook & Skript, Gedreht, Fertig &
    geplant, Veroeffentlicht). Alte 'produktion'-Eintraege werden beim
    naechsten Start auf 'gedreht' umgestellt -- ohne das fielen sie aus
    jeder Spalte heraus.
  * Neue Felder hook, format, saeule_id, geplant (ALTER TABLE, gefahrlos).
  * Content-Saeulen je Creator, drei bis fuenf, nach der 70/20/10-Regel.
    Mit Balance-Balken ueber 90 Tage, nur Veroeffentlichtes -- was auf dem
    Kanal steht, ist die Wahrheit ueber seine Ausrichtung.
  * Die Kachel VORRAT rechnet in TAGE um ("reicht noch etwa 7 Tage").
    "4 Videos uebrig" sagt nichts; die Tage sagen, ob man am Wochenende
    drehen muss. Gezaehlt wird nur Gedrehtes und Fertiges -- eine Idee ist
    kein Video, und wer Ideen mitzaehlt, steht am Donnerstag ohne Material.
  * "Ideen lassen sich direkt in Aufgabe/Termin/LIVE-Thema ueberfuehren"
    -- woertlich aus dem Konzept, das letzte unumgesetzte Stueck. Die Idee
    bleibt bestehen und bekommt einen Vermerk, damit niemand dieselbe
    Aufgabe zweimal anlegt.

Farben: Die fuenf Saeulenfarben sind Daten, keine Dekoration. Geprueft
gegen genau diesen Hintergrund (#0a0d13) auf Helligkeitsband,
Farbstaerke, Farbfehlsichtigkeit (schlechtestes Paar dE 8.4),
Normalsicht-Abstand (19.3) und Kontrast -- alle Pruefungen bestanden.
Immer mit Legende UND Beschriftung; Farbe allein traegt nie die
Information.

Beim Ansehen des ersten Bildes fiel der klassische Brett-Fehler auf: Die
Spalte "Veroeffentlicht" waechst ewig und schiebt alles andere aus dem
Bild. Sie zeigt jetzt die juengsten sechs, der Rest ist einen Klick
entfernt. Die vorderen Spalten bleiben ungekuerzt -- was dort liegt, ist
Arbeit.

Nur EIN Schreiber fuer die Tabelle eintraege: workspace-content.js
enthaelt ausschliesslich, was es woanders nicht gibt (Saeulen,
Kennzahlen, Ueberfuehren). Zwei Module auf denselben Zeilen waeren der
sichere Weg zu zwei Wahrheiten.

45 + 40 Pruefungen, Computer und Handy, dazu die Gegenprobe: Ein Creator
sieht die ganze Strecke, die Kennzahlen und die Verteilung -- und keinen
einzigen Knopf, auch keinen versteckten. Ausgeblendet ist nicht dasselbe
wie nicht vorhanden.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-08-31 21:45:14 +02:00

978 lines
44 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. */
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
]) {
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);
}
}
/* 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);
}
/* ---- 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')),
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
);
/* 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);
/* 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);
/* 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);
/* 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);
/* 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,
"/workspace/personen.html": ["admin", "manager"],
"/workspace/profil.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,
"/workspace/automation.html": ["admin", "manager"],
};
workspaceRouter.use((req, res, next) => {
if (!Object.hasOwn(GESCHUETZT, req.path)) return next();
const person = sitzungLesen(req);
if (!person) return res.redirect(302, "/workspace/");
const erlaubt = GESCHUETZT[req.path];
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);
let gefunden = null;
for (const k of kandidaten) {
if (gleich(hashe(code, k.code_salt, k.code_n), k.code_hash)) { gefunden = k; break; }
}
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. */
res.json({
id: person.id, name: person.name, rolle: person.rolle,
rolle_name: ROLLEN_NAME[person.rolle] ?? person.rolle,
});
});
/* ---------- 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.
===================================================================== */
/* Ids der Creator, die diese Person betreut. Fuer alle anderen Rollen
leer: Das Management sieht ohnehin alles, ein Creator sich selbst. */
export function betreuteIds(person) {
if (!person || person.rolle !== "scout") return [];
try {
return db().prepare("SELECT creator_id FROM betreuung WHERE betreuer_id = ?")
.all(person.id).map((z) => z.creator_id);
} catch {
return []; // im Zweifel nichts sehen, nie mehr
}
}
/* 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 };
}
/* Darf diese Person den Bereich dieses Creators sehen und bearbeiten? */
export function darfCreator(person, creatorId) {
if (!person || !creatorId) return false;
if (istLeitung(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 };
}
export function codeNeu(id, akteur = 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);
/* Alle offenen Sitzungen beenden -- ein neuer Code soll den alten Zugang
wirklich beenden, nicht nur die nächste Anmeldung betreffen. */
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);
}