Files
dogfather-universe/server/workspace.js
T
DogFatherGitandClaude Opus 5 65d278a18e Der Handy-Rundgang ist zum ersten Mal bei null Befunden
SECHS SACHEN, UND KEINE DAVON WAR DAS, WONACH ICH GESUCHT HABE.

1. ZWEI NATIVE prompt() IN teamlage.js -- die letzten im Haus.
   Sie halten die ganze Seite an, sehen auf jedem Browser anders aus
   als der Rest und koennen nicht sagen, was BLEIBT, wenn man absagt.
   Genau das ist dort die Frage, die jemand vor dem Klicken hat. Jetzt
   derselbe Dialog wie an den 42 anderen Stellen.

2. UND DER GRUND, WARUM SIE NIEMAND GEFUNDEN HAT.
   pruef-nachfrage suchte mit `(?:^|[^.\w])(confirm|prompt)\s*\(`.
   Das `[^.\w]` sollte fremde Methoden ausschliessen -- `angebot
   .prompt()` ist die Installations-Aufforderung des Browsers und kein
   nativer Dialog. Nur trifft dieser Ausschluss ausgerechnet die
   HAEUFIGSTE Schreibweise: `window.prompt(` hat einen Punkt davor.
   Die Pruefung war gruen, waehrend zwei native prompt() dastanden.
   Genau das Muster, vor dem dieses Haus warnt: eine Pruefung, die
   laeuft, gruen ist und das Falsche prueft. Die Gegenprobe kennt
   jetzt beide Schreibweisen -- haette sie das vorher getan, waere es
   am selben Tag aufgefallen.

3. willkommen.html LUD nachfrage.js NICHT.
   Gefunden von der geschaerften Pruefung. kopf.js ruft `frageNach(`
   ohne Absicherung -- auf dieser einen Seite haette ABMELDEN einen
   Absturz ausgeloest. Die Seite ist vom 21.09., die Luecke also
   einen Tag alt.

4. ZWEI FEHLALARME IM HANDY-RUNDGANG ABGESTELLT.
   Er meldete bei JEDEM Lauf dieselben drei Befunde: `button.schnitt
   bis 481px`, die Rechtetafel `table bis 877px`, `a.k-pille 8x8`.
   Nachgemessen bei 390 px: Das Dokument ist exakt 390 px breit,
   NICHTS laeuft ueber -- beide stehen in einem Kasten mit
   `overflow-x: auto`, und der rollt absichtlich. Die Kalenderpunkte
   tragen `pointer-events: none`; der Tipp gehoert der Tageszelle.
   Eine Warnung, die immer kommt, ist keine Warnung mehr (Hausregel
   vom 03.09.). Beide Regeln sind SCHMAL: nur ausdrueckliches
   `overflow-x: auto|scroll` (nicht `hidden` -- dort ist der Inhalt
   wirklich weg), nur ausdrueckliches `pointer-events: none`.
   Die Gegenprobe hat jetzt vier Faelle statt zwei: zwei, die gemeldet
   werden MUESSEN, und zwei, die es NICHT duerfen. Eine engere Messung
   kann auch zu eng sein.

5. DER LETZTE ECHTE BEFUND: 404 BEI JEDEM MODI.
   Der Rundgang meldete "404 (Not Found)" ohne Adresse -- eine
   Pruefung, die einen Fehler findet, ihn aber nicht auffindbar macht,
   kostet mehr Zeit als sie spart. Sie nennt jetzt die Adresse, und
   damit war es in einer Minute klar: `/workspace/api/werdegang/liste`.
   Der Code BEHANDELTE den 404 richtig, der Browser protokolliert ihn
   trotzdem -- derselbe Fall wie am 19.09. bei /anruf/adressen.
   Jetzt sagt der Server in /api/ich, ob jemand Team Dogi fuehrt.
   EIGENES FELD, kein Stellvertreter: `darf_verteilen` sieht aehnlich
   aus, ist aber nicht dasselbe -- ein Manager darf verteilen und
   fuehrt Team Dogi nicht.

   Dabei EINE Wartestelle statt zwei: `window.wennIchDaBin()`.
   `window.__ich` kommt ueber das Netz; zwei Seiten hatten dafuer
   jeweils ein eigenes setInterval. Zwei Fassungen desselben Wartens
   altern unterschiedlich.

6. pruef-werdegang MASS AUF DER FALSCHEN ADRESSE.
   Drei Pruefungen waren dauerhaft rot (`data-ton=null`) -- an einer
   Seite, die in Ordnung ist. Sie oeffnete `127.0.0.1`, also die
   Adresse der AGENTUR; dort ist `person.haus` nicht "crew" und die
   Kachelliste eine andere. Nachgemessen: Alle vier Rollen HABEN die
   Kachel, sobald das Haus stimmt. Jetzt derselbe https-Vorbau wie in
   pruef-willkommen und pruef-zuteilung.

   Beinahe haette ich hier etwas "repariert", das nicht kaputt war:
   Meine erste Messung rief `bereicheFuer(p, "crew")` auf -- das Haus
   wird aber aus `person.haus` gelesen, nicht als Argument. Sie sagte
   "DogFather hat keine Kachel". Eine plausible Herleitung ersetzt
   keine Messung, und eine falsch aufgesetzte Messung auch nicht.

GEMESSEN: pruef-handy-teamdogi 214 Seitenaufrufe, 108.564 Elemente,
4.126 Bedienelemente -- 0 Befunde. Alle vier Rollen, beide Breiten.
Zum ersten Mal.

pruef-werdegang 103/0 (war 100/3), pruef-nachfrage 51/0 (war 49),
pruef-stelle 15/0, pruef-entwicklung 48/0, pruef-start-ansicht 153/0,
pruef-wege-nach-draussen 67/0, pruef-tippziele 11/0, pruef-treff 80/0,
pruef-willkommen 67/0, pruef-sackgassen 13/0.

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

7001 lines
336 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 { darfSeite, OHNE_ANMELDUNG, seitenFuer } from "./rechte.js";
/* Ein Modul ohne eigene Importe -- deshalb entsteht hier kein Kreis,
obwohl workspace-treff.js seinerseits aus dieser Datei importiert.
Begruendung im Kopf von treff-tabellen.js. */
import { treffTabellen, treffSperreLesen, MINDESTALTER } from "./treff-tabellen.js";
import { hilfeTabellen } from "./hilfe-tabellen.js";
import { sitzungPasstZurAdresse, istOhneModiAdresse, istCrewAdresse, istPruefAdresse, AUSSEN_ROLLEN,
TEAM_DOGI_ROLLEN, CREW_ADRESSE } 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";
/* ===== WIE LANGE GILT EINE ANMELDUNG? (20.09.2026) ==================
Filipe: "kannst du bitte machen dass die leute sich nur einmal
anmelden muessen und dan nur noch abgemeldet werden wenn sie sich
selbst abmelden. damit sie auch die benarichtigungen sofort kriegen
... und auch sofort die anrufe annehmen koennen."
VORHER: zwoelf Stunden, feste Frist ab dem Anmelden. Wer morgens um
acht anfing, flog abends um acht raus -- mitten im Betrieb. Und wer
abgemeldet ist, hat die Seite nicht offen; ein Anruf erreicht ihn
dann nur noch ueber die Benachrichtigung, und bis er sich wieder
angemeldet hat, ist das Klingeln vorbei.
JETZT: ein GLEITENDES Fenster. Wer die Seite benutzt, bleibt
angemeldet -- ohne Ende, ohne Nachfrage. Abgemeldet wird nur, wer
sich selbst abmeldet.
WARUM NICHT "unendlich": Ein Zugang, der nie ablaeuft, ist auf einem
verlorenen Handy fuer immer offen -- und in diesem Haus liegen
vertrauliche Meldungen ueber Menschen. Wer ein halbes Jahr nicht da
war, meldet sich neu an; das ist niemandem zuzumuten zu viel und
schliesst das verlorene Geraet.
WARUM NICHT BEI JEDEM AUFRUF NEU SCHREIBEN: Das waere ein
Schreibzugriff auf die Datenbank bei jedem Bild, jeder Abfrage,
jedem Herzschlag des Ereignisstroms. Verlaengert wird erst, wenn
weniger als die Haelfte des Fensters uebrig ist -- also hoechstens
einmal alle drei Monate je Geraet.
==================================================================== */
const SITZUNG_TAGE = 180;
const SITZUNG_MS = SITZUNG_TAGE * 86400_000;
const VERSUCHE_MAX = 8; // pro IP
const VERSUCHE_FENSTER_MIN = 10;
const ROLLEN = new Set(["spicy", "admin", "manager", "scout", "creator",
"hand", "linke", "modi", "gast"]);
/* ===== DIE LINKE HAND (21.09.2026) ==================================
Filipe, aus dem Auftrag: *"Die linke Hand erhaelt grundsaetzlich
dieselben Rechte wie die rechte Hand in allen Bereichen, die die
Modis, Aufgaben, Aufgabenzuweisungen, Uebersichten, Kommunikation
und Auswertungen betreffen. Die linke Hand darf keine neuen Personen
oder Accounts hinzufuegen. Die linke Hand darf keine privaten bzw.
persoenlichen Bereiche oder Informationen von Dogfather sehen."*
DESHALB EINE MENGE UND NICHT DREISSIG ABSCHRIFTEN. Die Rolle "hand"
steht an 35 Stellen im Servercode. Haette ich ueberall ein "linke"
danebengeschrieben, waere die naechste Stelle, die jemand
hinzufuegt, die erste, die es vergisst -- und eine vergessene Stelle
heisst hier: Die linke Hand sieht etwas nicht, ohne dass jemand
merkt, warum. Genau diese Falle hat das Haus am 11.09. schon einmal
Datenspalten gekostet.
WAS AUSDRUECKLICH NICHT DAZUGEHOERT -- und warum jedes einzeln:
- Personen anlegen (ANLEGBAR) ausdruecklich im Auftrag
- Bewerbungen lesen das ist der Weg, auf dem
Personen hinzukommen
- Rollen aendern Kontoverwaltung, nicht
Aufgabenverwaltung
- der vertrauliche Meldeweg das Empfindlichste im Haus;
im Zweifel das kleinere Recht.
Ein Wort von Filipe kehrt das
um -- eine versehentlich
gelesene Meldung nicht.
Die Menge steht hier oben, VOR allem, was sie benutzt. */
export const WIE_RECHTE_HAND = new Set(["hand", "linke"]);
/** Hat diese Person die Team-Rechte einer Hand (rechts oder links)? */
export const istHand = (person) => !!person && WIE_RECHTE_HAND.has(person.rolle);
/* DIE EINE REIHENFOLGE, IN DER ROLLEN UEBERALL ERSCHEINEN.
Sie steht hier einmal, damit keine Liste eine eigene erfindet.
DIE RECHTE HAND STEHT AN ZWEITER STELLE (Filipe, 11.09.2026:
"rechte hand soll immer als erstes sein. ueberall. ... ausser bei
personen und zugaengen soll sie ueber jedem aber unter dogfather
sein. ich will dass auch ueberall immer nach der hirarchie
gearbeitet wird.").
EINE ZAHL STATT ZWEIER REGELN: Er hat zwei Saetze gesagt, aber es
braucht nur eine Reihenfolge. In den Team-Listen kommt DogFather
gar nicht vor (er beurteilt, er wird nicht beurteilt) -- dort steht
sie damit automatisch ganz vorn. In "Personen & Zugaenge" steht er
drin, also steht sie dort hinter ihm. Zwei Sonderfaelle waeren zwei
Stellen, an denen es auseinanderlaufen kann.
SPICY MEDIA BLEIBT DAVOR -- seine ausdrueckliche Entscheidung auf
Nachfrage am 11.09.2026. Die Agentur ist keine Stufe in seinem Team,
sondern steht daneben; an Rechten aendert die Reihenfolge ohnehin
nichts, sie sortiert nur Listen.
'gast' (Community) steht mit Absicht NICHT in der Liste: Sie faellt
dadurch ans Ende (siehe ELSE unten), und das ist richtig -- die
Community ist keine Stufe im Team. */
export const ROLLEN_REIHE = ["spicy", "admin", "hand", "linke", "manager",
"scout", "creator", "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.
*
* VIER, SEIT DER TREFF HIER WOHNT (Entscheidung Filipe, 11.09.2026:
* "das soll keine app fuer sich sein, das soll in der crew seite
* adaptiert werden" -- und auf die Frage, wie die Community durch
* diese Wand kommt: "vierte Kachel Community").
*
* WAS DAS KOSTET, und es gehoert hierhin und nicht in eine Fussnote:
* Ein Mitglied der Community liest vor seiner Anmeldung die Namen der
* drei Team-Rollen. Die Alternative waere gewesen, die Kachelreihe
* ganz abzuschaffen und allein den Code entscheiden zu lassen -- dann
* haette niemand eine Rolle gesehen, aber Filipes Entscheidung vom
* 10.09. waere wieder umgeworfen worden. Er hat sich fuer die vierte
* Kachel entschieden; der Preis ist damit bewusst bezahlt und nicht
* uebersehen. */
/* Die linke Hand meldet sich ueber dieselbe Wand an wie die rechte. */
const CREW_KACHEL = new Set(["admin", "hand", "linke", "modi", "gast"]);
/** Die Rollen mit einer Kachel auf der Zugangswand von treff.
*
* EINE, und deshalb zeigt die Wand dort gar keine Kachelreihe: Eine
* Auswahl mit einem einzigen Eintrag ist keine Auswahl, sondern eine
* Huerde. Dieselbe Ueberlegung wie bei der ersten Fassung der
* Crew-Wand -- nur ist sie hier dauerhaft richtig, weil es im Treff
* auch kuenftig nur eine Rolle gibt.
*
* Die Menge steht trotzdem hier: Die Anmeldung prueft gegen sie, und
* eine Wand ohne Kacheln darf nicht heissen, dass jede Rolle
* durchkommt. */
/* Als SQL-Ausdruck fuer ORDER BY. "ORDER BY rolle" waere alphabetisch
(admin, creator, manager, scout, spicy) -- also fast genau falsch
herum.
ABGELEITET UND NICHT ABGESCHRIEBEN (11.09.2026). Bis heute stand die
Reihenfolge zweimal da: einmal als Liste, einmal als CASE mit
Zahlen. Beim Hochziehen der rechten Hand haetten beide geaendert
werden muessen -- und eine gepflegte Liste, die mit einer zweiten
uebereinstimmen muss, ist genau die Bauart, an der im Projekt schon
dreimal etwas verlorengegangen ist (zuletzt drei Spalten beim
Tabellenumbau). Eine Liste, die niemand pflegt, kann nicht veralten.
Das Wort "rolle" kommt hier genau EINMAL vor -- in "CASE rolle".
Darauf verlassen sich die Aufrufer, die es per .replace() auf
"p.rolle" umstellen; kein Rollenname enthaelt die Zeichenfolge. */
export const ROLLEN_SORTIERUNG = "CASE rolle "
+ ROLLEN_REIHE.map((r, i) => `WHEN '${r}' THEN ${i}`).join(" ")
+ ` ELSE ${ROLLEN_REIHE.length} 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 AUFGABEN AN ANDERE VERTEILEN? (20.09.2026) ==========
Filipe: "jeder soll sich selber aufgabe vergeben koennen aber nur die
rechte hand und dogfather aufgaben an andere verteilen."
WARUM EINE EIGENE REGEL UND NICHT `hand` IN DIE LEITUNG:
`istLeitung` wird an 57 Stellen in 19 Dateien gefragt -- unter
anderem im vertraulichen Meldeweg, in der Personenverwaltung und in
den Auswertungen ueber Menschen. Die rechte Hand dort ueberall
mitzunehmen waere eine Entscheidung ueber Einsicht in fremde
Meldungen, und die hat niemand getroffen.
Verteilen ist eine Frage fuer sich. Sie bekommt deshalb ihren
eigenen Namen: Man sieht an der Aufrufstelle, worum es geht, und
wer sie spaeter aendert, aendert genau diese eine Sache.
spicy und manager bleiben drin: Auf der Agenturseite verteilen sie
seit jeher Aufgaben, und daran soll sich nichts aendern. */
/** Wer eine Aufgabe ANLEGEN darf.
*
* IM TEAM DOGI KOMMEN AUFGABEN VON DER LEITUNG (22.09.2026). Filipe:
* "sie sich nicht selber aufgaben geben" und "die modis sollen nur
* diese aufgaben wo sie von den drauf markiert werden annehmen
* koennen".
*
* Das gilt AUSDRUECKLICH NUR im Team Dogi. In der Agentur legt
* weiterhin jeder fuer sich selbst an -- dort ist eine Aufgabe eine
* Notiz an sich selbst, kein Auftrag von jemandem. Eine Regel, die
* beide Haeuser ueber einen Kamm schert, waere hier falsch. */
export const darfAufgabenAnlegen = (person) => {
if (!person) return false;
if (TEAM_DOGI_ROLLEN.has(person.rolle)) return darfAufgabenVerteilen(person);
return true;
};
export const darfAufgabenVerteilen = (person) =>
istLeitung(person) || istHand(person);
/* ===== WER DARF EINE ROLLE AENDERN -- UND ZU WELCHER? (20.09.2026) ==
Filipe: "ich will dass ich da auch die rollen der leute wechseln kann
ohne dass ich denen einen neuen account machen muss mit einem neuen
code. einfach rolle wechseln und gut ist. perfektionier das fuer die
rolle dogfather und rechte hand. nur die sollen das machen koennen."
Das Wechseln selbst gab es schon -- der Zugangscode bleibt dabei
derselbe, das stand auch in der Rueckfrage. Es konnte nur DogFather.
DREI GRENZEN, UND JEDE HAT EINEN GRUND:
(1) NIEMAND WIRD ZU "admin". Das gilt seit dem 11.09.2026 fuer alle,
auch fuer DogFather selbst -- siehe ANLEGBAR. Hier aendert sich
daran nichts.
(2) DIE RECHTE HAND FASST KEINE ROLLE AN, DIE UEBER ODER NEBEN IHR
STEHT. Sie kann also einen Modi zur Community machen und
umgekehrt -- aber keinen DogFather antasten und keine zweite
rechte Hand ernennen. Wer jemanden auf die eigene Ebene hebt,
vergibt Vertrauen, das ihm nicht gehoert; das bleibt bei
DogFather. Und wer eine Rolle UEBER sich aendern koennte,
koennte sich selbst befoerdern, indem er zuerst den anderen
herabstuft.
(3) NIEMAND AENDERT DIE EIGENE ROLLE. Sonst waere jede Grenze oben
nur ein Umweg: erst sich selbst hochstufen, dann alles duerfen.
Was sie danach vergeben darf, steht in `rollenZumAendern` -- an
EINER Stelle, damit die Oberflaeche keine zweite Liste fuehrt. Genau
daran ist es am 10.09.2026 schon einmal gescheitert. */
const ROLLEN_ZUM_AENDERN = {
/* DogFather: alles, was er auch anlegen darf. */
admin: null, // null = dieselbe Liste wie ANLEGBAR
/* SPICY MEDIA STAND HIER SCHON, BEVOR ES DIESE TABELLE GAB -- sie
durfte Rollen wechseln, seit es den Weg gibt. Sie muss deshalb
drinstehen, sonst nimmt die neue Regel ihr etwas weg, das niemand
ihr wegnehmen wollte. `null` heisst: dasselbe wie beim Anlegen,
also Agenturrollen. */
spicy: null,
hand: ["modi", "gast"],
};
/** Welche Rollen darf diese Person an ANDERE vergeben? */
export function rollenZumAendern(person) {
if (!person) return [];
const eigen = ROLLEN_ZUM_AENDERN[person.rolle];
if (eigen === undefined) return [];
return eigen === null ? darfAnlegen(person) : [...eigen];
}
/** Darf diese Person die Rolle DIESES Menschen anfassen? */
export function darfRolleAendern(person, ziel) {
if (!person || !ziel) return false;
if (person.id === ziel.id) return false; // (3)
if (!rollenZumAendern(person).length) return false;
/* DOGFATHER DARF JEDEN ANFASSEN -- auch einen zweiten DogFather.
Hier stand `return ziel.rolle !== "admin"`, und das war zu scharf:
Es machte aus "DogFather ist nicht mehr VERGEBBAR" ein "an einem
DogFather ist nichts mehr zu aendern". Gemessen hat es
pruef-haus-trennung -- "solange es zwei gibt, darf einer wechseln"
bekam 403 statt 200. Ein zweiter Zugang liess sich damit nicht mehr
zurueckstufen; er waere fuer immer DogFather geblieben.
Die beiden echten Gefahren haengen woanders und bleiben: Niemand
KANN 'admin' vergeben (steht nicht in rollenZumAendern), und der
LETZTE DogFather laesst sich nicht herabstufen (Sicherung in der
Route). Die eigene Zeile ist drei Zeilen darueber schon
ausgeschlossen. */
if (person.rolle === "admin") return true;
/* Spicy Media wie bisher: das ganze Haus ausser DogFather. */
if (person.rolle === "spicy") return ziel.rolle !== "admin";
if (person.rolle === "hand") {
/* (2): nur unter sich -- nicht admin, nicht eine andere hand. */
return ziel.rolle !== "admin" && ziel.rolle !== "hand";
}
return false;
}
/* =======================================================================
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 ausser der eigenen Rolle.
DIE ROLLE "admin" IST SEIT DEM 11.09.2026 NICHT MEHR VERGEBBAR --
von niemandem, auch nicht von DogFather selbst. Filipe: "dogfather
soll man nicht auswaehlen koennen. das ist die einzige die man
nicht auswaehlen kann bitte."
WAS DAS BEDEUTET, damit es niemand spaeter sucht: Es laesst sich
kein zweiter DogFather-Zugang mehr anlegen, und niemand laesst
sich zu einem befoerdern. Der bestehende bleibt unberuehrt und ist
durch Sicherung 3 (nie den letzten DogFather herabstufen)
geschuetzt -- er kann also nicht versehentlich verschwinden.
Zurueckdrehen laesst sich das nur hier in dieser Liste.
Nebenwirkung, und sie ist erwuenscht: Damit gibt es keinen Weg
mehr, sich ueber die Oberflaeche zur hoechsten Rolle zu machen. */
/* DASS AUCH "hand", "modi" UND "gast" DARIN STEHEN, IST ENTSCHIEDEN --
nicht uebersehen (11.09.2026, ausdrueckliche Nachfrage, Antwort
"so lassen").
Sie gehoeren zu Team Dogi. Wer damit auf workspace. jemanden
umstellt, schiebt ihn ins andere Haus: Er faellt aus der
Agenturliste und kommt an dieser Adresse nicht mehr herein. Das
ist kein Fehler, das ist der Preis dafuer, dass DogFather es von
hier aus auch kann.
WER DAS SPAETER "AUFRAEUMEN" WILL: Die symmetrische Regel waere
ein Filter wie unten fuer das Crew-Haus, nur andersherum. Sie
wurde angeboten und abgelehnt. Zwei Pruefungen halten den Stand
fest (pruef-personen-formular, pruef-creator-anlegen) -- wer ihn
aendert, macht dort rot, und das soll er auch. */
admin: [...ROLLEN].filter((r) => r !== "admin"),
/* Spicy Media: das ganze Team -- und seit dem 11.09.2026 auch die
eigene Rolle.
Filipe: "ich will dass die rolle spicy und dogfather, auch die
rollen wechseln koennen wenn die personen schon drin sind. von
alle kategorien, creator, scouts, manager spicy."
Dass jemand seine eigene Rolle weitergeben kann, ist eine
Entscheidung und kein Versehen: Spicy Media fuehrt die Agentur.
DogFather bleibt trotzdem ausserhalb der Liste -- niemand hebt
sich ueber die Rolle, die ihn eingesetzt hat. */
spicy: ["spicy", "manager", "scout", "creator"],
/* Ein Manager stellt Creator ein, die er dann auch betreut. */
manager: ["creator"],
/* DIE RECHTE HAND LEGT PERSONEN AN (22.09.2026).
Filipe: "dan will ich dass die rechte hand auch neue personen
hinzufuegen kann. also neue erstellen kann und die codes genau so
sieht wie dogfather, damit sie das auch machen kann wenn er live
ist."
DIESELBE LISTE, DIE SIE AUCH VERGEBEN DARF -- abgeleitet aus
ROLLEN_ZUM_AENDERN, nicht danebengeschrieben. Zwei Listen mit
denselben zwei Rollen waeren zwei Gelegenheiten, eine davon zu
aendern und die andere zu vergessen; dann duerfte sie eine Rolle
anlegen, aber nicht vergeben, und niemand wuesste warum.
WAS DAMIT AUSDRUECKLICH NICHT GEHT: eine zweite rechte Hand, eine
linke Hand oder einen zweiten DogFather. Sie kann sich also auch
ueber den Umweg "neuen Zugang anlegen" keine hoeheren Rechte
verschaffen. */
hand: ROLLEN_ZUM_AENDERN.hand,
/* DIE LINKE HAND LEGT NIEMANDEN AN (21.09.2026) -- ausdruecklich im
Auftrag. Sie stuende auch ohne diese Zeile auf [] (darfAnlegen
nimmt `?? []`), aber dann saehe es aus wie vergessen. Eine
ausdrueckliche leere Liste ist eine Entscheidung, eine fehlende
Zeile ist eine Luecke. */
linke: [],
};
/** 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 [];
const alle = [...(ANLEGBAR[person.rolle] ?? [])];
/* AUF DER TEAM-ADRESSE NUR TEAM-ROLLEN (10.09.2026).
Filipe: "bitte nur basiert auf diese seite." Wer dort jemanden
anlegt, meint jemanden aus dem Team -- einen Creator dort
einzutragen waere ein Versehen, das man erst auf der anderen Seite
bemerkt.
DIESELBE AUSKUNFT BAUT AUCH DIE KNOEPFE. `darfAnlegen` ist die
Quelle sowohl fuer die Pruefung in der Route als auch fuer die
Auswahl in der Oberflaeche -- deshalb verschwinden die anderen
Rollen dort von selbst, statt eine Absage zu bringen. */
if (person.haus !== "crew") return alle;
return alle.filter((r) => HAUS_TEAM_ROLLEN.has(r));
}
/** Wer darf die Rolle einer Person aendern, die schon da ist?
*
* (11.09.2026) Filipe: "ich will dass die rolle spicy und dogfather,
* auch die rollen wechseln koennen wenn die personen schon drin
* sind."
*
* EINE REGEL, DREI BENUTZER: die Schranke in nurAdmin, der Knopf in
* der Oberflaeche (ueber `darf_rollen_wechseln` in /api/ich) und die
* Route selbst. Stuende sie dreimal da, waere die dritte Abschrift
* die, die eine Rolle vergisst -- genau so ist am selben Tag im Chat
* ein Knopf unsichtbar geblieben, obwohl das Recht stimmte.
*
* WAS SIE NICHT ENTSCHEIDET: WELCHE Rolle vergeben werden darf. Das
* steht in ANLEGBAR und ist bewusst getrennt -- "darf ueberhaupt
* wechseln" und "darf DIESE Rolle vergeben" sind zwei Fragen, und
* ihre Antworten laufen auseinander, sobald eine Rolle dazukommt.
*/
/* WER DARF DEN WEG "Rolle aendern" UEBERHAUPT BETRETEN?
*
* DIE RECHTE HAND KAM AM 20.09.2026 DAZU. Filipe: "perfektionier das
* fuer die rolle dogfather und rechte hand. nur die sollen das machen
* koennen."
*
* DIESE ZEILE OEFFNET NUR DIE TUER. Wen sie anfassen darf und wozu,
* entscheidet darfRolleAendern() / rollenZumAendern() weiter oben --
* dort steht auch, warum die rechte Hand keine zweite rechte Hand
* ernennen kann. Zwei getrennte Fragen, zwei getrennte Regeln: Wer
* hier etwas aendert, oeffnet keine Grenze versehentlich mit. */
export const darfRollenWechseln = (person) =>
!!person && (person.rolle === "admin" || person.rolle === "spicy"
|| person.rolle === "hand");
/* OHNE "linke" -- UND DAS IST ABSICHT, KEIN VERGESSEN (21.09.2026).
Eine Rolle zu aendern ist Kontoverwaltung. Der Auftrag sagt zur
linken Hand ausdruecklich "keine neuen Personen oder Accounts
hinzufuegen"; wer Rollen vergeben kann, kann sich selbst und andere
zu allem machen, was es gibt. Deshalb hier die kleinere Antwort. */
/* 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" || istHand(person));
/* =======================================================================
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.)
===================================================================== */
/* DIE ROLLEN DER AGENTUR -- das andere Haus.
Sie stehen als eigene Menge da und nicht als "alles ausser Team
Dogi": Kaeme morgen eine sechste Rolle dazu, waere sie mit einem
`ausser` automatisch in der Agentur, ohne dass jemand darueber
nachgedacht hat. Eine Aufzaehlung zwingt zur Entscheidung -- dieselbe
Ueberlegung wie bei OHNE_MODI_HOSTS in crew-adresse.js. */
const AGENTUR_ROLLEN = new Set(["spicy", "manager", "scout", "creator"]);
/* Die gemeinsame Bauweise beider Filter. Sie stand bis zum 10.09.2026
nur einmal da, in ohneTeamDogi -- und beim zweiten Haus waere sie
abgeschrieben worden. Eine Abschrift ist hier besonders teuer: In ihr
steckt die NULL-Falle (siehe unten), und die sieht man einer Kopie
nicht an. */
function ohneRollen(praefix, spalten, rollen) {
const liste = [...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 ");
}
/* =====================================================================
NUR DAS HAUS VON TEAM DOGI (10.09.2026)
Das Gegenstueck zu ohneTeamDogi -- und ABSICHTLICH nach demselben
Muster gebaut: "keine Spalte zeigt auf jemanden aus dem anderen
Haus".
WARUM NICHT "MINDESTENS EINE SPALTE ZEIGT AUF TEAM DOGI": Das waere
die naheliegende Formulierung und sie waere falsch. Eine Aufgabe, die
DogFather fuer einen Creator anlegt, haette dann ueber `erstellt_von`
(er selbst gehoert zum Haus) trotzdem gepasst -- und stuende auf der
Team-Seite, obwohl sie die Agentur betrifft. Andersherum stimmt es:
Sobald IRGENDEINE Spalte auf einen Creator, Scout, Manager oder
Spicy Media zeigt, gehoert die Zeile ins andere Haus.
Zeilen ohne jede Zuordnung (alle Spalten NULL) bleiben sichtbar. Das
ist richtig: Sie gehoeren dem, der sie geschrieben hat, und ueber die
Regel darueber steht ohnehin schon, wer das sein darf.
===================================================================== */
export function ohneAgentur(praefix, spalten = ["creator_id", "erstellt_von"]) {
return ohneRollen(praefix, spalten, AGENTUR_ROLLEN);
}
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. */
return ohneRollen(praefix, spalten, TEAM_DOGI_ROLLEN);
}
/** 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",
/* DIE LINKE HAND (21.09.2026). Ohne diesen Eintrag heisst sie
ueberall dort, wo der Rollenname steht -- im Treff, an einem
Beitrag, in der Personenliste -- schlicht "linke". Gefunden hat
das pruef-rechtetafel, weil sie diese Tabelle gegen ROLLEN haelt
und rot wird, sobald eine Rolle nur in einer der beiden steht. */
linke: "Linke Hand",
modi: "Modi",
/* DIE COMMUNITY (11.09.2026). Der Name steht hier und NICHT in einer
Datei, die jeder herunterlaedt -- ausgeliefert wird er nur an den,
der ihn selbst traegt, und an die, die ihn sehen duerfen.
Filipe hat ausdruecklich gewaehlt, dass im Treff der volle
Rollenname neben einem Beitrag steht ("Modi", "Rechte Hand",
"DogFather"). Das bleibt damit vereinbar: Der Name kommt als DATEN
mit der Antwort, nicht als Konstante im Browser. */
gast: "Community",
};
/* 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, zur Freigabe", 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" },
/* RUECKMELDUNG -- IN BEIDE RICHTUNGEN (10.09.2026, Kapitel 5 und 6
des Pflichtenhefts).
Filipe: "Nicht nur ich soll meine Modis bewerten oder ihnen
Feedback geben koennen. Auch die Modis sollen mir Feedback geben
koennen." Und: "Es soll vor allem dabei helfen, als Team besser zu
werden."
Deshalb steht die Kachel bei ALLEN im Team an derselben Stelle und
heisst fuer alle gleich. "Feedback an DogFather" beim einen und
"Bewertung des Teams" beim anderen waeren zwei Einbahnstrassen --
und die Richtung stuende im Namen. */
{ gruppe: "Täglich", gruppeUnter: "Was du sowieso jeden Tag aufmachst",
name: "Rückmeldung", unter: "Was gut läuft, was hakt – in beide Richtungen",
zeichen: "reports", ton: 25, ziel: "bereich.html?b=rueckmeldung", szene: "lounge" },
{ 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",
/* DER NAME, DRITTE FASSUNG (11.09.2026, Filipe: "ich will dass du den
namen aenderst von dieser kacheln").
Zuerst hiess sie "Team-Lage" -- das klang nach Bericht ueber
Menschen. Dann "Eingang" -- das beschrieb nur die eine Haelfte
(die Rueckmeldungen und Angebote, die hereinkommen) und liess die
andere weg, die den groesseren Teil der Seite ausmacht: WER da
ist, wer heute kann, wer was offen hat.
"Dein Team" sagt beides und stellt niemanden ueber jemanden -- es
ist die Seite, auf der man sein Team ansieht, nicht die, auf der
man es beurteilt. Die Unterzeile nennt jetzt beide Haelften. */
name: "Dein Team", unter: "Wer da ist, wer was offen hat – und was auf dich wartet",
zeichen: "teamlage", ton: 24, ziel: "teamlage.html", szene: "halle",
};
/* PERSONEN & ZUGAENGE, ABER FUER DAS TEAM (10.09.2026)
Filipe: "die kategorie personen & zugaenge fehlt also muss das
hinzugefuegt werden und bitte nur basiert auf diese seite."
Sie fehlte, weil ich mit den Agenturkacheln auch diese entfernt habe
-- und ausgerechnet sie ist die, mit der man jemandem eine Rolle gibt.
Ohne sie war die Team-Adresse eine Seite, auf der man das Team nicht
verwalten kann.
NAME, ZEICHEN UND TON SIND DIESELBEN wie auf der Agenturseite. Es ist
dieselbe Seite mit demselben Zweck; ein zweiter Name dafuer waere ein
zweites Ding, das es nicht gibt.
"NUR BASIERT AUF DIESE SEITE" steht nicht hier, sondern im Server:
Auf dieser Adresse liefert die Liste nur Team Dogi, und angelegt
werden koennen nur Team-Rollen (siehe hausBedingung und
darfAnlegen). Eine Kachel, die etwas anderes verspricht als die
Antwort dahinter, waere schlimmer als keine. */
/* =====================================================================
ENTWICKLUNG UND TALENTE (11.09.2026)
Filipe: "ich will dass ich genau so eine kategorie habe in der
dogfather und rechten hand rollen, wo wir aufgaben oder bewertungen
ueber modis eingeben koennen. auch fuer zuschauer die vielleicht
modis werden koennten waere auch geil."
DIE GRUPPE HEISST NICHT "TEAM FUEHREN". Das waere der bequeme Name
und der falsche: Im Haus gilt, dass DogFather nicht ueber seinem Team
steht. "Entwicklung & Nachwuchs" sagt, WAS drinsteht, nicht, wer wem
uebergeordnet ist.
ZWEI KACHELN UND NICHT DREI: Aufgaben an das Team gibt es laengst --
sie stehen im Aufgabenbrett und haengen dort an einer Person, einer
Frist und einem Status. Eine zweite Stelle dafuer waere ein zweiter
Ort, an dem man nachsehen muesste, welche Aufgabe wirklich gilt. */
const FUEHRUNG_GRUPPE = {
gruppe: "Entwicklung & Nachwuchs",
gruppeUnter: "Was ihr über die Zeit beobachtet – und wer dazukommen könnte",
};
/* UMBENANNT AM 19.09.2026 -- von "Entwicklung" zu "Eure Aufgaben".
Filipe: "diese kategorie ist ja die aufgaben die ich an die modis
habe, also eher aufgabe und dan perfektionierst du eine neue wo
diagramme texte und so zu jedem modi gemacht wird und diese
kategorie kannst du entwicklung nenen."
Er hat recht, und der Name war seit dem 11.09. falsch: Damals fuehrte
die Kachel auf das Notizbrett (dort standen wirklich Beobachtungen),
seitdem fuehrt sie auf den KATALOG -- eine Liste dessen, was ein Modi
koennen soll. Das ist eine Aufgabenliste, und sie hiess weiter
"Entwicklung", weil beim Umhaengen des Ziels niemand den Namen
angefasst hat.
DER NAME "ENTWICKLUNG" WIRD DAMIT FREI fuer das, was Filipe wirklich
darunter versteht: eine Auswertung je Person, mit Verlauf und Text.
Die gibt es noch nicht -- sie ist der naechste Schritt und
ausdruecklich nicht diese Kachel. */
const ENTWICKLUNG_KACHEL = {
...FUEHRUNG_GRUPPE,
name: "Eure Aufgaben", unter: "Was ihr könnt – und was als Nächstes kommt",
/* SEIT DEM 11.09.2026 FUEHRT DIE KACHEL AUF DEN KATALOG, nicht mehr
auf das Notizbrett. Filipe: "ich will dass fertige aufgaben da
stehen und so". Das Brett gibt es weiter -- es ist von der
Katalogseite aus verlinkt und nimmt auf, was zwischen zwei
Durchgaengen auffaellt. */
zeichen: "steckbrief", ton: 26, ziel: "entwicklung.html", szene: "studio",
};
/* DIE KACHEL, FUER DIE DER NAME FREI GEMACHT WURDE (19.09.2026).
Filipe: "dan perfektionierst du eine neue wo diagramme texte und so
zu jedem modi gemacht wird und diese kategorie kannst du entwicklung
nenen."
SIE ZEIGT AUF werdegang.html UND NICHT AUF entwicklung.html. Das ist
Absicht und sieht beim ersten Hinsehen verdreht aus:
entwicklung.html = der KATALOG, Kachel "Eure Aufgaben"
werdegang.html = die AUSWERTUNG, Kachel "Entwicklung"
Die saubere Endform waere, die alte Datei umzubenennen. Das waeren
25 Fundstellen in 11 Dateien -- und damit zwei Aenderungen in einer:
Wenn danach etwas klemmt, weiss niemand, welche der beiden es war.
Deshalb steht es hier als Kommentar und in `pruef-werdegang` als
Pruefung, die rot wird, wenn die Kachel woanders hinzeigt.
EIGENER TON 40: GEMESSEN, NICHT GERATEN -- und der erste Anlauf lag
falsch. 38 sah frei aus, weil 37 die hoechste Nummer in DIESER Datei
ist. Gezaehlt in start.css sind es aber 39, alle vergeben: 38 ist
"Unsere Seiten", 39 "Vertraulich melden" -- beide kamen am 19.09.
frueh dazu und stehen nicht als `ton:` in workspace.js.
Genau davor warnt das Farbwerkzeug in seinem eigenen Kopf ("Zweiter
Anlauf war Ton 22, weil er frei AUSSAH"). Gezaehlt wird deshalb in
der Datei, die die Farben wirklich enthaelt.
Nach dem Hinzufuegen laeuft `node tools/kachel-farben.mjs --schreiben`
-- es rechnet alle 40 Toene neu gegeneinander, damit sich keine zwei
aehneln.
EIGENES ZEICHEN "werdegang": Die vorhandenen 22 sind entweder
vergeben (steckbrief viermal) oder sagen etwas anderes -- "zahlen"
ist eine Messkurve, "teamlage" sind zwei Koepfe. Hier geht es um
EINEN Menschen und seinen Weg. */
const WERDEGANG_KACHEL = {
...FUEHRUNG_GRUPPE,
name: "Entwicklung", unter: "Wie sich jemand über die Zeit bewegt – ohne Note",
zeichen: "werdegang", ton: 40, ziel: "werdegang.html", szene: "wald",
};
const TALENTE_KACHEL = {
...FUEHRUNG_GRUPPE,
name: "Talente", unter: "Zuschauer, die ins Team passen könnten",
zeichen: "scouting", ton: 27, ziel: "talente.html", szene: "portal",
};
const PERSONEN_KACHEL = {
gruppe: "Für dich", gruppeUnter: "Dein Platz im Team",
name: "Personen & Zugänge", unter: "Wer dabei ist – und mit welchem Zugang",
zeichen: "personen", ton: 11, ziel: "personen.html", szene: "zentrale",
};
/* 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. */
/* PERSONEN & ZUGAENGE GEHOERT DAZU (10.09.2026, zweite Fassung).
Erst stand die Kachel nur bei DogFather -- die Seite haengt am
Server an `nurAdmin`, und ein Knopf, der eine Absage bringt, ist
schlimmer als kein Knopf.
Filipe danach: "ich will dass die rechte hand auch alle sieht."
Also bekommt sie die Seite -- lesend. Damit stimmt der Knopf wieder,
und er steht in derselben Liste wie alles andere, was sie und
DogFather auf dieser Adresse teilen. */
/** Setzt Kacheln VOR die erste Kachel einer Gruppe.
*
* WOZU: Die Startseite gruppiert nach dem ersten Auftreten einer
* Gruppe im Feld -- die Reihenfolge der Gruppen ist also die
* Reihenfolge der Kacheln. (gruppeNach gilt nur fuer die
* Zusatzkacheln der Agenturseite, siehe start.js.)
*
* NACH DEM NAMEN UND NICHT NACH EINER ZAHL. "An Position 7 einfuegen"
* waere beim naechsten Umsortieren still falsch -- und still falsch
* heisst hier: eine Kategorie rutscht irgendwohin, ohne dass es
* jemand merkt.
*
* FINDET SICH DIE GRUPPE NICHT, wird angehaengt statt weggelassen.
* Eine Kachel, die wegen einer Reihenfolge verschwindet, waere der
* schlechtere Tausch -- dieselbe Entscheidung wie bei gruppeNach. */
function vorGruppe(liste, gruppe, ...neue) {
const i = liste.findIndex((k) => k?.gruppe === gruppe);
if (i < 0) return [...liste, ...neue];
return [...liste.slice(0, i), ...neue, ...liste.slice(i)];
}
/* ENTWICKLUNG & NACHWUCHS STEHT UEBER "RUND UMS LIVE" (Filipe,
11.09.2026, mit zwei Bildschirmfotos: "mach nur die kategorie auf
screen1 ueber die kategorie von screen2").
NUR AUF DIESER ADRESSE. Auf der Agenturseite bleiben die beiden
Kacheln ganz unten -- dort steht dieselbe Entscheidung mit dem
umgekehrten Vorzeichen ("das sind private informationen von meiner
community", 11.09.2026), und sie kommen dort ueber
ZUSATZ_BEREICHE.admin mit eigenem gruppeNach. Zwei Adressen, zwei
Plaetze, beide entschieden. */
/* WIE GEHT'S DIR? -- die einzige Kachel des Hauses, deren Inhalt
niemand ausser der Person selbst je sieht (11.09.2026).
SIE STEHT BEI "FUER DICH" und nicht bei "Taeglich": Es sind sechs
Fragen, die man einmal im Monat beantwortet, nicht jeden Tag. Eine
taegliche Kachel, die man nicht taeglich braucht, wird nach einer
Woche uebersehen.
UND SIE STEHT AUCH BEI DOGFATHER UND DER RECHTEN HAND. Sie sind
Teil des Teams, nicht darueber -- wenn es jemandem zu viel wird, dann
ihnen genauso. Eine Kachel, die nur die anderen ausfuellen, waere
genau die Schieflage, die dieses Haus nicht haben soll. */
/* WER SIEHT WAS -- auf BEIDEN Adressen (Filipe, 11.09.2026).
Sie stand nur bei den Zusatzkacheln der Agenturseite. Auf der
Team-Dogi-Seite arbeiten DogFather und die rechte Hand aber taeglich;
dort nicht nachsehen zu koennen, wer was darf, hiesse, die Frage an
dem Ort zu stellen, an dem man gerade nicht ist.
EINMAL BESCHRIEBEN, ZWEIMAL BENUTZT. Zwei Kachelobjekte mit
demselben Ziel waeren zwei Gelegenheiten, eines davon umzubenennen.
EIN EIGENER TON, UND ZWAR EIN NEUER (11.09.2026): Erster Anlauf war
Ton 26 -- das waere "Entwicklung" ein zweites Mal gewesen. Zweiter
Anlauf war Ton 22, weil er frei AUSSAH: Ich hatte die Toene ueber
bereicheFuer() gezaehlt, und das liefert die Zusatzkacheln gar nicht
mit. Gefunden hat es pruef-start-ansicht, die ueber die ECHTE Ansicht
zaehlt ("34 Farben auf 35 Kacheln"). Ton 35 ist gemessen, nicht
geraten. */
const RECHTE_KACHEL = {
name: "Wer sieht was", unter: "Jede Rolle, jede Seite – ausgerechnet, nicht notiert",
zeichen: "schutz", ton: 35, ziel: "rechte.html", szene: "studio",
};
/* WIE GEHT'S DIR? -- EIGENE SEITE SEIT DEM 15.09.2026.
Filipe mit dem Bildschirmfoto der Kachel: "ich will dass du diese
seite perfektionnierst, denn gerade wenn ich drauf druecke geht die
entwicklungsseite auf."
Er hatte recht, und es war schlimmer als ein falscher Verweis: Die
Kachel verspricht "die Antworten sieht nur du" und oeffnete eine
Seite mit Namen und Beobachtungen ueber ANDERE Menschen. Wer ihr
glaubt und draufdrueckt, sieht im ersten Moment das Gegenteil dessen,
was draufsteht.
Jetzt: eine Seite, ein Zweck. entwicklung.html wird dadurch selbst
einfacher -- sie tut nur noch eine Sache. */
const BEFINDEN_KACHEL = {
gruppe: "Für dich", gruppeUnter: "Dein Platz im Team",
name: "Wie geht's dir?", unter: "Ein paar Fragen – die Antworten siehst nur du",
zeichen: "steckbrief", ton: 37, ziel: "befinden.html", szene: "lounge",
};
const HAND_BEREICHE = vorGruppe(
[...MODI_BEREICHE, EINGANG_KACHEL, PERSONEN_KACHEL],
"Rund ums Live",
ENTWICKLUNG_KACHEL, WERDEGANG_KACHEL, TALENTE_KACHEL,
).concat(BEFINDEN_KACHEL, {
gruppe: "Für dich", gruppeUnter: "Dein Platz im Team",
...RECHTE_KACHEL,
});
/* =====================================================================
DIE SIEBEN BRETTER DES TREFFS (11.09.2026)
Filipe: "wieso nur eine kachel fuer die community ich will dass die
mehrere kacheln haben. mit mehreren optionen."
Eine Kachel war zu kurz gedacht: Ein Raum, in dem Ansagen, Fragen,
Wuensche und Clips durcheinandergehen, ist nach zwei Wochen
unbenutzbar -- die Ansage von gestern ist weg, die Frage von heute
geht unter.
SORTIERT NACH ZWECK, NICHT NACH THEMA, und in der Reihenfolge, in der
ein Neuer liest: erst was gilt, dann was laeuft, dann das Gespraech,
dann das Besondere.
Jede der sieben bedient eines der vier Dinge, die darueber
entscheiden, ob sich jemand zugehoerig fuehlt (Zugehoerigkeit,
Einfluss, Nutzen, geteilte Erlebnisse). Was keines bedient, ist nicht
dabei -- deshalb gibt es hier keine Bestenliste und keine Punkte.
VON OBEN NACH UNTEN GELESEN SIND SIE EIN WEG: lesen -> mitreden ->
mitgestalten -> dazugehoeren. Die letzte Kachel uebergibt an die
Talente-Kategorie, die es schon gibt.
DIE KACHELN STEHEN HIER UND NICHT IN assets/js/bereiche.js -- aus
demselben Grund wie bei den Modis: Diese Datei wird nicht
ausgeliefert, jene bekommt jeder Besucher. */
const TREFF_GRUPPE = {
gruppe: "Community",
gruppeUnter: "Der Treff \u2013 der Raum f\u00fcr die Zuschauer",
};
/* DIE BREITE KACHEL STEHT VORNE (17.09.2026).
Filipe, mit einem Bildschirmfoto: "das wie es jetzt ist ist es
einfach total scheisse ... gerade einfach nur dahin geknallt".
Er hatte recht, und die Ursache stand genau hier. Das Raster hat
DREI Spalten, "Der Treff" ist doppelt breit (`grid-column: span 2`)
-- stand aber an DRITTER Stelle. Nach zwei normalen Kacheln war noch
EINE Spalte frei, er passte nicht hinein und rutschte eine Reihe
tiefer. Zurueck blieb ein Loch mitten im Raster:
Reihe 1 [Anschlagbrett] [Was ansteht] ....... LUECKE
Reihe 2 [ Der Treff (2) ] [Wunschliste]
Reihe 3 [Highlights] [Regeln] [Mitmachen]
Reihe 4 [Meldungen] ...... LUECKE ...... LUECKE
DIE REGEL, und sie gilt fuer jedes Raster im Haus: Eine breite
Kachel gehoert an den ANFANG. Steht sie hinten, faellt die Luecke in
die MITTE -- und eine Luecke in der Mitte sieht kaputt aus, eine am
Ende sieht grosszuegig aus.
Jetzt fuellen Treff (2) plus Anschlagbrett die erste Reihe, die
naechsten drei die zweite, der Rest die dritte. Fuer das Team mit
der achten Kachel geht es genau auf: drei mal drei.
UND ES IST AUCH INHALTLICH RICHTIG. Der Treff ist das Herz dieses
Bereichs -- dass er vorne steht, ist keine Notloesung fuer das
Raster, sondern die Reihenfolge, die ohnehin stimmt. */
const TREFF_BEREICHE = [
/* AUS DEM TREFF-BRETT WIRD DIE WILLKOMMENSSEITE (21.09.2026, Auftrag
Abschnitt 7). Gemessen davor: Das Brett "treff" hatte NULL Eintraege,
der Treff-Chat daneben 29 Nachrichten -- geredet wird im Chat, das
Brett war eine Kachel ohne Inhalt. Sie zeigt jetzt auf die Seite,
die erklaert, was es hier ueberhaupt gibt. */
{ ...TREFF_GRUPPE, name: "Willkommen", unter: "Was es hier gibt, kurz erklaert",
zeichen: "community", ton: 34, gross: true, ziel: "willkommen.html", szene: "lounge" },
/* DER CHAT (19.09.2026). Filipe: "eine geile mischung von punkt 2
und drei ... nach mitternacht bis morgens 6 uhr keiner schreiben
kann damit auch die privatsphaere beruecksichtig wird ueber nacht
... und die sollen nicht anrufen koennen. nur schreiben."
GLEICH AN ZWEITER STELLE, direkt hinter dem grossen Treff-Brett.
Er ist das Lebendigste, was die Community hier hat -- weiter unten
waere er der Knopf, den man erst findet, wenn einem jemand sagt,
dass es ihn gibt.
ZIEL IST chat.html, dieselbe Seite wie beim Team. Kein zweiter
Chat: Zwei Oberflaechen fuer dasselbe waeren zwei Stellen, an
denen ein Fehler behoben werden muss, und die zweite waere die,
die man vergisst. Was jemand dort SIEHT, entscheidet die
Raummitgliedschaft -- ein Gast hat genau einen Raum.
TON 41: gezaehlt in start.css, nicht in dieser Datei. 40 war die
hoechste (siehe die Warnung an WERDEGANG_KACHEL, dort ist genau
dieser Fehler heute schon einmal passiert). */
/* „TREFF-CHAT“ UND NICHT NUR „CHAT“ (nachgeschärft 19.09.2026).
Gefunden beim Durchgehen aus Benutzersicht: Ein Modi und die
rechte Hand sahen ZWEI Kacheln mit demselben Namen „Chat“, beide
mit demselben Ziel `chat.html` -- eine unter „Täglich“ (das Team),
eine hier unter „Community“. Sie taten auch dasselbe: Man landete
in der Gesprächsliste und musste den richtigen Raum suchen.
Zwei gleich benannte Wege zum selben Ort sind schlimmer als ein
fehlender -- man klickt den falschen und sucht den Unterschied.
Jetzt trägt sie ihren eigenen Namen UND ihr eigenes Ziel:
`?raum=treff` öffnet den Raum der Community direkt. Für die
Community selbst ändert sich am Namen nichts Wesentliches -- sie
hat nur diese eine. */
{ ...TREFF_GRUPPE, name: "Treff-Chat", unter: "Miteinander reden – nachts schläft er",
zeichen: "chat", ton: 41, ziel: "chat.html?raum=treff", szene: "lounge" },
{ ...TREFF_GRUPPE, name: "Anschlagbrett", unter: "Was gerade gilt",
zeichen: "reports", ton: 33, ziel: "bereich.html?b=anschlag", szene: "halle" },
{ ...TREFF_GRUPPE, name: "Was ansteht", unter: "Die n\u00e4chsten Streams und Events",
zeichen: "kalender", ton: 31, ziel: "bereich.html?b=ansteht", szene: "skyline" },
{ ...TREFF_GRUPPE, name: "Wunschliste", unter: "Was ihr euch w\u00fcnscht \u2013 und wer mitwill",
zeichen: "content", ton: 30, ziel: "bereich.html?b=wunsch", szene: "portal" },
{ ...TREFF_GRUPPE, name: "Highlights", unter: "Eure Clips und Bilder",
zeichen: "live", ton: 29, ziel: "bereich.html?b=highlight", szene: "arena" },
/* HIESS BIS ZUM 21.09.2026 "Unsere Seiten" (Ziel und Datei bleiben
gleich, nur der Name auf der Kachel aendert sich).
Dahinter stehen jetzt drei Dinge statt zwei Adressen: ob gerade
gestreamt wird, wo man Dogi sonst findet, und die beiden Seiten mit
dem Rabatt. "Unsere Seiten" haette davon nur noch den letzten
Drittel benannt -- und wer wegen des Live-Status wiederkommt,
findet eine Kachel nicht, die nach etwas anderem klingt.
DER NAME STEHT AB JETZT NUR EINMAL IM HAUS: pruef-wege-nach-draussen
liest die Ueberschrift der Seite und verlangt, dass die Kachel
genauso heisst. Zwei Namen fuer dieselbe Sache waren schon einmal
ein echter Fehler (die zwei Kacheln "Chat" am 19.09.) -- man klickt
den falschen und sucht den Unterschied. */
/* MATERIAL (22.09.2026).
Filipe: "ich brauch auch noch eine neue kachel im bereich
community, wo wir als team ... bilder reinschicken koennen, mit
text und wo die leute sei es community oder modis sich die bilder
nehmen koennen zum posten ... sobald ein bild oder ein video ...
runtergeladen wurde, soll direkt blockiert werden."
SIE STEHT VOR "DRAUSSEN": Wer hier etwas holt, geht danach
hinaus und postet es. Die Reihenfolge ist der Weg.
Ton 42 ist GERECHNET, nicht gegriffen -- tools/kachel-farbe-
einzeln.mjs, Abstand 0,0823 zum naechsten Nachbarn (1,3-fach
besser als der erste Vorschlag), Kontrast 6,08:1. */
{ ...TREFF_GRUPPE, name: "Material", unter: "Bilder und Videos zum Posten – jedes nur einmal",
zeichen: "galerie", ton: 42, ziel: "material.html", szene: "studio" },
{ ...TREFF_GRUPPE, name: "Draußen", unter: "Ist er live? Kanäle, Shop & dein Rabatt",
zeichen: "hinaus", ton: 38, ziel: "unsere-seiten.html", szene: "portal" },
{ ...TREFF_GRUPPE, name: "Mitmachen", unter: "Wie du dazugeh\u00f6ren kannst",
zeichen: "personen", ton: 32, ziel: "bereich.html?b=mitmachen", szene: "garage" },
/* UNSERE SEITEN (19.09.2026) -- die einzige Kachel, die HINAUSFUEHRT.
Filipe: "ich brauch im community bereich noch eine kachel, die wenn
man drauf dr\u00fcckt dan kacheln hat die auf die website von dogfather
universe geht ... und die shop site von vanvan."
SIE IST KEIN BRETT, sondern eine Seite -- deshalb ein Dateiname als
Ziel und kein "bereich.html?b=...". Die Ableitung von TREFF_BRETTER
weiter unten nimmt sie dadurch von selbst nicht mit, genau wie die
Moderationskachel. Eine zweite Liste, die man pflegen muesste,
entsteht nicht.
UND SIE STEHT AM ENDE. Die sieben davor fuehren nach innen: lesen,
schreiben, mitmachen. Diese eine verlaesst den Treff -- das ist der
letzte Schritt, nicht der erste.
RASTERRECHNUNG (drei Spalten, "Der Treff" belegt zwei): Mit dieser
Kachel sind es fuer die Community 7 + 1 Kacheln = 9 Plaetze, also
genau drei volle Reihen. Vorher waren es acht Plaetze und damit
eine Luecke am Ende. Sie passt also nicht nur inhaltlich. */
{ ...TREFF_GRUPPE, name: "Regeln & Hilfe", unter: "Wie es hier l\u00e4uft",
/* ZEIGT JETZT AUF DIE SEITE, DIE DIE ANTWORT HAT (19.09.2026).
Sie fuehrte auf `bereich.html?b=regeln` -- ein Brett, auf das die
Leitung Regeln eintragen KANN und das am ersten Tag leer ist
("Hier stehen die Regeln des Treffs -- sobald sie eingetragen
sind.").
Die Regeln, die es WIRKLICH gibt, stehen auf `treff-regeln.html`:
Begruessung, die fuenf Regeln, die drei Stufen, das Mindestalter,
was bei Aerger zu tun ist. Diese Seite war von genau EINER Stelle
aus erreichbar -- einem kleinen Textlink unter der Unterzeile
einer Brettseite. Keine der elf Kacheln fuehrte hin.
Damit war die einzige "So laeuft das hier"-Seite des Hauses fuer
einen neuen Zuschauer praktisch unsichtbar, waehrend die Kachel,
die ihren Namen trug, auf eine leere Liste zeigte.
Das Brett gibt es weiter; treff-regeln.html verlinkt darauf. Ein
Ort fuer die Frage, nicht zwei. */
/* EIGENER TON SEIT 22.09.2026. Ton 28 traegt jetzt "Vertraulich
melden" -- ausdruecklicher Wunsch, dort gesetzt. Zwei Kacheln
mit derselben Farbe in derselben Ansicht waeren genau das, was
screen4 abschaffen soll. */
zeichen: "schutz", ton: 39, ziel: "treff-regeln.html", szene: "studio" },
];
/* MELDUNGEN & MASSNAHMEN -- FUER DAS TEAM, NICHT FUER DIE COMMUNITY
(11.09.2026).
Diese Kachel steht ausdruecklich NICHT in TREFF_BEREICHE. Die Liste
dort ist das, was ein Mitglied sieht; sie wird dem Team nur
angehaengt. Eine achte Kachel darin waere auch bei der Community
gelandet -- und zwar lautlos, denn sie saehe dort aus wie die
anderen sieben. Erst beim Klicken haette der Server 404 gesagt, und
das sieht aus wie ein kaputtes Programm.
SIE GEHOERT AUCH NICHT IN TREFF_BRETTER: Sie ist kein Brett mit
Eintraegen, sondern eine Seite. Das Ziel ist deshalb ein
Dateiname und kein "bereich.html?b=...", und die Ableitung weiter
unten nimmt sie von selbst nicht mit. */
/* EIGENE GRUPPE, NICHT DIE DER COMMUNITY (19.09.2026).
Filipe: "ich will das oben die sachen fuer dogfather sind, dan die
sachen fuer modis und dan community bereich."
Bis heute trug diese Kachel `...TREFF_GRUPPE` -- sie landete damit
IN der Gruppe \u201eCommunity" und dort, weil sie zuletzt angehaengt
wird, hinter allen sieben Brettern. Wer moderiert, musste also an
der ganzen Community vorbeiscrollen, um an seine Arbeit zu kommen.
Getrennt ist es auch inhaltlich richtig: Die sieben Bretter sind der
Raum der Zuschauer. Diese Kachel ist Arbeit AN diesem Raum -- und
die sieht nur, wer moderiert. */
const MODERATION_GRUPPE = {
gruppe: "Moderation",
gruppeUnter: "Die Arbeit am Treff \u2013 sieht nur, wer moderiert",
};
const TREFF_MODERATION_KACHEL = {
...MODERATION_GRUPPE,
name: "Meldungen & Ma\u00dfnahmen", unter: "Was gemeldet wurde \u2013 und was daraus wurde",
/* EIGENES ZEICHEN, NICHT DASSELBE WIE "REGELN & HILFE" (17.09.2026).
Hier stand zweimal `schutz` -- zwei Kacheln nebeneinander mit
demselben Schild. Auf dem Bildschirmfoto sind sie nicht zu
unterscheiden, und genau daran erkennt man eine Sammlung, die
niemand als Ganzes angesehen hat.
`startcheck` ist eine abgehakte Liste und sagt den Unterschied:
Bei "Regeln & Hilfe" steht, was GILT. Hier steht, was daraus
WURDE. */
zeichen: "startcheck", ton: 36, ziel: "treff-moderation.html", szene: "studio",
};
/* Welche Bretter zum Treff gehoeren -- ABGELEITET aus den Kacheln, nicht
danebengeschrieben. Eine zweite Liste waere die, die beim naechsten
neuen Brett vergessen wird. */
export const TREFF_BRETTER = [...new Set([
...TREFF_BEREICHE
.map((k) => new URL("http://x/" + k.ziel).searchParams.get("b"))
.filter(Boolean),
/* EIN BRETT OHNE KACHEL (berichtigt 20.09.2026).
----------------------------------------------------------------
Die Liste wird aus den Kachelzielen abgeleitet, und das ist
richtig: Was niemand anklicken kann, muss auch nicht offenstehen.
Am 19.09.2026 bekam die Kachel „Regeln & Hilfe" ein neues Ziel --
`treff-regeln.html` statt `bereich.html?b=regeln`, weil die
Kachel vorher auf ein leeres Brett zeigte. Damit fiel `regeln`
aus dieser Ableitung heraus, und das BRETT war ab da fuer die
Community gesperrt (404).
Nur verweist `treff-regeln.html` weiterhin darauf: „Haeufige
Fragen und Antworten stehen auf dem Brett Regeln & Hilfe." Ein
Verweis, der seit einem Tag ins Leere fuehrt -- und zwar auf der
einen Seite, die einem neuen Mitglied alles erklaeren soll.
Gefunden hat es `pruef-treff` (4 Fehler, seit dem 19.09.), nicht
ein Mensch. Deshalb steht es hier ausdruecklich und mit Grund:
Ein Brett kann erreichbar sein muessen, ohne eine eigene Kachel
zu haben. Wer die Ableitung liest und diese Zeile nicht sieht,
haelt die Liste fuer vollstaendig. */
"regeln",
/* UND DASSELBE EIN DRITTES MAL (21.09.2026) -- deshalb steht unten
jetzt eine Pruefung, die es laut macht.
----------------------------------------------------------------
Am 21.09. wurde die Kachel "Der Treff" zur Willkommensseite
(Auftrag Abschnitt 7). Damit fiel `treff` aus dieser Ableitung.
WAS DAS ANRICHTETE, ist schlimmer als beim letzten Mal: Die
Schranke in workspace-bereiche.js faengt Aussenrollen und
Team-Dogi-Rollen ab, laesst eine AGENTURROLLE aber unbesehen
durch ("if (!TEAM_DOGI_ROLLEN.has(rolle)) return next()"). Solange
`treff` hier stand, fing der Zweig darueber sie ab -- danach nicht
mehr. Eine Creatorin konnte das Brett des Treffs lesen.
Gefunden hat es wieder pruef-treff, nicht das Nachdenken. Und
wieder war es kein neuer Fehler, sondern derselbe: Ein Brett
gehoert zum Treff, WEIL es dazugehoert -- nicht, weil gerade
jemand eine Kachel darauf zeigen laesst. */
"treff",
])];
/* Wer den Treff ueberhaupt sehen darf: die Community selbst und das
Team von Dogi. Die Agentur ausdruecklich nicht -- Creator, Scouts und
Spicy Media haben mit den Zuschauern dieses Streams nichts zu tun. */
export const TREFF_ROLLEN = new Set(["gast", "modi", "hand", "linke", "admin"]);
/* DER VERTRAULICHE MELDEWEG (19.09.2026).
Eigene Gruppe, nicht bei den Brettern: Auf den sieben Brettern
schreibt man oeffentlich. Hier schreibt man an genau zwei Menschen.
Zwischen den anderen Kacheln wuerde das verschwimmen -- und wer ein
Problem hat, sucht nicht lange.
SIE STEHT VOR "FUER DICH": Ein Problem ist dringender als der eigene
Steckbrief. Die Reihenfolge innerhalb der Gruppe "Fuer dich" ergibt
sich aus der Liste, die Sortierung in start.js fasst beide ans Ende. */
const HILFE_KACHEL = {
gruppe: "Für dich", gruppeUnter: "Deine eigene Seite",
name: "Vertraulich melden", unter: "Nur DogFather und die rechte Hand",
/* DIESELBE FARBE WIE "REGELN & HILFE" -- AUSDRUECKLICH SO GEWOLLT
(22.09.2026, Filipe: "die farbe dieser kachel soll die farbe von
der kachel: regeln & hilfe werden ... und diese zwei kacheln
sollen egal wie dan auch so bleiben wie hier erwartet").
Beide gehoeren zusammen: Wer Hilfe sucht, landet bei einem von
beiden. Ton 28 ist damit GESETZT -- die Farbverteilung von
screen4 fasst ihn nicht an, sie weicht ihm aus. */
zeichen: "schutz", ton: 28, ziel: "hilfe.html", szene: "studio",
};
/* DAS TEAM SIEHT DEN TREFF MIT -- als eigene Gruppe unter seinen
uebrigen Kacheln.
Filipe: "die modis rechte hand und ich also dogfather sehen die aber
auch. die community aber nur die dann."
Zusammengesetzt und nicht in MODI_BEREICHE hineingeschrieben, weil
diese Liste weiter oben steht als die Kacheln des Treffs. Eine Kopie
dort waere die Stelle, an der beim naechsten neuen Brett eines fehlt. */
/* =====================================================================
WAS DER MODI UEBER SICH SELBST SIEHT (19.09.2026)
GEFUNDEN BEIM DURCHGEHEN DER SEITE AUS BENUTZERSICHT, und es war
mein eigener Fehler vom Vortag: `entwicklung.html` und
`werdegang.html` haben BEIDE eine ausgearbeitete Eigensicht --
entwicklung.js zeigt die eigene Karte, werdegang.js die eigene
Bewegung. Die Rechtetafel laesst den Modi auf beide Seiten
(rechte.js).
Nur: In seiner Kachelliste stand keine von beiden. Der Weg dorthin
existierte fuer ihn nicht -- er haette die Adresse von Hand tippen
muessen. Die ganze Eigensicht war damit toter Code, ausgerechnet
fuer den Menschen, fuer den sie geschrieben wurde.
ZWEI KACHELN UND NICHT DIE DER LEITUNG. Dieselben Ziele, aber die
Leitungstexte passen nicht: "Wie sich jemand ueber die Zeit bewegt"
liest sich, als ginge es um andere Leute. Aus seiner Sicht geht es
um ihn.
UND SIE LOESEN EINE NAMENSKOLLISION. Bis heute setzten BEIDE Seiten
fuer ihn die Ueberschrift "Deine Entwicklung" -- wer aus der Glocke
kam, konnte nicht sagen, auf welcher er stand. Jetzt heisst der
Katalog "Deine Aufgaben" (passend zur Kachel "Eure Aufgaben") und
die Auswertung "Deine Entwicklung". Ein Name, eine Sache.
ABGELEITET, NICHT ABGESCHRIEBEN: Ziel, Zeichen und Ton kommen aus
den Kacheln der Leitung. Wer dort das Ziel aendert, aendert es hier
mit -- zwei Kachelobjekte mit demselben Ziel waeren sonst zwei
Gelegenheiten, eines davon zu vergessen. */
const FUER_DICH = {
gruppe: "Für dich", gruppeUnter: "Deine Seite im Team",
};
const MEINE_AUFGABEN_KACHEL = {
...FUER_DICH,
zeichen: ENTWICKLUNG_KACHEL.zeichen, ton: ENTWICKLUNG_KACHEL.ton,
ziel: ENTWICKLUNG_KACHEL.ziel, szene: ENTWICKLUNG_KACHEL.szene,
name: "Deine Aufgaben", unter: "Was du kannst – und was als Nächstes kommt",
};
const MEINE_ENTWICKLUNG_KACHEL = {
...FUER_DICH,
zeichen: WERDEGANG_KACHEL.zeichen, ton: WERDEGANG_KACHEL.ton,
ziel: WERDEGANG_KACHEL.ziel, szene: WERDEGANG_KACHEL.szene,
name: "Deine Entwicklung", unter: "Was sich bei dir bewegt hat – ohne Note",
};
/* DER MODI BEKOMMT DENSELBEN BEREICH WIE DIE LEITUNG (22.09.2026).
Filipe zum Bildschirmfoto der Gruppe "Entwicklung & Nachwuchs":
"die modis sollen diesen bereich und diese zwei kacheln auch sehen,
die sollen aber nur ihre aufgaben und daten sehen."
DIESELBEN ZWEI KACHELN, NICHT EIGENE MIT ANDEREM NAMEN. Bis heute
standen hier MEINE_AUFGABEN_KACHEL und MEINE_ENTWICKLUNG_KACHEL --
zwei Kacheln mit denselben Zielen und anderen Namen. Zwei Namen fuer
dieselbe Sache ist genau der Fehler, der am 19.09. schon einmal zwei
Kacheln "Chat" ergeben hat: Man klickt den falschen und sucht den
Unterschied.
"TALENTE" BLEIBT DRAUSSEN. Dort stehen Notizen ueber Zuschauer, die
nichts davon wissen -- das ist etwas anderes als die eigene
Entwicklung. Der Riegel dafuer sitzt ohnehin in rechte.js; diese
Zeile sorgt nur dafuer, dass gar nicht erst eine Kachel dorthin
zeigt.
DASS ER DORT NUR SICH SELBST SIEHT, entscheiden die Seiten: In
workspace-werdegang.js steht `leitung ? alle : []`, in
workspace-entwicklungs-punkte.js dasselbe. Die Kachel oeffnet die
Seite -- was darauf steht, entscheidet der Server. */
const MODI_MIT_TREFF = [...MODI_BEREICHE, BEFINDEN_KACHEL,
ENTWICKLUNG_KACHEL, WERDEGANG_KACHEL, ...TREFF_BEREICHE,
TREFF_MODERATION_KACHEL];
const HAND_MIT_TREFF = [...HAND_BEREICHE, ...TREFF_BEREICHE,
TREFF_MODERATION_KACHEL, HILFE_KACHEL];
/* DIE LINKE HAND SIEHT "DEIN TEAM" NICHT (22.09.2026).
Filipe: "die rolle linke hand soll die kachel dein team auch nicht
sehen bitte danke."
ABGELEITET, NICHT NACHGEBAUT: Ihre Liste ist die der rechten Hand
MINUS dieser einen Kachel. Eine zweite, eigene Liste waere die, die
beim naechsten neuen Bereich auseinanderlaeuft -- dann haette sie
ihn, und die linke Hand nicht, und niemand wuesste warum.
Erkannt wird die Kachel an ihrem ZIEL, nicht an ihrem Namen: Der
Name ist am 17.09. schon einmal gewandert ("Eingang" -> "Dein
Team"), das Ziel nicht. */
const LINKE_MIT_TREFF = HAND_MIT_TREFF.filter((k) => k.ziel !== EINGANG_KACHEL.ziel);
/* AUSSEN_ROLLEN kommt aus crew-adresse.js und wird hier nur
weitergereicht -- die Adressregel braucht sie zuerst, und diese Datei
importiert von dort. Eine zweite Menge waere die, die auseinanderlaeuft. */
export { AUSSEN_ROLLEN };
/** Darf diese Person telefonieren?
*
* HIER UND NICHT AUS workspace-treffchat.js IMPORTIERT: Jene Datei
* importiert aus dieser. Ein Import zurueck waere ein Ring, und Ringe
* halten in JavaScript genau so lange, bis jemand eine Konstante auf
* oberster Ebene benutzt. `workspace-treffchat.js` reicht diese
* Funktion deshalb nur WEITER -- gerechnet wird sie an einer Stelle,
* naemlich hier. */
export function darfTelefonieren(person) {
return !AUSSEN_ROLLEN.has(person?.rolle);
}
/* =====================================================================
KACHEL UND TUER GEHOEREN ZUSAMMEN (11.09.2026)
=====================================================================
Seit DogFather die Rechtetafel von Hand umstellen kann, kann eine
Seite zugehen, ohne dass ihre Kachel verschwindet. Gefunden hat das
pruef-rechte-umstellen mit einer einzigen Zeile: Er nahm einem Modi
chat.html weg -- die Schranke griff sofort, und die Kachel stand
weiterhin da. Ein Knopf, der auf die Startseite zurueckwirft.
Der Satz "Kachel und Tuer gehoeren zusammen" steht seit dem
06.09.2026 in assets/js/bereiche.js. Bis heute war er eine Bitte an
den, der beides pflegt. Jetzt ist er eine Rechnung: Die Kachelliste
wird gegen dieselbe Tafel gefiltert, aus der die Schranke ihre
Entscheidung holt.
NACH DEM PFAD UND NICHT NACH DEM GANZEN ZIEL: "bereich.html?b=live"
ist die Seite bereich.html. Wer die Frage am Fragezeichen nicht
abschneidet, filtert jede Brett-Kachel weg -- lautlos, denn eine
fehlende Kachel sieht aus wie eine Rolle, die sie eben nicht hat.
WAS KEIN ZIEL HAT, BLEIBT. Eine Kachel ohne "ziel" ist kein Weg zu
einer Seite, und "ich kenne die Seite nicht" darf nicht "also weg
damit" heissen. */
function nurOffeneKacheln(liste, person) {
if (!Array.isArray(liste) || !person) return liste;
return liste.filter((k) => {
/* EINE KACHEL, DIE HINAUSFUEHRT, HAT HIER KEINE SEITE (21.09.2026).
Unten wird aus dem Ziel ein Pfad gebaut ("/workspace/" + ziel)
und die Rechtetafel gefragt. Bei einer vollstaendigen Adresse --
der Tuer zu Team Dogi -- kaeme dabei
"/workspace/https://crew..." heraus, das steht in keiner Tafel,
und die Kachel waere lautlos verschwunden. Lautlos ist das
Schlimme daran: Man sucht den Fehler dann bei der Rolle. */
if (k?.aussen) return true;
const ziel = String(k?.ziel || "");
if (!ziel) return true;
const pfad = "/workspace/" + ziel.split("?")[0];
return darfSeite(person, pfad);
});
}
/* JEDER HAT EINEN STECKBRIEF -- AUCH DIE COMMUNITY (19.09.2026).
Filipe: "jeder soll ein steckbrief haben, jeder der ein account hat
... jeder soll genau wie ich foto und so hinzufuegen koennen."
Das Team hatte die Kachel laengst (sie steht in MODI_BEREICHE unter
„Für dich"), die Community nicht -- und damit die groesste Gruppe
ueberhaupt. Die Seite selbst konnte es die ganze Zeit: `/mein` fragt
nach der eigenen Nummer und kennt gar keine Rolle.
EIGENE FASSUNG DES UNTERTEXTS: „Deine Seite im Team" waere fuer ein
Mitglied falsch -- es ist nicht im Team. Der Steckbrief ist fuer sie
das, was man von sich zeigt. */
const STECKBRIEF_KACHEL_TREFF = {
gruppe: "Für dich", gruppeUnter: "Deine eigene Seite",
name: "Mein Steckbrief", unter: "Dein Bild und deine Kanäle",
zeichen: "steckbrief", ton: 10, ziel: "steckbrief.html", szene: "halle",
};
/* Die Community: der Treff und ihre eigene Seite. Sonst nichts. */
const TREFF_MIT_EIGENEM = [...TREFF_BEREICHE, HILFE_KACHEL, STECKBRIEF_KACHEL_TREFF];
export function bereicheFuer(person) {
if (person?.rolle === "modi") return MODI_MIT_TREFF;
/* Die linke Hand vor der allgemeinen Hand-Regel -- sonst faenge
`istHand()` sie ab, und der Filter darunter liefe nie. */
if (person?.rolle === "linke") return LINKE_MIT_TREFF;
if (istHand(person)) return HAND_MIT_TREFF;
/* DIE COMMUNITY SIEHT DEN TREFF UND SONST NICHTS (11.09.2026) --
seit dem 19.09. plus den eigenen Steckbrief. */
if (person?.rolle === "gast") return TREFF_MIT_EIGENEM;
/* AUF DER ADRESSE VON TEAM DOGI SIEHT AUCH DOGFATHER DIE KACHELN DES
TEAMS (10.09.2026).
Filipe: "die hier soll ihre eigene daten haben und komplett von der
anderen getrennt sein." Fuenfundzwanzig Kacheln, von denen zwei
Drittel Creator, Scouts und Agentur betreffen, waeren dort
Fenster in ein Haus, in dem er gerade nicht ist -- und hinter
jedem stuende seit heute eine leere Liste.
ES IST DIESELBE LISTE WIE BEI DER RECHTEN HAND, nicht eine dritte.
Beide sehen auf dieser Adresse dasselbe; eine eigene Liste fuer
ihn waere die, die beim naechsten Umbau auseinanderlaeuft -- und
sie stuende ausserdem gegen die Hausregel, ihn nicht ueber sein
Team zu stellen. */
/* Auf der Team-Adresse sehen DogFather und die rechte Hand dieselben
Kacheln. Eine eigene Liste fuer ihn waere die, die beim naechsten
Umbau auseinanderlaeuft -- und sie stuende gegen die Hausregel,
ihn nicht ueber sein Team zu stellen. */
if (person?.haus === "crew") return HAND_MIT_TREFF;
/* `null` HEISST "NIMM DIE LISTE AUS DEM BROWSER" -- und die enthaelt
fuenfundzwanzig Kacheln des Agenturhauses. Bis zum 11.09.2026 stand
hier nur `return null`, also bekam JEDE Rolle ohne eigenen Zweig
diese Liste angeboten. Gefiltert wurde sie danach zwar nach
`rollen:` an jeder Kachel, aber das ist eine zweite Schranke an
einer anderen Stelle -- und sie lebt in einer Datei, die jeder
herunterlaedt.
Jetzt bekommen nur die fuenf Rollen `null`, fuer die diese Liste
geschrieben wurde. Alles andere bekommt eine leere Liste: keine
Kachel, kein Weg, nichts zu filtern. */
if (AGENTUR_LISTE.has(person?.rolle)) return null;
return [];
}
/* Die fuenf, deren Kacheln in assets/js/bereiche.js stehen. Nicht zu
verwechseln mit AGENTUR_ROLLEN (dort fehlt `admin`, weil es dabei um
die Sichtbarkeit von Zeilen geht und nicht um Kacheln). */
const AGENTUR_LISTE = new Set(["spicy", "admin", "manager", "scout", "creator"]);
/* 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. */
/* EINE KACHEL IST NICHT MEHR DER EINZIGE WEG ZU EINEM BRETT
(11.09.2026, gefunden von pruef-entwicklung).
Die Ableitung oben liest die Bretter aus den Kachelzielen. Das war
richtig, solange jede Kachel auf ein Brett zeigte. Seit „Entwicklung"
und „Talente" auf ihre KATALOGSEITEN zeigen, fielen genau diese
beiden Bretter aus der Liste -- und die rechte Hand bekam auf dem
Notizbrett, das die Katalogseite selbst verlinkt, ein 404.
Kein Fehler im Riegel, sondern in seiner QUELLE: Die Frage "welche
Bretter darf diese Rolle oeffnen" laesst sich nicht mehr allein aus
den Kacheln beantworten, weil es jetzt Bretter gibt, die man ueber
eine Seite erreicht statt ueber eine Kachel.
Beides wird deshalb zusammengezogen -- und die zweite Liste ist
ausdruecklich KURZ und begruendet, nicht offen: Hier stehen nur die
Bretter, zu denen eine erlaubte SEITE verlinkt. Wer ein drittes
dazunimmt, ohne es einzutragen, bekommt dasselbe 404 und findet
diesen Absatz. */
/* Bretter, die kein eigenes Kachelziel haben, sondern von einer SEITE
aus verlinkt sind -- das Notizbrett neben dem Katalog.
AUS EINER LISTE WURDE EINE ZUORDNUNG (19.09.2026), und das war ein
echtes Loch, kein Schoenheitsfehler:
Der Satz darueber lautete "Wer die Seite oeffnen darf, muss auch
dorthin kommen." Der Gedanke ist richtig -- nur stand er als feste
Liste da und fragte nie, WER gerade fragt. Ergebnis: Ein Modi kam
ueber `bereich.html?b=talente` an die Notizen zu Kandidaten, obwohl
`talente.html` fuer ihn ausdruecklich gesperrt ist. Die Begruendung
der Sperre steht in rechte.js und gilt hier genauso: "Hier stehen
Namen von Menschen, die nichts davon wissen."
Schlimmer noch: In workspace-bereiche.js stand ein Kommentar, der
das Gegenteil des Codes behauptete ("entwicklung, talente bleiben
404"). Ein falscher Kommentar an einer Rechtestelle ist schlimmer
als gar keiner -- der Naechste liest ihn und prueft nicht nach.
Jetzt steht neben jedem Brett die Seite, von der es abhaengt, und
gefragt wird `darfSeite()` -- dieselbe Tafel, die auch die Seite
selbst bewacht. Eine zweite Regel kann damit nicht auseinanderlaufen. */
const BRETTER_UEBER_SEITEN = {
entwicklung: "/workspace/entwicklung.html",
talente: "/workspace/talente.html",
};
/** Bretter mit eigener Kachel. Diese darf jeder aus dem Team, der die
* Kachel sieht -- sie sind daraus abgeleitet, nicht abgeschrieben. */
export const MODI_BEREICHE_ERLAUBT = new Set([
...HAND_MIT_TREFF
.map((k) => (/bereich\.html\?b=([a-z]+)/.exec(k.ziel) || [])[1])
.filter(Boolean),
/* DIESELBE LUECKE WIE BEI TREFF_BRETTER (20.09.2026), und aus
demselben Grund: `regeln` hat seit dem 19.09. keine Kachel mehr,
faellt damit aus jeder Ableitung ueber Kachelziele -- und ist
trotzdem ein Brett, das `treff-regeln.html` verlinkt.
Zwei Ableitungen, dieselbe blinde Stelle. Das ist der Grund,
warum hier eine Zeile steht statt einer zweiten klugen Regel:
Wer eine Kachel umleitet, nimmt einem Brett damit stillschweigend
die Erreichbarkeit. Das muss sichtbar sein, nicht gerechnet. */
"regeln",
/* Und `treff` aus demselben Grund (21.09.2026): Seine Kachel zeigt
seit heute auf die Willkommensseite. Ohne diese Zeile sieht ein
Modi sechs von sieben Treff-Brettern -- und das siebte ist
ausgerechnet das, das dem Bereich den Namen gibt. */
"treff",
]);
/** Und die Bretter hinter einer Seite -- nur fuer den, der die Seite darf.
*
* DER DRITTE AUSGANG FEHLT HIER ABSICHTLICH: Ein Brett, das nicht in
* der Zuordnung steht, ist keine offene Frage, sondern schlicht nicht
* gemeint. `false` ist die richtige Antwort. */
export function brettHinterSeiteOffen(person, brett) {
const seite = BRETTER_UEBER_SEITEN[brett];
return !!seite && darfSeite(person, seite);
}
/* 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. */
/* DIE VIERZEHN KATEGORIEN -- jetzt mit einem Satz dazu (16.09.2026).
*
* Filipe: "perfektionniere die kategorien wo ich aufgaben an die modis
* verteile."
*
* Bis heute standen hier vierzehn nackte Namen. "Organisation" und
* "Planung" nebeneinander, ohne einen Satz, der sagt, was worin
* gehoert -- und dann landet dieselbe Aufgabe beim einen unter
* Planung, beim anderen unter Organisation, und die Auswertung
* darueber ist wertlos.
*
* Der Satz steht deshalb an der Kategorie und nicht in einer
* Anleitung: Er wird dort gebraucht, wo man sich entscheidet. */
export const MODI_KATEGORIEN = [
{ wert: "chat", name: "Chat-Moderation",
text: "Was im Livechat passiert, während gesendet wird. Löschen, "
+ "stummschalten, begrüßen, deeskalieren." },
{ wert: "community", name: "Community",
text: "Die Menschen außerhalb der Sendung: wer wiederkommt, wer neu "
+ "ist, wer lange nicht da war." },
{ wert: "events", name: "Events",
text: "Alles mit einem Termin: Matches, Aktionen, Geburtstage, "
+ "besondere Sendungen." },
{ wert: "clipping", name: "Clipping",
text: "Aus einer Sendung viele Videos machen — schneiden, "
+ "beschriften, hochladen." },
{ wert: "social", name: "Social Media",
text: "Alles außerhalb des Livestreams: Ankündigungen, Beiträge, "
+ "Antworten unter Videos." },
{ wert: "technik", name: "Technik",
text: "Ton, Bild, Verbindung, Geräte. Meistens unsichtbar — und "
+ "sofort sichtbar, wenn es fehlt." },
{ wert: "planung", name: "Planung",
text: "Was NOCH NICHT ist: Sendeplan, Themen, wer wann kann." },
{ wert: "organisation", name: "Organisation",
text: "Was BEREITS ist, in Ordnung halten: Ablage, Listen, Zugänge, "
+ "Übersichten." },
{ wert: "team", name: "Team",
text: "Die Zusammenarbeit selbst: Übergaben, Einarbeitung, "
+ "Absprachen, Vertretung." },
{ wert: "kommunikation", name: "Kommunikation",
text: "Was nach außen geht und beantwortet werden muss: "
+ "Nachrichten, Anfragen, Kooperationen." },
{ wert: "analyse", name: "Analyse",
text: "Nachsehen statt schätzen: Zahlen, Verläufe, was sich "
+ "verändert hat." },
{ wert: "wachstum", name: "Wachstum",
text: "Gezielt mehr werden: neue Zuschauer, neue Kanäle, neue "
+ "Zeiten ausprobieren." },
{ wert: "branding", name: "Branding",
text: "Wie es aussieht und klingt: Farben, Zeichen, Sprache, "
+ "Wiedererkennung." },
{ wert: "sonstiges", name: "Sonstiges",
text: "Was in keine der dreizehn passt. Bleibt absichtlich klein — "
+ "wird es groß, fehlt eine Kategorie." },
];
/** Die drei Stufen einer Modi-Aufgabe.
*
* DIESELBE IDEE WIE BEI DEN CREATOR-VORLAGEN, andere Worte: Dort heisst
* es Anfaenger/Fortgeschritten/Profi, und das passt fuer jemanden, der
* etwas LERNT. Hier geht es nicht um Koennen, sondern darum, wie lange
* jemand dabei ist und wie viel Vertrauen die Aufgabe braucht.
*
* Eine Aufgabe der Stufe "erfahren" an einen Neuen zu geben ist kein
* Kompliment, sondern ein Ueberfallen. */
export const MODI_STUFEN = [
{ wert: "neu", name: "Neu dabei",
text: "Die ersten Wochen. Klar umrissen, ohne Folgen, wenn etwas "
+ "schiefgeht — und jemand ist erreichbar." },
{ wert: "dabei", name: "Eingearbeitet",
text: "Kennt den Laden. Kann allein entscheiden, ohne vorher zu "
+ "fragen, und weiß, wann man doch fragt." },
{ wert: "erfahren", name: "Erfahren",
text: "Trägt Verantwortung für andere: arbeitet ein, vertritt, "
+ "entscheidet im Zweifel." },
];
/**
* 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.
*/
/* =====================================================================
DIE KANAELE (17.09.2026)
Filipe: "ich hab ja auch 2 neben account, hasidog und dogfather
clips." Bis heute wusste der Arbeitsplatz davon nichts -- es gab
genau eine Welt, und die hiess nirgends.
WARUM DAS EIN ECHTER MANGEL WAR, nicht nur eine fehlende Spalte:
"Schneide drei Ausschnitte" ist bei drei Accounts keine Aufgabe,
sondern eine Frage. Wer sie bekommt, muss nachfragen -- oder raet.
Raet er falsch, ist die Arbeit nicht halb getan, sondern am
falschen Ort gelandet, und das faellt erst auf, wenn jemand es
sieht. Genau diese Sorte Mehrdeutigkeit kostet doppelt.
WARUM EINE LISTE IM QUELLTEXT UND KEINE TABELLE.
Sonst gilt hier: Eine abgeschriebene Liste altert. Diese hier ist
keine Abschrift -- sie laesst sich aus nichts ableiten, weil sie
eine Tatsache ueber Filipes Betrieb ist und nirgends sonst steht.
Sie darf deshalb hier stehen, und einen Kanal dazuzunehmen ist eine
Zeile. Sobald sich das oefter aendert als ein paarmal im Jahr,
gehoert sie in die Verwaltung -- vorher waere das eine Oberflaeche
fuer drei Zeilen.
KEIN KANAL IST EIN GUELTIGER ZUSTAND. Vieles gilt fuer alles (eine
Absprache im Team, ein Zugang, eine Auswertung). Ein Pflichtfeld
haette dafuer einen falschen Kanal erzwungen -- und ein falscher
Eintrag ist schlechter als ein leerer.
===================================================================== */
/* ZWEI SAETZE JE KANAL, WEIL ES ZWEI LESERSCHAFTEN GIBT (21.09.2026).
`text` erklaert dem TEAM, wofuer der Kanal da ist -- es steht an
einer Aufgabe und beantwortet "wo soll das hin". `fuerDraussen`
steht auf der Community-Seite und beantwortet etwas anderes: "was
finde ich da". "Hier wird geschnitten, beschriftet und hochgeladen"
ist fuer die Zuschauerin keine Auskunft, sondern ein Blick in eine
fremde Werkstatt.
ES BLEIBT EINE LISTE. Der zweite Satz haengt am Kanal, nicht in
einer zweiten Aufzaehlung woanders -- sonst haette ein vierter
Account wieder zwei Orte, und einer davon wird vergessen. Genau so
ist am selben Tag "@hasidog00804" entstanden. */
export const KANAELE = [
{ wert: "dogfather", name: "DogFather", handle: "@dogfather0804",
text: "Der Hauptkanal. Die Livestreams und alles, was unmittelbar "
+ "daran hängt.",
fuerDraussen: "Der Hauptkanal — hier laufen die Livestreams." },
{ wert: "hasidog", name: "HasiDog", handle: "@hasidog0804",
text: "Der zweite Account — eigene Zuschauer, eigener Ton. Was hier "
+ "läuft, ist nicht einfach dasselbe noch einmal.",
fuerDraussen: "Der zweite Account. Eigener Ton, eigene Leute." },
{ wert: "clips", name: "DogFather Clips", handle: "@dogis.modi.gang",
text: "Nur Ausschnitte. Hier wird geschnitten, beschriftet und "
+ "hochgeladen, nicht gesendet.",
fuerDraussen: "Die besten Ausschnitte zum Nachschauen." },
];
/** Zu welchem Kanal gehoert dieser TikTok-Name?
*
* DIE HANDLES STEHEN AM KANAL UND NICHT IN EINER ZWEITEN LISTE
* (17.09.2026). Der Video-Plan nennt genau das als die eine Stelle,
* an der etwas auseinanderlaufen kann: Sie stehen ohnehin schon in
* der Analyse-App (`TIKTOK_ACCOUNTS_LIST`). Wer einen vierten Account
* aufmacht und nur einen der beiden Orte pflegt, bekommt Videos, die
* zu keinem Kanal gehoeren.
*
* Hier gibt es deshalb genau EINE Liste, und sie haengt an dem
* Begriff, zu dem sie gehoert.
*
* `null` heisst: gehoert uns nicht. Das ist KEIN Sonderfall, sondern
* der wichtigste Ausgang -- ein fremdes Video hat in Filipes
* Highlights nichts zu suchen. */
export function kanalVonHandle(handle) {
const h = String(handle || "").trim().toLowerCase().replace(/^@?/, "@");
return KANAELE.find((k) => k.handle.toLowerCase() === h)?.wert ?? null;
}
/** Wer darf mit Kanaelen arbeiten? Dieselbe Runde wie bei den
* Kategorien -- das Team und DogFather. Fuer alle anderen kommt die
* Liste gar nicht erst mit, statt mitzukommen und ausgeblendet zu
* werden. */
export function kanaeleFuer(person) {
if (!person) return [];
return TEAM_DOGI_ROLLEN.has(person.rolle) || person.rolle === "admin"
? KANAELE : [];
}
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" && !istHand(person)) 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";
const LINKE_ROLLENTEXT = "Linke Hand · du fuehrst das Team durch den Tag";
export function rollentextFuer(person) {
if (person?.rolle === "modi") return MODI_ROLLENTEXT;
if (person?.rolle === "hand") return HAND_ROLLENTEXT;
if (person?.rolle === "linke") return LINKE_ROLLENTEXT;
/* DIE COMMUNITY HATTE NUR EIN WORT (nachgetragen 19.09.2026).
Bei jeder anderen Rolle steht unter dem Titel ein Satz. Beim
Community-Mitglied stand dort das nackte Wort "Community" -- und
das ist woertlich derselbe Mangel, den der Kommentar bei
MODI_ROLLENTEXT beschreibt und fuer den Modi behebt: "das sieht
nicht verborgen aus, sondern unfertig."
Fuer den Menschen, der von aussen kommt und niemanden hier kennt,
ist der erste Satz nach dem Anmelden der wichtigste im ganzen
Haus. */
if (person?.rolle === "gast") return "Community · dein Platz im Treff";
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. */
/* GANZ NACH UNTEN (11.09.2026).
Filipe: "diese zwei kategorien sollen auf dieser seite (workspace)
ganz unten sein. die letzten kategorien bei der rolle dogfather ...
goldene regel das sind private informationen von meiner community."
Vorher stand hier `gruppeNach: "Täglich"` -- damit sassen die
privatesten Kacheln des Hauses zwischen den taeglichen Werkzeugen,
also genau dort, wo der Blick zuerst hinfaellt und wo jemand
mitliest, der einem ueber die Schulter schaut.
"Team & System" ist die letzte Gruppe der Workspace-Liste
(bereiche.js). Sie steht hier als NAME und nicht als Position: Eine
Zahl ("an vierter Stelle") waere beim naechsten Umsortieren still
falsch -- und still falsch heisst hier: privates Material rutscht
wieder nach oben. Verschiebt jemand die Gruppen, wandert Team Dogi
mit, solange "Team & System" die letzte bleibt.
pruef-start-ansicht misst die Reihenfolge nach, damit aus "solange"
keine Hoffnung wird. */
const TEAM_GRUPPE = {
gruppe: "Team Dogi",
gruppeUnter: "Was dein Team macht – und was auf dich wartet",
gruppeNach: "Team & System",
};
/* =====================================================================
KEINE MISCHUNG MEHR (21.09.2026)
Filipe: "ich will dass du im workspace alles von team dogi weg
nimmst. nur was ich im kalender eintrage soll ich im workspace sehen
und im team dogi. aber die kacheln von team dogi soll ich nur bei
team dogi sehen und die von workspace nur bei workspace bitte. ich
will keine mischung mehr von kacheln. wie gesagt nur die der kalender
soll verbunden sein von dogfather sonst nichts."
---------------------------------------------------------------------
WAS HIER BIS HEUTE STAND
Eine Liste ZUSATZ_BEREICHE mit siebzehn Kacheln, die auf der
AGENTURADRESSE an DogFathers Seite gehaengt wurden: Dein Team,
Ideen-Board, Rueckmeldung, Wer sieht was, Entwicklung, Werdegang,
Talente -- und der ganze Treff samt Moderation. Nachgemessen am
21.09.2026 bekam er dort 0 eigene Kacheln und 17 fremde; auf der
Team-Adresse 30, in sechs Gruppen, aus beiden Haeusern.
Der Gedanke dahinter war gut gemeint: Er soll ueberall an alles
herankommen. Die Wirkung war eine Seite, auf der zwei Betriebe
uebereinanderliegen -- und zwar genau der Zustand, den dieselbe
Datei am 10.09. fuer die andere Richtung schon als Fehler erkannt
hatte ("Fuenfundzwanzig Kacheln, von denen zwei Drittel Creator,
Scouts und Agentur betreffen, waeren dort Fenster in ein Haus, in
dem er gerade nicht ist").
---------------------------------------------------------------------
DIE EINE AUSNAHME IST DER KALENDER -- UND SIE BRAUCHT NICHTS
Nachgemessen: Die Terminabfragen in workspace-kalender.js filtern
NICHT nach Haus. Was er eintraegt, steht ohnehin auf beiden
Adressen. Die Verbindung, die er will, existiert also bereits; sie
musste nicht gebaut, sondern nur nicht zerschnitten werden.
---------------------------------------------------------------------
EINE TUER IST KEINE MISCHUNG
Ohne Ersatz waere von der Agenturadresse aus kein Weg mehr zu Team
Dogi sichtbar -- er muesste die Adresse tippen. Das ist die Sorte
Sackgasse, die dieses Haus nicht baut. Statt siebzehn Brettern steht
dort jetzt EINE Kachel, die hinuebergeht.
SIE VERRAET NICHTS: Sie kommt vom Server und nur fuer die, die zu
Team Dogi gehoeren. In keiner ausgelieferten Datei steht sie, und
ein Manager bekommt sie nie zu sehen -- genau wie bei den Kacheln,
die sie ersetzt.
===================================================================== */
export function zusatzBereicheFuer(person) {
/* Auf der Team-Adresse liefert bereicheFuer() ohnehin die Kacheln des
Teams. Eine Tuer zu dem Haus, in dem man schon steht, waere ein
Knopf, der nichts tut. */
if (person?.haus === "crew") return [];
/* NUR DOGFATHER UND VANVAN -- also `admin`.
Der erste Entwurf gab die Tuer auch an TEAM_DOGI_ROLLEN (die
rechte Hand und die Moderation). Das war falsch, und pruef-modi
-checkliste hat es sofort gemeldet: "Modi bekommt 1
Zusatzkachel(n), erwartet keine".
WARUM ES FALSCH IST: Wer NUR zu Team Dogi gehoert, hat auf der
Agenturadresse gar nichts zu suchen -- die Tuer waere dort seine
einzige Kachel, also eine Seite, die aus nichts als einem Ausgang
besteht. Und sie wuerde eine Adresse nennen, die auf dieser Seite
niemand nennen soll.
`admin` ist der einzige Fall, der wirklich zwei Haeuser hat. */
if (person?.rolle !== "admin") return [];
return [{
gruppe: "Team Dogi",
gruppeUnter: "Das andere Haus \u2014 eigene Seite, eigene Daten",
name: "Zu Team Dogi",
unter: "Der Treff, das Team und die Moderation \u2014 auf ihrer eigenen Adresse",
zeichen: "hinaus",
ton: 40,
szene: "portal",
ziel: CREW_ADRESSE + "/workspace/start.html",
/* AUSSEN heisst: Diese Kachel fuehrt aus dem Haus hinaus. Ohne das
Merkmal wuerde nurOffeneKacheln() sie wegfiltern -- es baut aus
dem Ziel einen Pfad ("/workspace/" + ziel) und fragt die
Rechtetafel. Bei einer vollstaendigen Adresse kommt dabei
Unsinn heraus, und die Kachel verschwaende lautlos. */
aussen: true,
}];
}
/* 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, "");
/* ---- Woher ein Eintrag kommt: das Video (17.09.2026) ----
Stufe A des Video-Plans. `quelle_url` ist die Adresse bei TikTok,
`quelle_kanal` einer der drei Kanaele, `quelle_geholt_am` der
Zeitpunkt des Einlesens.
DAS COVERBILD STEHT HIER NICHT. Es liegt als Datei in unserer
eigenen Ablage (`dateien.eintrag_id`), und das ist der ganze
Punkt: TikToks Bildadresse traegt `x-expires` und haelt
GEMESSEN 48 Stunden. Wer sie speichert, hat uebermorgen schwarze
Kacheln -- und merkt es beim Bauen nicht und beim Testen nicht. */
try {
const spalten = d.prepare("PRAGMA table_info(eintraege)").all().map((s) => s.name);
for (const [name, typ] of [["quelle_url", "TEXT"], ["quelle_kanal", "TEXT"],
["quelle_geholt_am", "TEXT"],
/* ---- Stufe 5: fuehrt der Knopf noch irgendwohin? (18.09.2026) ----
Ein Video kann bei TikTok geloescht oder auf privat gestellt
werden. Bei uns bleibt der Eintrag stehen -- mit Cover, Text
und einem Knopf, der ins Leere fuehrt. Das faellt niemandem
auf, der die Seite baut; es faellt dem auf, der klickt.
`quelle_fehlversuche` ist der Grund, warum es drei Spalten
sind und nicht eine: Ein einzelner misslungener Abruf heisst
NICHT, dass das Video weg ist -- er heisst vielleicht, dass
TikTok gerade Schluckauf hat. Wer daraus sofort „geloescht"
macht, leert bei der naechsten Stoerung die halbe Galerie. */
["quelle_geprueft_am", "TEXT"], ["quelle_fehlt_seit", "TEXT"],
["quelle_fehlversuche", "INTEGER"]]) {
if (spalten.length && !spalten.includes(name)) {
d.exec(`ALTER TABLE eintraege ADD COLUMN ${name} ${typ}`);
console.log(`[workspace] Spalte '${name}' an den Eintraegen ergaenzt.`);
}
}
} catch (fehler) {
console.error("[workspace] Video-Spalten:", fehler?.message);
}
/* ---- Antworten an einem Beitrag (17.09.2026), Stufe 6 ----
"Der Treff -- Hallo sagen, fragen, loben" war eine Liste aus
Karten. Der einzige Bereich, in dem Menschen MITEINANDER reden
sollen, zwang sie, nebeneinander zu reden: Jeder schreibt eine
eigene Karte, niemand antwortet jemandem.
EINE EIGENE TABELLE UND KEIN EINTRAG MIT "antwort_auf": Eine
Antwort hat keine Art, keinen Zustand, keine Frist und gehoert in
keinen Filter. Sie als halben Eintrag zu fuehren hiesse, ihr
zwanzig Spalten mitzugeben, die immer leer sind -- und sie taeuchte
in jeder Zaehlung auf, die Beitraege zaehlt.
ON DELETE CASCADE: Verschwindet der Beitrag, verschwinden die
Antworten. Eine Antwort ohne ihre Frage ist nicht halb so viel
wert, sondern gar nichts. */
try {
d.exec(`
CREATE TABLE IF NOT EXISTS eintrag_antworten (
id INTEGER PRIMARY KEY AUTOINCREMENT,
eintrag_id INTEGER NOT NULL REFERENCES eintraege(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_antworten_eintrag
ON eintrag_antworten (eintrag_id, id);`);
} catch (fehler) {
console.error("[workspace] Antworten-Tabelle:", fehler?.message);
}
/* ---- Der Kreislauf: ein Eintrag kommt aus einem anderen (17.09.2026)
Aus dem Plan im Vault, Stufe 8 -- und die Stelle, an der der Plan
zum dritten Mal danebenlag: Dort stand "`aus_eintrag_id` gibt es in
der Tabelle bereits". Gibt es -- an den AUFGABEN. Die Eintraege
hatten nichts dergleichen.
WOZU: Heute sind die acht Bretter acht Silos. Ein Wunsch wird
angenommen, irgendwann passiert etwas im Stream, irgendwann liegt
ein Clip bei den Highlights -- und nichts davon weiss voneinander.
Wer den Wunsch geschrieben hat, erfaehrt nie, dass er erfuellt
wurde.
Mit dieser einen Spalte wird daraus eine Kette:
Wunsch -> Termin bei "Was ansteht" -> Clip bei "Highlights"
ON DELETE SET NULL und nicht CASCADE: Wird der Wunsch geloescht,
bleibt der Clip. Er ist ja trotzdem passiert. */
try {
const spalten = d.prepare("PRAGMA table_info(eintraege)").all().map((s) => s.name);
if (spalten.length && !spalten.includes("aus_eintrag_id")) {
d.exec("ALTER TABLE eintraege ADD COLUMN aus_eintrag_id INTEGER REFERENCES eintraege(id) ON DELETE SET NULL");
d.exec("CREATE INDEX IF NOT EXISTS idx_eintraege_aus ON eintraege (aus_eintrag_id)");
console.log("[workspace] Spalte 'aus_eintrag_id' an den Eintraegen ergaenzt.");
}
} catch (fehler) {
console.error("[workspace] Spalte 'aus_eintrag_id':", fehler?.message);
}
/* ---- Ein Bild an einem Community-Beitrag (17.09.2026) ----
"Highlights -- eure Clips und Bilder" konnte bis heute gar keine
Bilder tragen: `dateien` hing an `aufgabe_id`, nicht an einem
Eintrag. Der Plan im Vault sagte "Anhaenge sind schon da" -- sie
hingen nur am falschen Ding. */
try {
const spalten = d.prepare("PRAGMA table_info(dateien)").all().map((s) => s.name);
if (spalten.length && !spalten.includes("eintrag_id")) {
d.exec("ALTER TABLE dateien ADD COLUMN eintrag_id INTEGER REFERENCES eintraege(id) ON DELETE CASCADE");
d.exec("CREATE INDEX IF NOT EXISTS idx_dateien_eintrag ON dateien (eintrag_id)");
console.log("[workspace] Spalte 'eintrag_id' an den Dateien ergaenzt.");
}
} catch (fehler) {
console.error("[workspace] Spalte 'eintrag_id':", fehler?.message);
}
/* ---- Die Uhrzeit an einem Termin (17.09.2026) ----
"Was ansteht" bekommt einen Countdown: "Nächster Stream in 3 Std
12 Min". Beim Bauen stellte sich heraus, dass die Daten das gar
nicht hergeben -- `eintraege.datum` ist ein TAG
(`^\d{4}-\d{2}-\d{2}$`, die Pruefung lehnt eine Uhrzeit ab).
Das ist genau die Sorte Luecke, die man beim Planen uebersieht und
beim Bauen findet: Der Plan versprach eine Stundenangabe, und die
Datenbank kannte nur Tage.
Optional, nicht Pflicht. Viele Termine haben keine Uhrzeit ("diese
Woche", "im Oktober"), und ein Pflichtfeld haette dafuer eine
erfundene erzwungen. Ohne Uhrzeit zaehlt der Countdown in Tagen. */
try {
const spalten = d.prepare("PRAGMA table_info(eintraege)").all().map((s) => s.name);
if (spalten.length && !spalten.includes("uhrzeit")) {
d.exec("ALTER TABLE eintraege ADD COLUMN uhrzeit TEXT");
console.log("[workspace] Spalte 'uhrzeit' an den Eintraegen ergaenzt.");
}
} catch (fehler) {
console.error("[workspace] Spalte 'uhrzeit':", fehler?.message);
}
/* ---- Der Kanal an der Aufgabe (17.09.2026) ----
Drei Accounts, eine Aufgabenliste: Ohne diese Spalte ist
"schneide drei Ausschnitte" bei HasiDog und Clips mehrdeutig.
ADD COLUMN und kein Tabellenneubau -- es aendert sich keine
CHECK-Regel, alte Zeilen bleiben leer, und leer heisst hier
ausdruecklich "gilt fuer alles" und nicht "fehlt". */
try {
const spalten = d.prepare("PRAGMA table_info(aufgaben)").all().map((s) => s.name);
if (spalten.length && !spalten.includes("kanal")) {
d.exec("ALTER TABLE aufgaben ADD COLUMN kanal TEXT");
console.log("[workspace] Spalte 'kanal' an den Aufgaben ergaenzt.");
}
} catch (fehler) {
console.error("[workspace] Spalte 'kanal':", fehler?.message);
}
/* ---- 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
/* ---- WAS EIN SCOUT WIRKLICH AUFSCHREIBEN MUSS (11.09.2026) ----
Filipe, zur Pipeline: "perfektionnier diese kategorien ... und was
man da noch alles immer in jeder kategorie eintragen kann. weil
man kan da nichts machen."
Ein Lead hatte bis heute Name, Plattform, Handle, Priorität,
Aktivität, Potenzial, Notizen und zwei Daten. Das genügt, um
jemanden zu MERKEN, aber nicht, um ihn zu BEURTEILEN. Die sieben
Felder hier sind genau die Fragen, die ein Scout sonst im Kopf
behält -- und die der Nächste nicht mehr findet, wenn der Scout
im Urlaub ist.
`netzwerk` ist das wichtigste davon und kein Schmuck: Wer schon
bei einem anderen Creator-Netzwerk unter Vertrag steht, kann
nicht übernommen werden. Diese Frage ganz am Anfang zu stellen
spart die ganze übrige Arbeit -- und sie hier zu haben heisst,
dass niemand zweimal fragt.
`land` steht da, weil die Betreuung in DE, LU, AT und CH läuft
und die Schweiz rechtlich anders liegt als die drei EU-Länder
(siehe die TikTok-Recherche vom 10.09.). Auch dafür ist es
besser, es beim ersten Kontakt zu wissen als beim Vertrag. */
["leads", "follower", "INTEGER"], // Reichweite beim Fund
["leads", "land", "TEXT"], // DE / AT / CH / LU / andere
["leads", "woher", "TEXT"], // wie gefunden: LIVE, Empfehlung, Kommentar …
["leads", "kontaktweg", "TEXT"], // wo angeschrieben: TikTok-DM, Instagram …
["leads", "live_zeiten", "TEXT"], // wann die Person üblicherweise live ist
["leads", "netzwerk", "TEXT"], // schon bei einer Agentur? nein/ja/unbekannt
["leads", "absage_grund", "TEXT"], // warum es nicht gepasst hat
["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"],
/* Die eigene Kachelfarbe im Chat (20.09.2026). Ein Schluessel aus
CHAT_KACHELN, nicht die Farbe selbst: Sonst stuende in der
Datenbank ein CSS-Wert, und eine Aenderung am Farbton hiesse,
jede Zeile anzufassen. NULL heisst "Standard" und ist der
Normalfall -- ein Pflichtwert waere eine Entscheidung, die
niemand treffen wollte. */
["personen", "chat_kachel", "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". */
/* WIE EINE AUFGABE VERTEILT WURDE (21.09.2026).
Filipe, Abschnitt 3: *"Dogfather und die rechte Hand muessen
eine Aufgabe einer einzelnen Person zuweisen koennen. Sie
muessen eine Aufgabe auch mehreren bestimmten Personen
gleichzeitig zuweisen koennen. Zusaetzlich soll es eine Option
geben, bei der eine Aufgabe fuer mehrere Modis sichtbar ist und
die erste Modi, die gerade Zeit hat, die Aufgabe uebernehmen
kann."*
DREI WERTE, UND DER UNTERSCHIED ZWISCHEN DEN LETZTEN BEIDEN IST
DER GANZE PUNKT:
einzeln eine Person, sie macht es
mehrere mehrere Personen, JEDE macht ihren Teil
pool mehrere sehen es, EINE nimmt es -- danach ist es
fuer die anderen erledigt
Ohne diese Spalte waeren "mehrere" und "pool" in der Datenbank
nicht zu unterscheiden: Beide sind n Zeilen in
aufgaben_zuteilung. Die Oberflaeche muesste dann raten, ob eine
uebernommene Aufgabe fuer die anderen noch offen ist -- und
genau daraus entsteht die doppelte Bearbeitung, die der Auftrag
ausdruecklich verhindern will.
KEIN CHECK an dieser Spalte, mit Absicht: Sie wird nachgetragen,
und ein CHECK auf einer nachgetragenen Spalte hiesse die Tabelle
neu zu bauen -- der Weg, der in diesem Haus am 11.09. und am
21.09. schon Spalten gekostet hat. Geprueft wird beim
Schreiben, in der Route. */
["aufgaben", "verteilart", "TEXT"],
["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"],
/* WER DARF DIESE RUECKMELDUNG LESEN? (10.09.2026)
0 = das ganze Team, 1 = nur DogFather.
WARUM ES DIESE WAHL UEBERHAUPT GIBT: Filipe will beides -- eine
offene Teamkultur UND ehrliche Rueckmeldung an sich selbst. Das
ist kein Widerspruch, aber es sind zwei verschiedene Raeume. Wer
"was koenntest du besser machen" vor versammelter Mannschaft
sagen muss, sagt es nicht; wer alles nur unter vier Augen sagen
kann, hat kein Team-Gespraech. Also entscheidet der Schreibende,
Zeile fuer Zeile.
UND "NUR DOGFATHER" HEISST NUR DOGFATHER -- die rechte Hand
ausdruecklich nicht. Sie sieht sonst ueberall dasselbe wie er;
hier nicht, und zwar weil das Etikett sonst nicht stimmen wuerde.
Eine Zusage, die im Kleingedruckten eine Ausnahme hat, ist keine.
Sie kann selbst genauso schreiben -- auch ueber ihn. */
["eintraege", "nur_leitung", "INTEGER"],
/* WANN WURDE ES DER PERSON GESAGT? (11.09.2026)
Der Entwicklungs-Bereich ist eine Aufzeichnung fuer DogFather und
die rechte Hand -- die Person selbst sieht ihn nicht. Das ist
Absicht: Eine halb sichtbare Akte ist schlimmer als eine
geschlossene, weil niemand mehr weiss, was der andere gerade
liest.
Damit Anerkennung trotzdem ANKOMMT (die Forschung dazu ist
eindeutig: Dank und Rueckmeldung sind der staerkste Grund, warum
Freiwillige bleiben), kann jeder Eintrag als Nachricht an die
Person geschickt werden. Hier steht, WANN das geschehen ist.
Ohne diese Spalte gaebe es die Frage "habe ich ihr das eigentlich
schon gesagt?" -- und die beantwortet man nach zwei Wochen
falsch. */
["eintraege", "gesendet_am", "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"],
/* DAS BESTAETIGTE ALTER (15.09.2026, Stufe 8).
Auf der Regelseite des Treffs steht seit dem 11.09. "ab 18" --
und geprueft wurde es nirgends. Eine Regel, die nur dasteht, ist
keine; im Streitfall ist sie sogar schlechter als keine, weil man
sie versprochen hat.
NUR EIN ZEITSTEMPEL, KEIN GEBURTSDATUM. Gebraucht wird die
Antwort auf "hat bestaetigt, alt genug zu sein, und wann" --
nicht der Geburtstag. Ein Geburtsdatum waere mehr Daten fuer
dieselbe Auskunft, und Datenminimierung (Art. 5 Abs. 1 lit. c)
gilt auch fuer das eigene Nachweisbeduerfnis.
WARUM KEINE SPALTE FUER DIE RECHTSGRUNDLAGE: Sie folgt aus der
Rolle -- ein Modi arbeitet hier (Vertrag), ein Community-Mitglied
ist freiwillig da (Einwilligung). Was sich ableiten laesst, wird
nicht gespeichert; sonst haette man zwei Wahrheiten, von denen
eine veraltet. Siehe grundlageVon() in workspace-aufbewahrung.js. */
["personen", "alter_bestaetigt_am", "TEXT"],
/* WOHER EINE AUFGABE KAM (15.09.2026, Stufe 9: "Idee -> Aufgabe").
MIT deklariertem Fremdschluessel, und das ist hier nicht
Geschmack: Seit dem 15.09. prueft pruef-auskunft, dass JEDE
Spalte auf _id entweder einen Fremdschluessel hat oder
namentlich eingeordnet ist. Eine undeklarierte Spalte waere dort
sofort rot -- und genau so soll es sein, denn niemand kann einer
Zahl ansehen, worauf sie zeigt.
ON DELETE SET NULL und nicht CASCADE: Wird die Idee spaeter
geloescht, bleibt die Arbeit. Sie ist inzwischen etwas Eigenes;
sie mit der Notiz zu entfernen, aus der sie entstand, waere die
teuerste Art, Ordnung zu machen. */
["aufgaben", "aus_eintrag_id", "INTEGER REFERENCES eintraege(id) ON DELETE SET NULL"],
/* FASSUNGEN STATT UNVERBUNDENER DOPPEL (15.09.2026, Stufe 9).
GEMESSEN, BEVOR ETWAS GEBAUT WURDE: Der Plan sagt "Dateiversionen
statt Ueberschreiben" -- ueberschrieben wurde aber nie.
„name_datei“ ist eindeutig und zufaellig, jedes Hochladen legt
eine neue Zeile an. Der Mangel ist ein anderer, und er ist
gefaehrlicher als Ueberschreiben:
Zwei Dateien gleichen Namens stehen BEZIEHUNGSLOS nebeneinander.
Die alte kann "freigegeben" sein, die neue "entwurf" -- und wer
die Liste ansieht, laedt die freigegebene herunter. Also die
falsche. Kein Fehler, keine Meldung, nur die alte Fassung in der
Hand.
ON DELETE SET NULL: Wird die alte Fassung geloescht, bleibt die
neue -- sie verliert nur ihre Vorgeschichte. */
["dateien", "ersetzt_id", "INTEGER REFERENCES dateien(id) ON DELETE SET NULL"],
/* ANHAENGE AN AUFGABEN (15.09.2026, Stufe 9).
KEIN ZWEITER HOCHLADEWEG. Dateien leben in der Dateiablage -- mit
ihrer Sichtbarkeit, ihren Freigaben, ihren Fassungen und ihrem
Protokoll. Ein eigener Weg an der Aufgabe waere eine zweite
Ablage mit einer zweiten Rechtelogik, und die zweite ist immer
die, die etwas durchlaesst.
Deshalb nur eine Spalte: Die Datei WEISS, zu welcher Aufgabe sie
gehoert. Alles andere bleibt, wo es ist.
ON DELETE SET NULL, NICHT CASCADE -- und das ist der wichtigste
Teil dieser Zeile: Wer eine Aufgabe loescht, will die Aufgabe
loeschen, nicht den Vertrag, der daran hing. Die Datei verliert
nur ihren Bezug und steht danach wieder in der Ablage. */
["dateien", "aufgabe_id", "INTEGER REFERENCES aufgaben(id) ON DELETE SET NULL"],
/* DER AUFWAND EINER AUFGABE (15.09.2026, Stufe 9).
"Wichtig" gibt es schon -- das ist die Prioritaet. Was fehlte, ist
die zweite Angabe, ohne die man nicht entscheiden kann: Schaffe
ich das zwischendurch, oder muss ich mir Zeit nehmen?
OHNE CHECK-LISTE in der Datenbank, und das ist Absicht: Die
erlaubten Werte stehen in workspace-womit.js, und eine CHECK-Liste
daneben waere eine zweite Wahrheit, die beim naechsten Wert
nachgezogen werden muesste -- ueber einen Tabellenneubau, an dem
im Projekt schon dreimal Spalten verlorengegangen sind. Geprueft
wird beim Schreiben, an der Stelle, die die Liste kennt. */
["aufgaben", "aufwand", "TEXT"],
/* AUS WELCHER BEOBACHTUNG DIESE AUFGABE ENTSTANDEN IST (15.09.2026).
Der Schluessel aus dem Entwicklungskatalog, nichts weiter. Zusammen
mit `verantwortlich_id` ergibt er das Paar, um das es geht: DIESER
Punkt bei DIESEM Menschen.
WARUM ÜBERHAUPT: Eine Beobachtung, die nur auf einer Karte steht,
ist eine Notiz. Die Quellen zur laufenden Entwicklungsbegleitung
sagen dasselbe in einem Satz -- ein Entwicklungsplan wirkt dann,
wenn er an einer echten Aufgabe haengt, und sonst nicht.
KEIN FREMDSCHLUESSEL: Die Punkte stehen in einer Datei, nicht in
einer Tabelle. Ein REFERENCES darauf gaebe es nicht; geprueft wird
beim Schreiben, an der Stelle, die den Katalog kennt. */
["aufgaben", "aus_punkt", "TEXT"],
]) {
try {
const vorhanden = d.prepare(`PRAGMA table_info(${tabelle})`).all().map((s) => s.name);
if (vorhanden.length && !vorhanden.includes(spalte)) {
d.exec(`ALTER TABLE ${tabelle} ADD COLUMN ${spalte} ${typ}`);
console.log(`[workspace] Spalte '${spalte}' in ${tabelle} ergaenzt.`);
}
} catch (fehler) {
console.error(`[workspace] Spalte '${spalte}':`, fehler?.message);
}
}
/* Der Nachfüller fragt bei jedem Lauf "welche Ausprägungen dieser
Serie gibt es schon?". Ohne diesen Verbund-Index liest SQLite dafür
die ganze Termintabelle. Er steht hier unten und nicht oben im
Bauplan, weil die beiden Spalten erst durch die Schleife darüber
entstehen -- oben gäbe es sie beim ersten Start noch nicht. */
try {
/* 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. */
/* ---- BIS ZUM 21.09.2026 STAND HIER EIN NEUBAU VON HAND ----
Er zaehlte die Spalten einzeln auf -- einmal im CREATE, einmal im
INSERT ... SELECT. Achtzehn Stueck. Die Tabelle hatte zu dem
Zeitpunkt DREIUNDZWANZIG: `vorlage`, `kategorie`, `aus_eintrag_id`,
`aufwand` und `aus_punkt` kamen spaeter dazu und wurden nie
nachgetragen.
WAS DAS BEDEUTET HAETTE: Wer die Umstellung noch vor sich hat --
eine zurueckgespielte Sicherung, eine frische Anlage aus einem
alten Stand -- verliert diese fuenf Spalten MIT INHALT. Ohne
Fehlermeldung, bei unveraenderter Zeilenzahl. Die Zaehlung, die
als Sicherung gedacht war, kann einen Spaltenverlust gar nicht
sehen: 40 Zeilen vorher, 40 Zeilen nachher, alles "in Ordnung".
GENAU DAS IST IN DIESEM HAUS SCHON EINMAL PASSIERT (11.09.2026,
Eintragstabelle: 24 Spalten hinein, 21 heraus). Damals wurde die
Lehre gezogen, die Liste abzuleiten statt zu pflegen -- und
`checkListeErweitern` unten tut genau das (`PRAGMA table_info`).
Dieser Block hier war aelter und wurde bei der Umstellung
uebersehen; eine Liste, die niemand pflegt, kann nicht veralten,
aber eine zweite Fassung daneben eben doch.
Gefunden hat es nicht das Lesen, sondern pruef-abbrechen: Sie baut
absichtlich eine ALTE Datenbank und laesst die Umstellung darauf
laufen. Danach meldete der Server
"Aufgaben lesen: no such column: a.aufwand" -- und die Pruefung
bekam eine 503 statt einer Liste.
Auf dem echten Server ist die Umstellung laengst gelaufen (24
Spalten, CHECK mit 'abgebrochen'); dort ist nichts verloren
gegangen. Der Aufruf unten ist deshalb im Betrieb ein Nulldurchgang
-- er steht hier fuer den Tag, an dem jemand eine Sicherung
zurueckspielt. */
checkListeErweitern(d, "aufgaben", "status", "abgebrochen",
["offen", "arbeit", "review", "erledigt", "abgebrochen"], jetztStempel);
/* ---- 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. */
/* ---- Der sechste Bereich: "agentur" (06.09.2026) ----
ABGELEITET STATT ABGESCHRIEBEN (11.09.2026).
Hier stand bis heute der Bauplan der Tabelle von Hand abgeschrieben
-- alle Spalten, zweimal (einmal fuer CREATE, einmal fuer INSERT).
Am 07.09.2026 fiel auf, dass dabei drei Event-Spalten fehlten; sie
wurden nachgetragen, und daneben kam ein Kommentar, der genau vor
dieser Verlustart warnt.
DIESELBE FALLE HAT DANACH EIN ZWEITES MAL ZUGESCHNAPPT. Seit dem
07.09. kamen `einsatz`, `nur_leitung` und `gesendet_am` dazu. Der
Spalten-Nachtrag legt sie an, dieser Neubau warf sie unmittelbar
danach weg -- mit Inhalt, ohne Fehlermeldung, bei unveraenderter
Zeilenzahl. Gemessen: 24 Spalten hinein, 21 heraus.
Gefunden hat es pruef-agentur, und zwar seit Tagen: 31-mal "FEHL"
mit HTTP 503. Sagen konnte sie es erst, seit sie die
Serverausgabe zeigt -- dort stand es dann in drei Zeilen
untereinander.
DESHALB WIRD NICHTS MEHR ABGESCHRIEBEN. `checkListeErweitern` holt
den Bauplan aus sqlite_master und die Spaltenliste aus
PRAGMA table_info -- sie kann gar nicht veralten, weil sie nicht
gepflegt wird. Sie sichert vorher, zaehlt die Zeilen innerhalb der
Transaktion, nimmt die Indizes mit und prueft die Verweise, also
alles, was der Block hier auch tat.
Die echten Daten auf dem Server sind nicht betroffen: Ihr CHECK
kennt 'agentur' laengst, die Umstellung laeuft dort nie wieder.
Gefaehrlich war es beim Zurueckspielen einer Sicherung von vor dem
06.09.2026 -- also genau dann, wenn man sich am wenigsten einen
stillen Datenverlust leisten kann. */
checkListeErweitern(d, "eintraege", "bereich", "agentur",
["live", "content", "technik", "community", "schutz", "agentur"], jetztStempel);
/* ---- Rolle "manager" erlauben ---- */
/* ---- AUCH HIER STAND EIN NEUBAU VON HAND (ersetzt 21.09.2026) ----
Er zaehlte NEUN Spalten auf. `personen` hat neunzehn. Verloren
gegangen waeren:
bild, ueber_mich, tiktok, instagram, youtube, twitch,
chat_kachel, code_kennung, stufe, alter_bestaetigt_am
Das ist die empfindlichste Tabelle des Hauses, und zwei Eintraege
darin sind mehr als Komfort: `bild` sind die Profilfotos, und
`alter_bestaetigt_am` ist die Altersbestaetigung. Waere sie beim
Zurueckspielen einer Sicherung verschwunden, haette niemand eine
Fehlermeldung gesehen -- alle waeren still wieder unbestaetigt
gewesen, auf einer Seite, die genau das nicht sein darf.
Derselbe Befund wie beim Block darueber, und dieselbe Loesung:
`checkListeErweitern` leitet die Spaltenliste aus
`PRAGMA table_info` ab. Eine Liste, die niemand pflegt, kann nicht
veralten.
DIE KETTE IST KUMULATIV -- jeder Schritt nennt alle bis dahin
erlaubten Werte, und dieser ist der erste. Deshalb steht er VOR
den Aufrufen fuer spicy/modi/hand/gast weiter unten; die Reihenfolge
ist Teil der Bedeutung, nicht Zufall. */
checkListeErweitern(d, "personen", "rolle", "manager",
["admin", "manager", "scout", "creator"], jetztStempel);
/* =====================================================================
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);
/* DIE COMMUNITY (11.09.2026). Dieselbe Umstellung wie die drei
darueber: Sicherung vorher, Neubau, Zaehlung innerhalb der
Transaktion, Indizes zurueck. */
checkListeErweitern(d, "personen", "rolle", "gast",
["spicy", "admin", "manager", "scout", "creator", "modi", "hand", "gast"], jetztStempel);
/* DIE LINKE HAND (21.09.2026) -- UND SIE STEHT MIT ABSICHT GANZ
UNTEN, NICHT BEI DER RECHTEN HAND.
`checkListeErweitern` baut die Regel NEU aus der Liste, die man
ihm gibt. Stand mein Schritt weiter oben, nahm der Schritt fuer
"gast" darunter die Rolle wieder heraus -- seine Liste kennt sie
ja nicht. Die Kette ist kumulativ und deshalb von der Reihenfolge
abhaengig; wer hier etwas einschiebt, gehoert ans ENDE.
Ohne diesen Schritt laesst die Datenbank die Rolle nicht zu -- und
zwar erst beim ersten echten Anlegen, nicht beim Start. */
checkListeErweitern(d, "personen", "rolle", "linke",
["spicy", "admin", "manager", "scout", "creator", "modi", "hand",
"gast", "linke"], 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. */
/* DER BEREICH FUER DIE RUECKMELDUNG (10.09.2026, Kapitel 5 und 6). */
checkListeErweitern(d, "eintraege", "bereich", "rueckmeldung",
["live", "content", "technik", "community", "schutz", "agentur", "ideen",
"angebote", "rueckmeldung"], jetztStempel);
/* ENTWICKLUNG UND TALENTE (11.09.2026, Wunsch Filipe). */
checkListeErweitern(d, "eintraege", "bereich", "entwicklung",
["live", "content", "technik", "community", "schutz", "agentur", "ideen",
"angebote", "rueckmeldung", "entwicklung"], jetztStempel);
checkListeErweitern(d, "eintraege", "bereich", "talente",
["live", "content", "technik", "community", "schutz", "agentur", "ideen",
"angebote", "rueckmeldung", "entwicklung", "talente"], jetztStempel);
/* DIE SIEBEN BRETTER DES TREFFS (11.09.2026).
EINE Umstellung fuer alle sieben, nicht sieben einzelne. Jeder
Aufruf baut die Tabelle neu auf -- siebenmal hintereinander waeren
sieben Sicherungen, sieben Neubauten und sieben Gelegenheiten, dass
etwas schiefgeht. Der Marker ist "treff"; ist der schon in der
CHECK-Liste, passiert gar nichts. */
checkListeErweitern(d, "eintraege", "bereich", "treff",
["live", "content", "technik", "community", "schutz", "agentur", "ideen",
"angebote", "rueckmeldung", "entwicklung", "talente",
"anschlag", "ansteht", "treff", "wunsch", "highlight", "regeln", "mitmachen"],
jetztStempel);
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);
/* =================================================================
WER HAT DIE AUFGABE -- UND WIE STEHT ES BEI IHM? (21.09.2026)
Filipe, Abschnitt 4: *"Wenn eine Aufgabe mehreren Modis
zugewiesen wird, darf sie im Resuemee von Dogfather und der
rechten Hand nicht lediglich als eine einzige grosse
Sammelaufgabe erscheinen. Die Aufgabe muss in der Auswertung
auf die einzelnen zugewiesenen Personen aufgeteilt werden."*
GENAU DESHALB EINE EIGENE TABELLE und nicht mehrere Spalten an
der Aufgabe. Eine Aufgabe hat EINEN Titel und EINE Frist --
aber je Mensch einen eigenen Stand, einen eigenen Grund, wenn
er ablehnt, ein eigenes Erledigt-Datum und eine eigene
Bewertung. Das in Spalten zu pressen hiesse, dieselbe Aufgabe
mehrfach anzulegen; dann waere die uebergeordnete Struktur
weg, die der Auftrag ausdruecklich erhalten will.
"zustand" ist der Stand DIESES Menschen, nicht der Aufgabe:
offen er hat sie bekommen, aber noch nicht geantwortet
angenommen er hat ja gesagt
arbeit er ist dran
erledigt er ist fertig
abgelehnt er hat nein gesagt -- "grund" sagt, warum
"aufgaben.status" bleibt daneben unberuehrt. Zwei Ebenen, weil
es zwei Fragen sind: "Wie steht die Aufgabe?" und "Wie steht
SIE bei ihm?" Ein einziges Feld koennte bei drei Leuten nicht
gleichzeitig "angenommen" und "abgelehnt" sein.
WARUM HIER EIN CHECK STEHT, oben aber keiner: Diese Tabelle
entsteht neu. Ein CHECK auf einer neuen Tabelle kostet nichts;
einer auf einer nachgetragenen Spalte kostet einen Neubau.
KEINE BACKTICKS IN DIESEM KOMMENTAR: Er steht INNERHALB eines
Template-Literals (d.exec). Ein Backtick beendet es mitten im
Satz, und die Datei ist ab da syntaktisch kaputt -- an einer
Stelle, die nichts mit dem Fehler zu tun hat.
UNIQUE (aufgabe_id, person_id): Niemand bekommt dieselbe
Aufgabe zweimal. Ohne das entstuenden bei einem doppelten
Klick zwei Zeilen, und das Resuemee zaehlte eine Aufgabe
doppelt -- ein Fehler, den man erst an einer krummen Zahl
bemerkt und dann nicht mehr erklaeren kann.
ON DELETE CASCADE: Wird die Aufgabe geloescht, gehen die
Zuteilungen mit. Fremdschluessel sind in diesem Haus
eingeschaltet (am 20.09. gemessen, nicht angenommen). */
CREATE TABLE IF NOT EXISTS aufgaben_zuteilung (
id INTEGER PRIMARY KEY AUTOINCREMENT,
aufgabe_id INTEGER NOT NULL REFERENCES aufgaben(id) ON DELETE CASCADE,
person_id INTEGER NOT NULL REFERENCES personen(id) ON DELETE CASCADE,
zustand TEXT NOT NULL DEFAULT 'offen'
CHECK (zustand IN ('offen','angenommen','arbeit','erledigt','abgelehnt')),
grund TEXT,
zugeteilt_am TEXT NOT NULL,
zugeteilt_von INTEGER REFERENCES personen(id) ON DELETE SET NULL,
geantwortet_am TEXT,
erledigt_am TEXT,
/* DIE RUECKMELDUNG (Abschnitt 5). Sie haengt am MENSCHEN und
nicht an der Aufgabe: Bei drei Leuten koennen drei
verschiedene Rueckmeldungen richtig sein. */
bewertung TEXT CHECK (bewertung IN ('gut','nicht_gut','verbessern')),
bewertung_text TEXT,
bewertet_von INTEGER REFERENCES personen(id) ON DELETE SET NULL,
bewertet_am TEXT,
UNIQUE (aufgabe_id, person_id)
);
CREATE INDEX IF NOT EXISTS idx_zuteilung_aufgabe ON aufgaben_zuteilung (aufgabe_id);
CREATE INDEX IF NOT EXISTS idx_zuteilung_person ON aufgaben_zuteilung (person_id, zustand);
/* =================================================================
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);
/* ERWAEHNUNGEN -- "@Name" IM CHAT (20.09.2026).
Filipe: "ich will das man die leute mit @ markieren kann im
chat."
WARUM EINE EIGENE TABELLE UND NICHT NUR FARBIGER TEXT:
Markieren ist kein Schmuck, sondern eine Ansprache. Wer
gemeint ist, soll es MERKEN -- auch wenn er den Raum gerade
nicht offen hat und auch dann noch, wenn er ihn Stunden
spaeter aufmacht. Dafuer muss irgendwo stehen, wer gemeint
war.
WARUM NICHT AUS DEM TEXT NACHRECHNEN: Man koennte beim Lesen
jedes Mal "@" suchen. Dann muesste man aber bei jedem Aufbau
der Gespraechsliste den Text JEDER ungelesenen Nachricht
durchgehen -- bei einem lebhaften Raum sind das hunderte. Hier
steht das Ergebnis einmal und wird nie wieder gerechnet.
WER GEMEINT IST, ENTSCHEIDET DER SERVER -- nicht der Browser.
Der Browser schickt nur Text; wer daraus eine Person wird,
loest erwaehnungenFinden() gegen die TEILNEHMER DES RAUMS
auf. So kann niemand jemanden ansprechen, der gar nicht dabei
ist, und ein von Hand getipptes "@Anna" wirkt genauso wie
eines aus der Auswahlliste.
ON DELETE CASCADE an beiden Enden, wie bei den Reaktionen:
Verschwindet die Nachricht oder der Mensch, geht der Eintrag
mit. Eine Erwaehnung ohne Nachricht waere ein Hinweis, der
nirgendwohin fuehrt.
Der zusammengesetzte Primaerschluessel sagt "jede Person hoechstens
einmal je Nachricht": Wer dreimal "@Anna" in einen Satz
schreibt, spricht sie trotzdem nur einmal an. */
CREATE TABLE IF NOT EXISTS chat_erwaehnungen (
nachricht_id INTEGER NOT NULL REFERENCES chat_nachrichten(id) ON DELETE CASCADE,
person_id INTEGER NOT NULL REFERENCES personen(id) ON DELETE CASCADE,
PRIMARY KEY (nachricht_id, person_id)
);
/* Die Frage, die im Betrieb gestellt wird, lautet "bin ICH
irgendwo erwaehnt?" -- deshalb liegt person_id vorn. */
CREATE INDEX IF NOT EXISTS idx_chat_erwaehnungen_person
ON chat_erwaehnungen (person_id, nachricht_id);
/* REAKTIONEN (14.09.2026).
Filipe: "ich will dass man auf die nachrichten auch reagieren
kann mit einem emoji, die nachricht selbst ohne zu antworten."
DER SCHLUESSEL IST DIE ANTWORT AUF "WIE OFT?": Eine Person darf
dieselbe Nachricht mit VERSCHIEDENEN Zeichen bedenken, aber
jedes nur EINMAL. Genau das sagt der zusammengesetzte
Primaerschluessel -- und zwar so, dass die Datenbank es
durchsetzt und nicht eine Abfrage davor, die man vergessen
kann. Ein zweiter Klick auf dasselbe Zeichen nimmt es zurueck.
ON DELETE CASCADE an beiden Enden: Wird die Nachricht geloescht
oder der Mensch entfernt, geht die Reaktion mit. Eine Reaktion
ohne Nachricht waere eine Zeile, die niemand je wiederfindet.
WARUM KEIN ZAEHLER IN chat_nachrichten: Eine mitgefuehrte Zahl
muesste bei jedem Hin und Her stimmen -- und sie ist genau die
Art Angabe, die irgendwann nicht mehr stimmt und die niemand
nachrechnet. Gezaehlt wird beim Lesen. */
CREATE TABLE IF NOT EXISTS chat_reaktionen (
nachricht_id INTEGER NOT NULL REFERENCES chat_nachrichten(id) ON DELETE CASCADE,
person_id INTEGER NOT NULL REFERENCES personen(id) ON DELETE CASCADE,
zeichen TEXT NOT NULL,
wann TEXT NOT NULL,
PRIMARY KEY (nachricht_id, person_id, zeichen)
);
CREATE INDEX IF NOT EXISTS idx_chat_reaktionen_nachricht
ON chat_reaktionen (nachricht_id);
/* FAVORITEN (14.09.2026).
Filipe: "man soll auch auswaehlen koennen, also paar als
vorschlaege mit der moeglichkeit andere auszuwaehlen und als
favoriten zu speichern."
AM MENSCHEN, NICHT AM GERAET. Im Browser abgelegt waeren sie
am Handy andere als am Rechner -- und beim naechsten Loeschen
der Seitendaten weg. Wer sich etwas merkt, will es ueberall
wiederfinden.
Die Spalte "platz" haelt die REIHENFOLGE. Ohne sie waere die
Vorschlagszeile bei jedem Laden anders sortiert, und man
greift daneben, weil das Zeichen gewandert ist. */
CREATE TABLE IF NOT EXISTS chat_reaktion_favoriten (
person_id INTEGER NOT NULL REFERENCES personen(id) ON DELETE CASCADE,
zeichen TEXT NOT NULL,
platz INTEGER NOT NULL,
PRIMARY KEY (person_id, zeichen)
);
CREATE INDEX IF NOT EXISTS idx_chat_favoriten_person
ON chat_reaktion_favoriten (person_id, platz);
/* 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);
/* Die Tabellen des Treffs folgen weiter unten, gleich nachdem
dieser Bauplan durch ist -- sie haengen an eintraege und
personen und koennen deshalb nicht davor stehen. Ihr SQL steht
in treff-tabellen.js, samt Begruendung je Tabelle. */
/* 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)
);
/* ZAHLEN FUER EINEN ZEITRAUM (14.09.2026).
Filipe: "ich will dass das auch so geht, mach dass es
funktioniert ... sieh zu dass die excel auch so durch geht."
Backstage gibt eine Ausgabe fuer einen ZEITRAUM heraus --
"2026-09-01 ~ 2026-09-13", eine Zeile je Creator, alle Zahlen
aufsummiert. Bis heute wurde so eine Zeile abgewiesen, weil
daraus kein Tageswert wird.
DREI WEGE WAEREN MOEGLICH GEWESEN, und zwei davon waeren
Zahlenfaelschung:
* auf den ersten Tag schreiben -> 13 Tage auf einem Tag,
sieht richtig aus, ist es nicht;
* gleichmaessig verteilen -> erfundene Tage, die es nie gab.
Der dritte ist dieser: den Zeitraum ALS Zeitraum ablegen. Was
drinsteht, ist dann wahr -- und was es nicht sagt (welcher Tag
wie lief), behauptet es auch nicht.
EIGENE TABELLE UND NICHT EIN FELD IN "leistung": Dort ist der
Schluessel (creator, tag). Ein Zeitraum ab dem 1. September
wuerde mit dem ECHTEN 1. September zusammenstossen -- und eine
der beiden Zahlen waere weg. Getrennt kann keines das andere
ueberschreiben, und die Wochenzahlen bleiben, was sie sind:
aus Tagen gerechnet.
DER SCHLUESSEL IST (creator, von, bis): Dieselbe Datei zweimal
einzulesen ersetzt denselben Zeitraum, statt ihn zu
verdoppeln. */
CREATE TABLE IF NOT EXISTS leistung_zeitraum (
creator_id INTEGER NOT NULL REFERENCES personen(id) ON DELETE CASCADE,
von TEXT NOT NULL,
bis TEXT NOT NULL,
tage INTEGER NOT NULL,
diamanten INTEGER,
dauer_min INTEGER,
gueltige_tage INTEGER,
zuschauer_avg INTEGER,
zuschauer_max INTEGER,
verweildauer_s INTEGER,
schenker INTEGER,
follower_neu INTEGER,
erfasst TEXT NOT NULL,
erfasst_von INTEGER REFERENCES personen(id) ON DELETE SET NULL,
PRIMARY KEY (creator_id, von, bis)
);
CREATE INDEX IF NOT EXISTS idx_leistung_zeitraum_creator
ON leistung_zeitraum (creator_id, bis DESC);
CREATE INDEX IF NOT EXISTS idx_leistung_tag ON leistung (tag DESC);
/* ALLES, WAS IN DER DATEI STAND (16.09.2026)
Filipe: "ich will dass alles von der excel datei genommen wird
... wenn ich dir runter lade soll alles notiert und angezeigt
werden."
Seine echte Backstage-Ausgabe hat 41 SPALTEN. Die Tabelle
darueber nimmt acht davon. Dreiunddreissig Spalten -- Zahlen
vom letzten Monat, Prozentwerte, Matches, Multi-Gast-LIVEs,
Fanclub, Graduierungs- und Stufenstatus -- wurden beim Einlesen
weggeworfen, ohne dass irgendwo stand, dass es sie gab.
WARUM NICHT 33 WEITERE SPALTEN OBEN? Weil das eine ABGESCHRIEBENE
LISTE waere, und die hat in diesem Haus schon zweimal Daten
gekostet (siehe die Notiz zur Agentur-Umstellung). Sie waere
ausserdem beim naechsten Backstage-Update falsch: TikTok
benennt Spalten um und fuegt welche hinzu, ohne jemanden zu
fragen.
EINE ZEILE JE SPALTE, und der NAME ist der Schluessel. Damit
gilt: Was in der Datei steht, steht danach in der Datenbank --
auch eine Spalte, die es heute noch gar nicht gibt. Nichts zu
pflegen, nichts zu vergessen.
Die Spalte nr IST DIE REIHENFOLGE DER DATEI, nicht die Identitaet. Wer
die Reihenfolge zum Schluessel machte, verwechselte zwei
Spalten, sobald Backstage eine dazwischenschiebt.
BEIDES WIRD GESPEICHERT: der Text woertlich, wie er dastand
(daraus kann man immer noch alles herleiten), und die Zahl, wenn
es eine ist. Nur die Zahl zu behalten hiesse, "Nicht graduiert"
und "6Std. 24Min. 7Sek." zu verlieren; nur den Text zu behalten
hiesse, nicht rechnen zu koennen. */
CREATE TABLE IF NOT EXISTS leistung_zeitraum_feld (
creator_id INTEGER NOT NULL REFERENCES personen(id) ON DELETE CASCADE,
von TEXT NOT NULL,
bis TEXT NOT NULL,
nr INTEGER NOT NULL,
name TEXT NOT NULL,
text TEXT,
zahl REAL,
PRIMARY KEY (creator_id, von, bis, name)
);
CREATE INDEX IF NOT EXISTS idx_leistung_zfeld
ON leistung_zeitraum_feld (creator_id, von, bis, nr);
/* 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);
`);
/* Die Tabellen des Treffs. Eigene Datei, weil workspace-treff.js
aus DIESER Datei importiert -- ein Importkreis waere die Folge,
und ueber einen Kreis kommen Konstanten als undefined an, ohne
dass irgendwo ein Fehler erscheint. */
treffTabellen(d);
/* Der vertrauliche Meldeweg (19.09.2026). Haengt an personen und
kommt deshalb hierher, nicht in den Bauplan darueber. */
hilfeTabellen(d);
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;
}
/* ==== DAS FENSTER GLEITET MIT (20.09.2026) =======================
Wer die Seite benutzt, bleibt angemeldet. Verlaengert wird erst,
wenn weniger als die HAELFTE des Fensters uebrig ist -- sonst
waere hier ein Schreibzugriff bei jedem einzelnen Aufruf, auch
bei jedem Herzschlag des Ereignisstroms.
DER KEKS MUSS MIT. Ohne ihn liefe die Zeile in der Datenbank
weiter und der Browser wuerfe den Keks trotzdem weg -- die
Person waere abgemeldet, obwohl der Server sie noch kennt. Das
ist die Sorte Fehler, die niemand findet, weil beide Seiten fuer
sich richtig aussehen.
`req.res` STATT EINES ZWEITEN ARGUMENTS: Diese Funktion wird an
26 Stellen aufgerufen -- in jedem Fachmodul einmal. Einen
Parameter zu ergaenzen hiesse, 26 Aufrufe zu aendern und beim
27. Modul daran zu denken. Express haengt die Antwort ohnehin an
die Anfrage; wo sie fehlt (etwa in einer Pruefung), wird eben
nur die Datenbank verlaengert. */
const restMs = new Date(reihe.gueltig_bis).getTime() - Date.now();
if (restMs < SITZUNG_MS / 2) {
const neuBis = new Date(Date.now() + SITZUNG_MS).toISOString();
db().prepare("UPDATE sitzungen SET gueltig_bis = ? WHERE token_hash = ?")
.run(neuBis, tokenHash(token));
try {
req.res?.cookie?.(COOKIE, token, {
httpOnly: true, secure: !!req.secure, sameSite: "lax",
path: "/workspace", maxAge: SITZUNG_MS,
});
} catch { /* kein res (z. B. in einer Pruefung) -- die Zeile reicht */ }
}
/* 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;
/* IN WELCHEM HAUS STEHT DIESE PERSON GERADE? (10.09.2026)
Filipe: "die daten von dieser seite sollen nichts mit den daten
am hut haben von der workspace seite ... die hier soll ihre
eigene daten haben und komplett von der anderen getrennt sein.
dogfather soll die daten auch auf der anderen seite sehen in der
team dogi kategorie aber auch nur er und vanvan."
Dasselbe Konto, dieselbe Datenbank -- aber je nach Adresse ein
anderer AUSSCHNITT. Auf der Adresse von Team Dogi sieht auch
DogFather nur sein Team: keine Creator, keine Scouts, keine
Agentur. Auf der Agenturadresse aendert sich nichts.
ES HAENGT AN DER PERSON UND NICHT AN EINEM ZWEITEN PARAMETER.
Die Sichtbarkeitsregeln bekommen ueberall dieselbe Person
gereicht -- `sichtbar(person)`, `sichtbareCreatorIds(person)`,
`bereicheFuer(person)`. Ein zusaetzliches Argument muesste an
ueber dreissig Aufrufstellen mitgeschleift werden, und die eine
vergessene waere das Loch. So reist die Antwort mit dem
Menschen mit, durch jede Funktion, ohne dass eine davon davon
wissen muss.
UND ES AENDERT KEINE RECHTE. Es entscheidet, WAS jemand sieht,
nicht, was er darf -- genau wie die Sicht eines anderen
(sichtPerson) das auch nicht tut. Wer hier etwas anderes einbaut,
macht aus einer Anzeigefrage eine Rechtefrage. */
const haus = istCrewAdresse(req.get?.("host")) ? "crew"
: "agentur";
/* DER AUSSCHLUSS WIRKT HIER -- an derselben Stelle wie alles andere
(11.09.2026).
Ein Ausschluss aus dem Treff ist nicht "darf nichts mehr
schreiben", sondern "ist nicht mehr da". Stuende die Pruefung an
den Schreibwegen, koennte ein Ausgeschlossener weiter mitlesen --
und genau das ist bei Belaestigung der Teil, der weh tut.
AN DIESER STELLE UND NICHT NUR BEIM ANMELDEN: Wer schon
angemeldet ist, hat ein Sitzungsplaetzchen, das Tage gilt. Eine
Pruefung beim Anmelden haette ihn bis zum Ablauf drin gelassen --
also ausgerechnet in den Stunden, in denen die Entscheidung
gefallen ist.
NUR FUER AUSSENROLLEN: Eine Massnahme gegen jemanden aus dem Team
gibt es nicht (siehe /api/treff/massnahme), und ein Fehler hier
duerfte niemals das Team aussperren. Die Frage wird deshalb gar
nicht erst gestellt.
WIRFT DIE FRAGE, greift der catch weiter unten und die Antwort
ist "null" -- also "nicht angemeldet". Das ist die sichere
Richtung: Wer nicht nachsehen kann, laesst niemanden herein. */
if (AUSSEN_ROLLEN.has(reihe.rolle) && treffSperreLesen(db(), reihe.id)?.art === "ausschluss") {
return null;
}
return { id: reihe.id, name: reihe.name, rolle: reihe.rolle, haus };
} catch { return null; }
}
function sitzungSetzen(res, person, req) {
const token = randomBytes(32).toString("hex");
const bis = new Date(Date.now() + SITZUNG_MS).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_MS,
});
}
/* ---------- Router ----------------------------------------------------- */
export const workspaceRouter = express.Router();
/* DIE ZUGANGSTABELLE STEHT SEIT DEM 11.09.2026 IN rechte.js.
Sie ist dorthin UMGEZOGEN, nicht kopiert -- samt jeder
Begruendung, die hier ueber den Jahren entstanden ist. Zwei
Tabellen waeren zwei Wahrheiten, und die zweite haette niemand
gepflegt.
Der Grund fuer den Umzug: `null` hiess "jede angemeldete Rolle",
und ein FEHLENDER Eintrag hiess dasselbe. Beides ist jetzt
"niemand". Was nicht in der Tafel steht, ist verboten.
pruef-rechtetafel.mjs macht daraus eine Garantie: eine Rolle
ohne Eintrag und eine Seite ohne Eintrag machen den Prueflauf
rot. */
/* 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. */
/* SIE WIRD NICHT ZWEIMAL GESCHRIEBEN (11.09.2026).
Bis heute stand hier dieselbe Liste ein zweites Mal, von Hand. Sie
stimmte -- weil sie zwei Eintraege hatte und beide am selben Tag
entstanden. Beim dritten (der Wand des Treffs) waere genau das
passiert, wogegen die Rechtetafel gebaut wurde: Wer nur hier
nachtraegt, bekommt eine Wand, die die Pruefung als Luecke meldet;
wer nur dort nachtraegt, bekommt eine Endlosweiterleitung. Beides
faellt erst im Browser auf. Jetzt gibt es die Liste einmal. */
const OFFEN = new Set(OHNE_ANMELDUNG);
/** 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.
Seit dem 06.09.2026 gilt: JEDE .html unter /workspace ist
geschuetzt, ausser den ausdruecklich offenen.
UND SEIT DEM 11.09.2026 AUCH FUER DIE ROLLE. Bis dahin sagte die
Liste nur, welche Rollen ZUSAETZLICH eingeschraenkt sind -- eine
neue Seite war damit "nur fuer Angemeldete", also fuer JEDE Rolle.
Das war der sichere Rueckfall gegen Fremde, aber keiner gegen eine
Rolle, die man gerade erst angelegt hat.
Jetzt ist der Rueckfall "fuer niemanden". Eine neue Seite ohne
Eintrag in rechte.js oeffnet sich fuer keine Rolle, auch nicht fuer
DogFather -- und pruef-rechtetafel meldet sie, statt dass man es
beim Ausprobieren merkt. */
/* =====================================================================
AUF DER ADRESSE VON TEAM DOGI GIBT ES DIE AGENTURSEITEN NICHT
(15.09.2026)
Filipe, mit dem Bildschirmfoto des Start-Checks auf crew.:
„das gibt es in der app nicht, dass ist nur auf der workspace app
aber nicht hier."
---------------------------------------------------------------------
ES WAR GROESSER ALS DIESE EINE SEITE
Die Haustrennung von 10.09. entscheidet, WAS jemand sieht -- sie
filtert die Daten und die Kacheln. Sie entschied nie, welche SEITEN
es auf einer Adresse gibt. Die Rechtetafel wiederum kennt nur Rollen,
keine Adressen. Zwischen beidem lag das Loch.
Gemessen am 15.09.2026, auf crew.:
DogFather 12 Seiten ohne Kachel erreichbar, 10 davon Agentur
(Automationen, Content, Scouting, Reports, Zahlen,
Team, Calls, Creator-Profil, Dashboard, Start-Check)
rechte Hand 5, davon 3 Agentur
ein Modi 6, davon 3 Agentur -- auch der Start-Check
Der Start-Check sagt dort „Es gibt noch keinen Creator", weil die
Haustrennung ihm alle Daten wegnimmt. Das ist genau die Sorte Seite,
die aussieht wie ein Fehler: Sie laedt, sie ist leer, und niemand
weiss, ob das Absicht ist oder etwas kaputt.
---------------------------------------------------------------------
DIE REGEL WIRD ABGELEITET, NICHT GEPFLEGT
Welche Seiten zu dieser Adresse gehoeren, steht schon irgendwo:
in den KACHELN, die dieselbe Adresse dieser Person zeigt. Eine
zweite Liste daneben waere die, die beim naechsten Umbau
auseinanderlaeuft -- diesen Fehler hat das Projekt schon zweimal
bezahlt (die abgeschriebenen Spalten am 07. und 11.09.).
Dazu eine kurze Liste von Seiten, die in jedem Haus dazugehoeren und
trotzdem keine Kachel haben. Sie altert SICHER: Eine neue
Agenturseite ist hier automatisch gesperrt (richtig), eine neue
Crew-Seite ohne Kachel faellt beim ersten Klick auf (sichtbar) --
nicht still.
UND SIE AENDERT KEINE RECHTE. Was jemand DARF, steht weiterhin
allein in rechte.js. Diese Regel beantwortet eine andere Frage:
Gibt es das hier ueberhaupt? Deshalb steht sie neben der
Rechtepruefung und nicht in ihr.
NUR DIE SEITEN, NICHT DIE SCHNITTSTELLEN. Die filtern seit dem
10.09. selbst ueber person.haus und brauchen das hier nicht --
eine zweite Rechtelogik davor waere die, die irgendwann etwas
durchlaesst. Wer das aendert, aendert es dort.
===================================================================== */
/** Seiten, die in JEDEM Haus dazugehoeren und keine Kachel haben. */
const OHNE_KACHEL_UEBERALL = new Set([
/* Die Startseite selbst -- sie ist das Ziel der Umleitung. */
"/workspace/start.html",
/* Die Regeln des Treffs. Sie gehoeren der Community und dem Team. */
"/workspace/treff-regeln.html",
/* Die Entwicklungskarte. Ein Modi hat dafuer keine Kachel, kommt
aber ueber „Deine Karte“ und ueber den Verweis von befinden.html
dorthin -- und beides ist seine eigene Sache. */
"/workspace/entwicklung.html",
/* DIE MODI-ANFRAGE UND IHR EINGANG (17.09.2026).
BEIDE OHNE KACHEL, und zwar mit Grund -- nicht, weil kein Platz
war:
bewerben.html gehoert INS Brett "Mitmachen" und nicht daneben.
Eine achte Kachel im Treff waere ein zweiter Ort fuer dieselbe
Sache; das Brett erklaert, wie man dazugehoert, und der Knopf
steht genau dort, wo die Frage entsteht.
bewerbungen.html gehoert AUF die Talente-Seite. Die Anfragen
sind der Anfang desselben Trichters -- wer angenommen wird,
steht eine Sekunde spaeter dort als Talent. Eine eigene Kachel
daneben haette den Weg in zwei Seiten zerlegt, die man
abwechselnd aufmacht.
(Nebenbei: Ein 38. Kachelton laesst sich gemessen nicht mehr
unterbringen. Alle 37 sind vergeben, und der beste freie Rest im
Hausrahmen liegt bei einem Abstand von 39 -- das ist Dunkelrot,
also die Farbe von Spicy Media und die Farbe fuer Alarm. Das war
ein Hinweis, kein Grund.) */
"/workspace/bewerben.html",
"/workspace/bewerbungen.html",
/* „KLINGELT NICHTS?“ (nachgetragen 19.09.2026).
Gefunden beim Durchgehen der Seite aus Benutzersicht, und es war
der peinlichste Fund des Tages: `anruf-probe.html` ist die Seite,
die erklaert, WARUM das Telefon stumm bleibt. Sie steht in der
Rechtetafel fuer alle offen, und aus dem Chat zeigen zwei Knoepfe
darauf (chat.js, anruf.js).
Nur hatte sie nie eine Kachel -- absichtlich, sie ist kein
Bereich. Seit die Adressregel vom 15.09. eine Kachel VERLANGT,
landete damit jeder Klick auf der Startseite. Wortlos.
Das ist die schlimmste Sorte Sackgasse: Man ruft sie auf, WEIL
schon etwas nicht geht, und sie wirft einen hinaus. Wer das
erlebt, schliesst daraus, dass die ganze App kaputt ist -- und
genau das ist sie in dem Moment fuer ihn auch.
Die Hinweisleiste haengt mit dran: workspace-hinweise.js wirft
jeden Hinweis weg, dessen Ziel auf dieser Adresse nicht existiert.
Der Hinweis „Bei dir klingelt nichts -- kein Geraet angemeldet"
wurde auf crew deshalb NIE angezeigt. Ausgerechnet der, der acht
von elf Leuten betrifft. Eine Zeile hier heilt beides. */
"/workspace/anruf-probe.html",
]);
/** Gibt es diese Seite auf der Adresse, auf der die Person gerade steht? */
export function gehoertAufDieseAdresse(person, pfad) {
/* Auf der Agenturadresse aendert sich nichts. Dort leben diese
Seiten -- die Frage stellt sich nur andersherum. */
if (person?.haus !== "crew") return true;
if (OHNE_KACHEL_UEBERALL.has(pfad)) return true;
const ziele = new Set(
[...(bereicheFuer(person) || []), ...(zusatzBereicheFuer(person) || [])]
/* Ohne den Frageteil: Die Brettkacheln zeigen auf
bereich.html?b=treff -- gemeint ist die Seite, nicht das Brett. */
.map((k) => "/workspace/" + String(k?.ziel || "").split("?")[0]));
return ziele.has(pfad);
}
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/");
/* EIN FEHLENDER EINTRAG HEISST JETZT NEIN.
Vorher stand hier `if (erlaubt && ...)` -- und `erlaubt` war
`undefined`, sobald eine Seite gar nicht in der Tabelle stand.
Die Bedingung fiel dann durch, und die Seite war fuer jede
angemeldete Rolle offen. Am 11.09.2026 betraf das keine einzige
Seite (nachgemessen: 20 in der Tabelle, 2 Zugangswaende, 22
Dateien) -- aber jede NEUE waere so entstanden. */
if (!darfSeite(person, pfad)) {
return res.redirect(302, "/workspace/start.html");
}
/* UND: GIBT ES DIESE SEITE HIER? Dieselbe Umleitung wie oben und
nicht ein 404 -- an dieser Schranke gibt es eine Antwort auf
„nein", nicht zwei. Zwei Verhalten nebeneinander waeren die
Einladung, aus dem Unterschied etwas abzulesen. */
if (!gehoertAufDieseAdresse(person, pfad)) {
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;
}
}
/* DIE TAFEL LIEGT SEIT DEM 11.09.2026 IN workspace-rechte.js.
Hier stand ein nur LESENDER Weg. Filipe: "so dass ich die da auch
manuell wechseln und speichern kann" -- damit gehoeren Lesen und
Umstellen zusammen, samt der Tabelle dahinter und der Frage, wer was
darf. Ein Leseweg hier und ein Schreibweg dort waeren zwei Orte fuer
dieselbe Sache. */
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. */
/* UND AUCH NICHT AUF DER WAND DES TREFFS (11.09.2026).
Hier stand `!istOhneModiAdresse(...)` -- also "ueberall ausser auf
den alten Adressen". Als dritte Wand dazukam, hiess das
stillschweigend auch: dort. Ein Modi kam damit ueber den
verborgenen Weg in den Treff herein, obwohl die Kachelliste dort
nur `gast` kennt und die Adressregel ihn abweist.
Gefunden hat das nicht das Lesen, sondern eine Zeile in
pruef-treff, die das GEGENTEIL behauptete und rot wurde:
"auf treff. kommt sonst niemand herein -- auch DogFather nicht
(401/200)". DogFather wurde abgewiesen, der Modi nicht.
Die Lehre ist dieselbe wie bei `null` in der Rechtetafel: Eine
Bedingung, die durch AUSSCHLIESSEN formuliert ist, nimmt jede
neue Moeglichkeit automatisch mit auf. Jetzt steht dort, WO der
Weg gilt, statt wo er nicht gilt. */
const stilleWand = istCrewAdresse(req.get("host")) || istPruefAdresse(req.get("host"));
const still = codeBrauchbar && stilleWand ? 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" });
}
/* AUSGESCHLOSSEN HEISST: DER CODE STIMMT NICHT MEHR (11.09.2026).
sitzungLesen() weist eine ausgeschlossene Person ohnehin ab --
jede Anfrage bekommt 401. Ohne diese Zeile hier waere die
Anmeldung aber TROTZDEM gelungen: Plaetzchen gesetzt, "willkommen"
geantwortet, Weiterleitung auf die Startseite -- und dort dann
eine Seite, auf der nichts geht. Das sieht nicht aus wie eine
Entscheidung, sondern wie ein kaputtes Programm, und man
probiert es zehnmal.
DIESELBE ANTWORT WIE BEI EINEM FALSCHEN CODE, und zwar
wortgleich: Wer ausgeschlossen wurde, hat den Grund bereits
bekommen (die Massnahme verlangt einen). Die Anmeldeseite muss
ihn nicht wiederholen -- und sie darf niemandem, der einen
fremden Code probiert, verraten, dass es diesen Zugang gibt.
NUR FUER AUSSENROLLEN. Gegen jemanden aus dem Team gibt es diese
Massnahme gar nicht (siehe /api/treff/massnahme); die Frage
wird deshalb nicht gestellt. Ein Fehler hier duerfte niemals
das Team aussperren. */
if (AUSSEN_ROLLEN.has(gefunden.rolle)
&& treffSperreLesen(db(), gefunden.id)?.art === "ausschluss") {
db().prepare("INSERT INTO versuche (ip, zeitpunkt) VALUES (?,?)").run(ip, jetzt());
protokolliere("anmeldung_gesperrt_treff", { personId: gefunden.id, rolle, ip });
return res.status(401).json({ fehler: "ungueltig" });
}
/* ================================================================
DAS ALTER WIRD AN DER TUER BESTAETIGT (15.09.2026)
Die Regelseite verspricht "ab 18". Bis heute war das ein Satz.
Jetzt ist es eine Bedingung -- einmal, beim ersten Hereinkommen,
und danach nie wieder.
WARUM AN DER TUER UND NICHT AUF EINER SEITE DAHINTER: Eine Sperre
auf den Brettern liesse sich durch eine andere Adresse umgehen,
und sie muesste auf jeder kuenftigen Seite mitgedacht werden. Die
Tuer gibt es genau einmal.
WARUM EIN EIGENER FEHLER UND NICHT "ungueltig": Hier ist der Code
richtig. Wer dieselbe Antwort bekaeme wie bei einem Tippfehler,
wuerde seinen Code neu eintippen -- immer wieder, und es wuerde
nie besser. Das ist nicht dieselbe Lage und darf nicht dieselbe
Antwort sein.
NUR FUER DIE COMMUNITY: Wer zum Team gehoert, hat einen Zugang
von DogFather persoenlich bekommen -- da ist die Frage vorher
geklaert und eine Kachel an der Tuer nur im Weg. */
if (AUSSEN_ROLLEN.has(gefunden.rolle)) {
const bestaetigt = db().prepare(
"SELECT alter_bestaetigt_am FROM personen WHERE id = ?").get(gefunden.id)?.alter_bestaetigt_am;
if (!bestaetigt) {
if (req.body?.alter_ok !== true) {
/* KEIN Eintrag in `versuche`: Das war kein Fehlversuch. Wer
hier landet, hat den richtigen Code -- ihn nach acht
Anlaeufen auszusperren waere absurd. */
return res.status(400).json({ fehler: "alter_offen", mindestalter: MINDESTALTER });
}
db().prepare("UPDATE personen SET alter_bestaetigt_am = ? WHERE id = ?")
.run(jetzt(), gefunden.id);
protokolliere("alter_bestaetigt", {
personId: gefunden.id, rolle: gefunden.rolle, ip,
detail: `mindestens ${MINDESTALTER}`,
});
}
}
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),
/* OB DER KNOPF "ROLLE AENDERN" ERSCHEINT (11.09.2026). Aus
derselben Regel wie die Schranke dahinter -- die Oberflaeche
vergleicht keine Rollennamen mehr selbst. Vorher stand dort
`ich.rolle === 'admin'`, und dieselbe Zeile schaltete auch das
Loeschen frei; die beiden gehoeren nicht zusammen. */
darf_rollen_wechseln: darfRollenWechseln(person),
/* ZWEI LISTEN STATT EINER RECHNUNG IM BROWSER (20.09.2026).
Die Rollenwahl nahm bisher die Knoepfe des Anlege-Formulars --
also `darf_anlegen`. Fuer die rechte Hand ist das leer (sie legt
niemanden an), und damit waere die Wahl leer geblieben, obwohl
sie Rollen aendern darf. Ein Recht, das man hat und nicht
ausueben kann, ist keines.
`rollen_zum_aendern` = wozu darf ich machen
`rollen_anfassbar` = wessen Rolle darf ich ueberhaupt anfassen
Beide aus denselben Funktionen, die auch die Route fragt. Die
Oberflaeche fuehrt damit keine eigene Liste mehr -- genau daran
ist es am 10.09.2026 schon einmal gescheitert, als zwei Listen
drei Zeilen auseinander einander widersprachen. */
rollen_zum_aendern: rollenZumAendern(person),
/* [...ROLLEN] UND NICHT ROLLEN_REIHE: Die Reihe ist die
SORTIERUNG, und in ihr fehlt "gast" mit Absicht. Wer sie hier
nimmt, verliert die Community stillschweigend -- genau diese
Verwechslung hat am 17.09.2026 schon einmal dafuer gesorgt, dass
sich niemand zu "Community" machen liess. */
rollen_anfassbar: [...ROLLEN].filter((r) =>
darfRolleAendern(person, { id: -1, rolle: r })),
/* OB DER CHAT-KNOPF IN DER KOPFLEISTE ERSCHEINT (18.09.2026).
Er wurde auf JEDER Seite gebaut. Fuer ein Mitglied der Community
fuehrte er ins Leere: `chat.html` steht ihm nicht offen, der
Klick landete wieder auf der Startseite. Gemessen von
pruef-community-sicht: zehn Seiten, zehn tote Wege -- und der
Knopf steht oben rechts, wo man ihn am ehesten drueckt.
DIE ANTWORT KOMMT AUS DERSELBEN TABELLE wie die Schranke selbst.
Die Oberflaeche vergleicht keine Rollennamen -- das waere eine
zweite Wahrheit, und bei der naechsten Rechteaenderung liefe sie
auseinander. Genau die Ueberlegung steht schon zwei Zeilen
darueber; hier gilt sie noch einmal. */
darf_chat: darfSeite(person, "/workspace/chat.html"),
/* UND OB SIE TELEFONIEREN DARF (19.09.2026).
Nicht als Bequemlichkeit: Ohne diese Angabe holt `anruf.js` beim
Laden jeder Chatseite die Verbindungsadressen -- und bekommt
fuer die Community 404. Der Browser schreibt das in die Konsole,
BEVOR JavaScript es abfangen kann; abfangen hilft also nicht,
die Anfrage darf gar nicht erst gestellt werden.
Ein 404 bei jedem Seitenaufruf ist Rauschen, und Rauschen macht
den naechsten ECHTEN Fehler unsichtbar -- dieselbe Regel wie bei
der Warnung, die immer kommt. Gefunden hat es die Pruefung im
Browser, nicht das Lesen.
AUS DERSELBEN FUNKTION wie der Riegel am Anruf-Router. Zwei
Rechnungen waeren die Stelle, an der die Seite ein Telefon
anbietet, das der Server ablehnt. */
darf_anrufen: darfTelefonieren(person),
/* DARF ICH AUFGABEN AN ANDERE GEBEN? (20.09.2026)
AUSDRUECKLICH GESAGT, NICHT ERRATEN. Das Aufgabenbrett fragte
bisher `LEITUNG.has(ich.rolle)` -- eine zweite Fassung derselben
Regel im Browser. Seit die rechte Hand verteilen darf, waere sie
die falsche gewesen: Der Server haette es erlaubt, die Seite
haette die Felder nicht gezeigt. Eine Berechtigung, die man
hat und nicht sieht, ist keine.
Aus DERSELBEN Funktion, die auch die Route benutzt. */
darf_verteilen: darfAufgabenVerteilen(person),
/* Damit die Oberflaeche den Knopf gar nicht erst anbietet -- ein
Knopf, der mit 403 antwortet, ist schlimmer als keiner. */
darf_aufgaben_anlegen: darfAufgabenAnlegen(person),
/* FUEHRT JEMAND TEAM DOGI? (22.09.2026)
Damit die Entwicklungsseite die Kartenliste GAR NICHT ERST
abfragt, wenn es sie fuer diese Person nicht gibt. Der Code
behandelte den 404 schon richtig -- aber der Browser
protokolliert ihn trotzdem, und im Handy-Rundgang stand
deshalb bei jedem Modi "404 (Not Found)" in der Konsole.
Genau derselbe Fall wie am 19.09.2026 bei /anruf/adressen.
EIGENES FELD, kein Stellvertreter: `darf_verteilen` sieht
aehnlich aus, ist aber nicht dasselbe -- ein Manager darf
verteilen und fuehrt Team Dogi nicht. Zwei Regeln, zwei
Felder; eines fuer beides waere die Stelle, an der es beim
naechsten Umbau auseinanderlaeuft. */
fuehrt_team: fuehrtTeamDogi(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: nurOffeneKacheln(bereicheFuer(angesehen || person), 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: nurOffeneKacheln(zusatzBereicheFuer(angesehen || person),
angesehen || person),
/* DIE EIGENEN SEITEN -- damit die Oberflaeche ihre EIGENE
Kachelliste (assets/js/bereiche.js) ebenfalls filtern kann.
Geliefert wird, was die Person DARF, nicht was sie nicht darf:
Eine Liste der verbotenen Seiten waere eine Aufzaehlung dessen,
was es sonst noch gibt -- und damit genau die Auskunft, die
niemand bekommen soll. */
seiten: seitenFuer((angesehen || person).rolle),
});
});
/* ---------- Verwaltung (nur über die Kommandozeile) --------------------
Wird von workspace-code.js benutzt. Bewusst nicht über das Netz
erreichbar: Codes werden auf dem Server erzeugt, einmal angezeigt und
nie gespeichert -- weder in Git noch in einer Notiz. */
/* Alphabet ohne 0/O und 1/I/l: Diese Codes werden abgetippt und
weitergegeben, Verwechslungen kosten sonst unnötig Nerven. */
const ALPHABET = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789";
export function codeErzeugen(gruppen = 4, laenge = 4) {
const roh = randomBytes(gruppen * laenge);
let aus = "";
for (let i = 0; i < gruppen * laenge; i++) {
if (i && i % laenge === 0) aus += "-";
aus += ALPHABET[roh[i] % ALPHABET.length];
}
return aus;
}
/* `akteur` ist WER die Aktion ausloest -- nicht, wen sie betrifft. Das
muss getrennt bleiben: Stand im Protokoll die neu angelegte Person als
person_id, las sich der Eintrag so, als haette sie sich selbst angelegt.
Wer betroffen ist, steht im Text. Ohne Akteur (Kommandozeile) bleibt
das Feld leer. */
/* =====================================================================
Betreuung — wer darf sich um welchen Creator kuemmern.
Scouts betreuen Creator wie das Management, aber nur die ihnen
zugeteilten. Diese Regel steht bewusst NUR hier: Sie wird von sechs
Modulen gebraucht (Profile, Bereiche, Reports, Aufgaben, Kalender,
Dateien), und eine Rechteregel, die an sechs Stellen steht, ist eine
Rechteregel, die irgendwann an fuenf Stellen stimmt.
===================================================================== */
/** 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. */
/* UND AUF DER ADRESSE VON TEAM DOGI OHNE DIE AGENTUR (10.09.2026).
Hier steht die Bedingung IN termineSichtbar und nicht in
workspace-kalender.js -- sonst haetten der Kalender, die
Wiederholungen (workspace-serien.js) und der ICS-Abruf
(workspace-ics.js) sie einzeln gebraucht, und der ICS-Abruf ist
genau der, den man vergisst: Er laeuft ohne Bildschirm.
`teilnehmer_id` gehoert in die Liste. Ein Gespraech mit einem
Creator haengt oft an gar keiner creator_id -- der Creator IST das
Gegenueber. Wer nur zwei Spalten prueft, hat hier nichts geprueft. */
/* EINE AUSNAHME, UND ZWAR GENAU EINE (11.09.2026).
Filipe: "mein kalender (dogfather) auf dieser seite hier soll
komplett verbunden sein mit dem kalender in der workspace seite
ABER NUR IN DER DOGFATHER ROLLE BEI DOGFATHER: NICHT BEI VANVAN."
Ein Kalender ist etwas anderes als eine Liste. Bei den Aufgaben,
den Bereichen und den Dateien ist die Trennung eine Hilfe -- man
will das andere Haus gerade NICHT sehen. Beim Kalender waere sie
eine Falle: Wer auf der Team-Seite einen Termin einträgt und die
Haelfte seines Tages nicht sieht, legt ihn auf eine Zeit, in der er
schon woanders sitzt. Ein Kalender, der nur die halbe Wahrheit
zeigt, ist schlimmer als keiner.
NUR FUER DIE DOGFATHER-ROLLE, und das ist sein ausdrueckliches
Wort. Die rechte Hand behaelt die Trennung: Sie arbeitet auf dieser
Adresse mit dem Team, und die Termine der Agentur gehen sie dort
nichts an. Geprueft wird die ROLLE und nicht der Name -- ein Name
waere die Stelle, an der es beim naechsten Menschen bricht. */
if (person.haus === "crew" && person.rolle !== "admin") {
return {
wo: `(${eigen}) AND ${ohneAgentur(p, ["creator_id", "teilnehmer_id", "erstellt_von"])}`,
werte,
};
}
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. */
if (!z) return ich;
/* IN WELCHEM HAUS STEHT DIE ANGESEHENE PERSON? (11.09.2026)
Ohne diese vier Zeilen fehlte der angesehenen Person das Feld
`haus` -- und `nurHaus()` und `hausBedingung()` fangen beide mit
`person?.haus !== "crew"` an. Eine Sicht ohne Haus war damit
automatisch eine Agentursicht: DogFather sah in der Sicht auf
einen Modi die Dateien, die Personen und die Berichte des
ANDEREN Hauses -- also mehr, als die Person selbst je sieht, und
nicht das, was sie sieht.
Das Haus haengt hier an der ROLLE, nicht an der Adresse. Eine
rechte Hand und ein Modi gibt es nur im Haus von Team Dogi; eine
Managerin, eine Scout und eine Creatorin nur im Agenturhaus.
Wuerde stattdessen die Adresse entscheiden, saehe dieselbe
Person je nach aufgerufener Wand etwas anderes -- und genau das
soll die Sicht ja gerade nicht.
DogFather selbst ist in beiden Haeusern zu Hause. Sieht er sich
durch die Augen eines zweiten Kontos mit seiner Rolle an, gilt
das Haus, in dem er gerade steht. */
const haus = TEAM_DOGI_ROLLEN.has(z.rolle) ? "crew"
: z.rolle === "admin" ? (ich.haus || "agentur")
: "agentur";
return { ...z, haus };
} 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.
===================================================================== */
/* =====================================================================
PERSONENLISTEN IM HAUS VON TEAM DOGI (10.09.2026)
Die sechs Funktionen darunter beantworten "welche Menschen gehen
mich etwas an" -- fuer die Chat-Partnerwahl, die Teilnehmerwahl im
Kalender, die Creator-Auswahl in Formularen und die Pipeline.
Auf der Adresse von Team Dogi ist die Antwort eine andere: dort gibt
es keine Creator, keine Scouts, keine Manager. Nicht "sie sind
ausgeblendet", sondern: Sie kommen gar nicht erst aus der Datenbank.
WARUM ALS ZWEITE HUELLE UM ohneVerborgene UND NICHT DARIN: Die beiden
beantworten verschiedene Fragen. ohneVerborgene() nimmt heraus, was
jemand NICHT SEHEN DARF -- eine Rechtefrage, die ueberall gilt.
nurHaus() nimmt heraus, was HIER NICHT HINGEHOERT -- eine Frage der
Adresse. Wer beides in eine Funktion legt, kann spaeter nicht mehr
sagen, welche der beiden gerade zugeschlagen hat.
Die Reihenfolge ist egal (beide verkleinern nur), die Trennung nicht.
===================================================================== */
/* WER AUF DER TEAM-ADRESSE UEBERHAUPT VORKOMMT.
*
* Die Community steht seit dem 11.09.2026 mit drin (Filipe: "das soll
* keine app fuer sich sein, das soll in der crew seite adaptiert
* werden"). Der Treff wohnt jetzt hier, also muessen seine Mitglieder
* hier auch verwaltet werden koennen -- sonst legt DogFather einen
* Zugang an und er verschwindet im selben Moment aus der Liste.
*
* DAS MACHT SIE NICHT ZU KOLLEGEN. Diese Menge beantwortet nur "wer
* kommt auf dieser Adresse vor". Ob jemand in eine Einladung, eine
* Aufgabe oder einen Chat darf, beantwortet ohneAussen() weiter unten
* -- und das sagt bei der Community ueberall Nein. Zwei verschiedene
* Fragen, zwei verschiedene Funktionen; wer sie in eine legt, kann
* spaeter nicht mehr sagen, welche gerade zugeschlagen hat. */
const HAUS_TEAM_ROLLEN = new Set(["admin", "hand", "linke", "modi", "gast"]);
function nurHaus(ids, person) {
if (person?.haus !== "crew") return ids;
try {
const liste = [...HAUS_TEAM_ROLLEN].map((r) => `'${r}'`).join(", ");
const erlaubt = db().prepare(
`SELECT id FROM personen WHERE rolle IN (${liste})`).all().map((z) => z.id);
/* `null` heisst bei diesen Funktionen "alle". Sobald das Haus
einschraenkt, darf es das nicht mehr heissen -- die Liste wird
deshalb AUSGESCHRIEBEN, genau wie ohneVerborgene() es tut. */
if (ids === null) return erlaubt;
const menge = new Set(erlaubt);
return ids.filter((i) => menge.has(i));
} catch {
/* EIN FEHLSCHLAG SCHLIESST, ER OEFFNET NICHT. Gaebe es hier `ids`
zurueck, waere bei einer Datenbankstoerung ploetzlich die ganze
Agentur auf der Team-Adresse zu sehen -- und niemand haette eine
Fehlermeldung gesehen. Eine leere Liste ist sichtbar falsch, eine
zu volle nicht. */
return [];
}
}
/* DASSELBE ALS SQL-SCHNIPSEL -- fuer die zwei Abfragen im Haus, die
bewusst NICHT ueber die Listenfunktionen gehen.
Das ist der Ring in der Zentrale (er holt sich das ganze Haus selbst;
im Kommentar dort steht ausdruecklich, dass er die einzige solche
Stelle ist) und die Hinweiszeile darunter. Beide zaehlen Menschen,
und beide standen auf der Team-Adresse mit Zahlen aus der Agentur da
-- "9 IM TEAM", obwohl das Team drei Leute hat. Filipe hat genau
darauf gezeigt.
EINE FUNKTION UND KEINE ZWEITE ROLLENLISTE: Wer den Ausdruck
abschreibt, hat beim naechsten Rollenwechsel zwei Wahrheiten. */
export function hausBedingung(person, spalte = "rolle") {
if (person?.haus !== "crew") return "";
const liste = [...HAUS_TEAM_ROLLEN].map((r) => `'${r}'`).join(", ");
return ` AND ${spalte} IN (${liste})`;
}
/* EINE LISTE VON CREATOR-NUMMERN DARF NUR CREATOR ENTHALTEN (11.09.2026).
Klingt selbstverstaendlich, war es nicht. `ohneVerborgene` schreibt
ein `null` ("sieht alles") zu einer echten Liste aus, sobald es etwas
zu verbergen gibt -- und zwar zur Liste ALLER Personen, weil sie
nicht wissen kann, wovon "alles" gerade handelt. Fuer DogFather
greift das nie (er verbirgt nichts vor sich selbst), fuer Spicy
Media schon: Bei ihr kam die Liste mit Admins, Managern und Scouts
darin zurueck.
GEMESSEN, NICHT VERMUTET: Am 11.09.2026 bekam Spicy Media auf der
Zahlen-Seite "Agentur, Filipe, Luna, Max, NeuerCreator,
NeuerManager" zur Auswahl -- DogFather dagegen nur "Luna,
NeuerCreator". Aufgefallen ist es, weil eine Pruefung die beiden
Listen VERGLICHEN hat, statt bei jeder einzeln "ist nicht leer" zu
sagen.
WARUM DIE REPARATUR HIER STEHT UND NICHT BEIM AUFRUFER: Es gibt
zehn Aufrufer. Jeder von ihnen baut daraus ein `IN (...)`, und jeder
haette die Rolle selbst danebenschreiben muessen -- neun Stellen
richtig und eine vergessen sieht man nie. Der Name der Funktion ist
das Versprechen; es gehoert dorthin eingeloest, wo der Name steht. */
const nurCreatorIds = (ids) => {
if (ids === null || !ids.length) return ids;
try {
return db().prepare(
`SELECT id FROM personen WHERE rolle = 'creator' AND id IN (${ids.map(() => "?").join(",")})`)
.all(...ids).map((z) => z.id);
} catch { return ids; }
};
/* WER VON AUSSEN KOMMT, SIEHT NIEMANDEN (11.09.2026).
Filipe: "egal welche rolle oder person hinzugefuegt wird soll immer
nur das sehen wass ich erlaube."
GEFUNDEN, NICHT UEBERLEGT. Eine Zeile in pruef-treff behauptete
"Personen: nichts" und wurde rot. Nachgemessen bekam ein Gast:
{"personen":[{"id":1,"name":"Filipe","rolle":"admin", ...},
{"id":5,"name":"Kessi","rolle":"gast", ...}]}
Also DogFathers Namen und seine Rolle. Nicht dramatisch -- ein
Streamer ist kein Geheimnis -- aber es war eine Auskunft, die
niemand erlaubt hatte, und genau das ist der Punkt. Die Liste kam
zustande, weil `sichtbarePersonenIdsRoh` fuer jede Rolle DogFather
mitgibt (er ist das Gegenueber von allem) und die vier Umhuellungen
danach nichts daran aendern.
`nurEigene` steht deshalb GANZ AUSSEN -- nach allen anderen. Wer
innen ansetzt, muss darauf vertrauen, dass keine spaetere Huelle
wieder etwas hinzufuegt; aussen gilt es unabhaengig davon, was
innen passiert. Und es gilt fuer alle vier Listen gleich, statt
viermal einzeln.
`null` heisst bei diesen Funktionen "alle" -- deshalb wird es hier
ausdruecklich zu einer Liste mit genau einem Eintrag. Ein `null`
durchzureichen hiesse fuer eine Aussenrolle das Gegenteil von dem,
was hier steht. */
function nurEigene(ids, person) {
if (!AUSSEN_ROLLEN.has(person?.rolle)) return ids;
return person?.id ? [person.id] : [];
}
/* =====================================================================
WER VON AUSSEN IST, STEHT IN KEINER ARBEITSLISTE (11.09.2026)
=====================================================================
nurEigene() beantwortet "was sieht ein Mitglied der Community?".
Diese Huelle beantwortet die andere Richtung: "steht ein Mitglied der
Community in der Liste, die jemand ANDERS bekommt?"
DER ANLASS: Seit es die Rolle gibt, taucht sie in jeder Liste auf,
die "alle" bedeutet -- und fuer DogFather bedeutet fast jede Liste
"alle" (siehtAlles). Ein Mitglied der Community stand damit in der
Auswahl, wen man zu einem Termin einlaedt, wem man eine Aufgabe gibt
und mit wem man einen Chat anfaengt. Nichts davon ist vorgesehen, es
gab dafuer nie eine Entscheidung, und aufgefallen waere es zum ersten
Mal, wenn jemand versehentlich einen Zuschauer zu einer internen
Besprechung einlaedt.
WARUM ALS HUELLE UND NICHT IN DEN VIER FUNKTIONEN: Weil sie dann auch
fuer die Listen gilt, die es noch nicht gibt. Dieselbe Ueberlegung
steht schon bei nurHaus() und bei der Modi-Huelle in
workspace-bereiche.js -- und beide Male war der erste Anlauf ein
einzelner geflickter Fall, der den naechsten offen liess.
NULL HEISST "ALLE" -- und sobald hier etwas herausfaellt, darf es das
nicht mehr heissen. Die Liste wird deshalb AUSGESCHRIEBEN, so wie
ohneVerborgene() und nurHaus() es auch tun.
EIN FEHLSCHLAG SCHLIESST. Bei einer Datenbankstoerung eine
ungefilterte Liste zurueckzugeben hiesse, die Community genau dann in
die Einladungsauswahl zu setzen, wenn ohnehin gerade etwas nicht
stimmt. Eine leere Liste ist sichtbar falsch, eine zu volle nicht.
AUSDRUECKLICH NICHT ANGEWENDET auf sichtbarePersonenIds(): Das ist
die Verwaltungsliste, in der DogFather Zugaenge anlegt und Codes neu
setzt. Dort MUSS die Community stehen. Die Einladungsliste leitet
sich zwar davon ab -- aber sie bekommt diese Huelle aussen herum,
und damit ist die Verwaltung offen und die Zusammenarbeit zu. */
function ohneAussen(ids) {
const rollen = [...AUSSEN_ROLLEN];
if (!rollen.length) return ids;
try {
const platz = rollen.map(() => "?").join(", ");
const aussen = db().prepare(
`SELECT id FROM personen WHERE rolle IN (${platz})`).all(...rollen).map((z) => z.id);
if (!aussen.length) return ids;
if (ids === null) {
return db().prepare(
`SELECT id FROM personen WHERE rolle NOT IN (${platz})`).all(...rollen).map((z) => z.id);
}
const menge = new Set(aussen);
return ids.filter((i) => !menge.has(i));
} catch {
return [];
}
}
export const sichtbareCreatorIds = (person) => ohneAussen(nurEigene(nurCreatorIds(nurHaus(ohneVerborgene(sichtbareCreatorIdsRoh(person), person), person)), person));
export const sichtbarePersonenIds = (person) => nurEigene(nurHaus(ohneVerborgene(sichtbarePersonenIdsRoh(person), person), person), person);
export const betreuerIds = (person) => ohneAussen(nurEigene(nurHaus(ohneVerborgene(betreuerIdsRoh(person), person), person), person));
export const einladbareIds = (person) => ohneAussen(nurEigene(nurHaus(ohneVerborgene(einladbareIdsRoh(person), person), 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) => {
/* ==== DIE COMMUNITY SCHREIBT NIEMANDEM EINZELN (19.09.2026) ========
GEFUNDEN VON pruef-treffchat, NICHT BEIM LESEN. Ein Gast konnte
ein Zweier-Gespraech mit DogFather aufmachen -- die Route
antwortete mit 200 und legte den Raum an.
WARUM `ohneAussen` DAS NICHT VERHINDERT HAT: Diese Huelle nimmt
die Community aus den Listen ANDERER heraus. Sie sorgt dafuer,
dass ein Creator keinen Zuschauer anschreiben kann. Die
Gegenrichtung -- ein Zuschauer, der IN die Liste schaut -- kam nie
vor, weil die Chatseite fuer 'gast' gar nicht offen war.
SEIT HEUTE IST SIE OFFEN, und damit war die Erlaubnis da. Nicht,
weil jemand sie erteilt haette, sondern weil eine Aenderung an
einer ganz anderen Stelle sie freigelegt hat. Genau die Sorte
Erlaubnis, vor der ich im Anruf-Riegel zwei Dateien weiter
ausdruecklich warne -- und hier waere ich selbst hineingelaufen.
WAS DARAN SCHLIMM IST: Ein Zweier-Gespraech ist kein Kanal. Es hat
keine Nachtruhe (die gilt fuer den Treff), kein Modi liest mit,
und niemand koennte moderieren. Aus "die Community bekommt einen
Chat" waere ungewollt "jeder Zuschauer bekommt eine Standleitung
zu DogFather" geworden.
WAS SIE STATTDESSEN HAT: den Treff -- einen Raum, in dem Modis
dabei sind -- und den vertraulichen Meldeweg fuer alles, was
niemanden sonst angeht. Beides ist besser als eine Privatleitung:
Das eine wird moderiert, das andere hat eine Zustaendigkeit. */
if (AUSSEN_ROLLEN.has(person?.rolle)) return [];
const ids = ohneAussen(nurHaus(ohneVerborgene(schreibbareIdsRoh(person), person), person));
return ids === null ? null : ids.filter((i) => i !== person?.id);
};
export const pipelineIds = (person) => ohneAussen(nurHaus(ohneVerborgene(pipelineIdsRoh(person), person), person));