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