Letzter offener Punkt aus dem Konzept, Seite 4: Ein Creator hat laut Rollenbeschreibung "Aufgaben & Feedback" -- die Aufgaben gab es, das Feedback nicht. Ohne diese Moeglichkeit endet jede Rueckfrage ausserhalb des Systems, in WhatsApp, und damit ausserhalb dessen, was spaeter noch nachvollziehbar ist. Neue Tabelle aufgaben_notizen. Wer die Aufgabe sehen darf, darf auch mitreden -- geprueft ueber DIESELBE Sichtbarkeitsregel wie fuer die Aufgabe selbst. Eine eigene Regel gibt es bewusst nicht: Zwei Regeln fuer dieselbe Sache laufen irgendwann auseinander, und die Aufgaben- sichtbarkeit ist die schwierigere von beiden (Creator sieht eigene, Scout die seiner betreuten Creator, Leitung alles). 404 statt 403, wenn die Aufgabe nicht sichtbar ist -- wer sie nicht sehen darf, soll nicht erfahren, dass es sie gibt. Die eigene Rueckmeldung darf jeder zuruecknehmen, fremde nur die Leitung. Eine Rueckmeldung ist keine Abstimmung: Wer sich vertippt hat, soll das nicht bei jemandem beantragen muessen. Der Knopf auf der Karte zeigt die ANZAHL, nicht nur ein Symbol -- so sieht man ohne Aufklappen, wo schon etwas besprochen wurde, und die Karte bleibt trotzdem ruhig. Aufgeklappt wird direkt an der Karte, nicht in einem Fenster: Man liest die Rueckmeldungen im Zusammenhang mit der Aufgabe, sonst fehlt beim Antworten die Haelfte. Geprueft: - Chef und Luna schreiben sich gegenseitig, beide sehen beides - Mika (fremder Creator) bekommt 404 beim Lesen UND beim Schreiben - ohne Anmeldung 401, fremde Herkunft 403, leerer Text abgewiesen - Luna kann Chefs Rueckmeldung nicht loeschen, ihre eigene schon - Chef kann alle loeschen - Zaehler auf der Karte stimmt, Handy ohne Ueberlauf, 0 Konsolenfehler
864 lines
39 KiB
JavaScript
864 lines
39 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. */
|
|
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);
|
|
}
|
|
|
|
/* ---- 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);
|
|
|
|
/* 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/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,
|
|
};
|
|
|
|
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}`,
|
|
});
|
|
}
|
|
|
|
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);
|
|
}
|