ZWEI MELDUNGEN.
1. "DAS SIEHT JA MAL RICHTIG SCHEISSE AUS"
Die Rollenauswahl im Formular "Neue Person" lief unten aus dem
Formular heraus und legte sich ueber die Personenliste darunter.
Die Ursache war keine schlampige Gestaltung, sondern eine Regel, die
fuer etwas anderes gemacht ist: `.neu__raster > *` (aufgaben.css) gibt
JEDEM direkten Kind ein Raster mit fester Zeilenhoehe von 44 px, damit
Beschriftung und Eingabefeld in allen Spalten auf einer Linie sitzen.
Fuer ein einzeiliges Feld ist das genau richtig. Die Rollenauswahl ist
aber kein Feld, sondern ein Block aus vier hohen Karten -- die passten
nicht in 44 px, und was nicht hineinpasst, steht eben daneben.
Das Formular ist jetzt aus dem Spaltenraster geloest und liest sich
von oben nach unten: NAME (schmal, ein Name braucht keine 1200 px),
ROLLE (volle Breite, vier gleich breite Karten), Knoepfe. Das ist
nicht nur reparierter Ueberlauf, sondern auch die Reihenfolge, in der
man die Sache tatsaechlich entscheidet.
Dazu zwei Kleinigkeiten, die den Unterschied machen: Das Namensfeld
war ohne Rasterzeile 60 px hoch geworden (`height: 100%` einer Zeile,
die es nicht mehr gibt) -- hoeher als die Knoepfe daneben, was nach
Versehen aussieht, weil es eins war. Und die gewaehlte Rolle traegt
jetzt ein HAEKCHEN, nicht nur einen etwas helleren Rahmen: Genau die
Art Unterschied, die im Kontrastmodus verschwindet und fuer
farbunsichere Augen nie existiert hat.
2. "DIE MANAGER SOLLEN DIESE KATEGORIEN GARNICHT SEHEN"
"Personen & Zugaenge" und "Automationen" gehoeren ab sofort allein
DogFather.
Bei den Personen war es ohnehin ein Widerspruch im eigenen Haus: In
der Rollenauswahl steht seit jeher "Manager -- dieselben Rechte wie
DogFather, AUSSER der Personenverwaltung". Die Kachel stand trotzdem
bei ihm, und die Schranke liess ihn hinein: Codes erneuern, sperren,
Zuteilungen aendern. Eine Beschreibung, die etwas anderes sagt als die
Software, ist schlimmer als beides einzeln -- man weiss danach nicht
mehr, welcher von beiden man glauben soll.
Umgesetzt auf DREI Ebenen, weil Wegnehmen was man SIEHT kein
Rechteentzug ist:
1. die Kachel erscheint nicht mehr
2. die Seite leitet zur Startseite zurueck (GESCHUETZT)
3. die Schnittstellen antworten mit 404 -- Personenverwaltung,
Systemzustand, KI-Schalter
FOLGE, die bewusst in Kauf genommen wird: Die Betreuungs-Zuteilung
liegt auf der Personenseite. Ab jetzt kann also NUR DogFather
festlegen, wer welchen Creator betreut.
GEPRUEFT: server/pruef-personen-formular.mjs, 20 Pruefungen. Das Aussehen
wird gemessen, nicht angesehen: Endet die Rollenauswahl innerhalb des
Formulars? Ragt eine Karte seitlich hinaus? Liegt etwas ueber der Liste?
Sind alle vier Karten gleich breit (272-272 px)? Traegt die gewaehlte ein
Haekchen? Dazu zwei Gegenproben: DogFather kommt weiterhin an beide
Bereiche (sonst waere jede Sperr-Zeile auch bei einem fuer ALLE kaputten
Bereich gruen), und die Managerin arbeitet ansonsten unveraendert weiter.
Bestehende Laeufe gruen: Rollen 97, Handy 50, Sicherung 41, Personenliste
33, Team 30, Formulare 19, Code 17, Grosscheck 15.
Co-Authored-By: Claude Opus 5 <[email protected]>
1482 lines
68 KiB
JavaScript
1482 lines
68 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"],
|
|
]) {
|
|
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);
|
|
}
|
|
|
|
/* 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);
|
|
|
|
/* 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);
|
|
|
|
/* 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,
|
|
/* 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"],
|
|
};
|
|
|
|
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. */
|
|
/* 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);
|
|
}
|
|
|
|
/* 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;
|
|
const eigen = `(${p}.creator_id = ? OR ${p}.teilnehmer_id = ? OR ${p}.erstellt_von = ?)`;
|
|
const werte = [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;
|
|
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 };
|
|
}
|
|
|
|
/** 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);
|
|
}
|