ERST DIE METHODE, DANN DER FUND. An einem Tag habe ich in der Community elf Dinge gefunden, die dort nicht hingehoerten -- jedes einzelne, weil ich zufaellig hingesehen habe. Das ist keine Methode. Der Workspace ist fuer die Agentur gebaut worden, und die Community hat ihn geerbt; solche Reste findet man nicht durch Nachdenken, sondern indem man ALLE Seiten durchgeht, die ein Mitglied erreichen kann. server/pruef-community-sicht.mjs tut genau das. Sie leitet aus rechte.js ab, welche Seiten offenstehen (4) und welche nicht (27) -- von Hand aufgezaehlt waere das eine Liste, die bei der naechsten Seite niemand nachzieht --, oeffnet jede davon als Mitglied und fragt zweierlei: Fuehrt ein sichtbarer Weg auf eine verbotene Seite? Steht dort die Sprache eines Arbeitsplatzes? BEIM ERSTEN LAUF: 27 sichtbare Wege, davon ZEHN ins Leere -- und alle zehn derselbe Knopf. Der Chat steht oben rechts in der Kopfleiste, auf jeder einzelnen Seite, mit Zaehler. `chat.html` steht einem Mitglied aber nicht offen: Der Klick landet wieder auf der Startseite. Keine Meldung, kein Grund, nichts passiert -- an der Stelle, die man am ehesten drueckt. Der Knopf haengt jetzt an `darf_chat` aus /api/ich, und das kommt aus derselben Rechtetabelle wie die Schranke dahinter. Die Oberflaeche vergleicht keine Rollennamen -- das waere eine zweite Wahrheit, die bei der naechsten Rechteaenderung auseinanderlaeuft. Dieselbe Ueberlegung steht zwei Zeilen weiter oben schon einmal. UND DIE PRUEFUNG HAT GLEICH MEINEN EIGENEN FEHLER GEFUNDEN: Die erste Fassung stand in `aufbauChat`, wo es die Person gar nicht gibt -- ReferenceError auf allen zehn Seiten. Ohne den Skriptfehler-Abschnitt waere der Knopf verschwunden UND die Seite kaputt gewesen, und das haette wie ein Erfolg ausgesehen. Jetzt haengt es dort, wo die Person ankommt (`werZeigen`) -- ein vorhandener Knopf laesst sich immer entfernen, auf die Reihenfolge des Ladens zu bauen waere eine Annahme. GEGENPROBE: DogFather hat seinen Chat-Knopf weiterhin und 24 Wege auf andere Seiten. Ohne diesen Abschnitt saehe eine abgeschaffte Funktion genauso aus wie eine, die richtig entscheidet. 7 Pruefungen, 0 Fehler. Co-Authored-By: Claude Opus 5 <[email protected]>
6034 lines
287 KiB
JavaScript
6034 lines
287 KiB
JavaScript
/* =====================================================================
|
||
workspace.js — Anmeldung und Sitzungen für den Creator Workspace
|
||
(/workspace). Eigenständiger Router, nach dem Muster von gate.js und
|
||
webdesign-gate.js.
|
||
|
||
BEWUSST OHNE NEUE ABHÄNGIGKEITEN. Node 24 bringt `node:sqlite` mit, das
|
||
Hashen und die Zufallswerte kommen aus `node:crypto`. Damit gibt es
|
||
nichts zu kompilieren, nichts zu aktualisieren und keine fremde
|
||
Lieferkette in einem Bereich, in dem es um Zugangsdaten geht.
|
||
|
||
WICHTIG — dieses Modul darf die Website niemals mitreißen. Es läuft im
|
||
selben Prozess wie dogfather-universe.com. Deshalb:
|
||
* beim Laden wird KEINE Datenbank geöffnet (siehe `db()` weiter unten),
|
||
* jede Route fängt ihre Fehler selbst ab,
|
||
* schlägt die Datenbank fehl, antwortet nur /workspace/api/* mit 503 —
|
||
die restliche Seite merkt davon nichts.
|
||
|
||
Sicherheitsentscheidungen und ihre Gründe stehen jeweils direkt an der
|
||
betreffenden Stelle.
|
||
===================================================================== */
|
||
|
||
import express from "express";
|
||
import {
|
||
randomBytes, scryptSync, timingSafeEqual, createHash, 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 { sitzungPasstZurAdresse, istOhneModiAdresse, istCrewAdresse, istPruefAdresse, AUSSEN_ROLLEN,
|
||
TEAM_DOGI_ROLLEN } from "./crew-adresse.js";
|
||
/* Weitergereicht, damit die Fachmodule sie wie alles andere aus
|
||
workspace.js beziehen und nicht wissen muessen, wo sie wohnt. */
|
||
export { TEAM_DOGI_ROLLEN };
|
||
|
||
const __dirname = dirname(fileURLToPath(import.meta.url));
|
||
|
||
/* `node:sqlite` wird bewusst NICHT oben importiert, sondern erst in db()
|
||
nachgeladen. Ein fehlgeschlagener Import an dieser Stelle würde den
|
||
gesamten Website-Prozess beim Start abbrechen -- also auch
|
||
dogfather-universe.com. So bleibt der Schaden auf /workspace/api/
|
||
beschränkt. (Vorhanden ab Node 22; auf dem Server läuft Node 24.) */
|
||
const require = createRequire(pathToFileURL(__dirname + "/"));
|
||
|
||
/* Die Datenbank liegt bewusst AUSSERHALB des Repo-Ordners. Zwei Gründe:
|
||
express.static liefert den Repo-Ordner aus (eine .db darin wäre über das
|
||
Netz erreichbar), und ein `git pull` darf Nutzdaten nie anfassen. */
|
||
/* Wird ausgegeben, weil die Sicherung sowohl die Datei selbst als auch
|
||
ihr WAL nachschlagen muss. Aus DATEN_ORDNER + "workspace.db" laesst
|
||
sich das NICHT ableiten: Steht WORKSPACE_DB auf einem anderen Namen
|
||
(so laufen alle Pruefungen), zeigte die Sicherung auf eine Datei, die
|
||
es gar nicht gibt -- und meldete brav 0 Bytes. */
|
||
export const DB_PFAD = process.env.WORKSPACE_DB
|
||
|| join(__dirname, "..", "..", "workspace-daten", "workspace.db");
|
||
|
||
/* Ordner fuer hochgeladene Dateien -- neben der Datenbank, also ebenfalls
|
||
ausserhalb des Repos. Waeren sie im Repo, wuerde express.static sie
|
||
ungeschuetzt ausliefern. */
|
||
export const DATEN_ORDNER = dirname(DB_PFAD);
|
||
|
||
const COOKIE = "dfw_sitzung";
|
||
const SITZUNG_STUNDEN = 12;
|
||
const VERSUCHE_MAX = 8; // pro IP
|
||
const VERSUCHE_FENSTER_MIN = 10;
|
||
const ROLLEN = new Set(["spicy", "admin", "manager", "scout", "creator", "hand", "modi", "gast"]);
|
||
|
||
/* 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", "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. */
|
||
const CREW_KACHEL = new Set(["admin", "hand", "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 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"],
|
||
};
|
||
|
||
/** 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.
|
||
*/
|
||
export const darfRollenWechseln = (person) =>
|
||
!!person && (person.rolle === "admin" || person.rolle === "spicy");
|
||
|
||
/* WER FUEHRT TEAM DOGI? (10.09.2026)
|
||
*
|
||
* DogFather und seine rechte Hand -- und sonst niemand. Spicy Media und
|
||
* die Manager stehen bewusst NICHT darin: Sie fuehren die Agentur, nicht
|
||
* das Team.
|
||
*
|
||
* WARUM DAS HIER STEHT UND NICHT DORT, WO ES GEBRAUCHT WIRD: Es wurde
|
||
* schon zweimal gebraucht -- im Eingang (`darfEingang`) und jetzt bei
|
||
* den Kategorie-Kanaelen. Beim dritten Mal waere es dreimal
|
||
* hingeschrieben, und die dritte Abschrift ist die, die eine Rolle
|
||
* vergisst. Der Eingang ruft ab jetzt hierher.
|
||
*/
|
||
export const fuehrtTeamDogi = (person) =>
|
||
!!person && (person.rolle === "admin" || person.rolle === "hand");
|
||
|
||
/* =======================================================================
|
||
SPICY MEDIA (07.09.2026)
|
||
|
||
Wunsch Filipe: "eine neue rolle ... die den namen traegt, spicy media,
|
||
und soll die gleichen rechte haben wie dogfather ausser, bei
|
||
automationen sollen die nicht sehen und die kategorie personen und
|
||
zugaenge sollen die nur leute hinzufuegen koennen ... aber die sollen
|
||
meine privaten chats und so nicht sehen."
|
||
|
||
ZWEI FRAGEN, DIE MAN AUSEINANDERHALTEN MUSS -- und genau daran haette
|
||
man diesen Umbau kaputtmachen koennen:
|
||
|
||
WER SIEHT ALLES? -> siehtAlles() = DogFather ODER Spicy Media
|
||
WER ENTSCHEIDET? -> istDogFather() = nur DogFather
|
||
|
||
Die erste Frage stellt sich bei Listen, Uebersichten und Auswertungen:
|
||
Spicy Media soll alle Manager, Scouts und Creator sehen. Die zweite
|
||
bei allem Endgueltigen: loeschen, Rollen aendern, Codes neu setzen,
|
||
Sicherungen, die KI abschalten. Da bleibt DogFather allein.
|
||
|
||
Es waere viel weniger Arbeit gewesen, istDogFather() einfach um
|
||
"spicy" zu erweitern -- und genau das waere der Fehler: Spicy Media
|
||
koennte dann DogFather loeschen. Zwei Namen, zwei Bedeutungen, und an
|
||
jeder Stelle steht sichtbar, welche gemeint ist.
|
||
|
||
DER CHAT BRAUCHTE NICHTS. Er haengt ausschliesslich an der
|
||
Teilnehmerliste (chat_teilnehmer) und kennt kein "das Management sieht
|
||
alles". Spicy Media sieht fremde Gespraeche also nicht, weil es dafuer
|
||
gar keinen Weg gibt -- nicht, weil eine Abfrage es verbietet.
|
||
======================================================================= */
|
||
export const istSpicy = (person) => !!person && person.rolle === "spicy";
|
||
|
||
/* =======================================================================
|
||
SPICY MEDIA SIEHT ALLES -- AUSSER DEM, WAS DOGFATHER GEHOERT
|
||
(07.09.2026, Nachtrag am selben Tag)
|
||
|
||
Filipe: "wieso sieht die spicy rolle meine daten die ich habe und
|
||
speichere, meins sollen die nicht sehen von der rolle dogfather."
|
||
|
||
Beim ersten Anlauf bekam Spicy Media dieselbe Regel wie DogFather:
|
||
`1=1`. Damit stimmte der Ueberblick ueber Manager, Scouts und Creator
|
||
-- und nebenbei standen DogFathers eigene Termine, Aufgaben, Dateien
|
||
und Eintraege mit drin. Der Chat war ausgenommen (er haengt an der
|
||
Teilnehmerliste), alles andere nicht.
|
||
|
||
DIESE FUNKTION IST DIE EINE STELLE DAFUER. Sie baut die Bedingung
|
||
"gehoert keinem DogFather" fuer eine beliebige Tabelle. Jede
|
||
Sichtbarkeitsregel im Haus benutzt sie -- eine zweite, abgeschriebene
|
||
Fassung waere die Stelle, an der es beim naechsten Modul wieder
|
||
durchsickert.
|
||
|
||
ZWEI SPALTEN, NICHT EINE. `creator_id` sagt, UM WEN es geht;
|
||
`erstellt_von` sagt, WER es geschrieben hat. Beide muessen gepruegt
|
||
werden: Ein Termin, den DogFather fuer sich selbst anlegt, hat
|
||
moeglicherweise gar keine creator_id -- er haenge dann nur an
|
||
erstellt_von. Und eine Akte UEBER einen DogFather traegt seine
|
||
creator_id, auch wenn ein Manager sie geschrieben hat.
|
||
|
||
`IS NULL OR NOT IN` und nicht bloss `NOT IN`: In SQL ist
|
||
`NULL NOT IN (...)` weder wahr noch falsch, sondern NULL -- die Zeile
|
||
fiele stillschweigend heraus. Genau so verschwinden Daten, ohne dass
|
||
jemand einen Fehler sieht.
|
||
======================================================================= */
|
||
export function ohneDogFather(praefix, spalten = ["creator_id", "erstellt_von"]) {
|
||
return spalten
|
||
.map((sp) => `(${praefix}.${sp} IS NULL OR ${praefix}.${sp} NOT IN `
|
||
+ `(SELECT id FROM personen WHERE rolle = 'admin'))`)
|
||
.join(" AND ");
|
||
}
|
||
/* =====================================================================
|
||
WAS EINEM MODI GEHOERT, SIEHT SONST NIEMAND (10.09.2026)
|
||
|
||
Das Gegenstueck zu ohneDogFather() -- und es entstand aus einem
|
||
gemessenen Leck, nicht aus einer Ueberlegung.
|
||
|
||
WAS PASSIERT WAR: Fuer PERSONEN ist die Regel zentral
|
||
(verborgeneIds). Fuer ZEILEN gibt es sie dreimal -- in
|
||
workspace-aufgaben.js, workspace-bereiche.js und
|
||
workspace-dateien.js -- und alle drei geben Spicy Media dasselbe:
|
||
"alles ausser dem, was DogFather gehoert". Ein Modi-Eintrag gehoert
|
||
ihm aber nicht. Also sah Spicy Media die Aufgaben, die Eintraege und
|
||
die Dateien der Modis, waehrend die Modis selbst in jeder Namensliste
|
||
sauber verborgen waren. Die halbe Verborgenheit ist keine.
|
||
|
||
Aufgefallen ist es nicht beim Lesen des Codes, sondern durch eine
|
||
Pruefung, die etwas ANLEGT und danach mit fremden Augen nachsieht.
|
||
Manager, Scout und Creator waren nie betroffen -- ihre Regeln sind
|
||
ohnehin enger. Der Kalender auch nicht: termineSichtbar() gibt jedem
|
||
nur Eigenes. Nachgemessen, nicht angenommen.
|
||
|
||
SPALTEN MUESSEN BEIDE RICHTUNGEN ABDECKEN. `creator_id` sagt, UM WEN
|
||
es geht; `erstellt_von`/`hochgeladen_von`/`verantwortlich_id` sagen,
|
||
an WEM es haengt. Eine Modi-Aufgabe hat gar keine creator_id -- sie
|
||
haengt allein an verantwortlich_id. Wer nur eine Spalte prueft, hat
|
||
nichts geprueft.
|
||
|
||
`IS NULL OR NOT IN` und nicht bloss `NOT IN`: In SQL ist
|
||
`NULL NOT IN (...)` weder wahr noch falsch, sondern NULL -- die Zeile
|
||
fiele stillschweigend heraus. Genau so verschwinden Daten, ohne dass
|
||
jemand einen Fehler sieht. (Dieselbe Falle steht schon im Kommentar
|
||
von ohneDogFather; sie ist es wert, zweimal dazustehen.)
|
||
===================================================================== */
|
||
/* 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",
|
||
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, Review", zeichen: "aufgaben",
|
||
ton: 13, gross: true, ziel: "aufgaben.html", szene: "garage" },
|
||
{ gruppe: "Täglich", gruppeUnter: "Was du sowieso jeden Tag aufmachst",
|
||
name: "Chat", unter: "Nachrichten mit dem Team", zeichen: "chat",
|
||
ton: 4, ziel: "chat.html", szene: "lounge" },
|
||
{ gruppe: "Täglich", gruppeUnter: "Was du sowieso jeden Tag aufmachst",
|
||
name: "Kalender", unter: "Live-Zeiten und Termine", zeichen: "kalender",
|
||
ton: 8, ziel: "kalender.html", szene: "skyline" },
|
||
{ gruppe: "Täglich", gruppeUnter: "Was du sowieso jeden Tag aufmachst",
|
||
name: "Dateien", unter: "Ablage und Freigaben", zeichen: "dateien",
|
||
ton: 19, ziel: "dateien.html", szene: "arena" },
|
||
|
||
{ gruppe: "Täglich", gruppeUnter: "Was du sowieso jeden Tag aufmachst",
|
||
name: "Ideen-Board", unter: "Gesammelt und nach Dringlichkeit sortiert",
|
||
zeichen: "content", ton: 23, ziel: "bereich.html?b=ideen", szene: "portal" },
|
||
{ gruppe: "Täglich", gruppeUnter: "Was du sowieso jeden Tag aufmachst",
|
||
name: "Angebote", unter: "Vorschläge, über die DogFather entscheidet",
|
||
zeichen: "agentur", ton: 17, ziel: "bereich.html?b=angebote", szene: "halle" },
|
||
|
||
/* 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",
|
||
};
|
||
|
||
const ENTWICKLUNG_KACHEL = {
|
||
...FUEHRUNG_GRUPPE,
|
||
name: "Entwicklung", unter: "Was gut läuft, woran ihr arbeitet – je Person",
|
||
/* 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",
|
||
};
|
||
|
||
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 sieht 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, 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 = [
|
||
{ ...TREFF_GRUPPE, name: "Der Treff", unter: "Hallo sagen, fragen, loben",
|
||
zeichen: "community", ton: 34, gross: true, ziel: "bereich.html?b=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" },
|
||
{ ...TREFF_GRUPPE, name: "Regeln & Hilfe", unter: "Wie es hier l\u00e4uft",
|
||
zeichen: "schutz", ton: 28, ziel: "bereich.html?b=regeln", szene: "studio" },
|
||
{ ...TREFF_GRUPPE, name: "Mitmachen", unter: "Wie du dazugeh\u00f6ren kannst",
|
||
zeichen: "personen", ton: 32, ziel: "bereich.html?b=mitmachen", szene: "garage" },
|
||
];
|
||
|
||
/* 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. */
|
||
const TREFF_MODERATION_KACHEL = {
|
||
...TREFF_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 = TREFF_BEREICHE
|
||
.map((k) => new URL("http://x/" + k.ziel).searchParams.get("b"))
|
||
.filter(Boolean);
|
||
|
||
/* 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", "admin"]);
|
||
|
||
/* 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. */
|
||
const MODI_MIT_TREFF = [...MODI_BEREICHE, BEFINDEN_KACHEL, ...TREFF_BEREICHE,
|
||
TREFF_MODERATION_KACHEL];
|
||
const HAND_MIT_TREFF = [...HAND_BEREICHE, ...TREFF_BEREICHE, TREFF_MODERATION_KACHEL];
|
||
|
||
/* 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 };
|
||
|
||
/* =====================================================================
|
||
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) => {
|
||
const ziel = String(k?.ziel || "");
|
||
if (!ziel) return true;
|
||
const pfad = "/workspace/" + ziel.split("?")[0];
|
||
return darfSeite(person, pfad);
|
||
});
|
||
}
|
||
|
||
export function bereicheFuer(person) {
|
||
if (person?.rolle === "modi") return MODI_MIT_TREFF;
|
||
if (person?.rolle === "hand") return HAND_MIT_TREFF;
|
||
/* DIE COMMUNITY SIEHT DEN TREFF UND SONST NICHTS (11.09.2026). */
|
||
if (person?.rolle === "gast") return TREFF_BEREICHE;
|
||
/* 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. */
|
||
const BRETTER_UEBER_SEITEN = [
|
||
/* Beide sind von entwicklung.html bzw. talente.html aus verlinkt --
|
||
das Notizbrett neben dem Katalog. Wer die Seite oeffnen darf,
|
||
muss auch dorthin kommen. */
|
||
"entwicklung",
|
||
"talente",
|
||
];
|
||
|
||
export const MODI_BEREICHE_ERLAUBT = new Set([
|
||
...HAND_MIT_TREFF
|
||
.map((k) => (/bereich\.html\?b=([a-z]+)/.exec(k.ziel) || [])[1])
|
||
.filter(Boolean),
|
||
...BRETTER_UEBER_SEITEN,
|
||
]);
|
||
|
||
/* 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.
|
||
===================================================================== */
|
||
export const KANAELE = [
|
||
{ wert: "dogfather", name: "DogFather", handle: "@dogfather0804",
|
||
text: "Der Hauptkanal. Die Livestreams und alles, was unmittelbar "
|
||
+ "daran hängt." },
|
||
{ wert: "hasidog", name: "HasiDog", handle: "@hasidog00804",
|
||
text: "Der zweite Account — eigene Zuschauer, eigener Ton. Was hier "
|
||
+ "läuft, ist nicht einfach dasselbe noch einmal." },
|
||
{ wert: "clips", name: "DogFather Clips", handle: "@dogis.modi.gang",
|
||
text: "Nur Ausschnitte. Hier wird geschnitten, beschriftet und "
|
||
+ "hochgeladen, nicht gesendet." },
|
||
];
|
||
|
||
/** 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" && person.rolle !== "hand") return [];
|
||
try {
|
||
/* Auch die rechte Hand selbst: Sie traegt Aufgaben ein -- fuer
|
||
andere UND fuer sich. Waere ihre eigene Nummer nicht dabei,
|
||
verschwaende das Kategoriefeld genau dann, wenn sie sich selbst
|
||
etwas notiert. */
|
||
const liste = [...TEAM_DOGI_ROLLEN].map((r) => `'${r}'`).join(", ");
|
||
return db().prepare(
|
||
`SELECT id FROM personen WHERE rolle IN (${liste}) AND aktiv = 1`).all().map((z) => z.id);
|
||
} catch {
|
||
/* Ohne Datenbank lieber kein Feld als ein falsches. */
|
||
return [];
|
||
}
|
||
}
|
||
|
||
/* Der Satz der rechten Hand sagt, was ihre Rolle ist, ohne sie ueber
|
||
die anderen zu stellen: Sie haelt den Ueberblick, sie steht nicht
|
||
darueber. */
|
||
const HAND_ROLLENTEXT = "Rechte Hand · du hast alles im Blick und entscheidest mit";
|
||
|
||
export function rollentextFuer(person) {
|
||
if (person?.rolle === "modi") return MODI_ROLLENTEXT;
|
||
if (person?.rolle === "hand") return HAND_ROLLENTEXT;
|
||
return null;
|
||
}
|
||
|
||
/* DIE MARKE HAENGT AN ZWEI DINGEN, NICHT AN EINEM (10.09.2026).
|
||
*
|
||
* An der ROLLE: Wer zu Team Dogi gehoert, sieht ueberall den Namen
|
||
* seines Teams -- auch dann, wenn er die Seite ueber eine andere
|
||
* Adresse aufmacht.
|
||
*
|
||
* Und an der ADRESSE: Auf crew.dogfather-universe.com gibt es Spicy
|
||
* Media nicht. Dort stand ueber der Zentrale trotzdem "SPICY MEDIA" --
|
||
* gross, mitten im Bild, weil diese Funktion nur die Rolle kannte und
|
||
* DogFather nun einmal zur Agentur gehoert. Filipe: "ich will doch
|
||
* dass der eingang hier getrennt ist von der workspace seite."
|
||
*
|
||
* Zwei Fragen, EINE Antwortstelle. Wer die Adressbedingung stattdessen
|
||
* an der Aufrufstelle prueft, hat sie beim naechsten Aufruf nicht --
|
||
* und diese Funktion wird ueber kurz oder lang ein zweites Mal
|
||
* gebraucht. */
|
||
export function markeFuer(person, hostKopfzeile) {
|
||
if (istCrewAdresse(hostKopfzeile)) return MODI_MARKE;
|
||
return TEAM_DOGI_ROLLEN.has(person?.rolle) ? MODI_MARKE : null;
|
||
}
|
||
|
||
/* =====================================================================
|
||
KACHELN, DIE ZU EINER VORHANDENEN LISTE DAZUKOMMEN (10.09.2026)
|
||
|
||
bereicheFuer() ersetzt die Liste im Browser ganz -- das passt fuer
|
||
eine Rolle, die dort gar nicht vorkommt. Fuer die DogFather-Rolle
|
||
passt es nicht: Sie hat ihre 23 Kacheln, und es soll EINE dazu.
|
||
|
||
Sie steht hier und nicht in assets/js/bereiche.js, weil dort jeder
|
||
mitliest. "Team-Lage" allein verraet zwar nichts -- aber eine
|
||
Kachel, die nur bei einer einzigen Rolle erscheint, wirft die Frage
|
||
auf, fuer wen sie ist. Genau die soll niemand stellen.
|
||
===================================================================== */
|
||
/* ZWEIMAL "TEAM-LAGE" -- KORRIGIERT AM 10.09.2026.
|
||
|
||
Hier stand: Gruppe "Rund um das Team", Name "Team-Lage", Ton 22.
|
||
In bereiche.js gibt es aber SCHON eine Kachel "Team-Lage" mit Ton 22
|
||
und demselben Zeichen, die nach team.html fuehrt. DogFather hatte
|
||
damit zwei Kacheln, die gleich heissen, gleich aussehen, gleich
|
||
gefaerbt sind, nebeneinander in derselben Gruppe stehen -- und zu
|
||
verschiedenen Seiten fuehren.
|
||
|
||
Gefunden hat das nicht das Auge, sondern pruef-start-ansicht mit dem
|
||
Satz "jede Kachel hat ihre eigene Farbe (23 Farben auf 24 Kacheln)".
|
||
Ohne diese Zeile waere es stehen geblieben; zwei gleiche Kacheln
|
||
fallen niemandem auf, der weiss, welche er meint.
|
||
|
||
Jetzt drei Unterschiede statt keinem: eigener Name, eigene Gruppe,
|
||
eigene Farbe. Das Zeichen bleibt geteilt -- nachgemessen sind alle
|
||
22 Zeichen des Hauses bei DogFather bereits in Gebrauch, und ein
|
||
geteiltes Zeichen bei verschiedenem Namen, Ort und Ton ist keine
|
||
Verwechslungsgefahr (profil steht ohnehin schon zweimal).
|
||
|
||
"Eingang" und nicht "Rueckmeldungen": Die Kachel steht bei den
|
||
taeglichen Dingen, weil dort etwas WARTET -- Vorschlaege, ueber die
|
||
entschieden werden will. Das ist eine Aufgabe fuer heute, keine
|
||
Uebersicht fuer zwischendurch. */
|
||
/* =====================================================================
|
||
DIE EIGENE GRUPPE FUER DAS TEAM (10.09.2026)
|
||
|
||
Filipe zum Bildschirmfoto seiner Startseite: "ich will dass die
|
||
kacheln von den modis bei der rolle dogfather eine eigene kategorie
|
||
haben. damit ich nicht zwischen den kacheln suchen muss."
|
||
|
||
Er hatte recht, und es war schlimmer als unuebersichtlich: Der
|
||
Eingang stand unter "Taeglich" zwischen Chat und Kalender, das
|
||
Ideen-Board ebenfalls -- zwei Kacheln derselben Welt, verteilt auf
|
||
eine Reihe mit sieben anderen. Wer sie sucht, liest jedes Mal alle.
|
||
|
||
`gruppeNach` setzt die Gruppe direkt hinter "Taeglich" statt ans
|
||
Ende. Ganz unten waere zwar auch eine eigene Kategorie -- und immer
|
||
noch Scrollen.
|
||
|
||
DAS IDEEN-BOARD IST DABEI VON assets/js/bereiche.js HIERHER GEZOGEN.
|
||
Zwei Gruende, und der zweite ist der wichtigere:
|
||
1. Es gehoert in dieselbe Gruppe wie der Eingang, und die entsteht
|
||
hier.
|
||
2. Es stand mit `rollen: ['admin']` in einer Datei, die JEDER
|
||
herunterlaedt. Eine Kachel, die nur eine Rolle sieht, wirft dort
|
||
die Frage auf, wofuer sie ist -- genau die Frage, die niemand
|
||
stellen soll. Jetzt kommt sie vom Server und nur an die, die sie
|
||
bekommt.
|
||
|
||
Der Gruppenname "Team Dogi" ist unverfaenglich: Er steht so auf der
|
||
oeffentlichen Seite. */
|
||
/* 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",
|
||
};
|
||
|
||
const ZUSATZ_BEREICHE = {
|
||
admin: [
|
||
{ ...TEAM_GRUPPE, ...EINGANG_KACHEL, gruppe: TEAM_GRUPPE.gruppe,
|
||
gruppeUnter: TEAM_GRUPPE.gruppeUnter },
|
||
{
|
||
...TEAM_GRUPPE,
|
||
name: "Ideen-Board", unter: "Gesammelt und nach Dringlichkeit sortiert",
|
||
zeichen: "content", ton: 23, ziel: "bereich.html?b=ideen", szene: "portal",
|
||
},
|
||
/* DIESELBE Kachel wie im Team, nur in seiner Gruppe. Name, Ton und
|
||
Ziel sind Wort fuer Wort dieselben -- wer sie hier anders nennt,
|
||
hat zwei Namen fuer eine Sache, und das Team liest den einen,
|
||
DogFather den anderen. */
|
||
{
|
||
...TEAM_GRUPPE,
|
||
name: "Rückmeldung", unter: "Was gut läuft, was hakt – in beide Richtungen",
|
||
zeichen: "reports", ton: 25, ziel: "bereich.html?b=rueckmeldung", szene: "lounge",
|
||
},
|
||
/* Eine eigene Gruppe, direkt hinter Team Dogi: Was ueber Menschen
|
||
aufgeschrieben wird, steht nicht zwischen den taeglichen
|
||
Werkzeugen. */
|
||
/* WER SIEHT WAS (11.09.2026).
|
||
|
||
Filipe: "egal welche Rolle oder Person hinzugefuegt wird, soll
|
||
immer nur das sehen, was ich erlaube."
|
||
|
||
Dazu gehoert, dass er es nachsehen kann. Die Seite rechnet die
|
||
Uebersicht aus derselben Tafel, aus der die Schranke ihre
|
||
Entscheidung holt (rechte.js) -- eine von Hand gepflegte
|
||
Uebersicht waere eine zweite Wahrheit, und ausgerechnet die, der
|
||
man glaubt.
|
||
|
||
IN "Team & System" UND NICHT BEI DEN PERSONEN: Es geht nicht um
|
||
Menschen, sondern um das Haus. */
|
||
{
|
||
...TEAM_GRUPPE, ...RECHTE_KACHEL,
|
||
gruppe: TEAM_GRUPPE.gruppe, gruppeUnter: TEAM_GRUPPE.gruppeUnter,
|
||
},
|
||
{ ...ENTWICKLUNG_KACHEL, gruppeNach: TEAM_GRUPPE.gruppe },
|
||
{ ...TALENTE_KACHEL, gruppeNach: TEAM_GRUPPE.gruppe },
|
||
/* DER TREFF -- GANZ UNTEN, UND ZWAR ABSICHTLICH.
|
||
|
||
Filipe zu den beiden Gruppen darueber: "das sind private
|
||
informationen von meiner community." Dasselbe gilt hier, nur
|
||
noch deutlicher: Im Treff stehen Namen und Saetze von Leuten,
|
||
die nichts mit der Agentur zu tun haben.
|
||
|
||
Ohne `gruppeNach` haengt eine Gruppe hinten an -- genau richtig.
|
||
Wer sich den Bildschirm teilt oder jemandem ueber die Schulter
|
||
schauen laesst, hat sie dann nicht als Erstes im Blick. */
|
||
...TREFF_BEREICHE,
|
||
TREFF_MODERATION_KACHEL,
|
||
],
|
||
};
|
||
|
||
/* NICHT AUF DER TEAM-ADRESSE (10.09.2026). Dort liefert bereicheFuer()
|
||
ohnehin schon die Kacheln des Teams -- Eingang, Ideen-Board und
|
||
Rueckmeldung stuenden sonst zweimal auf der Seite, einmal in
|
||
"Taeglich" und einmal in "Team Dogi". Zwei gleiche Kacheln sind
|
||
schlimmer als eine fehlende: Man klickt die falsche und sucht den
|
||
Unterschied. */
|
||
export function zusatzBereicheFuer(person) {
|
||
if (person?.haus === "crew") return [];
|
||
return ZUSATZ_BEREICHE[person?.rolle] || [];
|
||
}
|
||
|
||
/* scrypt-Parameter. N=2^15 braucht auf diesem Server rund 150 ms — spürbar
|
||
genug, um Rateversuche auszubremsen, und unauffällig für einen echten
|
||
Anmeldevorgang. Die Werte werden je Datensatz mitgespeichert, damit sie
|
||
später erhöht werden können, ohne alte Codes ungültig zu machen. */
|
||
const SCRYPT = { N: 32768, r: 8, p: 1, keylen: 64 };
|
||
|
||
let _db = null;
|
||
let _dbFehler = null;
|
||
|
||
/* =====================================================================
|
||
Umstellungen an bestehenden Datenbanken.
|
||
|
||
CREATE TABLE IF NOT EXISTS legt eine Tabelle nur an, wenn sie fehlt --
|
||
eine vorhandene wird NICHT angefasst. Die CHECK-Regel fuer die Rolle
|
||
stand also weiterhin auf den alten drei Werten, und ein Manager waere
|
||
an der Datenbank gescheitert, obwohl im Code alles stimmt.
|
||
|
||
SQLite kann eine CHECK-Regel nicht aendern. Der einzige saubere Weg
|
||
ist: neue Tabelle, Daten hinueber, alte weg, neue umbenennen. Das ist
|
||
der Moment, in dem eine Datenbank kaputtgehen kann -- deshalb wird
|
||
vorher eine vollstaendige Sicherung geschrieben (VACUUM INTO, von
|
||
SQLite selbst und in sich konsistent, anders als ein Dateikopie
|
||
waehrend laufender Schreibvorgaenge).
|
||
===================================================================== */
|
||
/* =====================================================================
|
||
DIE ROLLENLISTE DER DATENBANK ERWEITERN (09.09.2026)
|
||
|
||
Welche Rollen es geben darf, steht als CHECK-Regel in der Tabelle
|
||
`personen`. SQLite kann eine CHECK-Regel nicht aendern -- der einzige
|
||
saubere Weg ist: neue Tabelle, Daten hinueber, alte weg, neue
|
||
umbenennen. Das ist der Moment, in dem eine Datenbank kaputtgehen
|
||
kann. Deshalb steht der Ablauf ab jetzt EINMAL hier.
|
||
|
||
WARUM ALS FUNKTION: Er stand vorher zweimal da -- einmal fuer
|
||
'manager', einmal fuer 'spicy'. Fuer 'modi' waere es die dritte
|
||
Abschrift geworden, und jede Abschrift ist eine Gelegenheit, eine der
|
||
vier Absicherungen zu vergessen: die Sicherung VORHER, die Zaehlung
|
||
INNERHALB der Transaktion, die Spaltenliste aus der Tabelle statt aus
|
||
dem Gedaechtnis, die Pruefung auf verwaiste Verweise DANACH. Der
|
||
manager-Block darueber bleibt unangetastet: Er baut die Tabelle mit
|
||
einer fest hingeschriebenen Spaltenliste auf und ist damit ein
|
||
anderer Fall -- ihn mit einzufangen waere ein zweiter Umbau an einer
|
||
Stelle, an der ein Fehler Daten kostet.
|
||
|
||
GEPRUEFT WIRD DIE REGEL, NICHT DER TEXT (07.09.2026, gefunden von
|
||
pruef-spicy): SQLite hebt den CREATE-Text woertlich auf, mitsamt
|
||
Kommentaren. Ein erklaerender Satz mit dem Wort 'spicy' genuegte, und
|
||
die Umstellung hielt die Tabelle fuer schon umgestellt, obwohl die
|
||
CHECK-Regel noch die alte war. Deshalb wird die Regel herausgeschnitten
|
||
und NUR darin gesucht.
|
||
|
||
@param marker Die Rolle, an der erkannt wird, ob schon umgestellt ist.
|
||
@param rollen Die vollstaendige neue Liste erlaubter Rollen.
|
||
===================================================================== */
|
||
function checkListeErweitern(d, tabelle, spalte, marker, werte, jetztStempel) {
|
||
const rollenPlan = d.prepare(
|
||
"SELECT sql FROM sqlite_master WHERE type = 'table' AND name = ?").get(tabelle)?.sql || "";
|
||
/* DOPPELTE BACKSLASHES, und das ist kein Schoenheitsfehler:
|
||
In einem Template-Literal verschluckt JavaScript den einfachen
|
||
Backslash -- aus `\s` wird schlicht `s`, das Muster hiesse dann
|
||
"bereichs+INs*(...)" und traefe nie etwas. Die Umstellung haette
|
||
daraufhin STILLSCHWEIGEND nichts getan: keine Regel gefunden,
|
||
also nichts umzustellen, kein Fehler, kein Hinweis. Gemessen
|
||
statt ueberlegt -- der Unterschied war im Quelltext nicht zu
|
||
sehen. */
|
||
const muster = new RegExp(`${spalte}\\s+IN\\s*\\(([^)]*)\\)`, "i");
|
||
const regel = rollenPlan.match(muster)?.[1] || "";
|
||
/* Keine Regel gefunden heisst: Es gibt nichts umzustellen. Nicht
|
||
etwa "dann bauen wir sie neu" -- eine Tabelle ohne CHECK ist ein
|
||
Zustand, den jemand ansehen sollte, kein Fall fuer Automatik. */
|
||
if (!rollenPlan || !regel || regel.includes(`'${marker}'`)) return;
|
||
|
||
const sicherung = `${DB_PFAD}.vor-${marker}-${jetztStempel}`;
|
||
try {
|
||
d.exec(`VACUUM INTO '${sicherung.replace(/'/g, "''")}'`);
|
||
console.log("[workspace] Sicherung vor der Umstellung:", sicherung);
|
||
} catch (fehler) {
|
||
/* Ohne Sicherung wird NICHT umgestellt. Lieber laeuft die neue Rolle
|
||
noch nicht, als dass Daten ohne Netz angefasst werden. */
|
||
console.error("[workspace] Sicherung fehlgeschlagen, Umstellung abgebrochen:", fehler?.message);
|
||
return;
|
||
}
|
||
|
||
/* Nur die Rollenliste ersetzen -- und pruefen, dass es geklappt hat.
|
||
|
||
`IF NOT EXISTS` MUSS MIT INS MUSTER (07.09.2026, gefunden von
|
||
pruef-spicy). Die Tabelle wurde mit `CREATE TABLE IF NOT EXISTS
|
||
personen` angelegt, also steht das auch in sqlite_master. Ohne diese
|
||
drei Woerter im Muster griff die Ersetzung nicht, der Bauplan blieb
|
||
unveraendert, die Schutzabfrage darunter schlug an -- und die
|
||
Umstellung waere auf dem echten Server NIE gelaufen. Sie haette es
|
||
gesagt (das ist der Wert der Abfrage), aber sie waere nie gelaufen. */
|
||
/* KEIN MUSTER FUER DEN TABELLENKOPF -- alles vor der ersten Klammer
|
||
IST der Kopf ("CREATE TABLE IF NOT EXISTS eintraege "). Hier stand
|
||
eine Regel mit Anfuehrungszeichen, Backticks und Backslashes in
|
||
einem Template-Literal, und genau daran ist sie gescheitert:
|
||
JavaScript verschluckt darin den einfachen Backslash, aus dem
|
||
Muster wurde "CREATE TABLEs+..." und es traf nie etwas. Die
|
||
Umstellung haette daraufhin STILLSCHWEIGEND nichts getan.
|
||
|
||
Ein Schnitt an der ersten Klammer braucht keine Maskierung, ist in
|
||
einer Zeile zu lesen und kann nicht falsch escaped sein. */
|
||
const klammer = rollenPlan.indexOf('(');
|
||
if (klammer < 0) {
|
||
console.error('[workspace] Umstellung abgebrochen: Bauplan ohne Klammer.');
|
||
return;
|
||
}
|
||
const neuerPlan = (`CREATE TABLE ${tabelle}_neu ` + rollenPlan.slice(klammer))
|
||
.replace(new RegExp(`${spalte}\\s+IN\\s*\\([^)]*\\)`, "i"),
|
||
`${spalte} IN (${werte.map((r) => `'${r}'`).join(",")})`);
|
||
if (!neuerPlan.includes(`'${marker}'`) || !neuerPlan.includes(`${tabelle}_neu`)) {
|
||
console.error("[workspace] Umstellung abgebrochen: Der Bauplan liess sich nicht "
|
||
+ "umschreiben. Steht die CHECK-Regel noch so da wie erwartet?");
|
||
return;
|
||
}
|
||
|
||
/* Die Spaltenliste kommt aus der Tabelle, nicht aus dem Gedaechtnis. */
|
||
const spalten = d.prepare(`PRAGMA table_info(${tabelle})`).all().map((z) => z.name);
|
||
if (!spalten.length) {
|
||
console.error("[workspace] Umstellung abgebrochen: keine Spalten gefunden.");
|
||
return;
|
||
}
|
||
const liste = spalten.map((n) => `"${n}"`).join(", ");
|
||
|
||
/* DIE INDIZES GEHEN MIT -- SONST GEHEN SIE VERLOREN (10.09.2026).
|
||
|
||
`DROP TABLE` nimmt jeden Index der Tabelle mit. Der Bauplan aus
|
||
sqlite_master beschreibt nur die TABELLE, nicht ihre Indizes; die
|
||
neue stand danach blank da.
|
||
|
||
Aufgefallen ist das nicht im Betrieb, sondern beim Nachdenken
|
||
darueber, was diese Auslieferung auf dem echten Server tut: Dort
|
||
wird `chat_raeume` umgebaut, und `idx_chat_raeume_letzte` waere
|
||
danach weg gewesen. Gemerkt haette es niemand -- die Abfrage laeuft
|
||
weiter, sie liest nur die ganze Tabelle. Beim naechsten Neustart
|
||
stuende der Index wieder da (er steht oben im Bauplan), aber "bis
|
||
zum naechsten Neustart falsch" ist kein Zustand, den man einbaut.
|
||
|
||
Bei einem EINDEUTIGEN Index waere es keine Frage der
|
||
Geschwindigkeit mehr, sondern der Richtigkeit: Die Zusage "einen
|
||
Kanal je Zustaendigkeit" haette bis zum Neustart still ausgesetzt.
|
||
|
||
`sql IS NULL` filtert die Indizes weg, die SQLite sich selbst zu
|
||
einer UNIQUE-Spalte baut: Die entstehen mit der neuen Tabelle von
|
||
allein, und ihr Name (`sqlite_autoindex_...`) ist gar nicht
|
||
anlegbar. */
|
||
const indizes = d.prepare(
|
||
"SELECT sql FROM sqlite_master WHERE type = 'index' AND tbl_name = ? AND sql IS NOT NULL")
|
||
.all(tabelle).map((z) => z.sql);
|
||
|
||
/* Fremdschluessel muessen aus sein, weil andere Tabellen auf
|
||
personen(id) zeigen -- und das laesst sich nicht innerhalb einer
|
||
Transaktion umschalten. */
|
||
d.exec("PRAGMA foreign_keys = OFF");
|
||
try {
|
||
const vorher = d.prepare(`SELECT COUNT(*) AS n FROM ${tabelle}`).get().n;
|
||
d.exec("BEGIN");
|
||
d.exec(neuerPlan);
|
||
d.exec(`INSERT INTO ${tabelle}_neu (${liste}) SELECT ${liste} FROM ${tabelle};`);
|
||
const nachher = d.prepare(`SELECT COUNT(*) AS n FROM ${tabelle}_neu`).get().n;
|
||
/* Die Zaehlung steht INNERHALB der Transaktion -- stimmt sie nicht,
|
||
wird zurueckgerollt und die alte Tabelle bleibt unberuehrt. */
|
||
if (nachher !== vorher) {
|
||
d.exec("ROLLBACK");
|
||
console.error(`[workspace] Umstellung abgebrochen: ${vorher} Zeilen vorher, `
|
||
+ `${nachher} nachher. Sicherung: ${sicherung}`);
|
||
} else {
|
||
d.exec(`DROP TABLE ${tabelle};`);
|
||
d.exec(`ALTER TABLE ${tabelle}_neu RENAME TO ${tabelle};`);
|
||
d.exec("COMMIT");
|
||
/* ERST NACH DEM COMMIT. Waere zurueckgerollt worden, stuenden die
|
||
alten Indizes noch -- ein zweites Anlegen waere dann ein Fehler
|
||
ohne Anlass. */
|
||
let indizesZurueck = 0;
|
||
for (const bau of indizes) {
|
||
try { d.exec(bau); indizesZurueck++; }
|
||
catch (f) { console.error("[workspace] Index nach der Umstellung:", f?.message); }
|
||
}
|
||
if (indizesZurueck !== indizes.length) {
|
||
console.error(`[workspace] ACHTUNG: nur ${indizesZurueck} von ${indizes.length} `
|
||
+ `Indizes auf ${tabelle} wiederhergestellt. Sicherung: ${sicherung}`);
|
||
}
|
||
const kaputt = d.prepare("PRAGMA foreign_key_check").all();
|
||
if (kaputt.length) {
|
||
console.error("[workspace] ACHTUNG: nach der Umstellung", kaputt.length,
|
||
"verwaiste Verweise. Sicherung liegt unter", sicherung);
|
||
} else {
|
||
console.log(`[workspace] '${marker}' in ${tabelle}.${spalte} freigeschaltet, `
|
||
+ `${nachher} Zeilen, ${spalten.length} Spalten, ${indizesZurueck} von `
|
||
+ `${indizes.length} Indizes uebernommen, Verweise geprueft.`);
|
||
}
|
||
}
|
||
} catch (fehler) {
|
||
try { d.exec("ROLLBACK"); } catch { /* schon zurueckgerollt */ }
|
||
console.error("[workspace] Umstellung fehlgeschlagen:", fehler?.message,
|
||
"-- Sicherung:", sicherung);
|
||
} finally {
|
||
d.exec("PRAGMA foreign_keys = ON");
|
||
}
|
||
}
|
||
|
||
function umstellungen(d) {
|
||
/* MIT SEKUNDEN, nicht nur mit Minuten (07.09.2026).
|
||
|
||
`VACUUM INTO` weigert sich, eine vorhandene Datei zu ueberschreiben
|
||
("output file already exists") -- zu Recht, eine Sicherung darf
|
||
nichts wegwerfen. Der Stempel hatte aber nur Minutenaufloesung:
|
||
Zwei Umstellungen innerhalb derselben Minute wollten in dieselbe
|
||
Datei, die zweite scheiterte, und weil ohne Sicherung nicht
|
||
umgestellt wird, blieb sie einfach aus.
|
||
|
||
Das ist genau die unangenehme Sorte Fehler: Es sieht nach "die
|
||
Umstellung lief" aus (die Datei liegt ja da) und war doch keine.
|
||
Gefunden von pruef-spicy, das den Server zweimal in derselben
|
||
Minute startet -- im Betrieb genuegt dafuer ein Neustart im
|
||
falschen Moment. */
|
||
const jetztStempel = new Date().toISOString().slice(0, 19).replace(/[-:T]/g, "");
|
||
|
||
/* ---- 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"],
|
||
|
||
/* ---- Wiederkehrende Termine (02.09.2026) ----
|
||
Drei Spalten an `termine`, damit eine Ausprägung weiss, woher sie
|
||
stammt:
|
||
serie_id die Regel, aus der sie entstanden ist
|
||
serie_tag der geplante Tag -- daran erkennt der Nachfüller,
|
||
dass es diese Ausprägung schon gibt, und legt sie
|
||
kein zweites Mal an
|
||
serie_beruehrt jemand hat sie von Hand angefasst (verschoben,
|
||
umbenannt, abgehakt). Solche Termine bleiben beim
|
||
Abstellen der Serie stehen -- was jemand
|
||
bearbeitet hat, wird ihm nicht weggeräumt. */
|
||
["termine", "serie_id", "INTEGER REFERENCES termin_serien(id) ON DELETE SET NULL"],
|
||
["termine", "serie_tag", "TEXT"],
|
||
["termine", "serie_beruehrt", "INTEGER NOT NULL DEFAULT 0"],
|
||
|
||
/* ---- Frei eingetragene Namen (02.09.2026) ----
|
||
Wunsch: "ich will da auch sachen selber noch eintragen können,
|
||
also aussuchen und selbst eintragen."
|
||
|
||
Bis hierher konnte an einem Eintrag nur stehen, wer auch ein Konto
|
||
im Workspace hat. Eine Agentur, eine Marke, ein Gast liess sich
|
||
nicht eintragen -- man haette dafuer ein Konto anlegen muessen,
|
||
das nie jemand benutzt.
|
||
|
||
Eine EIGENE Spalte je Feld, kein Missbrauch der id-Spalte: Ein
|
||
Fremdschluessel zeigt auf eine Person oder auf nichts. Einen
|
||
Namen dort hineinzuschreiben haette jede Verknuepfung zerstoert.
|
||
|
||
Es gilt entweder/oder: Steht eine Person drin, ist das Feld leer,
|
||
und umgekehrt. Durchgesetzt wird das beim Speichern (siehe
|
||
externPruefen), nicht durch eine CHECK-Regel -- die liesse sich
|
||
an einer bestehenden Tabelle nicht mehr nachtragen.
|
||
|
||
WICHTIG, damit nichts still verschwindet: Ueberall, wo bisher der
|
||
Name der verknuepften Person gelesen wurde, faellt die Abfrage
|
||
jetzt auf diesen Text zurueck (siehe externSql). Ein frei
|
||
eingetragener Name steht dadurch in jeder Liste, jeder Suche und
|
||
jeder Uebersicht mit da -- gekennzeichnet als "(extern)", damit
|
||
niemand ihn fuer ein Konto haelt. */
|
||
["termine", "teilnehmer_extern", "TEXT"],
|
||
["termin_serien", "teilnehmer_extern", "TEXT"],
|
||
["aufgaben", "creator_extern", "TEXT"],
|
||
["aufgaben", "verantwortlich_extern", "TEXT"],
|
||
["eintraege", "creator_extern", "TEXT"],
|
||
["dateien", "creator_extern", "TEXT"],
|
||
|
||
/* ABBRECHEN (05.09.2026). Wunsch: *"ich will dass man in jedem
|
||
status die aufgaben auch abbrechen kann."*
|
||
|
||
Drei Spalten, weil "abgebrochen" allein zu wenig sagt:
|
||
abbruch_grund WARUM. Pflichtfeld in der Oberflaeche -- ohne
|
||
Grund weiss in vier Wochen niemand mehr, warum
|
||
etwas wegfiel, und es sieht aus wie Loeschen.
|
||
abgebrochen_am WANN.
|
||
abbruch_von WER. Nur Leitung und Scouts duerfen es, und
|
||
wer es war, gehoert dazu.
|
||
status_vorher IN WELCHEM ZUSTAND. Diese Spalte ist der Grund,
|
||
warum "abgebrochen" ein eigener Status wurde
|
||
und nicht bloss ein Haken: Ohne sie ginge beim
|
||
Wechsel verloren, ob die Arbeit schon lief.
|
||
"Im Review abgebrochen" ist eine ganz andere
|
||
Aussage als "nie angefangen". */
|
||
["aufgaben", "abbruch_grund", "TEXT"],
|
||
["aufgaben", "abgebrochen_am", "TEXT"],
|
||
["aufgaben", "abbruch_von", "INTEGER REFERENCES personen(id) ON DELETE SET NULL"],
|
||
["aufgaben", "status_vorher", "TEXT"],
|
||
|
||
/* AGENTUR-EVENTS (07.09.2026).
|
||
|
||
Ein Event ist kein Notizzettel mit Ueberschrift und Text. Wer
|
||
mitmachen soll, muss vier Dinge finden, ohne zu fragen: bis wann
|
||
es laeuft, was zu tun ist, was es zu gewinnen gibt und was
|
||
verboten ist. Genau daran scheitern Aktionen -- nicht am Aufruf,
|
||
sondern an den Rueckfragen danach.
|
||
|
||
Der BEGINN ist `datum` (gibt es schon, ist NOT NULL). Neu ist
|
||
das Ende und die beiden Textfelder. `event_`-Vorsatz, weil es im
|
||
Workspace bereits eine Tabelle `aufgaben` gibt -- eine Spalte
|
||
`aufgaben` auf `eintraege` waere beim Lesen von Code jedes Mal
|
||
eine Verwechslung.
|
||
|
||
ADD COLUMN und kein Tabellenneubau: Es aendert sich kein CHECK,
|
||
also darf die Tabelle stehen bleiben. Der Neubau vom 06.09. war
|
||
noetig, weil dort der erlaubte Wertebereich von `bereich` wuchs
|
||
-- und er ist der Weg, bei dem man Inhalte verlieren kann. */
|
||
["eintraege", "event_ende", "TEXT"],
|
||
["eintraege", "event_aufgaben", "TEXT"],
|
||
["eintraege", "event_regeln", "TEXT"],
|
||
|
||
/* DER TAGESRUF BRAUCHT MEHR ALS AN/AUS (08.09.2026, screen10).
|
||
|
||
Filipe: "ich will das neben diesem kreis auch ein kleiner button
|
||
ist fuer den wecker von den aufgaben, oder quasi eine taetige
|
||
meldung einmal am tag zu aktivieren wenn noch aufgaben auf sind."
|
||
|
||
Eine Uhrzeit ist kein Schalter. Statt einer eigenen Tabelle fuer
|
||
eine einzige Zahl bekommt `push_einstellungen` eine allgemeine
|
||
Spalte `wert`: Wo eine Art mehr braucht als an/aus, steht es
|
||
dort. Die vorhandenen Arten lassen sie leer und merken nichts
|
||
davon.
|
||
|
||
KEIN `NOT NULL`, KEINE VORGABE IN DER DATENBANK: Die Vorgabe
|
||
gehoert nach workspace-push.js zu den Arten, sonst stuende sie an
|
||
zwei Stellen -- und die in der Datenbank wuerde bei einer
|
||
Aenderung vergessen. Genau diese Sorte Doppelung hat mich heute
|
||
schon dreimal aufgehalten. */
|
||
["push_einstellungen", "wert", "TEXT"],
|
||
|
||
/* ANTWORTEN MIT ZITAT (09.09.2026, Punkt 15).
|
||
|
||
Filipe: "ich will dass du diese seite viel krasser und
|
||
detaillierter machst ... alles reinsetzt was wir noch gebrauchen
|
||
koennten."
|
||
|
||
In einem Gespraech zu zweit weiss man meistens, worauf sich
|
||
etwas bezieht. In einer Gruppe nicht -- dort laufen drei Faeden
|
||
parallel, und "ja, mach das" kann alles heissen. Die Spalte
|
||
zeigt auf die Nachricht, auf die geantwortet wird.
|
||
|
||
KEIN FREMDSCHLUESSEL AUF chat_nachrichten: Wird die zitierte
|
||
Nachricht zurueckgenommen, bleibt ihre Zeile ohnehin stehen
|
||
(nur der Text wird geleert) -- und wird der ganze Raum
|
||
geloescht, geht die Antwort per CASCADE ueber `raum_id`
|
||
ohnehin mit. Ein zweiter Schluessel brauchte nur eine zweite
|
||
Gelegenheit fuer eine Sperre. */
|
||
["chat_nachrichten", "antwort_auf", "INTEGER"],
|
||
|
||
/* GESPRAECH LOESCHEN -- NUR BEI MIR (09.09.2026).
|
||
|
||
Filipe: "man muss die chats auch geloescht bekommen!!!"
|
||
Auf Nachfrage entschieden: nur bei mir, nicht bei beiden. Das
|
||
Gespraech verschwindet aus MEINER Liste, das Gegenueber behaelt
|
||
seinen Verlauf. Sonst koennte jeder jedem die Unterhaltung
|
||
vernichten -- und in einem Gespraech, in dem Absprachen stehen,
|
||
waere das ein Loch, das niemand mehr fuellen kann.
|
||
|
||
ZWEI SPALTEN, NICHT EINE, und der Grund ist ein Randfall:
|
||
`geloescht_bis` haelt fest, bis zu welcher Nachricht ich es nicht
|
||
mehr sehen will. Bei einem Gespraech OHNE jede Nachricht waere
|
||
das 0 -- und 0 heisst auch "nie geloescht". `geloescht_am`
|
||
unterscheidet die beiden Faelle. Kommt spaeter etwas Neues
|
||
(id > geloescht_bis), taucht das Gespraech von selbst wieder auf;
|
||
das Alte bleibt weg. */
|
||
["chat_teilnehmer", "geloescht_bis", "INTEGER NOT NULL DEFAULT 0"],
|
||
["chat_teilnehmer", "geloescht_am", "TEXT"],
|
||
|
||
/* ANHAENGE: FOTOS UND PDF (09.09.2026).
|
||
|
||
Filipe: "am besten waere es auch wenn man da auch im chat pdfs
|
||
schicken koennte. pdfs und fotos."
|
||
|
||
Je Nachricht hoechstens ein Anhang -- deshalb Spalten und keine
|
||
eigene Tabelle. Eine Tabelle waere die richtige Wahl fuer
|
||
"mehrere je Nachricht"; die gibt es hier nicht, und eine leere
|
||
Verbundabfrage bei jedem Verlauf waere Aufwand fuer einen Fall,
|
||
den es nicht gibt.
|
||
|
||
`anhang_datei` ist der ERZEUGTE Name auf der Platte, niemals der
|
||
eingeschickte. `anhang_art` ist "bild" oder "pdf" und wird aus
|
||
den ersten Bytes der Datei bestimmt, nicht aus dem Namen und
|
||
nicht aus dem Content-Type -- beide kommen vom Absender.
|
||
Breite und Hoehe stehen dabei, damit der Verlauf beim Laden
|
||
eines Bildes nicht springt. */
|
||
["chat_nachrichten", "anhang_datei", "TEXT"],
|
||
["chat_nachrichten", "anhang_name", "TEXT"],
|
||
["chat_nachrichten", "anhang_art", "TEXT"],
|
||
["chat_nachrichten", "anhang_typ", "TEXT"],
|
||
["chat_nachrichten", "anhang_groesse", "INTEGER"],
|
||
["chat_nachrichten", "anhang_breite", "INTEGER"],
|
||
["chat_nachrichten", "anhang_hoehe", "INTEGER"],
|
||
|
||
/* WELCHE VORLAGE DAHINTERSTECKT (09.09.2026).
|
||
|
||
Filipe: "ich will nur dass du dich informierst und schaust was
|
||
man vielleicht noch besser machen koennte von der benutzung ...
|
||
ich red von den aufgaben."
|
||
|
||
Beim Durchgehen der Bedienung ist aufgefallen: Man konnte
|
||
dieselbe Vorlage zweimal uebernehmen und bekam die Aufgabe
|
||
zweimal auf das Brett -- ohne jeden Hinweis. Im Vorlagenbrett
|
||
sah man nicht, was man schon geholt hatte.
|
||
|
||
Die Kennung der Vorlage steht jetzt an der Aufgabe. Damit kann
|
||
das Brett zeigen, was bereits uebernommen ist, und es braucht
|
||
dafuer KEINE zweite Abfrage: Die Aufgabenliste hat der Browser
|
||
ohnehin schon. Ueber den TITEL zu vergleichen waere die
|
||
naheliegende Abkuerzung gewesen -- und faellt in dem Moment um,
|
||
in dem jemand den Titel einer uebernommenen Aufgabe aendert. */
|
||
["aufgaben", "vorlage", "TEXT"],
|
||
|
||
/* DER SUCHSCHLUESSEL ZUM CODE (09.09.2026).
|
||
|
||
Bisher gab es nur den scrypt-Hash, und der ist mit Absicht
|
||
langsam (rund 150 ms). Wer eine Person AM CODE finden will, muss
|
||
deshalb alle Kandidaten der Reihe nach durchrechnen -- die
|
||
Anmeldung tut genau das, und im Code steht seit langem der
|
||
Hinweis, dass das ab etwa 50 Codes je Rolle spuerbar wird.
|
||
|
||
Diese Spalte ist die schnelle Antwort auf "zu welcher Person
|
||
gehoert dieser Code?": ein HMAC ueber den Code, in Mikrosekunden
|
||
berechnet und mit einem Index hinterlegt.
|
||
|
||
WARUM DAS UNBEDENKLICH IST -- nachgemessen, nicht angenommen: Ein
|
||
Code besteht aus 16 Zeichen eines 32er-Alphabets, das sind 80 Bit
|
||
Zufall. Selbst wenn jemand die ganze Datenbank haette, waere
|
||
Durchprobieren aussichtslos, egal wie schnell die Rechnung ist.
|
||
Der Schluessel steckt zusaetzlich nicht im Code, sondern in den
|
||
Einstellungen (siehe kennungSchluessel).
|
||
|
||
DER SCRYPT-HASH BLEIBT DIE ENTSCHEIDUNG. Der Suchschluessel sagt
|
||
nur, WEN man pruefen soll; ob der Code stimmt, sagt weiterhin
|
||
allein scrypt. Ein Treffer hier allein laesst niemanden herein. */
|
||
["personen", "code_kennung", "TEXT"],
|
||
|
||
/* DIE STUFE IM TEAM (10.09.2026).
|
||
|
||
Blueprint V3.0, Kapitel 4.1: Probe, Standard, Senior. Ein
|
||
Probe-Modi ist in der Einarbeitung, ein Senior kann die rechte
|
||
Hand bei Abwesenheit vertreten.
|
||
|
||
WARUM OHNE CHECK-REGEL, anders als bei `rolle`: Eine CHECK-Liste
|
||
laesst sich in SQLite nur ueber einen Tabellenneubau erweitern
|
||
(siehe checkListeErweitern) -- und die Stufen sind eine
|
||
ARBEITSEINTEILUNG, keine Rechtegrenze. Kaeme morgen eine vierte
|
||
dazu, waere ein Tabellenneubau auf einer Live-Datenbank ein
|
||
hoher Preis fuer eine Beschriftung. Geprueft wird deshalb im
|
||
Code, an genau einer Stelle (STUFEN), und was dort nicht
|
||
draufsteht, wird abgewiesen.
|
||
|
||
NULL HEISST "Probe" und nicht "unbekannt". Ein dritter Zustand
|
||
waere eine Frage, die niemand beantworten kann ("was ist er
|
||
denn nun?"); und ein neuer Mensch faengt ohnehin in der
|
||
Einarbeitung an. Beim Lesen wird NULL deshalb auf 'probe'
|
||
abgebildet -- an einer Stelle, nicht in jeder Abfrage.
|
||
|
||
Die Spalte heisst wie `punkt_stand.stufe`, meint aber etwas
|
||
anderes (dort: gut/verbessern). Zwei Tabellen, kein Konflikt --
|
||
aber wer beides im selben Kopf hat, sollte es wissen. */
|
||
["personen", "stufe", "TEXT"],
|
||
|
||
/* DIE KATEGORIE EINER AUFGABE (10.09.2026).
|
||
|
||
Kapitel 6.1 des Anforderungsdokuments verlangt sie an jeder
|
||
Aufgabe. Filipes Entscheidung dazu: nur bei den Modis -- fuer
|
||
Creator, Scouts und Manager bleibt das Formular, wie es war.
|
||
|
||
KEIN CHECK IN DER SPALTE, obwohl die Werte fest sind. Ein CHECK
|
||
laesst sich in SQLite nicht aendern; die Liste um eine Kategorie
|
||
zu erweitern hiesse jedes Mal, die ganze Tabelle neu zu bauen.
|
||
Geprueft wird stattdessen beim Schreiben (siehe KATEGORIEN in
|
||
workspace-aufgaben.js) -- an EINER Stelle, durch die alle
|
||
Schreibwege laufen. */
|
||
["aufgaben", "kategorie", "TEXT"],
|
||
|
||
/* WAS DOGFATHER DAZU TUN MUESSTE (10.09.2026).
|
||
|
||
Filipe: *"auch entscheiden was ich machen muss wenn das und das
|
||
erledigt wird."* Der Modi schreibt es beim Angebot dazu; wird
|
||
das Angebot angenommen, wird genau daraus seine Aufgabe.
|
||
|
||
Ein eigenes Feld und nicht im Fliesstext: Aus einem Absatz laesst
|
||
sich keine Aufgabe machen, ohne zu raten, welcher Satz gemeint
|
||
war. */
|
||
["eintraege", "einsatz", "TEXT"],
|
||
|
||
/* KATEGORIE-KANAELE (10.09.2026, Kapitel 7.2).
|
||
|
||
"Kategorie-Kanaele: z. B. Clipping-Team, Live-Moderation --
|
||
Owner/rechte Hand haben Zugriff auf alle Kanaele."
|
||
|
||
Ein Kanal ist KEINE vierte Tabelle, sondern eine dritte Art Raum.
|
||
Damit gilt fuer ihn ohne eine einzige neue Zeile alles, was schon
|
||
da ist: Verlauf, Anhaenge, Suche, Ungelesen-Zaehler, der
|
||
Live-Strom, das Wegraeumen. Eine eigene Tabelle haette all das
|
||
ein zweites Mal gebraucht -- und die zweite Fassung waere die
|
||
gewesen, in der die Zugriffsregel fehlt.
|
||
|
||
WELCHE Zustaendigkeit, steht in kategorie und kommt aus
|
||
MODI_KATEGORIEN -- derselben Liste, aus der auch die Aufgaben
|
||
ihre Kategorie nehmen. Ein Kanal "Clipping" und Aufgaben
|
||
"clipping" waeren sonst zwei Woerter fuer dieselbe Sache. */
|
||
["chat_raeume", "kategorie", "TEXT"],
|
||
|
||
/* 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. */
|
||
const aufgabenPlan = d.prepare(
|
||
"SELECT sql FROM sqlite_master WHERE type = 'table' AND name = 'aufgaben'").get()?.sql || "";
|
||
if (aufgabenPlan && !aufgabenPlan.includes("'abgebrochen'")) {
|
||
const sicherung = `${DB_PFAD}.vor-abbruch-${jetztStempel}`;
|
||
try {
|
||
d.exec(`VACUUM INTO '${sicherung.replace(/'/g, "''")}'`);
|
||
console.log("[workspace] Sicherung vor der Umstellung:", sicherung);
|
||
} catch (fehler) {
|
||
console.error("[workspace] Sicherung fehlgeschlagen, Umstellung abgebrochen:", fehler?.message);
|
||
return;
|
||
}
|
||
|
||
d.exec("PRAGMA foreign_keys = OFF");
|
||
try {
|
||
/* Die Spalten stehen hier vollstaendig, weil die Schleife oben
|
||
sie zu diesem Zeitpunkt schon ergaenzt hat. Wer hier eine
|
||
vergisst, verliert ihren Inhalt still -- deshalb wird nach dem
|
||
Tausch die Zeilenzahl verglichen. */
|
||
const vorher = d.prepare("SELECT COUNT(*) AS n FROM aufgaben").get().n;
|
||
d.exec("BEGIN");
|
||
d.exec(`
|
||
CREATE TABLE aufgaben_neu (
|
||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||
titel TEXT NOT NULL,
|
||
beschreibung TEXT,
|
||
status TEXT NOT NULL DEFAULT 'offen'
|
||
CHECK (status IN ('offen','arbeit','review','erledigt','abgebrochen')),
|
||
prioritaet TEXT NOT NULL DEFAULT 'mittel'
|
||
CHECK (prioritaet IN ('hoch','mittel','niedrig')),
|
||
creator_id INTEGER REFERENCES personen(id) ON DELETE SET NULL,
|
||
verantwortlich_id INTEGER REFERENCES personen(id) ON DELETE SET NULL,
|
||
frist TEXT,
|
||
erstellt TEXT NOT NULL,
|
||
erstellt_von INTEGER REFERENCES personen(id) ON DELETE SET NULL,
|
||
geaendert TEXT,
|
||
erledigt_am TEXT,
|
||
creator_extern TEXT,
|
||
verantwortlich_extern TEXT,
|
||
abbruch_grund TEXT,
|
||
abgebrochen_am TEXT,
|
||
abbruch_von INTEGER REFERENCES personen(id) ON DELETE SET NULL,
|
||
status_vorher TEXT
|
||
);
|
||
INSERT INTO aufgaben_neu
|
||
(id, titel, beschreibung, status, prioritaet, creator_id, verantwortlich_id,
|
||
frist, erstellt, erstellt_von, geaendert, erledigt_am,
|
||
creator_extern, verantwortlich_extern,
|
||
abbruch_grund, abgebrochen_am, abbruch_von, status_vorher)
|
||
SELECT id, titel, beschreibung, status, prioritaet, creator_id, verantwortlich_id,
|
||
frist, erstellt, erstellt_von, geaendert, erledigt_am,
|
||
creator_extern, verantwortlich_extern,
|
||
abbruch_grund, abgebrochen_am, abbruch_von, status_vorher
|
||
FROM aufgaben;
|
||
DROP TABLE aufgaben;
|
||
ALTER TABLE aufgaben_neu RENAME TO aufgaben;
|
||
CREATE INDEX IF NOT EXISTS idx_aufgaben_status ON aufgaben (status);
|
||
CREATE INDEX IF NOT EXISTS idx_aufgaben_creator ON aufgaben (creator_id);
|
||
`);
|
||
/* Nach dem RENAME heisst die neue Tabelle wieder "aufgaben" --
|
||
gezaehlt wird also unter dem alten Namen, und der Vergleich
|
||
laeuft noch INNERHALB der Transaktion. Stimmt er nicht, ist
|
||
ein ROLLBACK noch moeglich. */
|
||
const nachher = d.prepare("SELECT COUNT(*) AS n FROM aufgaben").get().n;
|
||
/* Stimmt die Zahl nicht, wird NICHT bestaetigt. Lieber laeuft das
|
||
Abbrechen noch nicht, als dass eine Aufgabe verschwindet. */
|
||
if (nachher !== vorher) {
|
||
d.exec("ROLLBACK");
|
||
console.error(`[workspace] Umstellung abgebrochen: ${vorher} Aufgaben vorher, `
|
||
+ `${nachher} nachher. Sicherung: ${sicherung}`);
|
||
} else {
|
||
d.exec("COMMIT");
|
||
const kaputt = d.prepare("PRAGMA foreign_key_check").all();
|
||
if (kaputt.length) {
|
||
console.error("[workspace] ACHTUNG: nach der Umstellung", kaputt.length,
|
||
"verwaiste Verweise. Sicherung liegt unter", sicherung);
|
||
} else {
|
||
console.log(`[workspace] Status 'abgebrochen' freigeschaltet, ${vorher} Aufgaben, Verweise geprueft.`);
|
||
}
|
||
}
|
||
} catch (fehler) {
|
||
try { d.exec("ROLLBACK"); } catch { /* schon zurueckgerollt */ }
|
||
console.error("[workspace] Umstellung 'abgebrochen' fehlgeschlagen:", fehler?.message);
|
||
} finally {
|
||
d.exec("PRAGMA foreign_keys = ON");
|
||
}
|
||
}
|
||
|
||
/* ---- Der sechste Bereich: "agentur" (06.09.2026) ----
|
||
|
||
Kernprinzip 04 des Konzepts lautet woertlich "Die Agentur bleibt
|
||
angebunden". Fuenf der sechs Bereiche standen (LIVE, Content,
|
||
Technik, Community, Schutz) -- der sechste fehlte vollstaendig. Was
|
||
dadurch nirgends stand: welche Kampagne laeuft und bis wann, welche
|
||
Schulung ansteht, und vor allem der OFFIZIELLE WEG zur Agentur mit
|
||
Stand und Antwort. Das lief bisher ueber private Nachrichten und
|
||
war damit weder auffindbar noch nachvollziehbar.
|
||
|
||
Und die Trennung, die das Konzept ausdruecklich verlangt und die im
|
||
Alltag sonst verschwimmt: Betreuung und Umsetzung sind UNSERE
|
||
Seite, offizielle Wege und Plattformthemen die der Agentur. Wer
|
||
wofuer zustaendig ist, gehoert auf den Bildschirm, nicht ins
|
||
Gedaechtnis.
|
||
|
||
Derselbe Umbau wie bei "abgebrochen" darueber -- ein CHECK laesst
|
||
sich in SQLite nicht aendern, die Tabelle muss neu gebaut werden:
|
||
erst sichern, dann tauschen, Zeilen zaehlen, Verweise pruefen. */
|
||
/* ---- 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 ---- */
|
||
const bauplan = d.prepare(
|
||
"SELECT sql FROM sqlite_master WHERE type = 'table' AND name = 'personen'").get()?.sql || "";
|
||
if (!bauplan.includes("'manager'")) {
|
||
const sicherung = `${DB_PFAD}.vor-manager-${jetztStempel}`;
|
||
try {
|
||
d.exec(`VACUUM INTO '${sicherung.replace(/'/g, "''")}'`);
|
||
console.log("[workspace] Sicherung vor der Umstellung:", sicherung);
|
||
} catch (fehler) {
|
||
/* Ohne Sicherung wird NICHT umgestellt. Lieber laeuft der Manager
|
||
noch nicht, als dass Daten ohne Netz angefasst werden. */
|
||
console.error("[workspace] Sicherung fehlgeschlagen, Umstellung abgebrochen:", fehler?.message);
|
||
return;
|
||
}
|
||
|
||
/* Fremdschluessel muessen aus sein, weil andere Tabellen auf
|
||
personen(id) zeigen -- und das laesst sich nicht innerhalb einer
|
||
Transaktion umschalten. */
|
||
d.exec("PRAGMA foreign_keys = OFF");
|
||
try {
|
||
d.exec("BEGIN");
|
||
d.exec(`
|
||
CREATE TABLE personen_neu (
|
||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||
name TEXT NOT NULL,
|
||
rolle TEXT NOT NULL CHECK (rolle IN ('admin','manager','scout','creator')),
|
||
code_hash TEXT NOT NULL,
|
||
code_salt TEXT NOT NULL,
|
||
code_n INTEGER NOT NULL,
|
||
aktiv INTEGER NOT NULL DEFAULT 1,
|
||
erstellt TEXT NOT NULL,
|
||
letzter_login TEXT
|
||
);
|
||
INSERT INTO personen_neu
|
||
(id, name, rolle, code_hash, code_salt, code_n, aktiv, erstellt, letzter_login)
|
||
SELECT id, name, rolle, code_hash, code_salt, code_n, aktiv, erstellt, letzter_login
|
||
FROM personen;
|
||
DROP TABLE personen;
|
||
ALTER TABLE personen_neu RENAME TO personen;
|
||
`);
|
||
d.exec("COMMIT");
|
||
|
||
/* Nach dem Tausch pruefen, ob die Verweise noch stimmen. Findet
|
||
sich etwas, wird das laut gemeldet -- stillschweigend kaputte
|
||
Verweise waeren das Schlimmste an dieser Stelle. */
|
||
const kaputt = d.prepare("PRAGMA foreign_key_check").all();
|
||
if (kaputt.length) {
|
||
console.error("[workspace] ACHTUNG: nach der Umstellung", kaputt.length,
|
||
"verwaiste Verweise. Sicherung liegt unter", sicherung);
|
||
} else {
|
||
console.log("[workspace] Rolle 'manager' freigeschaltet, Verweise geprueft.");
|
||
}
|
||
} catch (fehler) {
|
||
try { d.exec("ROLLBACK"); } catch { /* schon zurueckgerollt */ }
|
||
console.error("[workspace] Umstellung fehlgeschlagen:", fehler?.message);
|
||
} finally {
|
||
d.exec("PRAGMA foreign_keys = ON");
|
||
}
|
||
}
|
||
|
||
/* =====================================================================
|
||
ROLLE "spicy" (Spicy Media) FREISCHALTEN — 07.09.2026
|
||
|
||
Wunsch Filipe: "ich will dass ueber dogfather auch eine kategorie,
|
||
eine neue rolle entsteht die den namen traegt, spicy media."
|
||
|
||
---------------------------------------------------------------------
|
||
DAS IST DER RISKANTESTE UMBAU IM GANZEN HAUS
|
||
|
||
Eine Rolle ist ein erlaubter Wert in einer Spalte, und der steckt in
|
||
SQLite in einem CHECK. Ein CHECK laesst sich nicht aendern -- die
|
||
Tabelle muss neu gebaut werden. Bei `eintraege` war das schon
|
||
unangenehm; hier geht es um `personen`, und auf die zeigen
|
||
ZWEIUNDFUENFZIG Fremdschluessel aus einem Dutzend Tabellen. Jede
|
||
Sitzung, jede Aufgabe, jeder Termin, jede Datei haengt daran.
|
||
|
||
DESHALB WIRD DER BAUPLAN NICHT ABGESCHRIEBEN, SONDERN GELESEN.
|
||
|
||
Die beiden Umstellungen darueber schreiben ihre Spaltenliste von
|
||
Hand ab. Das ging gut, solange die Liste stimmte -- aber `personen`
|
||
hat seit damals SIEBEN Spalten dazubekommen (bild, ueber_mich,
|
||
tiktok, instagram, youtube, twitch und weitere, siehe den
|
||
Spalten-Nachtrag oben). Wer hier eine Liste abschreibt, verliert
|
||
beim naechsten Mal genau die Spalte, die jemand letzte Woche
|
||
ergaenzt hat -- samt allen Profilbildern.
|
||
|
||
Also: Der vorhandene CREATE-Text wird aus sqlite_master geholt und
|
||
darin AUSSCHLIESSLICH die Rollenliste ersetzt. Alles andere -- jede
|
||
Spalte, jeder Typ, jede Vorgabe -- bleibt woertlich stehen. Und die
|
||
Kopierliste kommt aus PRAGMA table_info, also aus der Tabelle
|
||
selbst.
|
||
|
||
WENN DIE ERSETZUNG NICHT GREIFT, WIRD NICHT UMGESTELLT. Ein
|
||
`replace`, das nichts findet, gibt den Text unveraendert zurueck --
|
||
die Umstellung liefe dann durch, baute dieselbe Tabelle noch einmal
|
||
und meldete Erfolg. Genau die Sorte gruener Haken, die nichts
|
||
geprueft hat. Deshalb wird danach nachgesehen, ob 'spicy' wirklich
|
||
drinsteht.
|
||
===================================================================== */
|
||
/* =====================================================================
|
||
MEHR TERMINARTEN (08.09.2026)
|
||
|
||
Wunsch Filipe: "da sollen auch noch kategorien wie bigmatch,
|
||
turniere, special-live ... informier dich, was man da alles noch
|
||
gebrauchen koennte."
|
||
|
||
Recherchiert (Streams Charts, Kick, StreamerCollabs): Was ein
|
||
Creator-Team im Kalender wirklich unterscheidet, sind neben den
|
||
internen Terminen die AUFTRITTE -- Wettkaempfe (BigMatch, Turnier),
|
||
gemeinsame Formate (Collab, Raid-Train) und die grossen Ausnahmen
|
||
(Special-Live, Charity). Genau diese sechs kommen dazu.
|
||
|
||
WARUM EIN TABELLENUMBAU: Die erlaubten Werte stecken in einem
|
||
CHECK, und ein CHECK laesst sich in SQLite nicht aendern. Dieselbe
|
||
Lage wie bei der Rolle 'spicy' -- und deshalb hier dasselbe,
|
||
bewaehrte Verfahren: Bauplan aus sqlite_master lesen, NUR die Liste
|
||
ersetzen, das Ergebnis pruefen, Spalten aus PRAGMA holen, Zeilen
|
||
INNERHALB der Transaktion zaehlen, Sicherung vorher.
|
||
|
||
Zwei Tabellen statt einer (termine und termin_serien), deshalb
|
||
eine Schleife -- zweimal derselbe Text waere zweimal dieselbe
|
||
Gelegenheit, eine Stelle zu vergessen. */
|
||
const ARTEN_NEU = "'termin','call','review','bigmatch','turnier','special','collab','raid','charity'";
|
||
for (const tabelle of ["termine", "termin_serien"]) {
|
||
const plan = d.prepare(
|
||
"SELECT sql FROM sqlite_master WHERE type = 'table' AND name = ?").get(tabelle)?.sql || "";
|
||
/* Geprueft wird die REGEL, nicht der ganze Bauplan: SQLite hebt ihn
|
||
woertlich auf, samt Kommentaren -- ein erklaerender Satz mit dem
|
||
Wort 'bigmatch' wuerde sonst genuegen, damit die Umstellung sich
|
||
fuer erledigt haelt. Genau dieser Fehler ist bei 'spicy' passiert. */
|
||
const artRegel = plan.match(/art\s+IN\s*\(([^)]*)\)/i)?.[1] || "";
|
||
if (!plan || !artRegel || artRegel.includes("'bigmatch'")) continue;
|
||
|
||
/* DER TABELLENNAME GEHOERT IN DEN DATEINAMEN (08.09.2026).
|
||
|
||
Hier stand `.vor-arten-${jetztStempel}` -- ohne die Tabelle. Der
|
||
Stempel wird EINMAL pro Serverstart gebildet, diese Schleife
|
||
laeuft aber ZWEIMAL. Der zweite Durchlauf wollte also dieselbe
|
||
Datei anlegen, `VACUUM INTO` weigert sich (die Datei ist schon
|
||
da), und das `break` unten beendete daraufhin die ganze Schleife.
|
||
|
||
Ergebnis: `termine` war umgestellt, `termin_serien` NICHT. Und
|
||
zwar still -- die Meldung ging in die Serverausgabe, die niemand
|
||
liest. Aufgefallen waere es erst, wenn jemand eine wiederkehrende
|
||
BigMatch-Reihe anlegt und die Datenbank sie ohne erkennbaren
|
||
Grund ablehnt.
|
||
|
||
Das `break` bleibt richtig: Wenn sich keine Sicherung anlegen
|
||
laesst, wird nicht umgebaut. Falsch war nur der Name. */
|
||
const sicherung = `${DB_PFAD}.vor-arten-${tabelle}-${jetztStempel}`;
|
||
try {
|
||
d.exec(`VACUUM INTO '${sicherung.replace(/'/g, "''")}'`);
|
||
console.log("[workspace] Sicherung vor der Artenumstellung:", sicherung);
|
||
} catch (fehler) {
|
||
console.error("[workspace] Sicherung fehlgeschlagen, Artenumstellung abgebrochen:",
|
||
fehler?.message);
|
||
break;
|
||
}
|
||
|
||
const neuerPlan = plan
|
||
/* DAS MUSTER WIRD ZUSAMMENGESETZT, NICHT IN EINEN TEMPLATE-STRING
|
||
GESCHRIEBEN. Dort wird \s beim Einlesen zu einem blossen "s" --
|
||
aus "CREATE TABLE\s+" wuerde "CREATE TABLEs+", das Muster
|
||
passte auf nichts, und die Umstellung waere STILL ausgeblieben.
|
||
Genau so stand es hier beim ersten Anlauf; nachgemessen mit
|
||
einem Einzeiler, der beide Schreibweisen gegen den echten
|
||
Bauplan haelt. */
|
||
.replace(new RegExp(
|
||
"CREATE TABLE\\s+(?:IF\\s+NOT\\s+EXISTS\\s+)?[\"'`]?" + tabelle + "[\"'`]?", "i"),
|
||
`CREATE TABLE ${tabelle}_neu`)
|
||
.replace(/art\s+IN\s*\([^)]*\)/i, `art IN (${ARTEN_NEU})`);
|
||
if (!neuerPlan.includes("'bigmatch'") || !neuerPlan.includes(`${tabelle}_neu`)) {
|
||
console.error(`[workspace] Artenumstellung abgebrochen: Bauplan von ${tabelle} `
|
||
+ "liess sich nicht umschreiben.");
|
||
continue;
|
||
}
|
||
|
||
const spalten = d.prepare(`PRAGMA table_info(${tabelle})`).all().map((z) => z.name);
|
||
if (!spalten.length) continue;
|
||
const liste = spalten.map((n) => `"${n}"`).join(", ");
|
||
|
||
d.exec("PRAGMA foreign_keys = OFF");
|
||
try {
|
||
const vorher = d.prepare(`SELECT COUNT(*) AS n FROM ${tabelle}`).get().n;
|
||
d.exec("BEGIN");
|
||
d.exec(neuerPlan);
|
||
d.exec(`INSERT INTO ${tabelle}_neu (${liste}) SELECT ${liste} FROM ${tabelle};`);
|
||
const nachher = d.prepare(`SELECT COUNT(*) AS n FROM ${tabelle}_neu`).get().n;
|
||
if (nachher !== vorher) {
|
||
d.exec("ROLLBACK");
|
||
console.error(`[workspace] Artenumstellung abgebrochen: ${vorher} Zeilen vorher, `
|
||
+ `${nachher} nachher. Sicherung: ${sicherung}`);
|
||
} else {
|
||
d.exec(`DROP TABLE ${tabelle};`);
|
||
d.exec(`ALTER TABLE ${tabelle}_neu RENAME TO ${tabelle};`);
|
||
d.exec("COMMIT");
|
||
const kaputt = d.prepare("PRAGMA foreign_key_check").all();
|
||
if (kaputt.length) {
|
||
console.error("[workspace] ACHTUNG: nach der Artenumstellung", kaputt.length,
|
||
"verwaiste Verweise. Sicherung:", sicherung);
|
||
} else {
|
||
console.log(`[workspace] Terminarten erweitert in ${tabelle}: ${nachher} Zeilen, `
|
||
+ `${spalten.length} Spalten uebernommen, Verweise geprueft.`);
|
||
}
|
||
}
|
||
} catch (fehler) {
|
||
try { d.exec("ROLLBACK"); } catch { /* schon zurueckgerollt */ }
|
||
console.error("[workspace] Artenumstellung fehlgeschlagen:", fehler?.message,
|
||
"-- Sicherung:", sicherung);
|
||
} finally {
|
||
d.exec("PRAGMA foreign_keys = ON");
|
||
}
|
||
}
|
||
|
||
/* Welche Rollen die Datenbank zulaesst. Der Ablauf steht in
|
||
rollenRegelUmstellen() -- einmal, nicht je Rolle abgeschrieben.
|
||
Beide Aufrufe stehen hier: Auf einer Datenbank, die 'spicy' schon
|
||
kennt, tut der erste nichts und nur der zweite laeuft. */
|
||
checkListeErweitern(d, "personen", "rolle", "spicy",
|
||
["spicy", "admin", "manager", "scout", "creator"], jetztStempel);
|
||
checkListeErweitern(d, "personen", "rolle", "modi",
|
||
["spicy", "admin", "manager", "scout", "creator", "modi"], jetztStempel);
|
||
checkListeErweitern(d, "personen", "rolle", "hand",
|
||
["spicy", "admin", "manager", "scout", "creator", "modi", "hand"], jetztStempel);
|
||
/* 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);
|
||
|
||
/* 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);
|
||
|
||
/* =================================================================
|
||
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);
|
||
|
||
/* 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);
|
||
umstellungen(d);
|
||
_db = d;
|
||
return d;
|
||
} catch (fehler) {
|
||
_dbFehler = fehler;
|
||
console.error("[workspace] Datenbank nicht verfügbar:", fehler?.message);
|
||
throw fehler;
|
||
}
|
||
}
|
||
|
||
/* ---------- Hilfsmittel ---------------------------------------------- */
|
||
|
||
const jetzt = () => new Date().toISOString();
|
||
|
||
/* Die echte Besucher-IP.
|
||
|
||
Warum das nötig ist: Die Kette lautet Besucher -> Cloudflare -> Caddy ->
|
||
Express. `trust proxy` steht auf 1, Express nimmt daher den letzten
|
||
Eintrag aus X-Forwarded-For -- und das ist die Adresse von Cloudflare,
|
||
nicht die des Besuchers. Folge: Im Protokoll stand bei jedem dieselbe
|
||
IP, und viel schlimmer -- ALLE Nutzer teilten sich einen einzigen
|
||
Sperr-Zähler. Ein paar Tippfehler von drei Leuten hätten den Zugang für
|
||
alle gesperrt.
|
||
|
||
Cloudflare setzt die echte Adresse in CF-Connecting-IP und überschreibt
|
||
den Kopf bei jeder Anfrage, ein Besucher kann ihn also nicht fälschen.
|
||
Einschränkung: Wer die Adresse des Servers kennt und ihn unter Umgehung
|
||
von Cloudflare direkt anspricht, könnte den Kopf frei setzen. Deshalb
|
||
wird auf die Peer-Adresse zurückgefallen, sobald der Kopf fehlt -- und
|
||
beide Werte landen im Protokoll, damit so etwas auffällt. */
|
||
export function echteIp(req) {
|
||
const cf = req.get("cf-connecting-ip");
|
||
if (cf && cf.length <= 45) return cf.trim();
|
||
return req.ip || "?";
|
||
}
|
||
|
||
function hashe(code, salt, N) {
|
||
return scryptSync(code, salt, SCRYPT.keylen,
|
||
{ N, r: SCRYPT.r, p: SCRYPT.p, maxmem: 256 * 1024 * 1024 }).toString("hex");
|
||
}
|
||
|
||
/* =====================================================================
|
||
DER SUCHSCHLUESSEL ZU EINEM CODE (09.09.2026)
|
||
|
||
Beantwortet in Mikrosekunden, WEN man pruefen soll -- nicht, ob der
|
||
Code stimmt. Das bleibt Sache von scrypt darunter.
|
||
|
||
WARUM EIN HMAC UND KEIN EINFACHER HASH: Ein einfacher Hash ueber den
|
||
Code waere ueberall gleich. Mit einem Schluessel, der nur in dieser
|
||
einen Datenbank steht, ist der Wert ausserhalb wertlos -- er laesst
|
||
sich nirgends nachschlagen und nirgends wiedererkennen.
|
||
|
||
WARUM DER SCHLUESSEL IN DIE DATENBANK GEHOERT UND NICHT IN DIE
|
||
UMGEBUNG: Eine neue Pflichtangabe in server/.env ist eine Angabe, die
|
||
beim naechsten Aufsetzen fehlen kann. Sie wuerde nicht laut scheitern,
|
||
sondern leise: Der stille Zugang fuende niemanden mehr, und die
|
||
Anmeldung saehe aus wie ein falscher Code. Aus der Datenbank kann sie
|
||
nicht verschwinden -- sie wird mitgesichert wie alles andere.
|
||
|
||
OHNE SCHLUESSEL GIBT ES KEINE ANTWORT, nicht etwa eine schlechtere:
|
||
`null` heisst "konnte nicht nachsehen", und die Anmeldung behandelt
|
||
das wie "nicht gefunden". Ein Rueckfall auf einen ungeschluesselten
|
||
Wert waere die stille Verschlechterung, die niemandem auffaellt. */
|
||
let _kennungSchluessel = null;
|
||
function kennungSchluessel() {
|
||
if (_kennungSchluessel) return _kennungSchluessel;
|
||
try {
|
||
const d = db();
|
||
const lies = () => d.prepare(
|
||
"SELECT wert FROM einstellungen WHERE schluessel = 'code_kennung_schluessel'").get()?.wert;
|
||
let wert = lies();
|
||
if (!wert) {
|
||
/* Bewusst NICHT ueber einstellungSetzen(): Das schriebe einen
|
||
Eintrag ins Protokoll, und ein Protokolleintrag ist genau die
|
||
Spur, die es hier nicht geben soll. */
|
||
d.prepare(`INSERT INTO einstellungen (schluessel, wert, geaendert, von)
|
||
VALUES (?,?,?,NULL) ON CONFLICT(schluessel) DO NOTHING`)
|
||
.run("code_kennung_schluessel", randomBytes(32).toString("hex"),
|
||
new Date().toISOString());
|
||
/* Danach nochmal LESEN statt den erzeugten Wert zu benutzen:
|
||
Legen zwei Vorgaenge ihn im selben Augenblick an, gewinnt einer
|
||
-- und beide muessen mit demselben weiterrechnen. */
|
||
wert = lies();
|
||
}
|
||
_kennungSchluessel = wert || null;
|
||
return _kennungSchluessel;
|
||
} catch {
|
||
return null;
|
||
}
|
||
}
|
||
|
||
export function codeKennung(code) {
|
||
const schluessel = kennungSchluessel();
|
||
if (!schluessel || !code) return null;
|
||
return createHmac("sha256", schluessel).update(String(code)).digest("hex");
|
||
}
|
||
|
||
/* Vergleich in konstanter Zeit. Ein normaler ===-Vergleich bricht beim
|
||
ersten abweichenden Zeichen ab; aus den Laufzeitunterschieden lässt sich
|
||
ein Geheimnis Zeichen für Zeichen erraten. */
|
||
function gleich(a, b) {
|
||
const x = Buffer.from(String(a), "utf8");
|
||
const y = Buffer.from(String(b), "utf8");
|
||
if (x.length !== y.length) return false;
|
||
return timingSafeEqual(x, y);
|
||
}
|
||
|
||
const tokenHash = (t) => createHash("sha256").update(t).digest("hex");
|
||
|
||
export function protokolliere(aktion, { personId = null, rolle = null, detail = null, ip = null } = {}) {
|
||
try {
|
||
db().prepare(
|
||
"INSERT INTO protokoll (zeitpunkt, person_id, rolle, aktion, detail, ip) VALUES (?,?,?,?,?,?)"
|
||
).run(jetzt(), personId, rolle, aktion, detail, ip);
|
||
} catch { /* Ein fehlgeschlagener Protokolleintrag darf nichts blockieren. */ }
|
||
}
|
||
|
||
function zuVieleVersuche(ip) {
|
||
const grenze = new Date(Date.now() - VERSUCHE_FENSTER_MIN * 60_000).toISOString();
|
||
db().prepare("DELETE FROM versuche WHERE zeitpunkt < ?").run(grenze);
|
||
const { anzahl } = db()
|
||
.prepare("SELECT COUNT(*) AS anzahl FROM versuche WHERE ip = ? AND zeitpunkt >= ?")
|
||
.get(ip, grenze);
|
||
return anzahl >= VERSUCHE_MAX;
|
||
}
|
||
|
||
/* ---------- Sitzung ---------------------------------------------------- */
|
||
|
||
export function sitzungLesen(req) {
|
||
const token = req.cookies?.[COOKIE];
|
||
if (!token) return null;
|
||
try {
|
||
const reihe = db().prepare(`
|
||
SELECT s.gueltig_bis, p.id, p.name, p.rolle, p.aktiv
|
||
FROM sitzungen s JOIN personen p ON p.id = s.person_id
|
||
WHERE s.token_hash = ?`).get(tokenHash(token));
|
||
if (!reihe || !reihe.aktiv) return null;
|
||
if (reihe.gueltig_bis < jetzt()) {
|
||
db().prepare("DELETE FROM sitzungen WHERE token_hash = ?").run(tokenHash(token));
|
||
return null;
|
||
}
|
||
/* DIE ADRESSE ENTSCHEIDET MIT (10.09.2026).
|
||
|
||
Seit die Modi-App unter crew.dogfather-universe.com laeuft, gehoert
|
||
jede Sitzung zu genau EINER Adresse: Modis dorthin, alle anderen auf
|
||
den Workspace. Die Regel steht in crew-adresse.js und ist dort ohne
|
||
laufenden Server pruefbar.
|
||
|
||
WARUM HIER UND NICHT IN DEN MODULEN: Durch diese Funktion geht jedes
|
||
der 26 Fachmodule und jede Seitenschranke. Eine Middleware daneben
|
||
koennte man in einem neuen Modul vergessen -- und ein vergessener
|
||
Rechteschutz faellt nicht auf, weil dann alles geht.
|
||
|
||
`null` heisst hier dasselbe wie bei einer abgelaufenen Sitzung:
|
||
nicht angemeldet. Die Seitenschranke schickt zur Zugangswand, die
|
||
Schnittstellen antworten mit 401. Beides ist genau richtig -- auf
|
||
dieser Adresse ist diese Person schlicht niemand. */
|
||
if (!sitzungPasstZurAdresse(reihe.rolle, req.get?.("host"))) return null;
|
||
|
||
/* 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_STUNDEN * 3600_000).toISOString();
|
||
db().prepare(
|
||
"INSERT INTO sitzungen (token_hash, person_id, erstellt, gueltig_bis, ip, browser) VALUES (?,?,?,?,?,?)"
|
||
).run(tokenHash(token), person.id, jetzt(), bis, echteIp(req),
|
||
(req.get("user-agent") || "").slice(0, 200));
|
||
|
||
res.cookie(COOKIE, token, {
|
||
httpOnly: true, // kein Zugriff aus JavaScript -> kein Diebstahl per XSS
|
||
/* Live immer true: Hinter Caddy ist req.secure dank `trust proxy` das
|
||
Ergebnis von X-Forwarded-Proto. Nur beim lokalen Test über http
|
||
fällt es weg -- sonst wäre der Ablauf gar nicht prüfbar, weil
|
||
Browser und curl ein Secure-Cookie über http verwerfen. */
|
||
secure: req.secure,
|
||
sameSite: "lax", // schützt vor fremden Formularen (CSRF)
|
||
path: "/workspace", // gilt nur hier, nicht auf der ganzen Domain
|
||
maxAge: SITZUNG_STUNDEN * 3600_000,
|
||
});
|
||
}
|
||
|
||
/* ---------- Router ----------------------------------------------------- */
|
||
|
||
export const workspaceRouter = express.Router();
|
||
|
||
/* 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",
|
||
]);
|
||
|
||
/** 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),
|
||
/* 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"),
|
||
/* 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", "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) => {
|
||
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));
|