Files
dogfather-universe/server/workspace.js
T
DogFatherGitandClaude Opus 5 72b36d1c31 Ein eigenes Haus fuer Team Dogi -- Lila und Babyblau statt Rot
Filipe, screen1: "die leiste da und alles andere was noch rot ist auf
team dogi seite soll lila werden. richtig geiles lila und babyblau
mischung ueberall."
Und screen2: "ich will doch dass der eingang hier getrennt ist von der
workspace seite ... aber es soll trotzdem so bleiben dass ich die daten
hier und da sehe."

GETRENNT WIRD DAS AUSSEHEN, NICHT DER BESTAND. Dieselbe Datenbank,
dieselben Seiten, dasselbe Programm -- und zwei Haeuser, die man nicht
verwechseln kann. Filipe und die rechte Hand sehen ihre Zahlen
weiterhin auf beiden Adressen.

EINE ZEILE JE SEITE, EINE REGEL IM SERVER. Jede Seite laedt als LETZTE
Stilvorlage `haus.css`. Auf der Agenturadresse ist die leer -- das Haus
dort IST der Grundzustand. Auf crew.dogfather-universe.com biegt die
Weiche genau diesen Dateinamen auf `crew-haus.css` um.

Drei Wege habe ich dafuer verworfen, und jeder hat einen Grund:
  * `data-haus` per JavaScript -> die Seite laedt erst im falschen Ton
    und faerbt sich um. Sichtbar, auf jeder Seite, bei jedem Aufruf.
  * dasselbe Attribut serverseitig in den HTML-Text schreiben -> jede
    HTML-Antwort muesste durch einen Umschreiber statt als Datei
    ausgeliefert zu werden, nur wegen einer Farbe.
  * zwanzig zweite <link>-Zeilen -> die muesste jemand bei jeder neuen
    Seite mitschreiben, und wer sie vergisst, bekommt eine Seite, die
    still zum falschen Haus gehoert.

DAS HAUS SIND FAST NUR VARIABLEN. Wer zwei Dutzend Farbwerte tauscht,
tauscht jede Kachel, jeden Rand, jeden Knopf und jedes Leuchten auf
einmal -- auch an Stellen, die man beim Nachbauen uebersehen wuerde.
Eine Datei, die stattdessen Regel fuer Regel umfaerbt, waere beim
naechsten neuen Bauteil sofort unvollstaendig, ohne dass es auffiele.

WO KEINE VARIABLE STAND, HAT DAS MESSEN SIE GEFUNDEN. Ich habe nicht
im Quelltext gesucht, sondern am fertigen Bildschirm jedes Element nach
Farben abgefragt, bei denen der Rotkanal deutlich ueber den anderen
liegt -- ueber alle Farbquellen, nicht nur `color` und
`background-color`. Erst das brachte die eigentliche Stelle ans Licht:

  DIE KOPFLEISTE HAT EINEN ROTEN VERLAUF. Genau die Leiste aus Filipes
  Bildschirmfoto. Sie glimmt im Agenturhaus wie Feuer -- sein eigener
  Wunsch von screen36, und dort bleibt das auch so. Hier schimmert sie
  jetzt lila, in derselben Bauweise: unten waermer, nach oben dunkel,
  in der Mitte kraeftiger, alles unter 30 % Deckkraft.

Dazu die Fassung der Zentrale (rot->lila, Silber und Babyblau
unberuehrt, Prozentzahlen auf den Punkt gleich -- sie sind am 08.09.
eigens nachgerechnet worden), die Uhr an vier Stellen, das Universum
dahinter, Glocke und Tagesruf, der Schriftzug, und auf der Zugangswand
Knopf, Kachelreihe, Innenglas und Karte.

ZWEI FEHLER, DIE ERST DER BILDSCHIRM ZEIGTE:

  Der Schriftzug wurde zu einem ausgefuellten Balken. `background` ist
  eine Kurzschreibweise und setzt `background-clip` mit zurueck -- und
  genau darueber wird der Text in die Buchstaben ausgestanzt. Jetzt
  `background-image`. Im Quelltext sah die Zeile voellig richtig aus.

  Zwei Regeln wurden geladen und taten nichts: Die Originale stehen
  unter `.kopfleiste .marke__haupt` und `.willkommen > .zuniversum`.
  Wer nur die halbe Kette schreibt, verliert gegen zwei Klassen.

DIE MARKE HAENGT JETZT AN ZWEI DINGEN. `markeFuer()` kannte nur die
Rolle -- und DogFather gehoert nun einmal zur Agentur. Ueber der
Zentrale stand deshalb auch auf der zweiten Adresse gross "SPICY
MEDIA". Jetzt entscheidet auch der Hostname, an EINER Stelle.

WAS WARM BLEIBT, UND WARUM: Warnungen. Eine Warnung ist keine
Verzierung, sondern eine Bedeutung -- faerbt man sie ins Lila der
Seite, sieht "etwas stimmt nicht" aus wie alles andere. Sie wird nur
ins Rosa gezogen, damit sie neben Lila kein Fremdkoerper ist. Ebenso
bleiben die 24 Kachelfarben: dass jede Kachel ihre eigene hat, ist
gepruefte Absicht.

Weisse Schrift auf dem Anmeldeknopf haelt jetzt 6,23 zu 1 statt 5,1 --
an echten Bildpunkten gemessen, nicht an der Farbangabe.

pruef-crew-adresse 123 -> 129 · pruef-crew-wand-bild 45 ·
pruef-css-klassen gruen (prueft ab jetzt das PAAR module.css/haus.css,
nicht mehr eine Datei) · pruef-modi-wortleck 5 · pruef-rollen 274 ·
pruef-start-ansicht gruen · pruef-chat-kanaele 79.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-09-10 22:35:14 +02:00

4307 lines
205 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/* =====================================================================
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, createHmac,
} 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";
import { sitzungPasstZurAdresse, istOhneModiAdresse, istCrewAdresse,
TEAM_DOGI_ROLLEN } from "./crew-adresse.js";
/* Weitergereicht, damit die Fachmodule sie wie alles andere aus
workspace.js beziehen und nicht wissen muessen, wo sie wohnt. */
export { TEAM_DOGI_ROLLEN };
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(["spicy", "admin", "manager", "scout", "creator", "hand", "modi"]);
/* Die Reihenfolge, in der Rollen ueberall erscheinen: Spicy Media
zuerst, dann DogFather, Manager, Scout, Creator. Steht hier einmal,
damit keine Liste eine eigene Reihenfolge erfindet. */
export const ROLLEN_REIHE = ["spicy", "admin", "manager", "scout", "creator", "hand", "modi"];
/* DIE ROLLEN MIT EINER KACHEL AUF DER ANMELDESEITE.
'modi' steht hier NICHT drin, und das ist der Kern des verborgenen
Zugangs: Es gibt keine sechste Kachel, und es laesst sich auch keine
erzwingen. Wer von aussen `rolle: "modi"` schickt, bekommt genau
dieselbe Antwort wie bei einer erfundenen Rolle -- er erfaehrt also
nicht einmal, dass es sie gibt.
Zwei Listen statt einer, weil es zwei verschiedene Fragen sind:
ROLLEN sagt, welche Rollen es GIBT (die Datenbank laesst nur diese
zu). Diese hier sagt, mit welchen man sich ANMELDEN kann, indem man
sie anklickt. Waere es eine Liste, haette 'modi' entweder eine Kachel
-- oder es gaebe die Rolle gar nicht. */
const ROLLEN_KACHEL = new Set(["spicy", "admin", "manager", "scout", "creator"]);
/** Die Rollen mit einer Kachel auf der Zugangswand von crew.
*
* DREI, seit Filipe am 10.09.2026 sagte: "3 rollen. dogfather. rechte
* hand und modis." Auf dieser Adresse gibt es also wieder etwas zu
* waehlen -- anders als in der Fassung davor, wo nur Modis
* hereinkamen und eine Kachelreihe mit einem Eintrag eine Huerde
* gewesen waere.
*
* DogFather steht auf BEIDEN Waenden. Er ist die einzige Person, die
* in beiden Welten arbeitet; das ist keine Ausnahme von der Trennung,
* sondern ihr Sinn. */
const CREW_KACHEL = new Set(["admin", "hand", "modi"]);
/* Als SQL-Ausdruck fuer ORDER BY. "ORDER BY rolle" waere alphabetisch
(admin, creator, manager, scout, spicy) -- also fast genau falsch
herum. */
export const ROLLEN_SORTIERUNG =
"CASE rolle WHEN 'spicy' THEN 0 WHEN 'admin' THEN 1 WHEN 'manager' THEN 2"
+ " WHEN 'scout' THEN 3 WHEN 'creator' THEN 4 WHEN 'hand' THEN 5 ELSE 6 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(["spicy", "admin", "manager"]);
export const istLeitung = (person) => !!person && LEITUNG.has(person.rolle);
export const istDogFather = (person) => !!person && person.rolle === "admin";
/* =======================================================================
WER DARF WEN ANLEGEN — die einzige Liste dazu (10.09.2026)
Filipe, mit Bildschirmfoto der Zugaenge-Seite: "die spicy rolle soll
auch manager und scouts hinzufuegen koennen."
Sie konnte es nicht. Der Grund war NICHT ein fehlendes Recht, sondern
ZWEI Listen, die einander widersprachen — in derselben Funktion, drei
Zeilen auseinander (personen.js):
const darf = ... : ich.rolle === 'spicy' ? ['manager', 'creator']
...
if ((r.wert === 'admin' || r.wert === 'manager') && ich.rolle !== 'admin') continue;
Die erste Zeile erlaubt Spicy Media einen Manager, die zweite nimmt
ihn wieder weg. Uebrig blieb genau ein Knopf: Creator. Serverseitig
war der Weg fuer den Manager die ganze Zeit offen — es gab nur keinen
Knopf dafuer.
Das ist im Haus die immer gleiche Sorte Fehler: zwei Stellen fuer
dieselbe Aussage, und die spaetere gewinnt still. Deshalb steht die
Antwort ab jetzt EINMAL hier, und alle fragen sie:
- die drei Anlege-Wege in workspace-personen.js
- die Oberflaeche, ueber `darf_anlegen` in /workspace/api/ich
Die Oberflaeche hat damit gar keine eigene Liste mehr. Sie kann
deshalb auch nicht mehr von der des Servers abweichen — weder zu
streng (ein Recht, das niemand findet) noch zu grosszuegig (ein Knopf,
der eine Absage bringt).
WARUM SPICY MEDIA SCOUTS ANLEGEN DARF, ABER KEINE LEITUNG:
Ein Scout und ein Manager arbeiten unter Spicy Media — sie
einzustellen ist genau die Aufgabe dieser Rolle. Einen DogFather
anzulegen ist es nicht: Das waere ein zweiter Zugang mit allen
endgueltigen Rechten, und den vergibt nur DogFather selbst.
======================================================================= */
const ANLEGBAR = {
/* DogFather: alles, was es gibt. Er geht ueber /verwaltung/personen. */
admin: [...ROLLEN],
/* Spicy Media: das ganze Team, aber keine Leitung ueber sich. */
spicy: ["manager", "scout", "creator"],
/* Ein Manager stellt Creator ein, die er dann auch betreut. */
manager: ["creator"],
};
/** Welche Rollen darf diese Person anlegen? Immer ein Feld, nie null —
* wer nichts darf, bekommt eine leere Liste und keinen Sonderfall. */
export function darfAnlegen(person) {
if (!person) return [];
return [...(ANLEGBAR[person.rolle] ?? [])];
}
/* WER FUEHRT TEAM DOGI? (10.09.2026)
*
* DogFather und seine rechte Hand -- und sonst niemand. Spicy Media und
* die Manager stehen bewusst NICHT darin: Sie fuehren die Agentur, nicht
* das Team.
*
* WARUM DAS HIER STEHT UND NICHT DORT, WO ES GEBRAUCHT WIRD: Es wurde
* schon zweimal gebraucht -- im Eingang (`darfEingang`) und jetzt bei
* den Kategorie-Kanaelen. Beim dritten Mal waere es dreimal
* hingeschrieben, und die dritte Abschrift ist die, die eine Rolle
* vergisst. Der Eingang ruft ab jetzt hierher.
*/
export const fuehrtTeamDogi = (person) =>
!!person && (person.rolle === "admin" || person.rolle === "hand");
/* =======================================================================
SPICY MEDIA (07.09.2026)
Wunsch Filipe: "eine neue rolle ... die den namen traegt, spicy media,
und soll die gleichen rechte haben wie dogfather ausser, bei
automationen sollen die nicht sehen und die kategorie personen und
zugaenge sollen die nur leute hinzufuegen koennen ... aber die sollen
meine privaten chats und so nicht sehen."
ZWEI FRAGEN, DIE MAN AUSEINANDERHALTEN MUSS -- und genau daran haette
man diesen Umbau kaputtmachen koennen:
WER SIEHT ALLES? -> siehtAlles() = DogFather ODER Spicy Media
WER ENTSCHEIDET? -> istDogFather() = nur DogFather
Die erste Frage stellt sich bei Listen, Uebersichten und Auswertungen:
Spicy Media soll alle Manager, Scouts und Creator sehen. Die zweite
bei allem Endgueltigen: loeschen, Rollen aendern, Codes neu setzen,
Sicherungen, die KI abschalten. Da bleibt DogFather allein.
Es waere viel weniger Arbeit gewesen, istDogFather() einfach um
"spicy" zu erweitern -- und genau das waere der Fehler: Spicy Media
koennte dann DogFather loeschen. Zwei Namen, zwei Bedeutungen, und an
jeder Stelle steht sichtbar, welche gemeint ist.
DER CHAT BRAUCHTE NICHTS. Er haengt ausschliesslich an der
Teilnehmerliste (chat_teilnehmer) und kennt kein "das Management sieht
alles". Spicy Media sieht fremde Gespraeche also nicht, weil es dafuer
gar keinen Weg gibt -- nicht, weil eine Abfrage es verbietet.
======================================================================= */
export const istSpicy = (person) => !!person && person.rolle === "spicy";
/* =======================================================================
SPICY MEDIA SIEHT ALLES -- AUSSER DEM, WAS DOGFATHER GEHOERT
(07.09.2026, Nachtrag am selben Tag)
Filipe: "wieso sieht die spicy rolle meine daten die ich habe und
speichere, meins sollen die nicht sehen von der rolle dogfather."
Beim ersten Anlauf bekam Spicy Media dieselbe Regel wie DogFather:
`1=1`. Damit stimmte der Ueberblick ueber Manager, Scouts und Creator
-- und nebenbei standen DogFathers eigene Termine, Aufgaben, Dateien
und Eintraege mit drin. Der Chat war ausgenommen (er haengt an der
Teilnehmerliste), alles andere nicht.
DIESE FUNKTION IST DIE EINE STELLE DAFUER. Sie baut die Bedingung
"gehoert keinem DogFather" fuer eine beliebige Tabelle. Jede
Sichtbarkeitsregel im Haus benutzt sie -- eine zweite, abgeschriebene
Fassung waere die Stelle, an der es beim naechsten Modul wieder
durchsickert.
ZWEI SPALTEN, NICHT EINE. `creator_id` sagt, UM WEN es geht;
`erstellt_von` sagt, WER es geschrieben hat. Beide muessen gepruegt
werden: Ein Termin, den DogFather fuer sich selbst anlegt, hat
moeglicherweise gar keine creator_id -- er haenge dann nur an
erstellt_von. Und eine Akte UEBER einen DogFather traegt seine
creator_id, auch wenn ein Manager sie geschrieben hat.
`IS NULL OR NOT IN` und nicht bloss `NOT IN`: In SQL ist
`NULL NOT IN (...)` weder wahr noch falsch, sondern NULL -- die Zeile
fiele stillschweigend heraus. Genau so verschwinden Daten, ohne dass
jemand einen Fehler sieht.
======================================================================= */
export function ohneDogFather(praefix, spalten = ["creator_id", "erstellt_von"]) {
return spalten
.map((sp) => `(${praefix}.${sp} IS NULL OR ${praefix}.${sp} NOT IN `
+ `(SELECT id FROM personen WHERE rolle = 'admin'))`)
.join(" AND ");
}
/* =====================================================================
WAS EINEM MODI GEHOERT, SIEHT SONST NIEMAND (10.09.2026)
Das Gegenstueck zu ohneDogFather() -- und es entstand aus einem
gemessenen Leck, nicht aus einer Ueberlegung.
WAS PASSIERT WAR: Fuer PERSONEN ist die Regel zentral
(verborgeneIds). Fuer ZEILEN gibt es sie dreimal -- in
workspace-aufgaben.js, workspace-bereiche.js und
workspace-dateien.js -- und alle drei geben Spicy Media dasselbe:
"alles ausser dem, was DogFather gehoert". Ein Modi-Eintrag gehoert
ihm aber nicht. Also sah Spicy Media die Aufgaben, die Eintraege und
die Dateien der Modis, waehrend die Modis selbst in jeder Namensliste
sauber verborgen waren. Die halbe Verborgenheit ist keine.
Aufgefallen ist es nicht beim Lesen des Codes, sondern durch eine
Pruefung, die etwas ANLEGT und danach mit fremden Augen nachsieht.
Manager, Scout und Creator waren nie betroffen -- ihre Regeln sind
ohnehin enger. Der Kalender auch nicht: termineSichtbar() gibt jedem
nur Eigenes. Nachgemessen, nicht angenommen.
SPALTEN MUESSEN BEIDE RICHTUNGEN ABDECKEN. `creator_id` sagt, UM WEN
es geht; `erstellt_von`/`hochgeladen_von`/`verantwortlich_id` sagen,
an WEM es haengt. Eine Modi-Aufgabe hat gar keine creator_id -- sie
haengt allein an verantwortlich_id. Wer nur eine Spalte prueft, hat
nichts geprueft.
`IS NULL OR NOT IN` und nicht bloss `NOT IN`: In SQL ist
`NULL NOT IN (...)` weder wahr noch falsch, sondern NULL -- die Zeile
fiele stillschweigend heraus. Genau so verschwinden Daten, ohne dass
jemand einen Fehler sieht. (Dieselbe Falle steht schon im Kommentar
von ohneDogFather; sie ist es wert, zweimal dazustehen.)
===================================================================== */
export function ohneTeamDogi(praefix, spalten = ["creator_id", "erstellt_von"]) {
/* Die Rollenliste kommt aus TEAM_DOGI_ROLLEN und steht nicht hier als
Text. Sie hiess bis zum 10.09.2026 fest `rolle = 'modi'` -- mit dem
Hinzukommen der rechten Hand waere das eine stille Luecke geworden:
Ihre Zeilen waeren fuer Spicy Media wieder sichtbar gewesen, ohne
dass irgendwo etwas rot wird.
Als Aufzaehlung in den SQL-Text eingesetzt und nicht als Parameter,
weil dieser Ausdruck in ein `WHERE` eingebaut wird, das an vielen
Stellen zusammengesetzt wird. Die Werte stammen aus einer festen
Menge im Code, nie aus einer Eingabe. */
const liste = [...TEAM_DOGI_ROLLEN].map((r) => `'${r}'`).join(", ");
return spalten
.map((sp) => `(${praefix}.${sp} IS NULL OR ${praefix}.${sp} NOT IN `
+ `(SELECT id FROM personen WHERE rolle IN (${liste})))`)
.join(" AND ");
}
/** Darf diese Person ueberhaupt etwas sehen, das einem Modi gehoert?
* Nur die DogFather-Rolle und die Modis selbst. */
export const siehtModis = (person) => !!person
&& (person.rolle === "admin" || TEAM_DOGI_ROLLEN.has(person.rolle));
export const siehtAlles = (person) => !!person
&& (person.rolle === "admin" || person.rolle === "spicy");
/* 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 = {
spicy: "Spicy Media",
admin: "DogFather",
manager: "Manager",
scout: "Scout",
creator: "Creator",
hand: "Rechte Hand",
modi: "Modi",
};
/* DIE UEBERSCHRIFT UEBER EINER PERSONENLISTE (10.09.2026).
*
* Sie ist NICHT dasselbe wie das Namensschild: Ueber einer Liste steht
* die Mehrzahl ("Scouts"), am einzelnen Menschen die Einzahl ("Scout").
* Der Browser hat diese Zuordnung in bereiche.js ebenfalls -- aber nur
* fuer die fuenf Rollen, die dort stehen duerfen.
*
* WARUM SIE HIER GEBRAUCHT WIRD: In der Personenauswahl des Chats
* wurde nach `ROLLENFOLGE` aus bereiche.js gezeichnet. Wer dort nicht
* vorkommt, wurde nicht gezeichnet -- ohne Fehler, ohne Luecke, ohne
* Hinweis. Team Dogi kommt dort nicht vor und darf es auch nicht (der
* Rollenname gehoert in keine Datei, die jeder herunterlaedt). Folge:
* DogFather konnte ueber die Auswahl niemandem aus seinem Team
* schreiben und keinen in einen Kanal setzen. Aufgefallen ist es nicht
* beim Lesen, sondern beim Hinsehen: Der Kanal hatte hinterher zwei
* Leute statt vier.
*
* Jetzt schickt der Server die Ueberschrift mit. Der Browser braucht
* dafuer keinen Rollennamen zu kennen -- er bekommt einen Text.
*/
export const ROLLEN_GRUPPE = {
...ROLLEN_NAME,
scout: "Scouts",
/* Beide unter EINER Ueberschrift: In der Auswahl sucht man einen
Menschen, keine Rangordnung -- und zwei Ueberschriften mit je
einem Namen darunter waeren mehr Gliederung als Inhalt. */
hand: "Team Dogi",
modi: "Team Dogi",
};
/* =====================================================================
DIE KACHELN EINES MODIS (10.09.2026)
Wo die anderen fuenf Rollen ihre Kacheln aus assets/js/bereiche.js
bekommen, kommen sie fuer einen Modi von hier. Der Grund ist derselbe
wie beim Anzeigenamen und bei der Rollenauswahl: bereiche.js wird an
JEDEN ausgeliefert, der die Seite oeffnet. Stuende dort auch nur
`rollen: [... , 'modi']`, waere die Rolle mit einem Blick in den
Quelltext gefunden -- und damit alles verraten.
NICHT NUR EINE LISTE VON ZIELEN, sondern die ganze Kachel. Wer nur
die Ziele schickte und die Beschriftung aus bereiche.js nehmen liesse,
bekaeme unter "Dashboard" den Untertitel "Alle Creator auf einen
Blick" -- fuer jemanden, der keine Creator betreut. Die Worte gehoeren
zum Empfaenger, nicht zum Ziel.
ZEICHEN, TON UND SZENE SIND SCHLUESSEL AUS bereiche.js, keine neuen.
Sie sagen nichts ueber Modis aus (jede andere Kachel benutzt dieselben)
und muessen deshalb nicht verborgen werden. Neue zu erfinden waere
genau falsch herum: Sie muessten in bereiche.js stehen, und dort
faellt jede unbenutzte Zeile irgendwann jemandem auf. Die Toene sind
dieselben wie an den entsprechenden Kacheln der anderen Rollen -- ein
Ton kennzeichnet die Kachel, nicht ihren Platz.
WAS NICHT DABEI IST, und warum: Uebersicht, Calls, Content, Reports,
Scouting, Personen und Automationen. Sie drehen sich um betreute
Creator, um die Agentur oder um Rechte -- fuer einen Modi waeren sie
leer oder nicht seine Sache. Der Rollen-Rundgang (pruef-rollen) zeigt,
dass sie ihn nicht abstuerzen lassen; das ist der Grund, sie
wegzulassen, und nicht der Grund, sie mitzunehmen.
ES IST EINE ROLLENFRAGE, KEINE PERSONENFRAGE: Alle Modis bekommen
dasselbe. Sollte das je auseinandergehen, gehoert die Auswahl an die
Person und nicht hierher -- dann aber bewusst und mit einer Stelle,
an der sie gepflegt wird.
===================================================================== */
const MODI_BEREICHE = [
{ gruppe: "Täglich", gruppeUnter: "Was du sowieso jeden Tag aufmachst",
name: "Aufgaben", unter: "Offen, in Arbeit, Review", zeichen: "aufgaben",
ton: 13, gross: true, ziel: "aufgaben.html", szene: "garage" },
{ gruppe: "Täglich", gruppeUnter: "Was du sowieso jeden Tag aufmachst",
name: "Chat", unter: "Nachrichten mit dem Team", zeichen: "chat",
ton: 4, ziel: "chat.html", szene: "lounge" },
{ gruppe: "Täglich", gruppeUnter: "Was du sowieso jeden Tag aufmachst",
name: "Kalender", unter: "Live-Zeiten und Termine", zeichen: "kalender",
ton: 8, ziel: "kalender.html", szene: "skyline" },
{ gruppe: "Täglich", gruppeUnter: "Was du sowieso jeden Tag aufmachst",
name: "Dateien", unter: "Ablage und Freigaben", zeichen: "dateien",
ton: 19, ziel: "dateien.html", szene: "arena" },
{ gruppe: "Täglich", gruppeUnter: "Was du sowieso jeden Tag aufmachst",
name: "Ideen-Board", unter: "Gesammelt und nach Dringlichkeit sortiert",
zeichen: "content", ton: 23, ziel: "bereich.html?b=ideen", szene: "portal" },
{ gruppe: "Täglich", gruppeUnter: "Was du sowieso jeden Tag aufmachst",
name: "Angebote", unter: "Vorschläge, über die DogFather entscheidet",
zeichen: "agentur", ton: 17, ziel: "bereich.html?b=angebote", szene: "halle" },
{ gruppe: "Rund ums Live", gruppeUnter: "Vor, während und nach der Sendung",
name: "Live-Ablauf", unter: "Checkliste für vorher, mittendrin und danach",
zeichen: "live", ton: 3, ziel: "bereich.html?b=live", szene: "portal" },
{ gruppe: "Rund ums Live", gruppeUnter: "Vor, während und nach der Sendung",
name: "Community", unter: "Begrüßen, vermitteln, wiedererkennen",
zeichen: "community", ton: 6, ziel: "bereich.html?b=community", szene: "lounge" },
{ gruppe: "Rund ums Live", gruppeUnter: "Vor, während und nach der Sendung",
name: "Technik", unter: "Ton, Bild, Verbindung", zeichen: "technik",
ton: 14, ziel: "bereich.html?b=technik", szene: "garage" },
{ gruppe: "Für dich", gruppeUnter: "Deine Seite im Team",
name: "Mein Steckbrief", unter: "Dein Bild und deine Kanäle", zeichen: "steckbrief",
ton: 10, ziel: "steckbrief.html", szene: "halle" },
{ gruppe: "Für dich", gruppeUnter: "Deine Seite im Team",
name: "Wissen", unter: "Nachschlagen statt nachfragen", zeichen: "wissen",
ton: 9, ziel: "wissen.html", szene: "arena" },
];
/**
* Die Kacheln dieser Person -- oder `null`, wenn sie aus bereiche.js
* kommen sollen.
*
* `null` und nicht eine leere Liste: Die Oberflaeche unterscheidet
* "nimm deine eigene Liste" von "du bekommst keine Kacheln". Eine leere
* Liste hiesse das Zweite, und die fuenf bekannten Rollen haetten ab
* sofort eine leere Startseite.
*/
/* Die Eingangs-Kachel steht hier -- VOR ihrer ersten Verwendung.
Sie stand zuerst weiter unten beim ZUSATZ_BEREICHE-Block, wo sie
thematisch hingehoert. Das Modul liess sich damit nicht mehr laden:
"Cannot access 'EINGANG_KACHEL' before initialization". `const` wird
erst an seiner Zeile gueltig, und HAND_BEREICHE greift weiter oben
darauf zu.
`node --check` hat das NICHT gemeldet -- es prueft die Schreibweise,
nicht die Reihenfolge. Gefunden hat es erst der Versuch, das Modul
wirklich zu laden. Eine Syntaxpruefung ist keine Ladeprobe. */
const EINGANG_KACHEL = {
gruppe: "Täglich", gruppeUnter: "Was du sowieso jeden Tag aufmachst",
name: "Eingang", unter: "Vorschläge und Angebote, die auf dich warten",
zeichen: "teamlage", ton: 24, ziel: "teamlage.html", szene: "halle",
};
/* DIE RECHTE HAND SIEHT DIESELBEN KACHELN WIE EIN MODI -- PLUS EINE.
Blueprint V3.0, Kapitel 3.1: "Gleicher Ueberblick wie Owner. Kann
Modis im Alltag koordinieren." Der gleiche Ueberblick heisst hier
ausdruecklich NICHT eine eigene Kachelwelt: Sie arbeitet an denselben
Dingen wie das Team, sieht darin aber alles statt nur das Eigene --
das entscheidet die Sichtbarkeitsregel, nicht die Kachelliste.
Dazu kommt der Eingang, denn sie "kann im Alltag vertretend
Entscheidungen treffen". Genau das passiert dort.
ABGELEITET UND NICHT ABGESCHRIEBEN: Wer eine Kachel fuer die Modis
hinzufuegt, bekommt sie hier automatisch mit. Eine zweite Liste waere
die Stelle, an der die rechte Hand irgendwann weniger sieht als das
Team, das sie koordinieren soll. */
const HAND_BEREICHE = [...MODI_BEREICHE, EINGANG_KACHEL];
export function bereicheFuer(person) {
if (person?.rolle === "modi") return MODI_BEREICHE;
if (person?.rolle === "hand") return HAND_BEREICHE;
return null;
}
/* WELCHE BEREICHSSEITEN EIN MODI OEFFNEN DARF -- abgeleitet aus seinen
eigenen Kacheln und nicht danebengeschrieben.
Eine zweite Liste waere die Stelle, an der es auseinanderlaeuft: Wer
eine Kachel hinzufuegt und den Eintrag vergisst, baut einen Knopf,
der auf die Startseite zurueckwirft. Wer eine Kachel entfernt und den
Eintrag vergisst, laesst eine Tuer offen. So kann beides nicht
passieren.
Gebraucht wird sie, weil `bereich.html` seit heute fuer ihn offen ist
-- ohne diese Einschraenkung kaeme er ueber die Adresszeile auch in
die Agentur-Ablage, die ihn nichts angeht. */
export const MODI_BEREICHE_ERLAUBT = new Set(
HAND_BEREICHE
.map((k) => (/bereich\.html\?b=([a-z]+)/.exec(k.ziel) || [])[1])
.filter(Boolean));
/* Der Satz unter der Begruessung. Die fuenf bekannten Rollen haben ihn
in assets/js/start.js stehen ("Creator · dein eigener Bereich"); fuer
einen Modi darf er dort nicht stehen und kommt deshalb von hier.
OHNE IHN STAND DA NUR DAS WORT "Modi" -- neben fuenf Rollen, die einen
ganzen Satz bekommen. Das sieht nicht verborgen aus, sondern
unfertig, und Filipe haette zu Recht gefragt, warum seine Seite
halbfertig wirkt. Gefunden hat das die Browserpruefung, nicht das
Nachdenken.
AUF AUGENHOEHE FORMULIERT: "dein Bereich im Team", nicht "dein
Bereich unter DogFather". Ein Modi ist Teil des Teams, kein
Untergebener -- das gilt fuer jeden Text im Haus. */
const MODI_ROLLENTEXT = "Modi · dein Bereich im Team";
/* DIE ZIERZEILE UEBER "DIE ZENTRALE".
Sie nennt den BETRIEB, unter dem gearbeitet wird -- seit dem
08.09.2026 auf Filipes Wunsch "Spicy Media" statt "Dogfather
Universe". Das steht fest in start.html, und fuer die fuenf bekannten
Rollen stimmt es auch.
FUER EINEN MODI STIMMT ES NICHT. Ein Modi gehoert nicht zur Agentur,
er moderiert die Lives -- "Team Dogi" heisst diese Runde auch auf der
oeffentlichen Seite (team-modis.html). Bis hierher stand ueber seiner
Startseite die Marke eines Betriebs, mit dem er nichts zu tun hat.
Aufgefallen ist das nicht beim Nachdenken, sondern auf dem
Bildschirmfoto der Browserpruefung.
Wie ueberall kommt der Text vom Server: Ein zweiter Markenname in
start.html waere zwar kein Verrat (er sagt nichts ueber Modis), aber
eine Zeile, die fuer fast jeden Leser tot dasteht -- und tote Zeilen
werden irgendwann falsch erklaert. */
const MODI_MARKE = "Team Dogi";
/* =====================================================================
DIE KATEGORIEN EINER MODI-AUFGABE (10.09.2026)
Es sind die des Anforderungsdokuments -- alle dreizehn, die der
Aufgabenkatalog in Teil 2 tatsaechlich benutzt, dazu "Sonstiges" aus
Kapitel 6.1.
BEIM ERSTEN ANLAUF WAREN ES ACHT, mit der Begruendung, dreizehn
Knoepfe seien auf einem Handy unbedienbar. Das war eine plausible
Erklaerung fuer etwas Falsches: Gebaut ist ein AUSWAHLFELD, keine
Knopfleiste -- vierzehn Eintraege darin sind voellig unauffaellig.
Die Zusammenfassung haette eine Uebersetzungstabelle noetig gemacht
("Branding gehoert zu Planung"), die spaeter niemand nachvollziehen
kann. Jetzt steht an jeder Aufgabe die Kategorie, die im Dokument
danebensteht.
DIE REIHENFOLGE IST DIE DER HAEUFIGKEIT, nicht die des Dokuments:
Was taeglich vorkommt, steht oben. Wer eine Kategorie sucht, soll
nicht scrollen muessen.
SIE STEHEN AUF DEM SERVER, nicht in assets/js/aufgaben.js. Nicht weil
die Woerter etwas verraten wuerden -- "Clipping" sagt nichts --,
sondern weil eine Liste, die im Browser jedes Menschen liegt und nur
bei einem einzigen benutzt wird, die Frage aufwirft, fuer wen sie da
ist. Genau diese Frage soll niemand stellen. */
export const MODI_KATEGORIEN = [
{ wert: "chat", name: "Chat-Moderation" },
{ wert: "community", name: "Community" },
{ wert: "events", name: "Events" },
{ wert: "clipping", name: "Clipping" },
{ wert: "social", name: "Social Media" },
{ wert: "technik", name: "Technik" },
{ wert: "planung", name: "Planung" },
{ wert: "organisation", name: "Organisation" },
{ wert: "team", name: "Team" },
{ wert: "kommunikation", name: "Kommunikation" },
{ wert: "analyse", name: "Analyse" },
{ wert: "wachstum", name: "Wachstum" },
{ wert: "branding", name: "Branding" },
{ wert: "sonstiges", name: "Sonstiges" },
];
/**
* Darf diese Person mit Kategorien arbeiten?
*
* Ein Modi (es sind seine Aufgaben) und die DogFather-Rolle (sie sieht
* seine Aufgaben und legt ihm welche an). Sonst niemand -- und "sonst
* niemand" heisst hier: Die Liste kommt gar nicht erst mit, statt
* mitzukommen und ausgeblendet zu werden.
*/
export function kategorienFuer(person) {
if (!person) return [];
return TEAM_DOGI_ROLLEN.has(person.rolle) || person.rolle === "admin"
? MODI_KATEGORIEN : [];
}
/**
* FUER WESSEN AUFGABEN gilt die Kategorie? (10.09.2026)
*
* null -> fuer alle eigenen; die Oberflaeche zeigt das Feld immer
* [Nummern] -> nur wenn eine dieser Personen gewaehlt ist
* [] -> nie
*
* WARUM DAS DER SERVER BEANTWORTET UND NICHT DER BROWSER: Die
* Oberflaeche muesste sonst `p.rolle === 'modi'` vergleichen -- und
* assets/js/aufgaben.js bekommt JEDER, der die Seite oeffnet. Genau so
* stand es beim ersten Anlauf da, an sechs Stellen. Mit Nummern statt
* einem Rollennamen steht im Browser nichts, woraus sich etwas
* schliessen laesst: Wer keine bekommt, sieht eine leere Liste, und
* eine leere Liste sagt nichts.
*/
export function kategoriePersonen(person) {
if (!person) return [];
/* Ein Modi arbeitet ausschliesslich an eigenen Aufgaben -- dort gilt
sie immer, unabhaengig davon, wen er eintraegt. */
if (person.rolle === "modi") return null;
if (person.rolle !== "admin" && person.rolle !== "hand") return [];
try {
/* Auch die rechte Hand selbst: Sie traegt Aufgaben ein -- fuer
andere UND fuer sich. Waere ihre eigene Nummer nicht dabei,
verschwaende das Kategoriefeld genau dann, wenn sie sich selbst
etwas notiert. */
const liste = [...TEAM_DOGI_ROLLEN].map((r) => `'${r}'`).join(", ");
return db().prepare(
`SELECT id FROM personen WHERE rolle IN (${liste}) AND aktiv = 1`).all().map((z) => z.id);
} catch {
/* Ohne Datenbank lieber kein Feld als ein falsches. */
return [];
}
}
/* Der Satz der rechten Hand sagt, was ihre Rolle ist, ohne sie ueber
die anderen zu stellen: Sie haelt den Ueberblick, sie steht nicht
darueber. */
const HAND_ROLLENTEXT = "Rechte Hand · du hast alles im Blick und entscheidest mit";
export function rollentextFuer(person) {
if (person?.rolle === "modi") return MODI_ROLLENTEXT;
if (person?.rolle === "hand") return HAND_ROLLENTEXT;
return null;
}
/* DIE MARKE HAENGT AN ZWEI DINGEN, NICHT AN EINEM (10.09.2026).
*
* An der ROLLE: Wer zu Team Dogi gehoert, sieht ueberall den Namen
* seines Teams -- auch dann, wenn er die Seite ueber eine andere
* Adresse aufmacht.
*
* Und an der ADRESSE: Auf crew.dogfather-universe.com gibt es Spicy
* Media nicht. Dort stand ueber der Zentrale trotzdem "SPICY MEDIA" --
* gross, mitten im Bild, weil diese Funktion nur die Rolle kannte und
* DogFather nun einmal zur Agentur gehoert. Filipe: "ich will doch
* dass der eingang hier getrennt ist von der workspace seite."
*
* Zwei Fragen, EINE Antwortstelle. Wer die Adressbedingung stattdessen
* an der Aufrufstelle prueft, hat sie beim naechsten Aufruf nicht --
* und diese Funktion wird ueber kurz oder lang ein zweites Mal
* gebraucht. */
export function markeFuer(person, hostKopfzeile) {
if (istCrewAdresse(hostKopfzeile)) return MODI_MARKE;
return TEAM_DOGI_ROLLEN.has(person?.rolle) ? MODI_MARKE : null;
}
/* =====================================================================
KACHELN, DIE ZU EINER VORHANDENEN LISTE DAZUKOMMEN (10.09.2026)
bereicheFuer() ersetzt die Liste im Browser ganz -- das passt fuer
eine Rolle, die dort gar nicht vorkommt. Fuer die DogFather-Rolle
passt es nicht: Sie hat ihre 23 Kacheln, und es soll EINE dazu.
Sie steht hier und nicht in assets/js/bereiche.js, weil dort jeder
mitliest. "Team-Lage" allein verraet zwar nichts -- aber eine
Kachel, die nur bei einer einzigen Rolle erscheint, wirft die Frage
auf, fuer wen sie ist. Genau die soll niemand stellen.
===================================================================== */
/* ZWEIMAL "TEAM-LAGE" -- KORRIGIERT AM 10.09.2026.
Hier stand: Gruppe "Rund um das Team", Name "Team-Lage", Ton 22.
In bereiche.js gibt es aber SCHON eine Kachel "Team-Lage" mit Ton 22
und demselben Zeichen, die nach team.html fuehrt. DogFather hatte
damit zwei Kacheln, die gleich heissen, gleich aussehen, gleich
gefaerbt sind, nebeneinander in derselben Gruppe stehen -- und zu
verschiedenen Seiten fuehren.
Gefunden hat das nicht das Auge, sondern pruef-start-ansicht mit dem
Satz "jede Kachel hat ihre eigene Farbe (23 Farben auf 24 Kacheln)".
Ohne diese Zeile waere es stehen geblieben; zwei gleiche Kacheln
fallen niemandem auf, der weiss, welche er meint.
Jetzt drei Unterschiede statt keinem: eigener Name, eigene Gruppe,
eigene Farbe. Das Zeichen bleibt geteilt -- nachgemessen sind alle
22 Zeichen des Hauses bei DogFather bereits in Gebrauch, und ein
geteiltes Zeichen bei verschiedenem Namen, Ort und Ton ist keine
Verwechslungsgefahr (profil steht ohnehin schon zweimal).
"Eingang" und nicht "Rueckmeldungen": Die Kachel steht bei den
taeglichen Dingen, weil dort etwas WARTET -- Vorschlaege, ueber die
entschieden werden will. Das ist eine Aufgabe fuer heute, keine
Uebersicht fuer zwischendurch. */
/* =====================================================================
DIE EIGENE GRUPPE FUER DAS TEAM (10.09.2026)
Filipe zum Bildschirmfoto seiner Startseite: "ich will dass die
kacheln von den modis bei der rolle dogfather eine eigene kategorie
haben. damit ich nicht zwischen den kacheln suchen muss."
Er hatte recht, und es war schlimmer als unuebersichtlich: Der
Eingang stand unter "Taeglich" zwischen Chat und Kalender, das
Ideen-Board ebenfalls -- zwei Kacheln derselben Welt, verteilt auf
eine Reihe mit sieben anderen. Wer sie sucht, liest jedes Mal alle.
`gruppeNach` setzt die Gruppe direkt hinter "Taeglich" statt ans
Ende. Ganz unten waere zwar auch eine eigene Kategorie -- und immer
noch Scrollen.
DAS IDEEN-BOARD IST DABEI VON assets/js/bereiche.js HIERHER GEZOGEN.
Zwei Gruende, und der zweite ist der wichtigere:
1. Es gehoert in dieselbe Gruppe wie der Eingang, und die entsteht
hier.
2. Es stand mit `rollen: ['admin']` in einer Datei, die JEDER
herunterlaedt. Eine Kachel, die nur eine Rolle sieht, wirft dort
die Frage auf, wofuer sie ist -- genau die Frage, die niemand
stellen soll. Jetzt kommt sie vom Server und nur an die, die sie
bekommt.
Der Gruppenname "Team Dogi" ist unverfaenglich: Er steht so auf der
oeffentlichen Seite. */
const TEAM_GRUPPE = {
gruppe: "Team Dogi",
gruppeUnter: "Was dein Team macht – und was auf dich wartet",
gruppeNach: "Täglich",
};
const ZUSATZ_BEREICHE = {
admin: [
{ ...TEAM_GRUPPE, ...EINGANG_KACHEL, gruppe: TEAM_GRUPPE.gruppe,
gruppeUnter: TEAM_GRUPPE.gruppeUnter },
{
...TEAM_GRUPPE,
name: "Ideen-Board", unter: "Gesammelt und nach Dringlichkeit sortiert",
zeichen: "content", ton: 23, ziel: "bereich.html?b=ideen", szene: "portal",
},
],
};
export function zusatzBereicheFuer(person) {
return ZUSATZ_BEREICHE[person?.rolle] || [];
}
/* 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).
===================================================================== */
/* =====================================================================
DIE ROLLENLISTE DER DATENBANK ERWEITERN (09.09.2026)
Welche Rollen es geben darf, steht als CHECK-Regel in der Tabelle
`personen`. 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 steht der Ablauf ab jetzt EINMAL hier.
WARUM ALS FUNKTION: Er stand vorher zweimal da -- einmal fuer
'manager', einmal fuer 'spicy'. Fuer 'modi' waere es die dritte
Abschrift geworden, und jede Abschrift ist eine Gelegenheit, eine der
vier Absicherungen zu vergessen: die Sicherung VORHER, die Zaehlung
INNERHALB der Transaktion, die Spaltenliste aus der Tabelle statt aus
dem Gedaechtnis, die Pruefung auf verwaiste Verweise DANACH. Der
manager-Block darueber bleibt unangetastet: Er baut die Tabelle mit
einer fest hingeschriebenen Spaltenliste auf und ist damit ein
anderer Fall -- ihn mit einzufangen waere ein zweiter Umbau an einer
Stelle, an der ein Fehler Daten kostet.
GEPRUEFT WIRD DIE REGEL, NICHT DER TEXT (07.09.2026, gefunden von
pruef-spicy): SQLite hebt den CREATE-Text woertlich auf, mitsamt
Kommentaren. Ein erklaerender Satz mit dem Wort 'spicy' genuegte, und
die Umstellung hielt die Tabelle fuer schon umgestellt, obwohl die
CHECK-Regel noch die alte war. Deshalb wird die Regel herausgeschnitten
und NUR darin gesucht.
@param marker Die Rolle, an der erkannt wird, ob schon umgestellt ist.
@param rollen Die vollstaendige neue Liste erlaubter Rollen.
===================================================================== */
function checkListeErweitern(d, tabelle, spalte, marker, werte, jetztStempel) {
const rollenPlan = d.prepare(
"SELECT sql FROM sqlite_master WHERE type = 'table' AND name = ?").get(tabelle)?.sql || "";
/* DOPPELTE BACKSLASHES, und das ist kein Schoenheitsfehler:
In einem Template-Literal verschluckt JavaScript den einfachen
Backslash -- aus `\s` wird schlicht `s`, das Muster hiesse dann
"bereichs+INs*(...)" und traefe nie etwas. Die Umstellung haette
daraufhin STILLSCHWEIGEND nichts getan: keine Regel gefunden,
also nichts umzustellen, kein Fehler, kein Hinweis. Gemessen
statt ueberlegt -- der Unterschied war im Quelltext nicht zu
sehen. */
const muster = new RegExp(`${spalte}\\s+IN\\s*\\(([^)]*)\\)`, "i");
const regel = rollenPlan.match(muster)?.[1] || "";
/* Keine Regel gefunden heisst: Es gibt nichts umzustellen. Nicht
etwa "dann bauen wir sie neu" -- eine Tabelle ohne CHECK ist ein
Zustand, den jemand ansehen sollte, kein Fall fuer Automatik. */
if (!rollenPlan || !regel || regel.includes(`'${marker}'`)) return;
const sicherung = `${DB_PFAD}.vor-${marker}-${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 die neue Rolle
noch nicht, als dass Daten ohne Netz angefasst werden. */
console.error("[workspace] Sicherung fehlgeschlagen, Umstellung abgebrochen:", fehler?.message);
return;
}
/* Nur die Rollenliste ersetzen -- und pruefen, dass es geklappt hat.
`IF NOT EXISTS` MUSS MIT INS MUSTER (07.09.2026, gefunden von
pruef-spicy). Die Tabelle wurde mit `CREATE TABLE IF NOT EXISTS
personen` angelegt, also steht das auch in sqlite_master. Ohne diese
drei Woerter im Muster griff die Ersetzung nicht, der Bauplan blieb
unveraendert, die Schutzabfrage darunter schlug an -- und die
Umstellung waere auf dem echten Server NIE gelaufen. Sie haette es
gesagt (das ist der Wert der Abfrage), aber sie waere nie gelaufen. */
/* KEIN MUSTER FUER DEN TABELLENKOPF -- alles vor der ersten Klammer
IST der Kopf ("CREATE TABLE IF NOT EXISTS eintraege "). Hier stand
eine Regel mit Anfuehrungszeichen, Backticks und Backslashes in
einem Template-Literal, und genau daran ist sie gescheitert:
JavaScript verschluckt darin den einfachen Backslash, aus dem
Muster wurde "CREATE TABLEs+..." und es traf nie etwas. Die
Umstellung haette daraufhin STILLSCHWEIGEND nichts getan.
Ein Schnitt an der ersten Klammer braucht keine Maskierung, ist in
einer Zeile zu lesen und kann nicht falsch escaped sein. */
const klammer = rollenPlan.indexOf('(');
if (klammer < 0) {
console.error('[workspace] Umstellung abgebrochen: Bauplan ohne Klammer.');
return;
}
const neuerPlan = (`CREATE TABLE ${tabelle}_neu ` + rollenPlan.slice(klammer))
.replace(new RegExp(`${spalte}\\s+IN\\s*\\([^)]*\\)`, "i"),
`${spalte} IN (${werte.map((r) => `'${r}'`).join(",")})`);
if (!neuerPlan.includes(`'${marker}'`) || !neuerPlan.includes(`${tabelle}_neu`)) {
console.error("[workspace] Umstellung abgebrochen: Der Bauplan liess sich nicht "
+ "umschreiben. Steht die CHECK-Regel noch so da wie erwartet?");
return;
}
/* Die Spaltenliste kommt aus der Tabelle, nicht aus dem Gedaechtnis. */
const spalten = d.prepare(`PRAGMA table_info(${tabelle})`).all().map((z) => z.name);
if (!spalten.length) {
console.error("[workspace] Umstellung abgebrochen: keine Spalten gefunden.");
return;
}
const liste = spalten.map((n) => `"${n}"`).join(", ");
/* DIE INDIZES GEHEN MIT -- SONST GEHEN SIE VERLOREN (10.09.2026).
`DROP TABLE` nimmt jeden Index der Tabelle mit. Der Bauplan aus
sqlite_master beschreibt nur die TABELLE, nicht ihre Indizes; die
neue stand danach blank da.
Aufgefallen ist das nicht im Betrieb, sondern beim Nachdenken
darueber, was diese Auslieferung auf dem echten Server tut: Dort
wird `chat_raeume` umgebaut, und `idx_chat_raeume_letzte` waere
danach weg gewesen. Gemerkt haette es niemand -- die Abfrage laeuft
weiter, sie liest nur die ganze Tabelle. Beim naechsten Neustart
stuende der Index wieder da (er steht oben im Bauplan), aber "bis
zum naechsten Neustart falsch" ist kein Zustand, den man einbaut.
Bei einem EINDEUTIGEN Index waere es keine Frage der
Geschwindigkeit mehr, sondern der Richtigkeit: Die Zusage "einen
Kanal je Zustaendigkeit" haette bis zum Neustart still ausgesetzt.
`sql IS NULL` filtert die Indizes weg, die SQLite sich selbst zu
einer UNIQUE-Spalte baut: Die entstehen mit der neuen Tabelle von
allein, und ihr Name (`sqlite_autoindex_...`) ist gar nicht
anlegbar. */
const indizes = d.prepare(
"SELECT sql FROM sqlite_master WHERE type = 'index' AND tbl_name = ? AND sql IS NOT NULL")
.all(tabelle).map((z) => z.sql);
/* 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 {
const vorher = d.prepare(`SELECT COUNT(*) AS n FROM ${tabelle}`).get().n;
d.exec("BEGIN");
d.exec(neuerPlan);
d.exec(`INSERT INTO ${tabelle}_neu (${liste}) SELECT ${liste} FROM ${tabelle};`);
const nachher = d.prepare(`SELECT COUNT(*) AS n FROM ${tabelle}_neu`).get().n;
/* Die Zaehlung steht INNERHALB der Transaktion -- stimmt sie nicht,
wird zurueckgerollt und die alte Tabelle bleibt unberuehrt. */
if (nachher !== vorher) {
d.exec("ROLLBACK");
console.error(`[workspace] Umstellung abgebrochen: ${vorher} Zeilen vorher, `
+ `${nachher} nachher. Sicherung: ${sicherung}`);
} else {
d.exec(`DROP TABLE ${tabelle};`);
d.exec(`ALTER TABLE ${tabelle}_neu RENAME TO ${tabelle};`);
d.exec("COMMIT");
/* ERST NACH DEM COMMIT. Waere zurueckgerollt worden, stuenden die
alten Indizes noch -- ein zweites Anlegen waere dann ein Fehler
ohne Anlass. */
let indizesZurueck = 0;
for (const bau of indizes) {
try { d.exec(bau); indizesZurueck++; }
catch (f) { console.error("[workspace] Index nach der Umstellung:", f?.message); }
}
if (indizesZurueck !== indizes.length) {
console.error(`[workspace] ACHTUNG: nur ${indizesZurueck} von ${indizes.length} `
+ `Indizes auf ${tabelle} wiederhergestellt. Sicherung: ${sicherung}`);
}
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] '${marker}' in ${tabelle}.${spalte} freigeschaltet, `
+ `${nachher} Zeilen, ${spalten.length} Spalten, ${indizesZurueck} von `
+ `${indizes.length} Indizes uebernommen, Verweise geprueft.`);
}
}
} catch (fehler) {
try { d.exec("ROLLBACK"); } catch { /* schon zurueckgerollt */ }
console.error("[workspace] Umstellung fehlgeschlagen:", fehler?.message,
"-- Sicherung:", sicherung);
} finally {
d.exec("PRAGMA foreign_keys = ON");
}
}
function umstellungen(d) {
/* MIT SEKUNDEN, nicht nur mit Minuten (07.09.2026).
`VACUUM INTO` weigert sich, eine vorhandene Datei zu ueberschreiben
("output file already exists") -- zu Recht, eine Sicherung darf
nichts wegwerfen. Der Stempel hatte aber nur Minutenaufloesung:
Zwei Umstellungen innerhalb derselben Minute wollten in dieselbe
Datei, die zweite scheiterte, und weil ohne Sicherung nicht
umgestellt wird, blieb sie einfach aus.
Das ist genau die unangenehme Sorte Fehler: Es sieht nach "die
Umstellung lief" aus (die Datei liegt ja da) und war doch keine.
Gefunden von pruef-spicy, das den Server zweimal in derselben
Minute startet -- im Betrieb genuegt dafuer ein Neustart im
falschen Moment. */
const jetztStempel = new Date().toISOString().slice(0, 19).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"],
/* DER TAGESRUF BRAUCHT MEHR ALS AN/AUS (08.09.2026, screen10).
Filipe: "ich will das neben diesem kreis auch ein kleiner button
ist fuer den wecker von den aufgaben, oder quasi eine taetige
meldung einmal am tag zu aktivieren wenn noch aufgaben auf sind."
Eine Uhrzeit ist kein Schalter. Statt einer eigenen Tabelle fuer
eine einzige Zahl bekommt `push_einstellungen` eine allgemeine
Spalte `wert`: Wo eine Art mehr braucht als an/aus, steht es
dort. Die vorhandenen Arten lassen sie leer und merken nichts
davon.
KEIN `NOT NULL`, KEINE VORGABE IN DER DATENBANK: Die Vorgabe
gehoert nach workspace-push.js zu den Arten, sonst stuende sie an
zwei Stellen -- und die in der Datenbank wuerde bei einer
Aenderung vergessen. Genau diese Sorte Doppelung hat mich heute
schon dreimal aufgehalten. */
["push_einstellungen", "wert", "TEXT"],
/* ANTWORTEN MIT ZITAT (09.09.2026, Punkt 15).
Filipe: "ich will dass du diese seite viel krasser und
detaillierter machst ... alles reinsetzt was wir noch gebrauchen
koennten."
In einem Gespraech zu zweit weiss man meistens, worauf sich
etwas bezieht. In einer Gruppe nicht -- dort laufen drei Faeden
parallel, und "ja, mach das" kann alles heissen. Die Spalte
zeigt auf die Nachricht, auf die geantwortet wird.
KEIN FREMDSCHLUESSEL AUF chat_nachrichten: Wird die zitierte
Nachricht zurueckgenommen, bleibt ihre Zeile ohnehin stehen
(nur der Text wird geleert) -- und wird der ganze Raum
geloescht, geht die Antwort per CASCADE ueber `raum_id`
ohnehin mit. Ein zweiter Schluessel brauchte nur eine zweite
Gelegenheit fuer eine Sperre. */
["chat_nachrichten", "antwort_auf", "INTEGER"],
/* GESPRAECH LOESCHEN -- NUR BEI MIR (09.09.2026).
Filipe: "man muss die chats auch geloescht bekommen!!!"
Auf Nachfrage entschieden: nur bei mir, nicht bei beiden. Das
Gespraech verschwindet aus MEINER Liste, das Gegenueber behaelt
seinen Verlauf. Sonst koennte jeder jedem die Unterhaltung
vernichten -- und in einem Gespraech, in dem Absprachen stehen,
waere das ein Loch, das niemand mehr fuellen kann.
ZWEI SPALTEN, NICHT EINE, und der Grund ist ein Randfall:
`geloescht_bis` haelt fest, bis zu welcher Nachricht ich es nicht
mehr sehen will. Bei einem Gespraech OHNE jede Nachricht waere
das 0 -- und 0 heisst auch "nie geloescht". `geloescht_am`
unterscheidet die beiden Faelle. Kommt spaeter etwas Neues
(id > geloescht_bis), taucht das Gespraech von selbst wieder auf;
das Alte bleibt weg. */
["chat_teilnehmer", "geloescht_bis", "INTEGER NOT NULL DEFAULT 0"],
["chat_teilnehmer", "geloescht_am", "TEXT"],
/* ANHAENGE: FOTOS UND PDF (09.09.2026).
Filipe: "am besten waere es auch wenn man da auch im chat pdfs
schicken koennte. pdfs und fotos."
Je Nachricht hoechstens ein Anhang -- deshalb Spalten und keine
eigene Tabelle. Eine Tabelle waere die richtige Wahl fuer
"mehrere je Nachricht"; die gibt es hier nicht, und eine leere
Verbundabfrage bei jedem Verlauf waere Aufwand fuer einen Fall,
den es nicht gibt.
`anhang_datei` ist der ERZEUGTE Name auf der Platte, niemals der
eingeschickte. `anhang_art` ist "bild" oder "pdf" und wird aus
den ersten Bytes der Datei bestimmt, nicht aus dem Namen und
nicht aus dem Content-Type -- beide kommen vom Absender.
Breite und Hoehe stehen dabei, damit der Verlauf beim Laden
eines Bildes nicht springt. */
["chat_nachrichten", "anhang_datei", "TEXT"],
["chat_nachrichten", "anhang_name", "TEXT"],
["chat_nachrichten", "anhang_art", "TEXT"],
["chat_nachrichten", "anhang_typ", "TEXT"],
["chat_nachrichten", "anhang_groesse", "INTEGER"],
["chat_nachrichten", "anhang_breite", "INTEGER"],
["chat_nachrichten", "anhang_hoehe", "INTEGER"],
/* WELCHE VORLAGE DAHINTERSTECKT (09.09.2026).
Filipe: "ich will nur dass du dich informierst und schaust was
man vielleicht noch besser machen koennte von der benutzung ...
ich red von den aufgaben."
Beim Durchgehen der Bedienung ist aufgefallen: Man konnte
dieselbe Vorlage zweimal uebernehmen und bekam die Aufgabe
zweimal auf das Brett -- ohne jeden Hinweis. Im Vorlagenbrett
sah man nicht, was man schon geholt hatte.
Die Kennung der Vorlage steht jetzt an der Aufgabe. Damit kann
das Brett zeigen, was bereits uebernommen ist, und es braucht
dafuer KEINE zweite Abfrage: Die Aufgabenliste hat der Browser
ohnehin schon. Ueber den TITEL zu vergleichen waere die
naheliegende Abkuerzung gewesen -- und faellt in dem Moment um,
in dem jemand den Titel einer uebernommenen Aufgabe aendert. */
["aufgaben", "vorlage", "TEXT"],
/* DER SUCHSCHLUESSEL ZUM CODE (09.09.2026).
Bisher gab es nur den scrypt-Hash, und der ist mit Absicht
langsam (rund 150 ms). Wer eine Person AM CODE finden will, muss
deshalb alle Kandidaten der Reihe nach durchrechnen -- die
Anmeldung tut genau das, und im Code steht seit langem der
Hinweis, dass das ab etwa 50 Codes je Rolle spuerbar wird.
Diese Spalte ist die schnelle Antwort auf "zu welcher Person
gehoert dieser Code?": ein HMAC ueber den Code, in Mikrosekunden
berechnet und mit einem Index hinterlegt.
WARUM DAS UNBEDENKLICH IST -- nachgemessen, nicht angenommen: Ein
Code besteht aus 16 Zeichen eines 32er-Alphabets, das sind 80 Bit
Zufall. Selbst wenn jemand die ganze Datenbank haette, waere
Durchprobieren aussichtslos, egal wie schnell die Rechnung ist.
Der Schluessel steckt zusaetzlich nicht im Code, sondern in den
Einstellungen (siehe kennungSchluessel).
DER SCRYPT-HASH BLEIBT DIE ENTSCHEIDUNG. Der Suchschluessel sagt
nur, WEN man pruefen soll; ob der Code stimmt, sagt weiterhin
allein scrypt. Ein Treffer hier allein laesst niemanden herein. */
["personen", "code_kennung", "TEXT"],
/* DIE STUFE IM TEAM (10.09.2026).
Blueprint V3.0, Kapitel 4.1: Probe, Standard, Senior. Ein
Probe-Modi ist in der Einarbeitung, ein Senior kann die rechte
Hand bei Abwesenheit vertreten.
WARUM OHNE CHECK-REGEL, anders als bei `rolle`: Eine CHECK-Liste
laesst sich in SQLite nur ueber einen Tabellenneubau erweitern
(siehe checkListeErweitern) -- und die Stufen sind eine
ARBEITSEINTEILUNG, keine Rechtegrenze. Kaeme morgen eine vierte
dazu, waere ein Tabellenneubau auf einer Live-Datenbank ein
hoher Preis fuer eine Beschriftung. Geprueft wird deshalb im
Code, an genau einer Stelle (STUFEN), und was dort nicht
draufsteht, wird abgewiesen.
NULL HEISST "Probe" und nicht "unbekannt". Ein dritter Zustand
waere eine Frage, die niemand beantworten kann ("was ist er
denn nun?"); und ein neuer Mensch faengt ohnehin in der
Einarbeitung an. Beim Lesen wird NULL deshalb auf 'probe'
abgebildet -- an einer Stelle, nicht in jeder Abfrage.
Die Spalte heisst wie `punkt_stand.stufe`, meint aber etwas
anderes (dort: gut/verbessern). Zwei Tabellen, kein Konflikt --
aber wer beides im selben Kopf hat, sollte es wissen. */
["personen", "stufe", "TEXT"],
/* DIE KATEGORIE EINER AUFGABE (10.09.2026).
Kapitel 6.1 des Anforderungsdokuments verlangt sie an jeder
Aufgabe. Filipes Entscheidung dazu: nur bei den Modis -- fuer
Creator, Scouts und Manager bleibt das Formular, wie es war.
KEIN CHECK IN DER SPALTE, obwohl die Werte fest sind. Ein CHECK
laesst sich in SQLite nicht aendern; die Liste um eine Kategorie
zu erweitern hiesse jedes Mal, die ganze Tabelle neu zu bauen.
Geprueft wird stattdessen beim Schreiben (siehe KATEGORIEN in
workspace-aufgaben.js) -- an EINER Stelle, durch die alle
Schreibwege laufen. */
["aufgaben", "kategorie", "TEXT"],
/* WAS DOGFATHER DAZU TUN MUESSTE (10.09.2026).
Filipe: *"auch entscheiden was ich machen muss wenn das und das
erledigt wird."* Der Modi schreibt es beim Angebot dazu; wird
das Angebot angenommen, wird genau daraus seine Aufgabe.
Ein eigenes Feld und nicht im Fliesstext: Aus einem Absatz laesst
sich keine Aufgabe machen, ohne zu raten, welcher Satz gemeint
war. */
["eintraege", "einsatz", "TEXT"],
/* KATEGORIE-KANAELE (10.09.2026, Kapitel 7.2).
"Kategorie-Kanaele: z. B. Clipping-Team, Live-Moderation --
Owner/rechte Hand haben Zugriff auf alle Kanaele."
Ein Kanal ist KEINE vierte Tabelle, sondern eine dritte Art Raum.
Damit gilt fuer ihn ohne eine einzige neue Zeile alles, was schon
da ist: Verlauf, Anhaenge, Suche, Ungelesen-Zaehler, der
Live-Strom, das Wegraeumen. Eine eigene Tabelle haette all das
ein zweites Mal gebraucht -- und die zweite Fassung waere die
gewesen, in der die Zugriffsregel fehlt.
WELCHE Zustaendigkeit, steht in kategorie und kommt aus
MODI_KATEGORIEN -- derselben Liste, aus der auch die Aufgaben
ihre Kategorie nehmen. Ein Kanal "Clipping" und Aufgaben
"clipping" waeren sonst zwei Woerter fuer dieselbe Sache. */
["chat_raeume", "kategorie", "TEXT"],
/* ANGEHEFTETE NACHRICHTEN (10.09.2026, Kapitel 7.2).
"Ankuendigungs-Funktion: Pin-Nachrichten von Owner/rechte Hand
an alle."
Am Anheften haengt ein ZWEITER Name (angeheftet_von) und nicht
nur ein Datum. Eine Ankuendigung ohne Absender ist ein Aushang
ohne Unterschrift -- und wer sie geloest hat, ist genau die
Frage, die spaeter gestellt wird.
KEIN FREMDSCHLUESSEL AUF personen: Wird jemand geloescht, soll
die Ankuendigung nicht mitverschwinden. Dieselbe Ueberlegung wie
bei antwort_auf weiter oben. */
["chat_nachrichten", "angeheftet_am", "TEXT"],
["chat_nachrichten", "angeheftet_von", "INTEGER"],
]) {
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 {
/* Ohne Index waere der Suchschluessel sinnlos -- SQLite laese die
ganze Personentabelle, und genau das sollte er ersparen. Steht
aus demselben Grund hier unten wie der Termin-Index: Die Spalte
entsteht erst durch die Schleife darueber. */
d.exec("CREATE INDEX IF NOT EXISTS idx_personen_kennung ON personen (code_kennung)");
} catch (fehler) {
console.error("[workspace] Index idx_personen_kennung:", fehler?.message);
}
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");
}
}
/* =====================================================================
ROLLE "spicy" (Spicy Media) FREISCHALTEN — 07.09.2026
Wunsch Filipe: "ich will dass ueber dogfather auch eine kategorie,
eine neue rolle entsteht die den namen traegt, spicy media."
---------------------------------------------------------------------
DAS IST DER RISKANTESTE UMBAU IM GANZEN HAUS
Eine Rolle ist ein erlaubter Wert in einer Spalte, und der steckt in
SQLite in einem CHECK. Ein CHECK laesst sich nicht aendern -- die
Tabelle muss neu gebaut werden. Bei `eintraege` war das schon
unangenehm; hier geht es um `personen`, und auf die zeigen
ZWEIUNDFUENFZIG Fremdschluessel aus einem Dutzend Tabellen. Jede
Sitzung, jede Aufgabe, jeder Termin, jede Datei haengt daran.
DESHALB WIRD DER BAUPLAN NICHT ABGESCHRIEBEN, SONDERN GELESEN.
Die beiden Umstellungen darueber schreiben ihre Spaltenliste von
Hand ab. Das ging gut, solange die Liste stimmte -- aber `personen`
hat seit damals SIEBEN Spalten dazubekommen (bild, ueber_mich,
tiktok, instagram, youtube, twitch und weitere, siehe den
Spalten-Nachtrag oben). Wer hier eine Liste abschreibt, verliert
beim naechsten Mal genau die Spalte, die jemand letzte Woche
ergaenzt hat -- samt allen Profilbildern.
Also: Der vorhandene CREATE-Text wird aus sqlite_master geholt und
darin AUSSCHLIESSLICH die Rollenliste ersetzt. Alles andere -- jede
Spalte, jeder Typ, jede Vorgabe -- bleibt woertlich stehen. Und die
Kopierliste kommt aus PRAGMA table_info, also aus der Tabelle
selbst.
WENN DIE ERSETZUNG NICHT GREIFT, WIRD NICHT UMGESTELLT. Ein
`replace`, das nichts findet, gibt den Text unveraendert zurueck --
die Umstellung liefe dann durch, baute dieselbe Tabelle noch einmal
und meldete Erfolg. Genau die Sorte gruener Haken, die nichts
geprueft hat. Deshalb wird danach nachgesehen, ob 'spicy' wirklich
drinsteht.
===================================================================== */
/* =====================================================================
MEHR TERMINARTEN (08.09.2026)
Wunsch Filipe: "da sollen auch noch kategorien wie bigmatch,
turniere, special-live ... informier dich, was man da alles noch
gebrauchen koennte."
Recherchiert (Streams Charts, Kick, StreamerCollabs): Was ein
Creator-Team im Kalender wirklich unterscheidet, sind neben den
internen Terminen die AUFTRITTE -- Wettkaempfe (BigMatch, Turnier),
gemeinsame Formate (Collab, Raid-Train) und die grossen Ausnahmen
(Special-Live, Charity). Genau diese sechs kommen dazu.
WARUM EIN TABELLENUMBAU: Die erlaubten Werte stecken in einem
CHECK, und ein CHECK laesst sich in SQLite nicht aendern. Dieselbe
Lage wie bei der Rolle 'spicy' -- und deshalb hier dasselbe,
bewaehrte Verfahren: Bauplan aus sqlite_master lesen, NUR die Liste
ersetzen, das Ergebnis pruefen, Spalten aus PRAGMA holen, Zeilen
INNERHALB der Transaktion zaehlen, Sicherung vorher.
Zwei Tabellen statt einer (termine und termin_serien), deshalb
eine Schleife -- zweimal derselbe Text waere zweimal dieselbe
Gelegenheit, eine Stelle zu vergessen. */
const ARTEN_NEU = "'termin','call','review','bigmatch','turnier','special','collab','raid','charity'";
for (const tabelle of ["termine", "termin_serien"]) {
const plan = d.prepare(
"SELECT sql FROM sqlite_master WHERE type = 'table' AND name = ?").get(tabelle)?.sql || "";
/* Geprueft wird die REGEL, nicht der ganze Bauplan: SQLite hebt ihn
woertlich auf, samt Kommentaren -- ein erklaerender Satz mit dem
Wort 'bigmatch' wuerde sonst genuegen, damit die Umstellung sich
fuer erledigt haelt. Genau dieser Fehler ist bei 'spicy' passiert. */
const artRegel = plan.match(/art\s+IN\s*\(([^)]*)\)/i)?.[1] || "";
if (!plan || !artRegel || artRegel.includes("'bigmatch'")) continue;
/* DER TABELLENNAME GEHOERT IN DEN DATEINAMEN (08.09.2026).
Hier stand `.vor-arten-${jetztStempel}` -- ohne die Tabelle. Der
Stempel wird EINMAL pro Serverstart gebildet, diese Schleife
laeuft aber ZWEIMAL. Der zweite Durchlauf wollte also dieselbe
Datei anlegen, `VACUUM INTO` weigert sich (die Datei ist schon
da), und das `break` unten beendete daraufhin die ganze Schleife.
Ergebnis: `termine` war umgestellt, `termin_serien` NICHT. Und
zwar still -- die Meldung ging in die Serverausgabe, die niemand
liest. Aufgefallen waere es erst, wenn jemand eine wiederkehrende
BigMatch-Reihe anlegt und die Datenbank sie ohne erkennbaren
Grund ablehnt.
Das `break` bleibt richtig: Wenn sich keine Sicherung anlegen
laesst, wird nicht umgebaut. Falsch war nur der Name. */
const sicherung = `${DB_PFAD}.vor-arten-${tabelle}-${jetztStempel}`;
try {
d.exec(`VACUUM INTO '${sicherung.replace(/'/g, "''")}'`);
console.log("[workspace] Sicherung vor der Artenumstellung:", sicherung);
} catch (fehler) {
console.error("[workspace] Sicherung fehlgeschlagen, Artenumstellung abgebrochen:",
fehler?.message);
break;
}
const neuerPlan = plan
/* DAS MUSTER WIRD ZUSAMMENGESETZT, NICHT IN EINEN TEMPLATE-STRING
GESCHRIEBEN. Dort wird \s beim Einlesen zu einem blossen "s" --
aus "CREATE TABLE\s+" wuerde "CREATE TABLEs+", das Muster
passte auf nichts, und die Umstellung waere STILL ausgeblieben.
Genau so stand es hier beim ersten Anlauf; nachgemessen mit
einem Einzeiler, der beide Schreibweisen gegen den echten
Bauplan haelt. */
.replace(new RegExp(
"CREATE TABLE\\s+(?:IF\\s+NOT\\s+EXISTS\\s+)?[\"'`]?" + tabelle + "[\"'`]?", "i"),
`CREATE TABLE ${tabelle}_neu`)
.replace(/art\s+IN\s*\([^)]*\)/i, `art IN (${ARTEN_NEU})`);
if (!neuerPlan.includes("'bigmatch'") || !neuerPlan.includes(`${tabelle}_neu`)) {
console.error(`[workspace] Artenumstellung abgebrochen: Bauplan von ${tabelle} `
+ "liess sich nicht umschreiben.");
continue;
}
const spalten = d.prepare(`PRAGMA table_info(${tabelle})`).all().map((z) => z.name);
if (!spalten.length) continue;
const liste = spalten.map((n) => `"${n}"`).join(", ");
d.exec("PRAGMA foreign_keys = OFF");
try {
const vorher = d.prepare(`SELECT COUNT(*) AS n FROM ${tabelle}`).get().n;
d.exec("BEGIN");
d.exec(neuerPlan);
d.exec(`INSERT INTO ${tabelle}_neu (${liste}) SELECT ${liste} FROM ${tabelle};`);
const nachher = d.prepare(`SELECT COUNT(*) AS n FROM ${tabelle}_neu`).get().n;
if (nachher !== vorher) {
d.exec("ROLLBACK");
console.error(`[workspace] Artenumstellung abgebrochen: ${vorher} Zeilen vorher, `
+ `${nachher} nachher. Sicherung: ${sicherung}`);
} else {
d.exec(`DROP TABLE ${tabelle};`);
d.exec(`ALTER TABLE ${tabelle}_neu RENAME TO ${tabelle};`);
d.exec("COMMIT");
const kaputt = d.prepare("PRAGMA foreign_key_check").all();
if (kaputt.length) {
console.error("[workspace] ACHTUNG: nach der Artenumstellung", kaputt.length,
"verwaiste Verweise. Sicherung:", sicherung);
} else {
console.log(`[workspace] Terminarten erweitert in ${tabelle}: ${nachher} Zeilen, `
+ `${spalten.length} Spalten uebernommen, Verweise geprueft.`);
}
}
} catch (fehler) {
try { d.exec("ROLLBACK"); } catch { /* schon zurueckgerollt */ }
console.error("[workspace] Artenumstellung fehlgeschlagen:", fehler?.message,
"-- Sicherung:", sicherung);
} finally {
d.exec("PRAGMA foreign_keys = ON");
}
}
/* Welche Rollen die Datenbank zulaesst. Der Ablauf steht in
rollenRegelUmstellen() -- einmal, nicht je Rolle abgeschrieben.
Beide Aufrufe stehen hier: Auf einer Datenbank, die 'spicy' schon
kennt, tut der erste nichts und nur der zweite laeuft. */
checkListeErweitern(d, "personen", "rolle", "spicy",
["spicy", "admin", "manager", "scout", "creator"], jetztStempel);
checkListeErweitern(d, "personen", "rolle", "modi",
["spicy", "admin", "manager", "scout", "creator", "modi"], jetztStempel);
checkListeErweitern(d, "personen", "rolle", "hand",
["spicy", "admin", "manager", "scout", "creator", "modi", "hand"], jetztStempel);
/* DER BEREICH FUER DAS IDEEN-BOARD (10.09.2026, Kapitel 5.6).
Dieselbe Umstellung, andere Tabelle -- und genau deshalb steht sie
ab jetzt EINMAL da. Der alte agentur-Block weiter unten schreibt
den ganzen Bauplan von Hand ab; im Kommentar dort steht, dass dabei
schon einmal drei Spalten vergessen wurden. Eine vierte Abschrift
waere die vierte Gelegenheit dazu. */
checkListeErweitern(d, "eintraege", "bereich", "ideen",
["live", "content", "technik", "community", "schutz", "agentur", "ideen"], jetztStempel);
/* Die Angebote (10.09.2026) -- Vorschlaege der Modis, ueber die
DogFather und VanVan entscheiden. */
checkListeErweitern(d, "eintraege", "bereich", "angebote",
["live", "content", "technik", "community", "schutz", "agentur", "ideen", "angebote"],
jetztStempel);
/* ZWEI NEUE ZUSTAENDE. Bisher kannte ein Eintrag nur offen/erledigt.
Ein Angebot hat aber eine ENTSCHEIDUNG: angenommen oder abgelehnt
-- und das ist etwas anderes als "erledigt". Wer beides in einen
Topf wirft, kann hinterher nicht mehr sagen, was aus einem
Vorschlag geworden ist. */
checkListeErweitern(d, "eintraege", "status", "angenommen",
["offen", "erledigt", "angenommen", "abgelehnt"], jetztStempel);
/* DIE DRITTE RAUMART (10.09.2026, Kapitel 7.2). Auf einer frischen
Anlage steht 'kanal' schon im Bauplan -- dann findet der Aufruf die
Regel bereits erweitert und tut nichts. */
checkListeErweitern(d, "chat_raeume", "art", "kanal",
["direkt", "gruppe", "kanal"], jetztStempel);
/* EIN KANAL JE ZUSTAENDIGKEIT, und die Datenbank haelt das fest.
Die Regel steht auch in der Route -- aber eine Regel, die nur in
einer Route steht, gilt genau so lange, bis jemand eine zweite
Route schreibt. Zwei Kanaele "Clipping" waeren zwei halbe
Verlaeufe, und man merkt es erst, wenn jemand die Antwort im
falschen sucht.
TEILINDEX (`WHERE art = 'kanal'`): Gruppen und Zweier-Gespraeche
haben gar keine Kategorie; ohne die Bedingung waere schon die
zweite Gruppe mit NULL ein Verstoss -- nein, NULL zaehlt in SQLite
nicht als Dublette, aber der Index waere trotzdem groesser als
noetig und wuerde etwas versprechen, was er nicht meint.
ER STEHT HIER UNTEN und nicht im Bauplan: Die Spalte `kategorie`
entsteht auf einer bestehenden Anlage erst durch die Schleife
weiter oben -- im Bauplan gaebe es sie beim Start noch nicht. */
try {
d.exec(`CREATE UNIQUE INDEX IF NOT EXISTS idx_chat_kanal_kategorie
ON chat_raeume (kategorie) WHERE art = 'kanal'`);
} catch (fehler) {
console.error("[workspace] Kanal-Index:", fehler?.message);
}
}
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;
/* ROLLENLISTE: 'spicy' steht hier mit drin, nicht nur in der
Umstellung (07.09.2026, gefunden von pruef-spicy). Vorher legte
eine frische Datenbank die alte Liste an, und die Umstellung
lief beim allerersten Start sofort hinterher -- Tabelle neu
bauen, Sicherung schreiben, umbenennen, fuer nichts. Ein
Bauplan, der sofort umgebaut werden muss, ist der falsche.
KEIN KOMMENTAR INNERHALB DIESES CREATE-TEXTES. SQLite hebt ihn
woertlich in sqlite_master auf -- samt Kommentaren. Ein Satz mit
dem Wort 'spicy' darin haette bedeutet, dass die Umstellung die
Tabelle fuer bereits umgestellt haelt, obwohl die CHECK-Regel
noch die alte ist. Genau das ist beim Bauen passiert: Der
Bauplan enthielt das Wort, die Regel nicht. */
CREATE TABLE IF NOT EXISTS personen (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL,
rolle TEXT NOT NULL CHECK (rolle IN ('spicy','admin','manager','scout','creator','modi')),
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','kanal')),
/* Gruppen und Kanaele 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.
Bei einem Kanal steht in kategorie, WORUM es geht; der Name
wird daraus gebildet. Zwei Namen fuer dieselbe Zustaendigkeit
sind der sichere Weg zu zwei Kanaelen mit je der Haelfte der
Nachrichten darin. */
name TEXT,
kategorie 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','bigmatch','turnier','special','collab','raid','charity')),
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);
/* =================================================================
DER WECKER (07.09.2026)
Wunsch Filipe: "wie so ein wecker, den man auch in den
eintraegen aktivieren oder ausschalten kann, den soll man sogar
so einstellen koennen, dass er einen auch mehrmals informiert,
einmal eine woche vorher, einmal drei tage vorher und einmal am
tag selber. das soll jeder fuer sich selbst einstellen koennen."
EINE ZEILE IST EIN WECKER. Nicht ein Feld am Termin mit einer
Liste darin: Mehrere Vorlaufzeiten je Termin sind der ganze
Zweck, und "jeder fuer sich" heisst, dass zwei Personen am
SELBEN Termin verschiedene Wecker haben. Beides zusammen ist
eine klassische n:m-Beziehung, und die gehoert in eine eigene
Tabelle. Ein Feld mit kommagetrennten Zahlen waere beim ersten
"zeig mir alle faelligen Wecker" nicht mehr abfragbar.
Die Spalte minuten_vorher statt eines Zeitpunkts: Ein Zeitpunkt muesste
bei JEDER Terminverschiebung nachgezogen werden -- und genau
das vergisst man. Ein Abstand rechnet sich beim Wecken aus dem
aktuellen Beginn und ist damit immer richtig, egal wie oft der
Termin wandert.
ON DELETE CASCADE an beiden Seiten: Ein Wecker ohne Termin
weckt niemanden, und ein Wecker ohne Person auch nicht.
Zusammengesetzter Primaerschluessel: Dieselbe Person kann
denselben Abstand nicht zweimal setzen -- sonst klingelt es
doppelt, und das merkt man erst nachts.
Recherche (Google Calendar, Outlook, Morgen): Fuenf Wecker je
Termin sind das uebliche Maximum, und die verbreitete Empfehlung
fuer Wichtiges lautet "eine Woche, ein Tag, am Tag selbst".
Genau diese Staffel bietet die Oberflaeche an. */
CREATE TABLE IF NOT EXISTS termin_wecker (
termin_id INTEGER NOT NULL REFERENCES termine(id) ON DELETE CASCADE,
person_id INTEGER NOT NULL REFERENCES personen(id) ON DELETE CASCADE,
minuten_vorher INTEGER NOT NULL,
gesetzt TEXT NOT NULL,
PRIMARY KEY (termin_id, person_id, minuten_vorher)
);
CREATE INDEX IF NOT EXISTS idx_termin_wecker_person
ON termin_wecker (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','bigmatch','turnier','special','collab','raid','charity')),
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','ideen')),
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");
}
/* =====================================================================
DER SUCHSCHLUESSEL ZU EINEM CODE (09.09.2026)
Beantwortet in Mikrosekunden, WEN man pruefen soll -- nicht, ob der
Code stimmt. Das bleibt Sache von scrypt darunter.
WARUM EIN HMAC UND KEIN EINFACHER HASH: Ein einfacher Hash ueber den
Code waere ueberall gleich. Mit einem Schluessel, der nur in dieser
einen Datenbank steht, ist der Wert ausserhalb wertlos -- er laesst
sich nirgends nachschlagen und nirgends wiedererkennen.
WARUM DER SCHLUESSEL IN DIE DATENBANK GEHOERT UND NICHT IN DIE
UMGEBUNG: Eine neue Pflichtangabe in server/.env ist eine Angabe, die
beim naechsten Aufsetzen fehlen kann. Sie wuerde nicht laut scheitern,
sondern leise: Der stille Zugang fuende niemanden mehr, und die
Anmeldung saehe aus wie ein falscher Code. Aus der Datenbank kann sie
nicht verschwinden -- sie wird mitgesichert wie alles andere.
OHNE SCHLUESSEL GIBT ES KEINE ANTWORT, nicht etwa eine schlechtere:
`null` heisst "konnte nicht nachsehen", und die Anmeldung behandelt
das wie "nicht gefunden". Ein Rueckfall auf einen ungeschluesselten
Wert waere die stille Verschlechterung, die niemandem auffaellt. */
let _kennungSchluessel = null;
function kennungSchluessel() {
if (_kennungSchluessel) return _kennungSchluessel;
try {
const d = db();
const lies = () => d.prepare(
"SELECT wert FROM einstellungen WHERE schluessel = 'code_kennung_schluessel'").get()?.wert;
let wert = lies();
if (!wert) {
/* Bewusst NICHT ueber einstellungSetzen(): Das schriebe einen
Eintrag ins Protokoll, und ein Protokolleintrag ist genau die
Spur, die es hier nicht geben soll. */
d.prepare(`INSERT INTO einstellungen (schluessel, wert, geaendert, von)
VALUES (?,?,?,NULL) ON CONFLICT(schluessel) DO NOTHING`)
.run("code_kennung_schluessel", randomBytes(32).toString("hex"),
new Date().toISOString());
/* Danach nochmal LESEN statt den erzeugten Wert zu benutzen:
Legen zwei Vorgaenge ihn im selben Augenblick an, gewinnt einer
-- und beide muessen mit demselben weiterrechnen. */
wert = lies();
}
_kennungSchluessel = wert || null;
return _kennungSchluessel;
} catch {
return null;
}
}
export function codeKennung(code) {
const schluessel = kennungSchluessel();
if (!schluessel || !code) return null;
return createHmac("sha256", schluessel).update(String(code)).digest("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;
}
/* DIE ADRESSE ENTSCHEIDET MIT (10.09.2026).
Seit die Modi-App unter crew.dogfather-universe.com laeuft, gehoert
jede Sitzung zu genau EINER Adresse: Modis dorthin, alle anderen auf
den Workspace. Die Regel steht in crew-adresse.js und ist dort ohne
laufenden Server pruefbar.
WARUM HIER UND NICHT IN DEN MODULEN: Durch diese Funktion geht jedes
der 26 Fachmodule und jede Seitenschranke. Eine Middleware daneben
koennte man in einem neuen Modul vergessen -- und ein vergessener
Rechteschutz faellt nicht auf, weil dann alles geht.
`null` heisst hier dasselbe wie bei einer abgelaufenen Sitzung:
nicht angemeldet. Die Seitenschranke schickt zur Zugangswand, die
Schnittstellen antworten mit 401. Beides ist genau richtig -- auf
dieser Adresse ist diese Person schlicht niemand. */
if (!sitzungPasstZurAdresse(reihe.rolle, req.get?.("host"))) 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,
/* AUSDRUECKLICH OHNE 'modi' (10.09.2026). Vorher stand hier `null`
("jede angemeldete Rolle") -- und mit der neuen Rolle hiess das
auch: sie. Die Seite laedt dann zwar, ihre Schnittstelle antwortet
ihm aber mit 404, weil `content` nicht zu seinen Bereichen gehoert.
Eine Seite, die man oeffnen kann und die dann leer bleibt, sieht
aus wie ein Fehler -- und ist einer.
Das ist die Kehrseite von "geschuetzt ist die Regel, nicht die
Ausnahme": Eine NEUE ROLLE erbt jedes `null` automatisch. Wer eine
Rolle hinzufuegt, muss diese Liste durchgehen -- der Kachel-Durchgang
in pruef-rollen findet die Faelle, die dabei auffallen. */
"/workspace/content.html": ["spicy", "admin", "manager", "scout", "creator"],
"/workspace/aufgaben.html": null,
/* Chat (06.09.2026): jede Rolle. WEN man erreicht, entscheidet
schreibbareIds() -- nicht der Zugang zur Seite. Ein Creator, der
die Seite nicht öffnen dürfte, könnte auch nicht antworten, und
genau das Antworten ist der Zweck. */
"/workspace/chat.html": null,
/* NUR DogFather (07.09.2026). Wunsch Filipe: "nimm die kategorie
zahlen bei jedem weg, nur die rolle dogfather soll die sehen."
Bis heute stand hier `null` -- jede Rolle durfte die Seite oeffnen,
und WESSEN Zahlen jemand sah, entschied sichtbareCreatorIds().
Die Begruendung dafuer war gut (es sind SEINE Zahlen), sie ist
jetzt aber nicht mehr meine Entscheidung.
WICHTIG: Die Kachel wegzunehmen haette NICHT gereicht. Eine Kachel
ist ein Weg, keine Schranke -- wer die Adresse kennt oder ein altes
Lesezeichen hat, waere weiterhin hineingekommen. Beides gehoert
zusammen: bereiche.js zeigt sie nur noch DogFather, diese Zeile
laesst nur ihn hinein. */
/* NUR DogFather -- auch nicht Spicy Media (07.09.2026, zweite
Praezisierung). Beim Anlegen der Rolle stand hier ["spicy","admin"],
weil Spicy Media "dieselben Rechte ausser Automationen" bekommen
sollte. Filipe hat es an der laufenden Seite gesehen und
widersprochen: "bei der spicy rolle, die zahlen diese kategorie
nicht sehen." */
"/workspace/leistung.html": ["admin"],
/* 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". */
/* PERSONEN & ZUGAENGE: jetzt auch fuer Manager und Spicy Media
(07.09.2026).
Filipe: "die manager sehen immer noch nicht die kachel personen &
zugangscode, wo sie dan die neuen creator hinzufuegen koennen ...
und die sollen in der kategorie wo die jetzt sehen werden auch noch
das mit den hinzufuegen sehen, alles andere auf der seite sollen die
weiterhin nicht sehen."
DIE SEITE OEFFNET SICH, DIE SCHNITTSTELLEN NICHT. Das ist der ganze
Trick und der Grund, warum das hier ungefaehrlich ist: Alles unter
/workspace/api/verwaltung haengt weiterhin an `nurAdmin` -- Rollen
aendern, Codes neu setzen, sperren, loeschen, Protokoll lesen
bleiben zu und antworten mit 404. Wer die Seite oeffnet, bekommt
also genau die Teile zu sehen, fuer die es auch einen Weg gibt.
Eine Seite ist kein Schutz, sie ist ein Weg. Der Schutz steht in den
Schnittstellen, und der ist unveraendert. */
"/workspace/personen.html": ["spicy", "admin", "manager"],
"/workspace/profil.html": ["spicy", "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. */
/* 'modi' kam am 10.09.2026 dazu. Der Steckbrief ist die EIGENE Seite
("Dein Bild und deine Kanäle"); profil.html daneben ist der
Entwicklungsplan eines Creators und fuer ihn sinnlos. Der erste
Anlauf hatte genau das verwechselt -- die Kachel hiess "Mein
Profil" und fuehrte auf eine Seite, deren Schnittstelle mit 404
antwortet, weil sie ausdruecklich nur Creator kennt. Gefunden vom
neuen Kachel-Durchgang in pruef-rollen. */
"/workspace/steckbrief.html": ["spicy", "admin", "manager", "scout", "creator", "hand", "modi"],
"/workspace/kalender.html": null,
"/workspace/dateien.html": null,
/* 'modi' kam am 10.09.2026 dazu -- und es war ein echter Fund: Ohne
diese Zeile leiteten VIER seiner Kacheln (Live-Ablauf, Community,
Technik, Ideen-Board) wortlos auf die Startseite zurueck. Der
Rollen-Rundgang meldete sie trotzdem als "ok": Eine Umleitung ist
kein Fehler, die Seite laedt ja -- nur eben eine andere. Seither
prueft pruef-rollen fuer JEDE Rolle, dass jede Kachel, die sie
bekommt, auch wirklich dort ankommt.
WELCHE Bereiche ein Modi oeffnen darf, entscheidet die Liste
seiner Kacheln (MODI_BEREICHE_ERLAUBT) -- sonst kaeme er ueber die
Adresszeile auch in die Agentur-Ablage. */
"/workspace/bereich.html": ["spicy", "admin", "manager", "scout", "creator", "hand", "modi"],
"/workspace/report.html": ["spicy", "admin", "manager", "scout", "creator"],
"/workspace/scouting.html": ["spicy", "admin", "manager", "scout"],
/* DIE ARBEITSLAGE DES TEAMS (09.09.2026).
Filipe: "so eine kategorie wie ueber die creator will ich dass nur
fuer die spicy und dogfather rolle auch ueber manager und scouts
gibt."
Nur diese beiden Rollen. Ein Manager sieht seine eigene Zeile hier
ausdruecklich NICHT -- das ist kein Versehen: Eine Uebersicht ueber
die Arbeit der Kollegen gehoert der Leitung, nicht der Reihe. Der
Schutz steht ohnehin in der Schnittstelle (workspace-team.js);
diese Zeile sorgt nur dafuer, dass niemand vor einer leeren Seite
steht. */
"/workspace/team.html": ["spicy", "admin"],
"/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. */
/* AUTOMATIONEN BLEIBEN BEI DogFather (07.09.2026). Ausdruecklich:
"bei automationen sollen die nicht sehen." Dahinter liegen
Sicherungen, die KI und der Zustand des Servers -- das ist Betrieb,
nicht Betreuung. */
"/workspace/automation.html": ["admin"],
/* Die gemeinsame Lage des Teams -- nur die DogFather-Rolle, also
Filipe und VanVan (10.09.2026). Fuer alle anderen leitet die
Schranke auf die Startseite; die Schnittstelle dahinter antwortet
zusaetzlich mit 404. Zwei Schloesser, absichtlich: Faellt eines
weg, haelt das andere. */
/* Der Eingang: DogFather und seine rechte Hand. Blueprint 3.1 --
"Kann im Alltag vertretend Entscheidungen treffen." Genau das
passiert auf dieser Seite. */
"/workspace/teamlage.html": ["admin", "hand"],
};
/* Die einzige Seite unter /workspace, die offen sein MUSS -- man kann
sich schlecht anmelden, wenn die Anmeldeseite eine Anmeldung verlangt. */
/* DIE ZUGANGSWAENDE. Zwei, seit die Modi-App ihre eigene Adresse hat:
crew-index.html ist dieselbe Wand in Dogis Farben, ohne Kachelreihe.
SIE MUSS HIER STEHEN, sonst dreht sich die Seite im Kreis: crewWeiche
biegt /workspace/ auf /workspace/crew-index.html um; stuende die
Datei nicht als offen, schickte die Schranke sie zurueck nach
/workspace/ -- und von dort wieder hierher. Der Browser bricht das
nach ein paar Runden mit "zu viele Weiterleitungen" ab, und man
sucht den Fehler ueberall, nur nicht in dieser Zeile. */
const OFFEN = new Set(["/workspace/index.html", "/workspace/crew-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();
});
/**
* Gehoert dieser Code zu einem verborgenen Zugang?
*
* Gibt die Person zurueck oder null. `null` heisst hier ausdruecklich
* "nein oder nicht feststellbar" -- ohne Suchschluessel (etwa weil die
* Einstellung fehlt) gibt codeKennung() null zurueck, und dann wird
* niemand angemeldet. Das ist die richtige Richtung: Im Zweifel kommt
* niemand herein, statt dass im Zweifel jemand hereinkommt.
*/
function stillerZugang(code) {
try {
const kennung = codeKennung(code);
if (!kennung) return null;
const k = db().prepare(
"SELECT id, name, rolle, code_hash, code_salt, code_n FROM personen "
+ `WHERE code_kennung = ? AND rolle IN (${
[...TEAM_DOGI_ROLLEN].map((r) => `'${r}'`).join(", ")}) AND aktiv = 1`).get(kennung);
if (!k) return null;
/* Trotz Treffer wird geprueft. Der Suchschluessel ist ein
Wegweiser, kein Ausweis -- und ein Wegweiser, dem man blind
folgt, ist eine Hintertuer. */
return gleich(hashe(code, k.code_salt, k.code_n), k.code_hash) ? k : null;
} catch (fehler) {
console.error("[workspace] Stiller Zugang nicht pruefbar:", fehler?.message);
return null;
}
}
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 || "");
const codeBrauchbar = code.length >= 4 && code.length <= 200;
/* =================================================================
DER STILLE ZUGANG (09.09.2026)
Wunsch Filipe: *"dass die keine neue eingangs kachel bekommen wie
spicy dogfather und so sondern einfach einen code. damit die von
der workspace auch nicht mal sehen dass die modis von mir einen
eigenen zugang haben."*
Ein Modi oeffnet dieselbe Seite, tippt auf IRGENDEINE vorhandene
Kachel und gibt seinen Code ein. Welche Kachel, ist gleichgueltig
-- der Code allein entscheidet. Von aussen sieht das aus wie jede
andere Anmeldung: dieselbe Seite, dieselbe Anfrage, dieselben
Felder.
ZWEI NAHELIEGENDE WEGE, DIE ABSICHTLICH NICHT GEWAEHLT WURDEN:
* Ein Merkmal im Code ("M-..."): waere ein sichtbares Kennzeichen
auf dem Zettel des Modis. Wer den Code sieht, sieht die Sorte.
* Eine eigene Adresse (/modi.html): waere eine Seite, die man
finden kann -- und die Seitenpruefung zaehlt Seiten.
WARUM DAS VOR DEM GEWOHNTEN WEG STEHT UND NICHT DAHINTER: Ein
Rueckfall NACH einem Fehlversuch haette den gewohnten Weg
verlaengert -- und zwar nur dann, wenn er scheitert. Genau daran
waere es zu erkennen gewesen: Fehlversuche dauern ploetzlich
laenger als frueher. Der Suchschluessel kostet Mikrosekunden und
trifft in aller Regel nichts; fuer alle anderen bleibt der Ablauf
unveraendert, auch in der Zeit.
DER SUCHSCHLUESSEL ALLEIN LAESST NIEMANDEN HEREIN. Er sagt nur,
WEN man pruefen soll -- danach entscheidet scrypt wie ueberall. */
/* AUF DEN ALTEN ADRESSEN WIRD DER VERBORGENE ZUGANG GAR NICHT ERST
GEFRAGT (Entscheidung Filipe, 10.09.2026).
Vorher stand hier eine Weiterleitung: Wer seinen Code noch auf
workspace.dogfather-universe.com eintippte, bekam keine Sitzung,
aber den Weg zur neuen Adresse. Filipes Entscheidung dagegen:
"gar nichts -- Code stimmt nicht". Die alte Wand soll sich
verhalten, als waere der Code erfunden.
WARUM DAS NICHT NUR EINE ANDERE ANTWORT IST, SONDERN EIN ANDERER
WEG: Haette ich hier bloss `return 401` gesetzt, waere der Ablauf
trotzdem ein anderer geblieben -- ein Suchschluessel-Treffer und
EIN scrypt-Durchlauf statt der Kandidatenschleife der gewaehlten
Kachel. Das ist messbar, und Zeitunterschiede sind genau die
Spur, die dieser ganze Zugang vermeiden soll.
Deshalb wird `stillerZugang` auf diesen drei Adressen ueberhaupt
nicht aufgerufen. Der Code faellt danach durch den gewohnten Weg
wie jeder unbekannte: dieselbe Schleife, derselbe Eintrag in
`versuche`, dieselbe Antwort, dieselbe Dauer. Die alte Wand
verhaelt sich damit exakt so wie vor dem Tag, an dem es diese
Rollen gab.
DER PREIS, und er gehoert genannt: Ein Modi, der die alte
Verknuepfung auf dem Handy hat, sammelt dort Fehlversuche wie
jeder andere auch. Acht in zehn Minuten sperren seine IP -- und
zwar auch fuer die neue Adresse, denn die Sperre haengt an der
IP, nicht am Hostnamen. Das ist der Preis der Stille; er faellt
nur an, wenn jemand die alte Adresse mehrfach probiert. */
const still = codeBrauchbar && !istOhneModiAdresse(req.get("host"))
? stillerZugang(code)
: null;
if (still) {
db().prepare("DELETE FROM versuche WHERE ip = ?").run(ip);
db().prepare("UPDATE personen SET letzter_login = ? WHERE id = ?").run(jetzt(), still.id);
sitzungSetzen(res, still, req);
protokolliere("anmeldung", {
personId: still.id, rolle: still.rolle, ip, detail: still.name,
});
return res.json({
weiter: "/workspace/start.html", name: still.name, rolle: still.rolle,
});
}
/* Ab hier der gewohnte Weg -- unveraendert. 'modi' ist keine
Kachel-Rolle und faellt deshalb in dieselbe Antwort wie eine
erfundene. */
/* AUF DER MODI-ADRESSE ENDET DER GEWOHNTE WEG HIER (10.09.2026).
crew.dogfather-universe.com ist nicht die zweite Tuer in den
Workspace. Wer sie errät und seinen eigenen Code eintippt, soll
genau das erleben, was er bei einem Tippfehler erlebt: Die
Zugangswand sieht aus wie immer, der Code stimmt nicht. Wuerde er
stattdessen hereinkommen, wuesste er im selben Moment, dass es
hier noch etwas gibt.
BEWUSST IN DIESELBE BEDINGUNG GESCHRIEBEN und nicht als eigener
Block davor: So ist es nicht nur dieselbe ANTWORT, sondern
derselbe Weg -- ein Eintrag in `versuche`, kein scrypt-Durchlauf,
401 "ungueltig". Ein eigener Block waere messbar schneller oder
langsamer gewesen als der Weg daneben, und genau daran erkennt
man eine Sonderbehandlung. */
/* JE ADRESSE EIN ANDERER KACHELSATZ (10.09.2026).
Bis eben stand hier `fremdAufCrew` -- auf crew. wurde JEDE
Kachelanmeldung abgewiesen, weil dort nur Modis ueber den
verborgenen Weg hereinkamen. Seit Filipes "3 rollen. dogfather.
rechte hand und modis" gibt es dort drei Kacheln, und der
gewohnte Weg wird gebraucht.
Zwei Mengen statt einer Bedingung mit Ausnahmen: Welche Kachel
auf welcher Wand steht, ist EINE Tatsache -- sie steht oben bei
ROLLEN_KACHEL und CREW_KACHEL, und hier wird nur gefragt, welche
Wand gerade dran ist. Ein Manager, der crew. erraet und seinen
Code eintippt, faellt dadurch in genau dieselbe Antwort wie
jemand mit einer erfundenen Rolle. */
const kachelSatz = istCrewAdresse(req.get("host")) ? CREW_KACHEL : ROLLEN_KACHEL;
if (!kachelSatz.has(rolle) || !codeBrauchbar) {
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,
/* WELCHE ROLLEN ICH ANLEGEN DARF (10.09.2026).
Damit hat die Oberflaeche keine eigene Liste mehr. Vorher standen
dort zwei, die einander widersprachen, und Spicy Media sah
deshalb nur den Creator-Knopf — obwohl der Weg fuer den Manager
serverseitig offen war. Wer die Antwort nur an einer Stelle hat,
kann sie nicht an zweien verschieden haben. */
darf_anlegen: darfAnlegen(person),
/* DIE KACHELN, WENN SIE NICHT IM BROWSER STEHEN DUERFEN.
Fuer die fuenf bekannten Rollen steht hier `null`, und die
Oberflaeche nimmt wie bisher ihre eigene Liste -- der Ablauf
aendert sich fuer sie also nicht.
NACH DER ANGESEHENEN PERSON, nicht nach der eigenen: Sieht sich
DogFather den Arbeitsplatz eines Modis an, soll er DESSEN Kacheln
sehen. Genau daran war beim Sicht-Umschalter schon einmal zu
erkennen, dass er nicht greift ("ich seh immer noch die Seite
genau wie meine"). */
bereiche: bereicheFuer(angesehen || person),
rolle_text: rollentextFuer(angesehen || person),
marke: markeFuer(angesehen || person, req.get("host")),
/* Kacheln, die zur eigenen Liste DAZUkommen -- im Gegensatz zu
`bereiche`, das sie ersetzt. Leer fuer alle, die keine haben. */
bereiche_zusatz: zusatzBereicheFuer(angesehen || person),
});
});
/* ---------- 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. */
function pipelineIdsRoh(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). */
function sichtbareCreatorIdsRoh(person) {
if (!person) return [];
if (siehtAlles(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. */
/* =====================================================================
WEN DARF DIESE PERSON NICHT SEHEN? (09.09.2026)
Wunsch Filipe, ausdruecklich und dringlich: *"und noch gaaaaaanz
wichtig keiner soll vanvan sehen ausser ich, ueberall soll keiner
vanvan sehen ausser dogfather."*
Es gab davon bisher nur eine Haelfte: In der Personenliste wurde der
zweite Admin-Zugang fuer Spicy Media ausgeblendet (Wunsch vom
31.08.). Ueberall sonst -- Chat, Kalender, Aufgaben, Dateien, Suche
-- war er sichtbar, und `sichtbarePersonenIds` hat ihn sogar
ausdruecklich JEDER Rolle gezeigt, weil sie alle Admins einsammelt
("DogFather ist fuer alle sichtbar").
WORAN DIE BEIDEN AUSEINANDERGEHALTEN WERDEN: In der Datenbank tragen
beide die Rolle `admin`, es gibt kein unterscheidendes Feld. Der
Unterschied, den es wirklich gibt, ist das Alter -- DogFather ist
der erste Zugang des Hauses. Deshalb gilt: der Admin mit der
KLEINSTEN Nummer ist DogFather, alle weiteren Admin-Zugaenge sind
verborgen.
DREI AUSNAHMEN, und nur diese drei:
* DogFather selbst sieht alle.
* Ein verborgener Zugang sieht sich selbst (sonst faende er sein
eigenes Profil nicht).
* Gibt es nur EINEN Admin, ist nichts zu verbergen.
DIE SCHWACHSTELLE STEHT HIER, damit sie niemand suchen muss: Wuerde
Zugang 1 geloescht, rueckte der naechste Admin nach und waere
ploetzlich sichtbar. Sollte das eintreten, gehoert ein
ausdrueckliches Merkmal in die Tabelle (`haupt`-Feld). Solange
DogFather der erste Zugang ist, ist die Nummer die ehrlichste
Antwort ohne Datenbankumbau -- und sie haengt nicht am NAMEN, der
sich aendern kann.
WARUM AN DIESER STELLE UND NICHT IN DEN ABFRAGEN: Es gibt 23
Abfragen allein in workspace-personen.js, die Personen lesen. Eine
Regel, die man an 23 Stellen wiederholt, ist 23 Gelegenheiten, sie
zu vergessen -- und beim Vergessen faellt hier niemand auf die Nase,
sondern es sieht jemand etwas, das er nicht sehen soll. Deshalb
sitzt sie in den SECHS Listenfunktionen, durch die alles laeuft.
===================================================================== */
export function verborgeneIds(person) {
try {
const d = db();
/* ERSTER TEIL: die verborgenen Admin-Zugaenge (Regel vom 09.09.2026).
Der Admin mit der kleinsten Nummer ist DogFather, jeder weitere
Admin-Zugang ist verborgen. */
const admins = d.prepare(
"SELECT id FROM personen WHERE rolle = 'admin' ORDER BY id").all().map((z) => z.id);
const versteckteAdmins = admins.length >= 2 ? admins.slice(1) : [];
/* ZWEITER TEIL: das Modi-Team.
Hier haengt die Regel an der ROLLE und nicht an einer Nummer --
und das ist der Unterschied, auf den es ankommt. Beim ersten Teil
steht die Schwachstelle im Kommentar oben ausdruecklich da: Wuerde
Zugang 1 geloescht, rueckte der naechste nach und waere ploetzlich
sichtbar. Eine Rolle kann das nicht passieren. Wer einen Modi
verbergen will, muss ihm nichts anhaengen und nichts nachhalten --
er IST verborgen, solange er ein Modi ist.
WER DARF SIE SEHEN, und nur diese zwei:
* die DogFather-Rolle (admin) -- also auch die rechte Hand,
denn sie traegt dieselbe Rolle. So gewollt (Kapitel 3 des
Anforderungsdokuments: gleicher Ueberblick).
* ein Modi selbst -- sie sind untereinander ein Team.
Manager, Scouts, Creator und Spicy Media sehen sie nicht. Nicht
"sehen sie ohne Einzelheiten", sondern gar nicht: kein Name, kein
Eintrag, keine Zahl, die sie mitzaehlt. */
const modis = d.prepare(
`SELECT id FROM personen WHERE rolle IN (${
[...TEAM_DOGI_ROLLEN].map((r) => `'${r}'`).join(", ")})`).all().map((z) => z.id);
/* Ohne bekannte Person wird BEIDES verborgen. Das ist die strengere
Antwort, und bei einer Sichtbarkeitsregel ist die strengere die
richtige: Wer nicht weiss, wer fragt, zeigt nichts. */
const darfAdminsSehen = !!person
&& (person.id === admins[0] || versteckteAdmins.includes(person.id));
const darfModisSehen = !!person
&& (person.rolle === "admin" || TEAM_DOGI_ROLLEN.has(person.rolle));
const weg = [];
if (!darfAdminsSehen) weg.push(...versteckteAdmins);
if (!darfModisSehen) weg.push(...modis);
/* Doppelte heraus: Ein Modi-Zugang koennte theoretisch auch in der
ersten Liste stehen, wenn jemand die Rollen umhaengt. */
return [...new Set(weg)];
} catch {
/* Ohne Datenbank lieber nichts verbergen als abstuerzen -- diese
Funktion darf keine Seite lahmlegen. Sie laeuft in jedem
Listenaufruf. */
return [];
}
}
/** Dieselbe Regel als Filter auf eine fertige Liste. */
export function ohneVerborgene(ids, person) {
const weg = verborgeneIds(person);
if (!weg.length) return ids;
if (ids === null) {
/* `null` heisst bisher "sieht alles". Sobald es etwas zu verbergen
gibt, darf das nicht mehr gelten -- die Liste wird deshalb
AUSGESCHRIEBEN. Das ist strenger, nicht lockerer, und die
Aufrufer bauen daraus ohnehin ein `IN (...)`. */
try {
return db().prepare(
`SELECT id FROM personen WHERE id NOT IN (${weg.map(() => "?").join(",")})`)
.all(...weg).map((z) => z.id);
} catch { return null; }
}
return ids.filter((i) => !weg.includes(i));
}
function sichtbarePersonenIdsRoh(person) {
if (!person) return [];
if (siehtAlles(person)) return null;
const creator = sichtbareCreatorIds(person) || [];
const scouts = person.rolle === "manager" ? scoutsVon(person.id) : [];
/* DOGFATHER IST FUER ALLE SICHTBAR (07.09.2026, Wunsch Filipe:
"dogfather und agentur sollen die alle sehen").
Das ist keine Aufweichung der Regel, sondern ihre Vervollstaendigung:
Jede Rolle arbeitet mit DogFather -- er gibt frei, er entscheidet,
er steht in jedem Protokoll. Ihn aus der Personenliste
herauszuhalten hiess bisher, dass sein Name in Terminen und
Aufgaben auftaucht, aber nirgends ein Gesicht dazugehoert.
Sichtbar heisst hier NUR: Name, Rolle und Bild -- dieselben drei
Angaben wie bei jedem anderen in dieser Liste. Alles Weitere (seine
Eintraege, seine Chats, seine Zahlen) haengt weiterhin an den
eigenen Sichtbarkeitsregeln der jeweiligen Module und ist davon
nicht beruehrt. */
const dogi = [];
try {
for (const z of db().prepare(
"SELECT id FROM personen WHERE rolle = 'admin' AND aktiv = 1").all()) dogi.push(z.id);
} catch { /* ohne Datenbank bleibt die Liste, wie sie war */ }
/* DAS MODI-TEAM SIEHT SICH UNTEREINANDER (Entscheidung Filipe,
09.09.2026: "ja, sie sind untereinander ein Team").
Ohne diese Zeilen waere jeder Modi allein: kein Team-Gruppenchat,
keine Schicht, die man tauschen kann, keine Eskalation, die man
weiterreicht, ohne dass DogFather sie einzeln durchstellt.
Es ist KEIN Loch in der Verborgenheit, sondern ihre andere Seite.
Was diese Liste zusaetzlich hergibt, wird gleich danach durch
ohneVerborgene() wieder eingeschraenkt -- und dort steht, dass ein
Modi nur fuer die DogFather-Rolle und fuer Modis sichtbar ist. Wer
kein Modi ist, bekommt diese Nummern hier gar nicht erst. */
const modis = [];
if (TEAM_DOGI_ROLLEN.has(person.rolle)) {
try {
const liste = [...TEAM_DOGI_ROLLEN].map((r) => `'${r}'`).join(", ");
for (const z of db().prepare(
`SELECT id FROM personen WHERE rolle IN (${liste}) AND aktiv = 1`).all()) modis.push(z.id);
} catch { /* ohne Datenbank bleibt die Liste, wie sie war */ }
}
return [...new Set([person.id, ...creator, ...scouts, ...dogi, ...modis])];
}
/** 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(). */
function betreuerIdsRoh(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. */
function einladbareIdsRoh(person) {
if (!person) return [];
if (siehtAlles(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). */
function schreibbareIdsRoh(person) {
if (!person) return [];
if (siehtAlles(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 */ }
}
/* DOGFATHER UND SPICY MEDIA SIND FUER JEDEN ERREICHBAR (07.09.2026).
Filipe: "im chat muss die spicy rolle und die dogfather rolle fuer
jeden zugaenglich sein."
DogFather kam bisher ueber einladbareIds() mit hinein; Spicy Media
nicht -- die Rolle steht in keiner Betreuungskette, sie steht
daneben. Genau deshalb war sie fuer einen Creator im Chat gar nicht
vorhanden, obwohl sie fuer alle zustaendig ist.
Beide werden hier ausdruecklich hinzugefuegt statt sich auf einen
Nebeneffekt zu verlassen: Eine Zustaendigkeit, die nur zufaellig
aus einer anderen Regel herausfaellt, faellt beim naechsten Umbau
genauso zufaellig wieder heraus.
Die Gegenrichtung stimmt ohne Zutun: Beide Rollen haben
`siehtAlles` und damit ohnehin `null` = jeder. */
try {
for (const z of db().prepare(
"SELECT id FROM personen WHERE rolle IN ('admin','spicy') AND aktiv = 1").all()) {
basis.add(z.id);
}
} catch { /* im Zweifel bleibt 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") {
const p = praefix;
/* =====================================================================
JEDER SIEHT NUR SEINE EIGENEN TERMINE (07.09.2026)
Wunsch Filipe, zuerst: "calls oder termine soll jeder nur sehen
die er selber macht oder jeden betrifft." Wenige Stunden spaeter,
nachdem er das Ergebnis gesehen hat, praeziser: "jeder soll und
darf im kalender immer nur seine eigenen eintraege nur sehen."
Die zweite Fassung gilt -- Begruendung unten beim entfallenen
Fall "geht alle an".
DAS GILT AUCH FUER DOGFATHER, und das ist die eigentliche
Aenderung. Bis hierher bekam er `1=1` -- er sah jeden Termin von
jedem. Bei einer Handvoll Leuten war das ein Ueberblick; bei
dreissig ist es eine Wand aus fremden Verabredungen, in der die
eigenen untergehen. Ein Kalender, in dem alles steht, ist kein
Kalender mehr.
"EIGEN" IST HIER GENAU DEFINIERT, sonst wird es Auslegung: Ich habe
den Termin angelegt (erstellt_von), er ist mir zugeordnet
(creator_id), ich bin das Gegenueber (teilnehmer_id) -- oder ich
stehe in der Teilnehmerliste. Sonst nichts. Kein Rollenrecht, keine
Betreuung, keine Ausnahme fuer teilnehmerlose Termine.
Diese Regel ersetzt die Rollenfrage vollstaendig: Es gibt hier
nichts mehr, was DogFather sieht und ein Creator nicht. Was ein
Betreuer ueber seine Creator wissen muss, steht in deren Bereichen
und Aufgaben -- nicht in ihrem Kalender.
===================================================================== */
/* 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];
/* HIER STAND EIN ZWEITER FALL, UND ER IST AM 07.09.2026 ENTFALLEN.
Er hiess "geht alle an": ein Termin ohne Gegenueber und ohne
Teilnehmer war fuer jeden sichtbar, nach der Ueberlegung "wer
niemanden eintraegt, meint alle".
Die Ueberlegung war falsch, und Filipe hat es an einem echten Fall
gezeigt: In Cigdems Kalender stand sein "Manager Meeting" -- SEIN
Termin, den er fuer sich eingetragen hatte. Wer keinen Teilnehmer
eintraegt, meint eben meistens nicht "alle", sondern "mich". Und
die Auslegung lag nicht bei dem, der den Termin anlegt, sondern
hier im Code -- das ist die eigentliche Schwaeche gewesen.
Filipe: "jeder soll und darf im kalender immer nur seine eigenen
eintraege nur sehen."
Was alle angeht, geht sie jetzt an, weil jemand sie EINTRAEGT:
"bei calls und protocoll will ich dass jeder seine termine von
seinem kalender sieht und die wenn sie markiert werden im termin."
Die Teilnehmerliste ist damit die einzige Antwort auf die Frage,
wen ein Termin etwas angeht -- eine sichtbare Entscheidung im
Formular statt einer unsichtbaren Regel im Server.
DIESELBE REGEL GILT FUER CALLS & PROTOKOLLE: Die Call-Liste ruft
genau diese Funktion auf (workspace-calls.js -> sichtbar). Beide
Seiten koennen deshalb gar nicht auseinanderlaufen. */
return { wo: eigen, 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 (siehtAlles(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, code_kennung, aktiv, erstellt)"
+ " VALUES (?,?,?,?,?,?,1,?)"
).run(name, rolle, hash, salt, SCRYPT.N, codeKennung(code), 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 = ?, code_kennung = ? WHERE id = ?")
.run(hashe(code, salt, SCRYPT.N), salt, SCRYPT.N, codeKennung(code), 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);
}
/* =====================================================================
DER MANTEL UM DIE SECHS LISTENFUNKTIONEN (09.09.2026)
Jede von ihnen hat mehrere Rueckgabewege -- `sichtbarePersonenIds`
allein drei. Die Verbergungsregel an jedem einzelnen anzubringen
waere achtzehn Gelegenheiten, einen zu vergessen; und wer hier einen
vergisst, merkt es nicht an einem Fehler, sondern daran, dass jemand
etwas sieht, das er nicht sehen soll.
Deshalb bleiben die Funktionen unveraendert (jetzt mit `Roh` im
Namen) und werden EINMAL ummantelt. Der Mantel ist die einzige
Stelle, an der die Regel steht -- und er kann keinen Rueckgabeweg
uebersehen, weil er hinter allen sitzt.
Die Namen nach aussen bleiben gleich: Kein Aufrufer muss geaendert
werden, und niemand kann versehentlich die ungefilterte Fassung
benutzen -- die `Roh`-Funktionen werden nicht exportiert.
===================================================================== */
export const sichtbareCreatorIds = (person) => ohneVerborgene(sichtbareCreatorIdsRoh(person), person);
export const sichtbarePersonenIds = (person) => ohneVerborgene(sichtbarePersonenIdsRoh(person), person);
export const betreuerIds = (person) => ohneVerborgene(betreuerIdsRoh(person), person);
export const einladbareIds = (person) => ohneVerborgene(einladbareIdsRoh(person), person);
/* SICH SELBST NICHT -- UND ZWAR AUCH DANN NICHT, WENN DIE LISTE
AUSGESCHRIEBEN WIRD (10.09.2026).
schreibbareIdsRoh() endet mit `basis.delete(person.id)`. Das greift
aber nur, solange eine Liste gebaut wird. Wer `siehtAlles` hat,
bekam `null` = jeder, und die Route setzte dafuer `id <> ?` ein --
auch richtig.
Dazwischen liegt der Fall, den beide nicht abdecken: Sobald es
jemanden zu VERBERGEN gibt, schreibt ohneVerborgene() aus `null`
eine echte Liste -- und die enthaelt alle ausser den Verborgenen,
also auch die fragende Person selbst. Spicy Media stand dadurch in
der eigenen Auswahl, und darfSchreibenMit() haette ein Gespraech mit
sich selbst durchgelassen.
Aufgefallen ist das nicht beim Lesen des Codes, sondern an einer
Zeile Messausgabe: "Spicy Media hat es NICHT (VanVan, Filipe,
Cigdem)" -- der erste Name war ihr eigener. Der Fall entsteht erst,
seit es ueberhaupt jemanden zu verbergen gibt; vorher konnte es ihn
nicht geben. */
export const schreibbareIds = (person) => {
const ids = ohneVerborgene(schreibbareIdsRoh(person), person);
return ids === null ? null : ids.filter((i) => i !== person?.id);
};
export const pipelineIds = (person) => ohneVerborgene(pipelineIdsRoh(person), person);