Zwei Auftraege in einem Zug: "mach weiter" (Blueprint Kapitel 4/4.1)
und, zum Bildschirmfoto der Seite, "das muss viel krasser sein".
KAPITEL 4 -- DIE MITGLIEDER-KACHEL
Der Blueprint nennt: "Name & Foto · Rolle(n)/Kategorie(n) · Status
(aktiv/pausiert) · Anzahl offener Aufgaben · Datum 'Modi seit' ·
Schnellaktionen". Name, Foto und Zahlen standen schon; Status, "dabei
seit", Stufe und ein Knopf zum Schreiben kommen dazu.
KAPITEL 4.1 -- DIE DREI STUFEN
Probe, Standard, Senior. Sie sind eine ARBEITSEINTEILUNG, keine
Rechtegrenze -- was ein Modi darf, haengt an der Rolle; die Stufe sagt,
wo er im Team steht. Gesetzt werden sie nur von DogFather: Der
Blueprint gibt der rechten Hand den gleichen UEBERBLICK, aber
"Verwaltungsrechte optional durch Owner freischaltbar", also aus.
NULL heisst "Probe" und nicht "unbekannt" -- ein dritter Zustand waere
eine Frage, die niemand beantworten kann.
ZWEI ECHTE FEHLER, BEIDE VON DER NEUEN PRUEFUNG GEFUNDEN
1. PAUSIERTE VERSCHWANDEN KOMPLETT. In der Abfrage stand `AND aktiv =
1`. Wer jemanden pausierte, bei dem verschwand er samt seiner
OFFENEN RUECKMELDUNGEN aus dem Eingang -- die warteten weiter auf
eine Antwort, nur sah sie niemand mehr.
2. DER EINGANG HAETTE AUF FRISCHER ANLAGE 503 GELIEFERT. Die
Checklisten-Tabellen entstehen beim ersten Aufruf einer Checkliste;
diese Seite liest sie aber auch. Wer sie oeffnete, bevor je jemand
eine Checkliste angesehen hatte, bekam einen Fehler ohne Erklaerung
-- auf einer neuen Anlage also beim allerersten Blick. Das Schema
hat jetzt einen Besitzer, der es herausgibt; ein zweites CREATE
TABLE waere der Anfang von zwei Schemata gewesen.
"VIEL KRASSER" -- UND ZWAR MIT INFORMATION, NICHT MIT LAERM
* ZWEI REIHEN STATT EINER. Alle fuenf Kaesten lagen in EINEM Raster
mit 150 px Mindestbreite: Im Bildschirmfoto stand "Community 12
von" -- abgeschnitten mitten in der Zahl -- und die laengste Liste
machte die ganze Reihe so hoch wie sich selbst. Jetzt oben, was
eine Antwort braucht, darunter das Team; 300 px Mindestbreite.
* DIE KARTE WAR FAHL, und das war ein Fehler: `--r` fiel auf ein
helles Grau zurueck, aus dem das Kantenlicht einen Nebel ueber die
ganze Karte legte. Sie traegt jetzt die Farbe der Stufe -- kein
Nebel, und man sieht am Rand, wer wo steht.
* SECHS ZAHLENKAESTEN WURDEN DREI BALKEN. "40 offen" beantwortet
nicht, wie weit man ist: 40 von 40 ist etwas anderes als 40 von
100. Gruen (in Ordnung) und Bernstein (zu besprechen) fuellen den
Balken; die ganze Zeile ist der Weg dorthin.
* DIE DREI ZAHLEN DER SEITE stehen oben als Zahlen statt in einem
Satz. Die Warnfarbe erscheint NUR, wenn wirklich etwas wartet --
eine Null in Bernstein waere ein Alarm ohne Anlass, und ab dem
dritten Mal sieht man ihn nicht mehr.
GEMESSEN
pruef-team-stufen (neu) 24, mit Gegenprobe: hoch- und zurueckstufen,
und dieselbe Stufe zweimal zu setzen darf
keine zweite Protokollzeile erzeugen
pruef-rollen 274 pruef-modi-checkliste 59
pruef-zwischenspeicher 21
Ansicht: Rechner 1440 und Handy 412 -- keine Skriptfehler, nichts
ragt seitlich heraus (0 px).
Co-Authored-By: Claude Opus 5 <[email protected]>
4008 lines
191 KiB
JavaScript
4008 lines
191 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 { sitzungPasstZurAdresse, istOhneModiAdresse, istCrewAdresse,
|
|
TEAM_DOGI_ROLLEN } from "./crew-adresse.js";
|
|
/* Weitergereicht, damit die Fachmodule sie wie alles andere aus
|
|
workspace.js beziehen und nicht wissen muessen, wo sie wohnt. */
|
|
export { TEAM_DOGI_ROLLEN };
|
|
|
|
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
|
|
/* `node:sqlite` wird bewusst NICHT oben importiert, sondern erst in db()
|
|
nachgeladen. Ein fehlgeschlagener Import an dieser Stelle würde den
|
|
gesamten Website-Prozess beim Start abbrechen -- also auch
|
|
dogfather-universe.com. So bleibt der Schaden auf /workspace/api/
|
|
beschränkt. (Vorhanden ab Node 22; auf dem Server läuft Node 24.) */
|
|
const require = createRequire(pathToFileURL(__dirname + "/"));
|
|
|
|
/* Die Datenbank liegt bewusst AUSSERHALB des Repo-Ordners. Zwei Gründe:
|
|
express.static liefert den Repo-Ordner aus (eine .db darin wäre über das
|
|
Netz erreichbar), und ein `git pull` darf Nutzdaten nie anfassen. */
|
|
/* Wird ausgegeben, weil die Sicherung sowohl die Datei selbst als auch
|
|
ihr WAL nachschlagen muss. Aus DATEN_ORDNER + "workspace.db" laesst
|
|
sich das NICHT ableiten: Steht WORKSPACE_DB auf einem anderen Namen
|
|
(so laufen alle Pruefungen), zeigte die Sicherung auf eine Datei, die
|
|
es gar nicht gibt -- und meldete brav 0 Bytes. */
|
|
export const DB_PFAD = process.env.WORKSPACE_DB
|
|
|| join(__dirname, "..", "..", "workspace-daten", "workspace.db");
|
|
|
|
/* Ordner fuer hochgeladene Dateien -- neben der Datenbank, also ebenfalls
|
|
ausserhalb des Repos. Waeren sie im Repo, wuerde express.static sie
|
|
ungeschuetzt ausliefern. */
|
|
export const DATEN_ORDNER = dirname(DB_PFAD);
|
|
|
|
const COOKIE = "dfw_sitzung";
|
|
const SITZUNG_STUNDEN = 12;
|
|
const VERSUCHE_MAX = 8; // pro IP
|
|
const VERSUCHE_FENSTER_MIN = 10;
|
|
const ROLLEN = new Set(["spicy", "admin", "manager", "scout", "creator", "hand", "modi"]);
|
|
|
|
/* Die Reihenfolge, in der Rollen ueberall erscheinen: Spicy Media
|
|
zuerst, dann DogFather, Manager, Scout, Creator. Steht hier einmal,
|
|
damit keine Liste eine eigene Reihenfolge erfindet. */
|
|
export const ROLLEN_REIHE = ["spicy", "admin", "manager", "scout", "creator", "hand", "modi"];
|
|
|
|
/* DIE ROLLEN MIT EINER KACHEL AUF DER ANMELDESEITE.
|
|
|
|
'modi' steht hier NICHT drin, und das ist der Kern des verborgenen
|
|
Zugangs: Es gibt keine sechste Kachel, und es laesst sich auch keine
|
|
erzwingen. Wer von aussen `rolle: "modi"` schickt, bekommt genau
|
|
dieselbe Antwort wie bei einer erfundenen Rolle -- er erfaehrt also
|
|
nicht einmal, dass es sie gibt.
|
|
|
|
Zwei Listen statt einer, weil es zwei verschiedene Fragen sind:
|
|
ROLLEN sagt, welche Rollen es GIBT (die Datenbank laesst nur diese
|
|
zu). Diese hier sagt, mit welchen man sich ANMELDEN kann, indem man
|
|
sie anklickt. Waere es eine Liste, haette 'modi' entweder eine Kachel
|
|
-- oder es gaebe die Rolle gar nicht. */
|
|
const ROLLEN_KACHEL = new Set(["spicy", "admin", "manager", "scout", "creator"]);
|
|
|
|
|
|
/** Die Rollen mit einer Kachel auf der Zugangswand von crew.
|
|
*
|
|
* DREI, seit Filipe am 10.09.2026 sagte: "3 rollen. dogfather. rechte
|
|
* hand und modis." Auf dieser Adresse gibt es also wieder etwas zu
|
|
* waehlen -- anders als in der Fassung davor, wo nur Modis
|
|
* hereinkamen und eine Kachelreihe mit einem Eintrag eine Huerde
|
|
* gewesen waere.
|
|
*
|
|
* DogFather steht auf BEIDEN Waenden. Er ist die einzige Person, die
|
|
* in beiden Welten arbeitet; das ist keine Ausnahme von der Trennung,
|
|
* sondern ihr Sinn. */
|
|
const CREW_KACHEL = new Set(["admin", "hand", "modi"]);
|
|
|
|
/* Als SQL-Ausdruck fuer ORDER BY. "ORDER BY rolle" waere alphabetisch
|
|
(admin, creator, manager, scout, spicy) -- also fast genau falsch
|
|
herum. */
|
|
export const ROLLEN_SORTIERUNG =
|
|
"CASE rolle WHEN 'spicy' THEN 0 WHEN 'admin' THEN 1 WHEN 'manager' THEN 2"
|
|
+ " WHEN 'scout' THEN 3 WHEN 'creator' THEN 4 WHEN 'hand' THEN 5 ELSE 6 END";
|
|
|
|
/* LEITUNG = DogFather und Manager. Ein Manager darf alles, was
|
|
DogFather darf -- mit genau zwei Ausnahmen, die in
|
|
workspace-personen.js stehen: Er kann keine Leitung anlegen und keine
|
|
Leitung veraendern. Sonst koennte er sich selbst zum DogFather machen
|
|
oder den echten aussperren. "Nur DogFather hat alle endgueltigen
|
|
Rechte" heisst genau das. */
|
|
const LEITUNG = new Set(["spicy", "admin", "manager"]);
|
|
export const istLeitung = (person) => !!person && LEITUNG.has(person.rolle);
|
|
export const istDogFather = (person) => !!person && person.rolle === "admin";
|
|
|
|
/* =======================================================================
|
|
SPICY MEDIA (07.09.2026)
|
|
|
|
Wunsch Filipe: "eine neue rolle ... die den namen traegt, spicy media,
|
|
und soll die gleichen rechte haben wie dogfather ausser, bei
|
|
automationen sollen die nicht sehen und die kategorie personen und
|
|
zugaenge sollen die nur leute hinzufuegen koennen ... aber die sollen
|
|
meine privaten chats und so nicht sehen."
|
|
|
|
ZWEI FRAGEN, DIE MAN AUSEINANDERHALTEN MUSS -- und genau daran haette
|
|
man diesen Umbau kaputtmachen koennen:
|
|
|
|
WER SIEHT ALLES? -> siehtAlles() = DogFather ODER Spicy Media
|
|
WER ENTSCHEIDET? -> istDogFather() = nur DogFather
|
|
|
|
Die erste Frage stellt sich bei Listen, Uebersichten und Auswertungen:
|
|
Spicy Media soll alle Manager, Scouts und Creator sehen. Die zweite
|
|
bei allem Endgueltigen: loeschen, Rollen aendern, Codes neu setzen,
|
|
Sicherungen, die KI abschalten. Da bleibt DogFather allein.
|
|
|
|
Es waere viel weniger Arbeit gewesen, istDogFather() einfach um
|
|
"spicy" zu erweitern -- und genau das waere der Fehler: Spicy Media
|
|
koennte dann DogFather loeschen. Zwei Namen, zwei Bedeutungen, und an
|
|
jeder Stelle steht sichtbar, welche gemeint ist.
|
|
|
|
DER CHAT BRAUCHTE NICHTS. Er haengt ausschliesslich an der
|
|
Teilnehmerliste (chat_teilnehmer) und kennt kein "das Management sieht
|
|
alles". Spicy Media sieht fremde Gespraeche also nicht, weil es dafuer
|
|
gar keinen Weg gibt -- nicht, weil eine Abfrage es verbietet.
|
|
======================================================================= */
|
|
export const istSpicy = (person) => !!person && person.rolle === "spicy";
|
|
|
|
/* =======================================================================
|
|
SPICY MEDIA SIEHT ALLES -- AUSSER DEM, WAS DOGFATHER GEHOERT
|
|
(07.09.2026, Nachtrag am selben Tag)
|
|
|
|
Filipe: "wieso sieht die spicy rolle meine daten die ich habe und
|
|
speichere, meins sollen die nicht sehen von der rolle dogfather."
|
|
|
|
Beim ersten Anlauf bekam Spicy Media dieselbe Regel wie DogFather:
|
|
`1=1`. Damit stimmte der Ueberblick ueber Manager, Scouts und Creator
|
|
-- und nebenbei standen DogFathers eigene Termine, Aufgaben, Dateien
|
|
und Eintraege mit drin. Der Chat war ausgenommen (er haengt an der
|
|
Teilnehmerliste), alles andere nicht.
|
|
|
|
DIESE FUNKTION IST DIE EINE STELLE DAFUER. Sie baut die Bedingung
|
|
"gehoert keinem DogFather" fuer eine beliebige Tabelle. Jede
|
|
Sichtbarkeitsregel im Haus benutzt sie -- eine zweite, abgeschriebene
|
|
Fassung waere die Stelle, an der es beim naechsten Modul wieder
|
|
durchsickert.
|
|
|
|
ZWEI SPALTEN, NICHT EINE. `creator_id` sagt, UM WEN es geht;
|
|
`erstellt_von` sagt, WER es geschrieben hat. Beide muessen gepruegt
|
|
werden: Ein Termin, den DogFather fuer sich selbst anlegt, hat
|
|
moeglicherweise gar keine creator_id -- er haenge dann nur an
|
|
erstellt_von. Und eine Akte UEBER einen DogFather traegt seine
|
|
creator_id, auch wenn ein Manager sie geschrieben hat.
|
|
|
|
`IS NULL OR NOT IN` und nicht bloss `NOT IN`: In SQL ist
|
|
`NULL NOT IN (...)` weder wahr noch falsch, sondern NULL -- die Zeile
|
|
fiele stillschweigend heraus. Genau so verschwinden Daten, ohne dass
|
|
jemand einen Fehler sieht.
|
|
======================================================================= */
|
|
export function ohneDogFather(praefix, spalten = ["creator_id", "erstellt_von"]) {
|
|
return spalten
|
|
.map((sp) => `(${praefix}.${sp} IS NULL OR ${praefix}.${sp} NOT IN `
|
|
+ `(SELECT id FROM personen WHERE rolle = 'admin'))`)
|
|
.join(" AND ");
|
|
}
|
|
/* =====================================================================
|
|
WAS EINEM MODI GEHOERT, SIEHT SONST NIEMAND (10.09.2026)
|
|
|
|
Das Gegenstueck zu ohneDogFather() -- und es entstand aus einem
|
|
gemessenen Leck, nicht aus einer Ueberlegung.
|
|
|
|
WAS PASSIERT WAR: Fuer PERSONEN ist die Regel zentral
|
|
(verborgeneIds). Fuer ZEILEN gibt es sie dreimal -- in
|
|
workspace-aufgaben.js, workspace-bereiche.js und
|
|
workspace-dateien.js -- und alle drei geben Spicy Media dasselbe:
|
|
"alles ausser dem, was DogFather gehoert". Ein Modi-Eintrag gehoert
|
|
ihm aber nicht. Also sah Spicy Media die Aufgaben, die Eintraege und
|
|
die Dateien der Modis, waehrend die Modis selbst in jeder Namensliste
|
|
sauber verborgen waren. Die halbe Verborgenheit ist keine.
|
|
|
|
Aufgefallen ist es nicht beim Lesen des Codes, sondern durch eine
|
|
Pruefung, die etwas ANLEGT und danach mit fremden Augen nachsieht.
|
|
Manager, Scout und Creator waren nie betroffen -- ihre Regeln sind
|
|
ohnehin enger. Der Kalender auch nicht: termineSichtbar() gibt jedem
|
|
nur Eigenes. Nachgemessen, nicht angenommen.
|
|
|
|
SPALTEN MUESSEN BEIDE RICHTUNGEN ABDECKEN. `creator_id` sagt, UM WEN
|
|
es geht; `erstellt_von`/`hochgeladen_von`/`verantwortlich_id` sagen,
|
|
an WEM es haengt. Eine Modi-Aufgabe hat gar keine creator_id -- sie
|
|
haengt allein an verantwortlich_id. Wer nur eine Spalte prueft, hat
|
|
nichts geprueft.
|
|
|
|
`IS NULL OR NOT IN` und nicht bloss `NOT IN`: In SQL ist
|
|
`NULL NOT IN (...)` weder wahr noch falsch, sondern NULL -- die Zeile
|
|
fiele stillschweigend heraus. Genau so verschwinden Daten, ohne dass
|
|
jemand einen Fehler sieht. (Dieselbe Falle steht schon im Kommentar
|
|
von ohneDogFather; sie ist es wert, zweimal dazustehen.)
|
|
===================================================================== */
|
|
export function ohneTeamDogi(praefix, spalten = ["creator_id", "erstellt_von"]) {
|
|
/* Die Rollenliste kommt aus TEAM_DOGI_ROLLEN und steht nicht hier als
|
|
Text. Sie hiess bis zum 10.09.2026 fest `rolle = 'modi'` -- mit dem
|
|
Hinzukommen der rechten Hand waere das eine stille Luecke geworden:
|
|
Ihre Zeilen waeren fuer Spicy Media wieder sichtbar gewesen, ohne
|
|
dass irgendwo etwas rot wird.
|
|
|
|
Als Aufzaehlung in den SQL-Text eingesetzt und nicht als Parameter,
|
|
weil dieser Ausdruck in ein `WHERE` eingebaut wird, das an vielen
|
|
Stellen zusammengesetzt wird. Die Werte stammen aus einer festen
|
|
Menge im Code, nie aus einer Eingabe. */
|
|
const liste = [...TEAM_DOGI_ROLLEN].map((r) => `'${r}'`).join(", ");
|
|
return spalten
|
|
.map((sp) => `(${praefix}.${sp} IS NULL OR ${praefix}.${sp} NOT IN `
|
|
+ `(SELECT id FROM personen WHERE rolle IN (${liste})))`)
|
|
.join(" AND ");
|
|
}
|
|
|
|
/** Darf diese Person ueberhaupt etwas sehen, das einem Modi gehoert?
|
|
* Nur die DogFather-Rolle und die Modis selbst. */
|
|
export const siehtModis = (person) => !!person
|
|
&& (person.rolle === "admin" || TEAM_DOGI_ROLLEN.has(person.rolle));
|
|
|
|
export const siehtAlles = (person) => !!person
|
|
&& (person.rolle === "admin" || person.rolle === "spicy");
|
|
|
|
/* Der Rollenschluessel bleibt "admin" -- er steckt in der CHECK-Regel der
|
|
Datenbank, in jeder Sitzung und in jeder Rechteabfrage. Umbenannt wird
|
|
nur, was man LIEST. Diese Zuordnung ist die einzige Stelle dafuer:
|
|
Stand der Anzeigename in elf Dateien, waere er beim naechsten Mal in
|
|
zehn davon geaendert.
|
|
|
|
Hinweis fuer spaeter: In den Kommentaren der Fachmodule heisst die
|
|
Rolle "admin" weiterhin "das Management". Gemeint ist dieselbe Rolle,
|
|
die in der Oberflaeche "DogFather" heisst. */
|
|
export const ROLLEN_NAME = {
|
|
spicy: "Spicy Media",
|
|
admin: "DogFather",
|
|
manager: "Manager",
|
|
scout: "Scout",
|
|
creator: "Creator",
|
|
hand: "Rechte Hand",
|
|
modi: "Modi",
|
|
};
|
|
|
|
/* =====================================================================
|
|
DIE KACHELN EINES MODIS (10.09.2026)
|
|
|
|
Wo die anderen fuenf Rollen ihre Kacheln aus assets/js/bereiche.js
|
|
bekommen, kommen sie fuer einen Modi von hier. Der Grund ist derselbe
|
|
wie beim Anzeigenamen und bei der Rollenauswahl: bereiche.js wird an
|
|
JEDEN ausgeliefert, der die Seite oeffnet. Stuende dort auch nur
|
|
`rollen: [... , 'modi']`, waere die Rolle mit einem Blick in den
|
|
Quelltext gefunden -- und damit alles verraten.
|
|
|
|
NICHT NUR EINE LISTE VON ZIELEN, sondern die ganze Kachel. Wer nur
|
|
die Ziele schickte und die Beschriftung aus bereiche.js nehmen liesse,
|
|
bekaeme unter "Dashboard" den Untertitel "Alle Creator auf einen
|
|
Blick" -- fuer jemanden, der keine Creator betreut. Die Worte gehoeren
|
|
zum Empfaenger, nicht zum Ziel.
|
|
|
|
ZEICHEN, TON UND SZENE SIND SCHLUESSEL AUS bereiche.js, keine neuen.
|
|
Sie sagen nichts ueber Modis aus (jede andere Kachel benutzt dieselben)
|
|
und muessen deshalb nicht verborgen werden. Neue zu erfinden waere
|
|
genau falsch herum: Sie muessten in bereiche.js stehen, und dort
|
|
faellt jede unbenutzte Zeile irgendwann jemandem auf. Die Toene sind
|
|
dieselben wie an den entsprechenden Kacheln der anderen Rollen -- ein
|
|
Ton kennzeichnet die Kachel, nicht ihren Platz.
|
|
|
|
WAS NICHT DABEI IST, und warum: Uebersicht, Calls, Content, Reports,
|
|
Scouting, Personen und Automationen. Sie drehen sich um betreute
|
|
Creator, um die Agentur oder um Rechte -- fuer einen Modi waeren sie
|
|
leer oder nicht seine Sache. Der Rollen-Rundgang (pruef-rollen) zeigt,
|
|
dass sie ihn nicht abstuerzen lassen; das ist der Grund, sie
|
|
wegzulassen, und nicht der Grund, sie mitzunehmen.
|
|
|
|
ES IST EINE ROLLENFRAGE, KEINE PERSONENFRAGE: Alle Modis bekommen
|
|
dasselbe. Sollte das je auseinandergehen, gehoert die Auswahl an die
|
|
Person und nicht hierher -- dann aber bewusst und mit einer Stelle,
|
|
an der sie gepflegt wird.
|
|
===================================================================== */
|
|
const MODI_BEREICHE = [
|
|
{ gruppe: "Täglich", gruppeUnter: "Was du sowieso jeden Tag aufmachst",
|
|
name: "Aufgaben", unter: "Offen, in Arbeit, Review", zeichen: "aufgaben",
|
|
ton: 13, gross: true, ziel: "aufgaben.html", szene: "garage" },
|
|
{ gruppe: "Täglich", gruppeUnter: "Was du sowieso jeden Tag aufmachst",
|
|
name: "Chat", unter: "Nachrichten mit dem Team", zeichen: "chat",
|
|
ton: 4, ziel: "chat.html", szene: "lounge" },
|
|
{ gruppe: "Täglich", gruppeUnter: "Was du sowieso jeden Tag aufmachst",
|
|
name: "Kalender", unter: "Live-Zeiten und Termine", zeichen: "kalender",
|
|
ton: 8, ziel: "kalender.html", szene: "skyline" },
|
|
{ gruppe: "Täglich", gruppeUnter: "Was du sowieso jeden Tag aufmachst",
|
|
name: "Dateien", unter: "Ablage und Freigaben", zeichen: "dateien",
|
|
ton: 19, ziel: "dateien.html", szene: "arena" },
|
|
|
|
{ gruppe: "Täglich", gruppeUnter: "Was du sowieso jeden Tag aufmachst",
|
|
name: "Ideen-Board", unter: "Gesammelt und nach Dringlichkeit sortiert",
|
|
zeichen: "content", ton: 23, ziel: "bereich.html?b=ideen", szene: "portal" },
|
|
{ gruppe: "Täglich", gruppeUnter: "Was du sowieso jeden Tag aufmachst",
|
|
name: "Angebote", unter: "Vorschläge, über die DogFather entscheidet",
|
|
zeichen: "agentur", ton: 17, ziel: "bereich.html?b=angebote", szene: "halle" },
|
|
|
|
{ gruppe: "Rund ums Live", gruppeUnter: "Vor, während und nach der Sendung",
|
|
name: "Live-Ablauf", unter: "Checkliste für vorher, mittendrin und danach",
|
|
zeichen: "live", ton: 3, ziel: "bereich.html?b=live", szene: "portal" },
|
|
{ gruppe: "Rund ums Live", gruppeUnter: "Vor, während und nach der Sendung",
|
|
name: "Community", unter: "Begrüßen, vermitteln, wiedererkennen",
|
|
zeichen: "community", ton: 6, ziel: "bereich.html?b=community", szene: "lounge" },
|
|
{ gruppe: "Rund ums Live", gruppeUnter: "Vor, während und nach der Sendung",
|
|
name: "Technik", unter: "Ton, Bild, Verbindung", zeichen: "technik",
|
|
ton: 14, ziel: "bereich.html?b=technik", szene: "garage" },
|
|
|
|
{ gruppe: "Für dich", gruppeUnter: "Deine Seite im Team",
|
|
name: "Mein Steckbrief", unter: "Dein Bild und deine Kanäle", zeichen: "steckbrief",
|
|
ton: 10, ziel: "steckbrief.html", szene: "halle" },
|
|
{ gruppe: "Für dich", gruppeUnter: "Deine Seite im Team",
|
|
name: "Wissen", unter: "Nachschlagen statt nachfragen", zeichen: "wissen",
|
|
ton: 9, ziel: "wissen.html", szene: "arena" },
|
|
];
|
|
|
|
/**
|
|
* Die Kacheln dieser Person -- oder `null`, wenn sie aus bereiche.js
|
|
* kommen sollen.
|
|
*
|
|
* `null` und nicht eine leere Liste: Die Oberflaeche unterscheidet
|
|
* "nimm deine eigene Liste" von "du bekommst keine Kacheln". Eine leere
|
|
* Liste hiesse das Zweite, und die fuenf bekannten Rollen haetten ab
|
|
* sofort eine leere Startseite.
|
|
*/
|
|
/* Die Eingangs-Kachel steht hier -- VOR ihrer ersten Verwendung.
|
|
|
|
Sie stand zuerst weiter unten beim ZUSATZ_BEREICHE-Block, wo sie
|
|
thematisch hingehoert. Das Modul liess sich damit nicht mehr laden:
|
|
"Cannot access 'EINGANG_KACHEL' before initialization". `const` wird
|
|
erst an seiner Zeile gueltig, und HAND_BEREICHE greift weiter oben
|
|
darauf zu.
|
|
|
|
`node --check` hat das NICHT gemeldet -- es prueft die Schreibweise,
|
|
nicht die Reihenfolge. Gefunden hat es erst der Versuch, das Modul
|
|
wirklich zu laden. Eine Syntaxpruefung ist keine Ladeprobe. */
|
|
const EINGANG_KACHEL = {
|
|
gruppe: "Täglich", gruppeUnter: "Was du sowieso jeden Tag aufmachst",
|
|
name: "Eingang", unter: "Vorschläge und Angebote, die auf dich warten",
|
|
zeichen: "teamlage", ton: 24, ziel: "teamlage.html", szene: "halle",
|
|
};
|
|
|
|
|
|
/* DIE RECHTE HAND SIEHT DIESELBEN KACHELN WIE EIN MODI -- PLUS EINE.
|
|
|
|
Blueprint V3.0, Kapitel 3.1: "Gleicher Ueberblick wie Owner. Kann
|
|
Modis im Alltag koordinieren." Der gleiche Ueberblick heisst hier
|
|
ausdruecklich NICHT eine eigene Kachelwelt: Sie arbeitet an denselben
|
|
Dingen wie das Team, sieht darin aber alles statt nur das Eigene --
|
|
das entscheidet die Sichtbarkeitsregel, nicht die Kachelliste.
|
|
|
|
Dazu kommt der Eingang, denn sie "kann im Alltag vertretend
|
|
Entscheidungen treffen". Genau das passiert dort.
|
|
|
|
ABGELEITET UND NICHT ABGESCHRIEBEN: Wer eine Kachel fuer die Modis
|
|
hinzufuegt, bekommt sie hier automatisch mit. Eine zweite Liste waere
|
|
die Stelle, an der die rechte Hand irgendwann weniger sieht als das
|
|
Team, das sie koordinieren soll. */
|
|
const HAND_BEREICHE = [...MODI_BEREICHE, EINGANG_KACHEL];
|
|
|
|
export function bereicheFuer(person) {
|
|
if (person?.rolle === "modi") return MODI_BEREICHE;
|
|
if (person?.rolle === "hand") return HAND_BEREICHE;
|
|
return null;
|
|
}
|
|
|
|
/* WELCHE BEREICHSSEITEN EIN MODI OEFFNEN DARF -- abgeleitet aus seinen
|
|
eigenen Kacheln und nicht danebengeschrieben.
|
|
|
|
Eine zweite Liste waere die Stelle, an der es auseinanderlaeuft: Wer
|
|
eine Kachel hinzufuegt und den Eintrag vergisst, baut einen Knopf,
|
|
der auf die Startseite zurueckwirft. Wer eine Kachel entfernt und den
|
|
Eintrag vergisst, laesst eine Tuer offen. So kann beides nicht
|
|
passieren.
|
|
|
|
Gebraucht wird sie, weil `bereich.html` seit heute fuer ihn offen ist
|
|
-- ohne diese Einschraenkung kaeme er ueber die Adresszeile auch in
|
|
die Agentur-Ablage, die ihn nichts angeht. */
|
|
export const MODI_BEREICHE_ERLAUBT = new Set(
|
|
HAND_BEREICHE
|
|
.map((k) => (/bereich\.html\?b=([a-z]+)/.exec(k.ziel) || [])[1])
|
|
.filter(Boolean));
|
|
|
|
/* Der Satz unter der Begruessung. Die fuenf bekannten Rollen haben ihn
|
|
in assets/js/start.js stehen ("Creator · dein eigener Bereich"); fuer
|
|
einen Modi darf er dort nicht stehen und kommt deshalb von hier.
|
|
|
|
OHNE IHN STAND DA NUR DAS WORT "Modi" -- neben fuenf Rollen, die einen
|
|
ganzen Satz bekommen. Das sieht nicht verborgen aus, sondern
|
|
unfertig, und Filipe haette zu Recht gefragt, warum seine Seite
|
|
halbfertig wirkt. Gefunden hat das die Browserpruefung, nicht das
|
|
Nachdenken.
|
|
|
|
AUF AUGENHOEHE FORMULIERT: "dein Bereich im Team", nicht "dein
|
|
Bereich unter DogFather". Ein Modi ist Teil des Teams, kein
|
|
Untergebener -- das gilt fuer jeden Text im Haus. */
|
|
const MODI_ROLLENTEXT = "Modi · dein Bereich im Team";
|
|
|
|
/* DIE ZIERZEILE UEBER "DIE ZENTRALE".
|
|
|
|
Sie nennt den BETRIEB, unter dem gearbeitet wird -- seit dem
|
|
08.09.2026 auf Filipes Wunsch "Spicy Media" statt "Dogfather
|
|
Universe". Das steht fest in start.html, und fuer die fuenf bekannten
|
|
Rollen stimmt es auch.
|
|
|
|
FUER EINEN MODI STIMMT ES NICHT. Ein Modi gehoert nicht zur Agentur,
|
|
er moderiert die Lives -- "Team Dogi" heisst diese Runde auch auf der
|
|
oeffentlichen Seite (team-modis.html). Bis hierher stand ueber seiner
|
|
Startseite die Marke eines Betriebs, mit dem er nichts zu tun hat.
|
|
Aufgefallen ist das nicht beim Nachdenken, sondern auf dem
|
|
Bildschirmfoto der Browserpruefung.
|
|
|
|
Wie ueberall kommt der Text vom Server: Ein zweiter Markenname in
|
|
start.html waere zwar kein Verrat (er sagt nichts ueber Modis), aber
|
|
eine Zeile, die fuer fast jeden Leser tot dasteht -- und tote Zeilen
|
|
werden irgendwann falsch erklaert. */
|
|
const MODI_MARKE = "Team Dogi";
|
|
|
|
/* =====================================================================
|
|
DIE KATEGORIEN EINER MODI-AUFGABE (10.09.2026)
|
|
|
|
Es sind die des Anforderungsdokuments -- alle dreizehn, die der
|
|
Aufgabenkatalog in Teil 2 tatsaechlich benutzt, dazu "Sonstiges" aus
|
|
Kapitel 6.1.
|
|
|
|
BEIM ERSTEN ANLAUF WAREN ES ACHT, mit der Begruendung, dreizehn
|
|
Knoepfe seien auf einem Handy unbedienbar. Das war eine plausible
|
|
Erklaerung fuer etwas Falsches: Gebaut ist ein AUSWAHLFELD, keine
|
|
Knopfleiste -- vierzehn Eintraege darin sind voellig unauffaellig.
|
|
Die Zusammenfassung haette eine Uebersetzungstabelle noetig gemacht
|
|
("Branding gehoert zu Planung"), die spaeter niemand nachvollziehen
|
|
kann. Jetzt steht an jeder Aufgabe die Kategorie, die im Dokument
|
|
danebensteht.
|
|
|
|
DIE REIHENFOLGE IST DIE DER HAEUFIGKEIT, nicht die des Dokuments:
|
|
Was taeglich vorkommt, steht oben. Wer eine Kategorie sucht, soll
|
|
nicht scrollen muessen.
|
|
|
|
SIE STEHEN AUF DEM SERVER, nicht in assets/js/aufgaben.js. Nicht weil
|
|
die Woerter etwas verraten wuerden -- "Clipping" sagt nichts --,
|
|
sondern weil eine Liste, die im Browser jedes Menschen liegt und nur
|
|
bei einem einzigen benutzt wird, die Frage aufwirft, fuer wen sie da
|
|
ist. Genau diese Frage soll niemand stellen. */
|
|
export const MODI_KATEGORIEN = [
|
|
{ wert: "chat", name: "Chat-Moderation" },
|
|
{ wert: "community", name: "Community" },
|
|
{ wert: "events", name: "Events" },
|
|
{ wert: "clipping", name: "Clipping" },
|
|
{ wert: "social", name: "Social Media" },
|
|
{ wert: "technik", name: "Technik" },
|
|
{ wert: "planung", name: "Planung" },
|
|
{ wert: "organisation", name: "Organisation" },
|
|
{ wert: "team", name: "Team" },
|
|
{ wert: "kommunikation", name: "Kommunikation" },
|
|
{ wert: "analyse", name: "Analyse" },
|
|
{ wert: "wachstum", name: "Wachstum" },
|
|
{ wert: "branding", name: "Branding" },
|
|
{ wert: "sonstiges", name: "Sonstiges" },
|
|
];
|
|
/**
|
|
* Darf diese Person mit Kategorien arbeiten?
|
|
*
|
|
* Ein Modi (es sind seine Aufgaben) und die DogFather-Rolle (sie sieht
|
|
* seine Aufgaben und legt ihm welche an). Sonst niemand -- und "sonst
|
|
* niemand" heisst hier: Die Liste kommt gar nicht erst mit, statt
|
|
* mitzukommen und ausgeblendet zu werden.
|
|
*/
|
|
export function kategorienFuer(person) {
|
|
if (!person) return [];
|
|
return TEAM_DOGI_ROLLEN.has(person.rolle) || person.rolle === "admin"
|
|
? MODI_KATEGORIEN : [];
|
|
}
|
|
|
|
/**
|
|
* FUER WESSEN AUFGABEN gilt die Kategorie? (10.09.2026)
|
|
*
|
|
* null -> fuer alle eigenen; die Oberflaeche zeigt das Feld immer
|
|
* [Nummern] -> nur wenn eine dieser Personen gewaehlt ist
|
|
* [] -> nie
|
|
*
|
|
* WARUM DAS DER SERVER BEANTWORTET UND NICHT DER BROWSER: Die
|
|
* Oberflaeche muesste sonst `p.rolle === 'modi'` vergleichen -- und
|
|
* assets/js/aufgaben.js bekommt JEDER, der die Seite oeffnet. Genau so
|
|
* stand es beim ersten Anlauf da, an sechs Stellen. Mit Nummern statt
|
|
* einem Rollennamen steht im Browser nichts, woraus sich etwas
|
|
* schliessen laesst: Wer keine bekommt, sieht eine leere Liste, und
|
|
* eine leere Liste sagt nichts.
|
|
*/
|
|
export function kategoriePersonen(person) {
|
|
if (!person) return [];
|
|
/* Ein Modi arbeitet ausschliesslich an eigenen Aufgaben -- dort gilt
|
|
sie immer, unabhaengig davon, wen er eintraegt. */
|
|
if (person.rolle === "modi") return null;
|
|
if (person.rolle !== "admin" && person.rolle !== "hand") return [];
|
|
try {
|
|
/* Auch die rechte Hand selbst: Sie traegt Aufgaben ein -- fuer
|
|
andere UND fuer sich. Waere ihre eigene Nummer nicht dabei,
|
|
verschwaende das Kategoriefeld genau dann, wenn sie sich selbst
|
|
etwas notiert. */
|
|
const liste = [...TEAM_DOGI_ROLLEN].map((r) => `'${r}'`).join(", ");
|
|
return db().prepare(
|
|
`SELECT id FROM personen WHERE rolle IN (${liste}) AND aktiv = 1`).all().map((z) => z.id);
|
|
} catch {
|
|
/* Ohne Datenbank lieber kein Feld als ein falsches. */
|
|
return [];
|
|
}
|
|
}
|
|
|
|
/* Der Satz der rechten Hand sagt, was ihre Rolle ist, ohne sie ueber
|
|
die anderen zu stellen: Sie haelt den Ueberblick, sie steht nicht
|
|
darueber. */
|
|
const HAND_ROLLENTEXT = "Rechte Hand · du hast alles im Blick und entscheidest mit";
|
|
|
|
export function rollentextFuer(person) {
|
|
if (person?.rolle === "modi") return MODI_ROLLENTEXT;
|
|
if (person?.rolle === "hand") return HAND_ROLLENTEXT;
|
|
return null;
|
|
}
|
|
|
|
export function markeFuer(person) {
|
|
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. */
|
|
const ZUSATZ_BEREICHE = { admin: [EINGANG_KACHEL] };
|
|
|
|
export function zusatzBereicheFuer(person) {
|
|
return ZUSATZ_BEREICHE[person?.rolle] || [];
|
|
}
|
|
|
|
/* scrypt-Parameter. N=2^15 braucht auf diesem Server rund 150 ms — spürbar
|
|
genug, um Rateversuche auszubremsen, und unauffällig für einen echten
|
|
Anmeldevorgang. Die Werte werden je Datensatz mitgespeichert, damit sie
|
|
später erhöht werden können, ohne alte Codes ungültig zu machen. */
|
|
const SCRYPT = { N: 32768, r: 8, p: 1, keylen: 64 };
|
|
|
|
let _db = null;
|
|
let _dbFehler = null;
|
|
|
|
/* =====================================================================
|
|
Umstellungen an bestehenden Datenbanken.
|
|
|
|
CREATE TABLE IF NOT EXISTS legt eine Tabelle nur an, wenn sie fehlt --
|
|
eine vorhandene wird NICHT angefasst. Die CHECK-Regel fuer die Rolle
|
|
stand also weiterhin auf den alten drei Werten, und ein Manager waere
|
|
an der Datenbank gescheitert, obwohl im Code alles stimmt.
|
|
|
|
SQLite kann eine CHECK-Regel nicht aendern. Der einzige saubere Weg
|
|
ist: neue Tabelle, Daten hinueber, alte weg, neue umbenennen. Das ist
|
|
der Moment, in dem eine Datenbank kaputtgehen kann -- deshalb wird
|
|
vorher eine vollstaendige Sicherung geschrieben (VACUUM INTO, von
|
|
SQLite selbst und in sich konsistent, anders als ein Dateikopie
|
|
waehrend laufender Schreibvorgaenge).
|
|
===================================================================== */
|
|
/* =====================================================================
|
|
DIE ROLLENLISTE DER DATENBANK ERWEITERN (09.09.2026)
|
|
|
|
Welche Rollen es geben darf, steht als CHECK-Regel in der Tabelle
|
|
`personen`. SQLite kann eine CHECK-Regel nicht aendern -- der einzige
|
|
saubere Weg ist: neue Tabelle, Daten hinueber, alte weg, neue
|
|
umbenennen. Das ist der Moment, in dem eine Datenbank kaputtgehen
|
|
kann. Deshalb steht der Ablauf ab jetzt EINMAL hier.
|
|
|
|
WARUM ALS FUNKTION: Er stand vorher zweimal da -- einmal fuer
|
|
'manager', einmal fuer 'spicy'. Fuer 'modi' waere es die dritte
|
|
Abschrift geworden, und jede Abschrift ist eine Gelegenheit, eine der
|
|
vier Absicherungen zu vergessen: die Sicherung VORHER, die Zaehlung
|
|
INNERHALB der Transaktion, die Spaltenliste aus der Tabelle statt aus
|
|
dem Gedaechtnis, die Pruefung auf verwaiste Verweise DANACH. Der
|
|
manager-Block darueber bleibt unangetastet: Er baut die Tabelle mit
|
|
einer fest hingeschriebenen Spaltenliste auf und ist damit ein
|
|
anderer Fall -- ihn mit einzufangen waere ein zweiter Umbau an einer
|
|
Stelle, an der ein Fehler Daten kostet.
|
|
|
|
GEPRUEFT WIRD DIE REGEL, NICHT DER TEXT (07.09.2026, gefunden von
|
|
pruef-spicy): SQLite hebt den CREATE-Text woertlich auf, mitsamt
|
|
Kommentaren. Ein erklaerender Satz mit dem Wort 'spicy' genuegte, und
|
|
die Umstellung hielt die Tabelle fuer schon umgestellt, obwohl die
|
|
CHECK-Regel noch die alte war. Deshalb wird die Regel herausgeschnitten
|
|
und NUR darin gesucht.
|
|
|
|
@param marker Die Rolle, an der erkannt wird, ob schon umgestellt ist.
|
|
@param rollen Die vollstaendige neue Liste erlaubter Rollen.
|
|
===================================================================== */
|
|
function checkListeErweitern(d, tabelle, spalte, marker, werte, jetztStempel) {
|
|
const rollenPlan = d.prepare(
|
|
"SELECT sql FROM sqlite_master WHERE type = 'table' AND name = ?").get(tabelle)?.sql || "";
|
|
/* DOPPELTE BACKSLASHES, und das ist kein Schoenheitsfehler:
|
|
In einem Template-Literal verschluckt JavaScript den einfachen
|
|
Backslash -- aus `\s` wird schlicht `s`, das Muster hiesse dann
|
|
"bereichs+INs*(...)" und traefe nie etwas. Die Umstellung haette
|
|
daraufhin STILLSCHWEIGEND nichts getan: keine Regel gefunden,
|
|
also nichts umzustellen, kein Fehler, kein Hinweis. Gemessen
|
|
statt ueberlegt -- der Unterschied war im Quelltext nicht zu
|
|
sehen. */
|
|
const muster = new RegExp(`${spalte}\\s+IN\\s*\\(([^)]*)\\)`, "i");
|
|
const regel = rollenPlan.match(muster)?.[1] || "";
|
|
/* Keine Regel gefunden heisst: Es gibt nichts umzustellen. Nicht
|
|
etwa "dann bauen wir sie neu" -- eine Tabelle ohne CHECK ist ein
|
|
Zustand, den jemand ansehen sollte, kein Fall fuer Automatik. */
|
|
if (!rollenPlan || !regel || regel.includes(`'${marker}'`)) return;
|
|
|
|
const sicherung = `${DB_PFAD}.vor-${marker}-${jetztStempel}`;
|
|
try {
|
|
d.exec(`VACUUM INTO '${sicherung.replace(/'/g, "''")}'`);
|
|
console.log("[workspace] Sicherung vor der Umstellung:", sicherung);
|
|
} catch (fehler) {
|
|
/* Ohne Sicherung wird NICHT umgestellt. Lieber laeuft die neue Rolle
|
|
noch nicht, als dass Daten ohne Netz angefasst werden. */
|
|
console.error("[workspace] Sicherung fehlgeschlagen, Umstellung abgebrochen:", fehler?.message);
|
|
return;
|
|
}
|
|
|
|
/* Nur die Rollenliste ersetzen -- und pruefen, dass es geklappt hat.
|
|
|
|
`IF NOT EXISTS` MUSS MIT INS MUSTER (07.09.2026, gefunden von
|
|
pruef-spicy). Die Tabelle wurde mit `CREATE TABLE IF NOT EXISTS
|
|
personen` angelegt, also steht das auch in sqlite_master. Ohne diese
|
|
drei Woerter im Muster griff die Ersetzung nicht, der Bauplan blieb
|
|
unveraendert, die Schutzabfrage darunter schlug an -- und die
|
|
Umstellung waere auf dem echten Server NIE gelaufen. Sie haette es
|
|
gesagt (das ist der Wert der Abfrage), aber sie waere nie gelaufen. */
|
|
/* KEIN MUSTER FUER DEN TABELLENKOPF -- alles vor der ersten Klammer
|
|
IST der Kopf ("CREATE TABLE IF NOT EXISTS eintraege "). Hier stand
|
|
eine Regel mit Anfuehrungszeichen, Backticks und Backslashes in
|
|
einem Template-Literal, und genau daran ist sie gescheitert:
|
|
JavaScript verschluckt darin den einfachen Backslash, aus dem
|
|
Muster wurde "CREATE TABLEs+..." und es traf nie etwas. Die
|
|
Umstellung haette daraufhin STILLSCHWEIGEND nichts getan.
|
|
|
|
Ein Schnitt an der ersten Klammer braucht keine Maskierung, ist in
|
|
einer Zeile zu lesen und kann nicht falsch escaped sein. */
|
|
const klammer = rollenPlan.indexOf('(');
|
|
if (klammer < 0) {
|
|
console.error('[workspace] Umstellung abgebrochen: Bauplan ohne Klammer.');
|
|
return;
|
|
}
|
|
const neuerPlan = (`CREATE TABLE ${tabelle}_neu ` + rollenPlan.slice(klammer))
|
|
.replace(new RegExp(`${spalte}\\s+IN\\s*\\([^)]*\\)`, "i"),
|
|
`${spalte} IN (${werte.map((r) => `'${r}'`).join(",")})`);
|
|
if (!neuerPlan.includes(`'${marker}'`) || !neuerPlan.includes(`${tabelle}_neu`)) {
|
|
console.error("[workspace] Umstellung abgebrochen: Der Bauplan liess sich nicht "
|
|
+ "umschreiben. Steht die CHECK-Regel noch so da wie erwartet?");
|
|
return;
|
|
}
|
|
|
|
/* Die Spaltenliste kommt aus der Tabelle, nicht aus dem Gedaechtnis. */
|
|
const spalten = d.prepare(`PRAGMA table_info(${tabelle})`).all().map((z) => z.name);
|
|
if (!spalten.length) {
|
|
console.error("[workspace] Umstellung abgebrochen: keine Spalten gefunden.");
|
|
return;
|
|
}
|
|
const liste = spalten.map((n) => `"${n}"`).join(", ");
|
|
|
|
/* 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");
|
|
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 uebernommen, Verweise geprueft.`);
|
|
}
|
|
}
|
|
} catch (fehler) {
|
|
try { d.exec("ROLLBACK"); } catch { /* schon zurueckgerollt */ }
|
|
console.error("[workspace] Umstellung fehlgeschlagen:", fehler?.message,
|
|
"-- Sicherung:", sicherung);
|
|
} finally {
|
|
d.exec("PRAGMA foreign_keys = ON");
|
|
}
|
|
}
|
|
|
|
function umstellungen(d) {
|
|
/* MIT SEKUNDEN, nicht nur mit Minuten (07.09.2026).
|
|
|
|
`VACUUM INTO` weigert sich, eine vorhandene Datei zu ueberschreiben
|
|
("output file already exists") -- zu Recht, eine Sicherung darf
|
|
nichts wegwerfen. Der Stempel hatte aber nur Minutenaufloesung:
|
|
Zwei Umstellungen innerhalb derselben Minute wollten in dieselbe
|
|
Datei, die zweite scheiterte, und weil ohne Sicherung nicht
|
|
umgestellt wird, blieb sie einfach aus.
|
|
|
|
Das ist genau die unangenehme Sorte Fehler: Es sieht nach "die
|
|
Umstellung lief" aus (die Datei liegt ja da) und war doch keine.
|
|
Gefunden von pruef-spicy, das den Server zweimal in derselben
|
|
Minute startet -- im Betrieb genuegt dafuer ein Neustart im
|
|
falschen Moment. */
|
|
const jetztStempel = new Date().toISOString().slice(0, 19).replace(/[-:T]/g, "");
|
|
|
|
/* ---- Zeitpunkt des Hochladens in der Bibliothek ----
|
|
Eine neue Spalte laesst SQLite anstandslos anhaengen -- anders als
|
|
eine CHECK-Regel. Alte Eintraege bleiben leer und fallen auf das
|
|
Veroeffentlichungsdatum zurueck. */
|
|
try {
|
|
const spalten = d.prepare("PRAGMA table_info(wissen)").all().map((s) => s.name);
|
|
if (spalten.length && !spalten.includes("hochgeladen")) {
|
|
d.exec("ALTER TABLE wissen ADD COLUMN hochgeladen TEXT");
|
|
console.log("[workspace] Spalte 'hochgeladen' in der Bibliothek ergaenzt.");
|
|
}
|
|
} catch (fehler) {
|
|
console.error("[workspace] Spalte 'hochgeladen':", fehler?.message);
|
|
}
|
|
|
|
/* ---- Content-Planung: aus der Liste wird eine Strecke ----------------
|
|
|
|
Die Content-Planung war eine flache Liste mit drei Arten. Die
|
|
Recherche vom 31.08.2026 ist an dieser Stelle eindeutig: *"A content
|
|
calendar for creators is a pipeline, not a datebook."* Und der
|
|
wichtigste Einzelwert eines Kurzvideos ist der Aufhaenger -- die
|
|
ersten ein bis drei Sekunden entscheiden ueber die Verbreitung. Der
|
|
gehoert in ein eigenes Feld und nicht irgendwo in den Fliesstext.
|
|
|
|
Vier neue Spalten. ALTER TABLE ADD COLUMN laesst SQLite anstandslos
|
|
zu; alte Eintraege bleiben leer und stoeren nicht. */
|
|
/* ---- Der eigene Steckbrief (01.09.2026) ------------------------------
|
|
|
|
Jede Person -- Creator, Scout, Manager, DogFather -- bekommt ein
|
|
eigenes Profil: ein Bild, ein Satz ueber sich, und die Kanaele, auf
|
|
denen sie unterwegs ist.
|
|
|
|
Warum der TikTok-Name als eigene Spalte und nicht als freier Text:
|
|
Er wird zu einem Verweis, taucht in der Personenliste auf und soll
|
|
eindeutig sein. Gespeichert wird NUR der oeffentliche Name (das
|
|
Handle ohne @) -- kein Zugriffstoken, kein Passwort. Eine echte
|
|
Anmeldung bei TikTok braucht ein Entwicklerkonto mit Freischaltung
|
|
und laufender Pruefung durch TikTok; das waere eine eigene Baustelle
|
|
mit Kosten und Abhaengigkeit. Der Verweis leistet das, was hier
|
|
gebraucht wird: Man kommt mit einem Klick auf den Kanal, und in der
|
|
Uebersicht steht, wer wo unterwegs ist.
|
|
|
|
Das Bild liegt als Datei auf der Platte, in der Datenbank steht nur
|
|
der Dateiname. Bilder in eine SQLite-Datei zu legen macht jede
|
|
Sicherung um ein Vielfaches groesser -- und die Sicherung laeuft
|
|
jede Nacht. */
|
|
for (const [tabelle, spalte, typ] of [
|
|
["eintraege", "hook", "TEXT"], // die ersten Sekunden
|
|
["eintraege", "format", "TEXT"], // Talking Head, Tutorial, Story …
|
|
["eintraege", "saeule_id", "INTEGER"],// zu welcher Content-Saeule
|
|
["eintraege", "geplant", "TEXT"], // wann es raus soll
|
|
|
|
["personen", "bild", "TEXT"], // Dateiname des Profilbildes
|
|
["personen", "ueber_mich", "TEXT"], // ein Satz zur Person
|
|
["personen", "tiktok", "TEXT"], // öffentlicher Name, ohne @
|
|
["personen", "instagram", "TEXT"],
|
|
["personen", "youtube", "TEXT"],
|
|
["personen", "twitch", "TEXT"],
|
|
|
|
/* ---- Wiederkehrende Termine (02.09.2026) ----
|
|
Drei Spalten an `termine`, damit eine Ausprägung weiss, woher sie
|
|
stammt:
|
|
serie_id die Regel, aus der sie entstanden ist
|
|
serie_tag der geplante Tag -- daran erkennt der Nachfüller,
|
|
dass es diese Ausprägung schon gibt, und legt sie
|
|
kein zweites Mal an
|
|
serie_beruehrt jemand hat sie von Hand angefasst (verschoben,
|
|
umbenannt, abgehakt). Solche Termine bleiben beim
|
|
Abstellen der Serie stehen -- was jemand
|
|
bearbeitet hat, wird ihm nicht weggeräumt. */
|
|
["termine", "serie_id", "INTEGER REFERENCES termin_serien(id) ON DELETE SET NULL"],
|
|
["termine", "serie_tag", "TEXT"],
|
|
["termine", "serie_beruehrt", "INTEGER NOT NULL DEFAULT 0"],
|
|
|
|
/* ---- Frei eingetragene Namen (02.09.2026) ----
|
|
Wunsch: "ich will da auch sachen selber noch eintragen können,
|
|
also aussuchen und selbst eintragen."
|
|
|
|
Bis hierher konnte an einem Eintrag nur stehen, wer auch ein Konto
|
|
im Workspace hat. Eine Agentur, eine Marke, ein Gast liess sich
|
|
nicht eintragen -- man haette dafuer ein Konto anlegen muessen,
|
|
das nie jemand benutzt.
|
|
|
|
Eine EIGENE Spalte je Feld, kein Missbrauch der id-Spalte: Ein
|
|
Fremdschluessel zeigt auf eine Person oder auf nichts. Einen
|
|
Namen dort hineinzuschreiben haette jede Verknuepfung zerstoert.
|
|
|
|
Es gilt entweder/oder: Steht eine Person drin, ist das Feld leer,
|
|
und umgekehrt. Durchgesetzt wird das beim Speichern (siehe
|
|
externPruefen), nicht durch eine CHECK-Regel -- die liesse sich
|
|
an einer bestehenden Tabelle nicht mehr nachtragen.
|
|
|
|
WICHTIG, damit nichts still verschwindet: Ueberall, wo bisher der
|
|
Name der verknuepften Person gelesen wurde, faellt die Abfrage
|
|
jetzt auf diesen Text zurueck (siehe externSql). Ein frei
|
|
eingetragener Name steht dadurch in jeder Liste, jeder Suche und
|
|
jeder Uebersicht mit da -- gekennzeichnet als "(extern)", damit
|
|
niemand ihn fuer ein Konto haelt. */
|
|
["termine", "teilnehmer_extern", "TEXT"],
|
|
["termin_serien", "teilnehmer_extern", "TEXT"],
|
|
["aufgaben", "creator_extern", "TEXT"],
|
|
["aufgaben", "verantwortlich_extern", "TEXT"],
|
|
["eintraege", "creator_extern", "TEXT"],
|
|
["dateien", "creator_extern", "TEXT"],
|
|
|
|
/* ABBRECHEN (05.09.2026). Wunsch: *"ich will dass man in jedem
|
|
status die aufgaben auch abbrechen kann."*
|
|
|
|
Drei Spalten, weil "abgebrochen" allein zu wenig sagt:
|
|
abbruch_grund WARUM. Pflichtfeld in der Oberflaeche -- ohne
|
|
Grund weiss in vier Wochen niemand mehr, warum
|
|
etwas wegfiel, und es sieht aus wie Loeschen.
|
|
abgebrochen_am WANN.
|
|
abbruch_von WER. Nur Leitung und Scouts duerfen es, und
|
|
wer es war, gehoert dazu.
|
|
status_vorher IN WELCHEM ZUSTAND. Diese Spalte ist der Grund,
|
|
warum "abgebrochen" ein eigener Status wurde
|
|
und nicht bloss ein Haken: Ohne sie ginge beim
|
|
Wechsel verloren, ob die Arbeit schon lief.
|
|
"Im Review abgebrochen" ist eine ganz andere
|
|
Aussage als "nie angefangen". */
|
|
["aufgaben", "abbruch_grund", "TEXT"],
|
|
["aufgaben", "abgebrochen_am", "TEXT"],
|
|
["aufgaben", "abbruch_von", "INTEGER REFERENCES personen(id) ON DELETE SET NULL"],
|
|
["aufgaben", "status_vorher", "TEXT"],
|
|
|
|
/* AGENTUR-EVENTS (07.09.2026).
|
|
|
|
Ein Event ist kein Notizzettel mit Ueberschrift und Text. Wer
|
|
mitmachen soll, muss vier Dinge finden, ohne zu fragen: bis wann
|
|
es laeuft, was zu tun ist, was es zu gewinnen gibt und was
|
|
verboten ist. Genau daran scheitern Aktionen -- nicht am Aufruf,
|
|
sondern an den Rueckfragen danach.
|
|
|
|
Der BEGINN ist `datum` (gibt es schon, ist NOT NULL). Neu ist
|
|
das Ende und die beiden Textfelder. `event_`-Vorsatz, weil es im
|
|
Workspace bereits eine Tabelle `aufgaben` gibt -- eine Spalte
|
|
`aufgaben` auf `eintraege` waere beim Lesen von Code jedes Mal
|
|
eine Verwechslung.
|
|
|
|
ADD COLUMN und kein Tabellenneubau: Es aendert sich kein CHECK,
|
|
also darf die Tabelle stehen bleiben. Der Neubau vom 06.09. war
|
|
noetig, weil dort der erlaubte Wertebereich von `bereich` wuchs
|
|
-- und er ist der Weg, bei dem man Inhalte verlieren kann. */
|
|
["eintraege", "event_ende", "TEXT"],
|
|
["eintraege", "event_aufgaben", "TEXT"],
|
|
["eintraege", "event_regeln", "TEXT"],
|
|
|
|
/* DER TAGESRUF BRAUCHT MEHR ALS AN/AUS (08.09.2026, screen10).
|
|
|
|
Filipe: "ich will das neben diesem kreis auch ein kleiner button
|
|
ist fuer den wecker von den aufgaben, oder quasi eine taetige
|
|
meldung einmal am tag zu aktivieren wenn noch aufgaben auf sind."
|
|
|
|
Eine Uhrzeit ist kein Schalter. Statt einer eigenen Tabelle fuer
|
|
eine einzige Zahl bekommt `push_einstellungen` eine allgemeine
|
|
Spalte `wert`: Wo eine Art mehr braucht als an/aus, steht es
|
|
dort. Die vorhandenen Arten lassen sie leer und merken nichts
|
|
davon.
|
|
|
|
KEIN `NOT NULL`, KEINE VORGABE IN DER DATENBANK: Die Vorgabe
|
|
gehoert nach workspace-push.js zu den Arten, sonst stuende sie an
|
|
zwei Stellen -- und die in der Datenbank wuerde bei einer
|
|
Aenderung vergessen. Genau diese Sorte Doppelung hat mich heute
|
|
schon dreimal aufgehalten. */
|
|
["push_einstellungen", "wert", "TEXT"],
|
|
|
|
/* ANTWORTEN MIT ZITAT (09.09.2026, Punkt 15).
|
|
|
|
Filipe: "ich will dass du diese seite viel krasser und
|
|
detaillierter machst ... alles reinsetzt was wir noch gebrauchen
|
|
koennten."
|
|
|
|
In einem Gespraech zu zweit weiss man meistens, worauf sich
|
|
etwas bezieht. In einer Gruppe nicht -- dort laufen drei Faeden
|
|
parallel, und "ja, mach das" kann alles heissen. Die Spalte
|
|
zeigt auf die Nachricht, auf die geantwortet wird.
|
|
|
|
KEIN FREMDSCHLUESSEL AUF chat_nachrichten: Wird die zitierte
|
|
Nachricht zurueckgenommen, bleibt ihre Zeile ohnehin stehen
|
|
(nur der Text wird geleert) -- und wird der ganze Raum
|
|
geloescht, geht die Antwort per CASCADE ueber `raum_id`
|
|
ohnehin mit. Ein zweiter Schluessel brauchte nur eine zweite
|
|
Gelegenheit fuer eine Sperre. */
|
|
["chat_nachrichten", "antwort_auf", "INTEGER"],
|
|
|
|
/* GESPRAECH LOESCHEN -- NUR BEI MIR (09.09.2026).
|
|
|
|
Filipe: "man muss die chats auch geloescht bekommen!!!"
|
|
Auf Nachfrage entschieden: nur bei mir, nicht bei beiden. Das
|
|
Gespraech verschwindet aus MEINER Liste, das Gegenueber behaelt
|
|
seinen Verlauf. Sonst koennte jeder jedem die Unterhaltung
|
|
vernichten -- und in einem Gespraech, in dem Absprachen stehen,
|
|
waere das ein Loch, das niemand mehr fuellen kann.
|
|
|
|
ZWEI SPALTEN, NICHT EINE, und der Grund ist ein Randfall:
|
|
`geloescht_bis` haelt fest, bis zu welcher Nachricht ich es nicht
|
|
mehr sehen will. Bei einem Gespraech OHNE jede Nachricht waere
|
|
das 0 -- und 0 heisst auch "nie geloescht". `geloescht_am`
|
|
unterscheidet die beiden Faelle. Kommt spaeter etwas Neues
|
|
(id > geloescht_bis), taucht das Gespraech von selbst wieder auf;
|
|
das Alte bleibt weg. */
|
|
["chat_teilnehmer", "geloescht_bis", "INTEGER NOT NULL DEFAULT 0"],
|
|
["chat_teilnehmer", "geloescht_am", "TEXT"],
|
|
|
|
/* ANHAENGE: FOTOS UND PDF (09.09.2026).
|
|
|
|
Filipe: "am besten waere es auch wenn man da auch im chat pdfs
|
|
schicken koennte. pdfs und fotos."
|
|
|
|
Je Nachricht hoechstens ein Anhang -- deshalb Spalten und keine
|
|
eigene Tabelle. Eine Tabelle waere die richtige Wahl fuer
|
|
"mehrere je Nachricht"; die gibt es hier nicht, und eine leere
|
|
Verbundabfrage bei jedem Verlauf waere Aufwand fuer einen Fall,
|
|
den es nicht gibt.
|
|
|
|
`anhang_datei` ist der ERZEUGTE Name auf der Platte, niemals der
|
|
eingeschickte. `anhang_art` ist "bild" oder "pdf" und wird aus
|
|
den ersten Bytes der Datei bestimmt, nicht aus dem Namen und
|
|
nicht aus dem Content-Type -- beide kommen vom Absender.
|
|
Breite und Hoehe stehen dabei, damit der Verlauf beim Laden
|
|
eines Bildes nicht springt. */
|
|
["chat_nachrichten", "anhang_datei", "TEXT"],
|
|
["chat_nachrichten", "anhang_name", "TEXT"],
|
|
["chat_nachrichten", "anhang_art", "TEXT"],
|
|
["chat_nachrichten", "anhang_typ", "TEXT"],
|
|
["chat_nachrichten", "anhang_groesse", "INTEGER"],
|
|
["chat_nachrichten", "anhang_breite", "INTEGER"],
|
|
["chat_nachrichten", "anhang_hoehe", "INTEGER"],
|
|
|
|
/* WELCHE VORLAGE DAHINTERSTECKT (09.09.2026).
|
|
|
|
Filipe: "ich will nur dass du dich informierst und schaust was
|
|
man vielleicht noch besser machen koennte von der benutzung ...
|
|
ich red von den aufgaben."
|
|
|
|
Beim Durchgehen der Bedienung ist aufgefallen: Man konnte
|
|
dieselbe Vorlage zweimal uebernehmen und bekam die Aufgabe
|
|
zweimal auf das Brett -- ohne jeden Hinweis. Im Vorlagenbrett
|
|
sah man nicht, was man schon geholt hatte.
|
|
|
|
Die Kennung der Vorlage steht jetzt an der Aufgabe. Damit kann
|
|
das Brett zeigen, was bereits uebernommen ist, und es braucht
|
|
dafuer KEINE zweite Abfrage: Die Aufgabenliste hat der Browser
|
|
ohnehin schon. Ueber den TITEL zu vergleichen waere die
|
|
naheliegende Abkuerzung gewesen -- und faellt in dem Moment um,
|
|
in dem jemand den Titel einer uebernommenen Aufgabe aendert. */
|
|
["aufgaben", "vorlage", "TEXT"],
|
|
|
|
/* DER SUCHSCHLUESSEL ZUM CODE (09.09.2026).
|
|
|
|
Bisher gab es nur den scrypt-Hash, und der ist mit Absicht
|
|
langsam (rund 150 ms). Wer eine Person AM CODE finden will, muss
|
|
deshalb alle Kandidaten der Reihe nach durchrechnen -- die
|
|
Anmeldung tut genau das, und im Code steht seit langem der
|
|
Hinweis, dass das ab etwa 50 Codes je Rolle spuerbar wird.
|
|
|
|
Diese Spalte ist die schnelle Antwort auf "zu welcher Person
|
|
gehoert dieser Code?": ein HMAC ueber den Code, in Mikrosekunden
|
|
berechnet und mit einem Index hinterlegt.
|
|
|
|
WARUM DAS UNBEDENKLICH IST -- nachgemessen, nicht angenommen: Ein
|
|
Code besteht aus 16 Zeichen eines 32er-Alphabets, das sind 80 Bit
|
|
Zufall. Selbst wenn jemand die ganze Datenbank haette, waere
|
|
Durchprobieren aussichtslos, egal wie schnell die Rechnung ist.
|
|
Der Schluessel steckt zusaetzlich nicht im Code, sondern in den
|
|
Einstellungen (siehe kennungSchluessel).
|
|
|
|
DER SCRYPT-HASH BLEIBT DIE ENTSCHEIDUNG. Der Suchschluessel sagt
|
|
nur, WEN man pruefen soll; ob der Code stimmt, sagt weiterhin
|
|
allein scrypt. Ein Treffer hier allein laesst niemanden herein. */
|
|
["personen", "code_kennung", "TEXT"],
|
|
|
|
/* DIE STUFE IM TEAM (10.09.2026).
|
|
|
|
Blueprint V3.0, Kapitel 4.1: Probe, Standard, Senior. Ein
|
|
Probe-Modi ist in der Einarbeitung, ein Senior kann die rechte
|
|
Hand bei Abwesenheit vertreten.
|
|
|
|
WARUM OHNE CHECK-REGEL, anders als bei `rolle`: Eine CHECK-Liste
|
|
laesst sich in SQLite nur ueber einen Tabellenneubau erweitern
|
|
(siehe checkListeErweitern) -- und die Stufen sind eine
|
|
ARBEITSEINTEILUNG, keine Rechtegrenze. Kaeme morgen eine vierte
|
|
dazu, waere ein Tabellenneubau auf einer Live-Datenbank ein
|
|
hoher Preis fuer eine Beschriftung. Geprueft wird deshalb im
|
|
Code, an genau einer Stelle (STUFEN), und was dort nicht
|
|
draufsteht, wird abgewiesen.
|
|
|
|
NULL HEISST "Probe" und nicht "unbekannt". Ein dritter Zustand
|
|
waere eine Frage, die niemand beantworten kann ("was ist er
|
|
denn nun?"); und ein neuer Mensch faengt ohnehin in der
|
|
Einarbeitung an. Beim Lesen wird NULL deshalb auf 'probe'
|
|
abgebildet -- an einer Stelle, nicht in jeder Abfrage.
|
|
|
|
Die Spalte heisst wie `punkt_stand.stufe`, meint aber etwas
|
|
anderes (dort: gut/verbessern). Zwei Tabellen, kein Konflikt --
|
|
aber wer beides im selben Kopf hat, sollte es wissen. */
|
|
["personen", "stufe", "TEXT"],
|
|
|
|
/* DIE KATEGORIE EINER AUFGABE (10.09.2026).
|
|
|
|
Kapitel 6.1 des Anforderungsdokuments verlangt sie an jeder
|
|
Aufgabe. Filipes Entscheidung dazu: nur bei den Modis -- fuer
|
|
Creator, Scouts und Manager bleibt das Formular, wie es war.
|
|
|
|
KEIN CHECK IN DER SPALTE, obwohl die Werte fest sind. Ein CHECK
|
|
laesst sich in SQLite nicht aendern; die Liste um eine Kategorie
|
|
zu erweitern hiesse jedes Mal, die ganze Tabelle neu zu bauen.
|
|
Geprueft wird stattdessen beim Schreiben (siehe KATEGORIEN in
|
|
workspace-aufgaben.js) -- an EINER Stelle, durch die alle
|
|
Schreibwege laufen. */
|
|
["aufgaben", "kategorie", "TEXT"],
|
|
|
|
/* WAS DOGFATHER DAZU TUN MUESSTE (10.09.2026).
|
|
|
|
Filipe: *"auch entscheiden was ich machen muss wenn das und das
|
|
erledigt wird."* Der Modi schreibt es beim Angebot dazu; wird
|
|
das Angebot angenommen, wird genau daraus seine Aufgabe.
|
|
|
|
Ein eigenes Feld und nicht im Fliesstext: Aus einem Absatz laesst
|
|
sich keine Aufgabe machen, ohne zu raten, welcher Satz gemeint
|
|
war. */
|
|
["eintraege", "einsatz", "TEXT"],
|
|
]) {
|
|
try {
|
|
const vorhanden = d.prepare(`PRAGMA table_info(${tabelle})`).all().map((s) => s.name);
|
|
if (vorhanden.length && !vorhanden.includes(spalte)) {
|
|
d.exec(`ALTER TABLE ${tabelle} ADD COLUMN ${spalte} ${typ}`);
|
|
console.log(`[workspace] Spalte '${spalte}' in ${tabelle} ergaenzt.`);
|
|
}
|
|
} catch (fehler) {
|
|
console.error(`[workspace] Spalte '${spalte}':`, fehler?.message);
|
|
}
|
|
}
|
|
|
|
/* Der Nachfüller fragt bei jedem Lauf "welche Ausprägungen dieser
|
|
Serie gibt es schon?". Ohne diesen Verbund-Index liest SQLite dafür
|
|
die ganze Termintabelle. Er steht hier unten und nicht oben im
|
|
Bauplan, weil die beiden Spalten erst durch die Schleife darüber
|
|
entstehen -- oben gäbe es sie beim ersten Start noch nicht. */
|
|
try {
|
|
/* Ohne Index waere der Suchschluessel sinnlos -- SQLite laese die
|
|
ganze Personentabelle, und genau das sollte er ersparen. Steht
|
|
aus demselben Grund hier unten wie der Termin-Index: Die Spalte
|
|
entsteht erst durch die Schleife darueber. */
|
|
d.exec("CREATE INDEX IF NOT EXISTS idx_personen_kennung ON personen (code_kennung)");
|
|
} catch (fehler) {
|
|
console.error("[workspace] Index idx_personen_kennung:", fehler?.message);
|
|
}
|
|
|
|
try {
|
|
d.exec("CREATE INDEX IF NOT EXISTS idx_termine_serie ON termine (serie_id, serie_tag)");
|
|
} catch (fehler) {
|
|
console.error("[workspace] Index idx_termine_serie:", fehler?.message);
|
|
}
|
|
|
|
/* ---- Die vorhandenen Teilnehmer in die neue Tabelle uebernehmen ----
|
|
|
|
Ohne diesen Schritt haette jeder Termin, der vor dem 05.09.2026
|
|
angelegt wurde, eine LEERE Teilnehmerliste -- und weil an dieser
|
|
Liste die Sichtbarkeit haengt, faende die eingetragene Person ihren
|
|
eigenen Termin nicht mehr. Ein stiller Datenverlust, der erst
|
|
auffaellt, wenn jemand einen Call sucht, den es noch gibt.
|
|
|
|
`INSERT OR IGNORE` macht den Lauf wiederholbar: Beim zweiten Start
|
|
ist alles schon da und nichts passiert. Deshalb steht das hier bei
|
|
den Umstellungen und nicht in einem einmaligen Skript, das jemand
|
|
vergessen koennte. */
|
|
for (const [tabelle, quelle, ziel, schluessel] of [
|
|
["termin_teilnehmer", "termine", "termin_id", "id"],
|
|
["serie_teilnehmer", "termin_serien", "serie_id", "id"],
|
|
]) {
|
|
try {
|
|
const spalten = d.prepare(`PRAGMA table_info(${quelle})`).all().map((s) => s.name);
|
|
if (!spalten.includes("teilnehmer_id")) continue;
|
|
const vorher = d.prepare(`SELECT COUNT(*) AS n FROM ${tabelle}`).get().n;
|
|
d.exec(`INSERT OR IGNORE INTO ${tabelle} (${ziel}, person_id)
|
|
SELECT ${schluessel}, teilnehmer_id FROM ${quelle}
|
|
WHERE teilnehmer_id IS NOT NULL`);
|
|
const nachher = d.prepare(`SELECT COUNT(*) AS n FROM ${tabelle}`).get().n;
|
|
if (nachher > vorher) {
|
|
console.log(`[workspace] ${nachher - vorher} vorhandene Teilnehmer nach ${tabelle} uebernommen.`);
|
|
}
|
|
} catch (fehler) {
|
|
console.error(`[workspace] Uebernahme nach ${tabelle}:`, fehler?.message);
|
|
}
|
|
}
|
|
|
|
/* Content-Saeulen: die drei bis fuenf Themen, aus denen der Kanal
|
|
besteht. Aus der Recherche: 3-5 Saeulen nach der 70/20/10-Regel
|
|
(70 % Wert, 20 % Community, 10 % Eigenwerbung); eine Saeule wird
|
|
erst brauchbar, wenn Formate an ihr haengen, und einmal im Monat
|
|
schaut man nach, welche ihr Gewicht traegt.
|
|
|
|
Je Creator eigene Saeulen -- ein Gaming-Kanal und ein Bastelkanal
|
|
haben nichts gemeinsam, eine gemeinsame Liste waere fuer beide
|
|
falsch. */
|
|
try {
|
|
d.exec(`
|
|
CREATE TABLE IF NOT EXISTS content_saeulen (
|
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
creator_id INTEGER NOT NULL REFERENCES personen(id) ON DELETE CASCADE,
|
|
name TEXT NOT NULL,
|
|
ziel INTEGER, -- gewuenschter Anteil in Prozent
|
|
slot INTEGER NOT NULL DEFAULT 1, -- Farbplatz 1..5, siehe content.css
|
|
erstellt TEXT NOT NULL
|
|
);
|
|
CREATE INDEX IF NOT EXISTS idx_saeulen_creator ON content_saeulen (creator_id);
|
|
`);
|
|
} catch (fehler) {
|
|
console.error("[workspace] content_saeulen:", fehler?.message);
|
|
}
|
|
|
|
/* Die alte Art "produktion" wird zu "gedreht". Vorher gab es drei
|
|
Stufen (Idee, Produktion, Veroeffentlicht) -- dazwischen lag alles,
|
|
was in Wahrheit den Unterschied macht: Aufhaenger geschrieben,
|
|
abgedreht, geschnitten, terminiert. Ohne diese Umbenennung fielen
|
|
bestehende Eintraege aus jeder Spalte heraus und waeren unsichtbar. */
|
|
try {
|
|
const betroffen = d.prepare(
|
|
"SELECT COUNT(*) AS n FROM eintraege WHERE bereich = 'content' AND art = 'produktion'").get()?.n ?? 0;
|
|
if (betroffen) {
|
|
d.prepare("UPDATE eintraege SET art = 'gedreht' WHERE bereich = 'content' AND art = 'produktion'").run();
|
|
console.log(`[workspace] ${betroffen} Content-Eintraege von 'produktion' auf 'gedreht' umgestellt.`);
|
|
}
|
|
} catch (fehler) {
|
|
console.error("[workspace] Content-Arten:", fehler?.message);
|
|
}
|
|
|
|
/* ---- Status "abgebrochen" erlauben (05.09.2026) ----
|
|
|
|
Der CHECK-Constraint einer Tabelle laesst sich in SQLite nicht
|
|
aendern -- kein ALTER TABLE der Welt hilft, die Tabelle muss neu
|
|
gebaut werden. Deshalb derselbe Weg wie bei der Rolle "manager"
|
|
darunter: erst sichern, dann tauschen, danach die Verweise pruefen.
|
|
|
|
WARUM UEBERHAUPT EIN NEUER STATUS und nicht bloss ein Haken
|
|
"abgebrochen": Weil die vier Spalten des Bretts nach Status
|
|
gruppieren. Eine abgebrochene Aufgabe faellt damit von selbst aus
|
|
allen vieren heraus -- ohne dass irgendeine Abfrage angefasst
|
|
werden muss. Ein zusaetzlicher Haken haette bedeutet, JEDE Abfrage
|
|
um "AND nicht abgebrochen" zu ergaenzen, und die eine vergessene
|
|
waere ein stiller Fehler gewesen: Die Aufgabe stuende weiter im
|
|
Brett, und niemand wuesste warum. */
|
|
const aufgabenPlan = d.prepare(
|
|
"SELECT sql FROM sqlite_master WHERE type = 'table' AND name = 'aufgaben'").get()?.sql || "";
|
|
if (aufgabenPlan && !aufgabenPlan.includes("'abgebrochen'")) {
|
|
const sicherung = `${DB_PFAD}.vor-abbruch-${jetztStempel}`;
|
|
try {
|
|
d.exec(`VACUUM INTO '${sicherung.replace(/'/g, "''")}'`);
|
|
console.log("[workspace] Sicherung vor der Umstellung:", sicherung);
|
|
} catch (fehler) {
|
|
console.error("[workspace] Sicherung fehlgeschlagen, Umstellung abgebrochen:", fehler?.message);
|
|
return;
|
|
}
|
|
|
|
d.exec("PRAGMA foreign_keys = OFF");
|
|
try {
|
|
/* Die Spalten stehen hier vollstaendig, weil die Schleife oben
|
|
sie zu diesem Zeitpunkt schon ergaenzt hat. Wer hier eine
|
|
vergisst, verliert ihren Inhalt still -- deshalb wird nach dem
|
|
Tausch die Zeilenzahl verglichen. */
|
|
const vorher = d.prepare("SELECT COUNT(*) AS n FROM aufgaben").get().n;
|
|
d.exec("BEGIN");
|
|
d.exec(`
|
|
CREATE TABLE aufgaben_neu (
|
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
titel TEXT NOT NULL,
|
|
beschreibung TEXT,
|
|
status TEXT NOT NULL DEFAULT 'offen'
|
|
CHECK (status IN ('offen','arbeit','review','erledigt','abgebrochen')),
|
|
prioritaet TEXT NOT NULL DEFAULT 'mittel'
|
|
CHECK (prioritaet IN ('hoch','mittel','niedrig')),
|
|
creator_id INTEGER REFERENCES personen(id) ON DELETE SET NULL,
|
|
verantwortlich_id INTEGER REFERENCES personen(id) ON DELETE SET NULL,
|
|
frist TEXT,
|
|
erstellt TEXT NOT NULL,
|
|
erstellt_von INTEGER REFERENCES personen(id) ON DELETE SET NULL,
|
|
geaendert TEXT,
|
|
erledigt_am TEXT,
|
|
creator_extern TEXT,
|
|
verantwortlich_extern TEXT,
|
|
abbruch_grund TEXT,
|
|
abgebrochen_am TEXT,
|
|
abbruch_von INTEGER REFERENCES personen(id) ON DELETE SET NULL,
|
|
status_vorher TEXT
|
|
);
|
|
INSERT INTO aufgaben_neu
|
|
(id, titel, beschreibung, status, prioritaet, creator_id, verantwortlich_id,
|
|
frist, erstellt, erstellt_von, geaendert, erledigt_am,
|
|
creator_extern, verantwortlich_extern,
|
|
abbruch_grund, abgebrochen_am, abbruch_von, status_vorher)
|
|
SELECT id, titel, beschreibung, status, prioritaet, creator_id, verantwortlich_id,
|
|
frist, erstellt, erstellt_von, geaendert, erledigt_am,
|
|
creator_extern, verantwortlich_extern,
|
|
abbruch_grund, abgebrochen_am, abbruch_von, status_vorher
|
|
FROM aufgaben;
|
|
DROP TABLE aufgaben;
|
|
ALTER TABLE aufgaben_neu RENAME TO aufgaben;
|
|
CREATE INDEX IF NOT EXISTS idx_aufgaben_status ON aufgaben (status);
|
|
CREATE INDEX IF NOT EXISTS idx_aufgaben_creator ON aufgaben (creator_id);
|
|
`);
|
|
/* Nach dem RENAME heisst die neue Tabelle wieder "aufgaben" --
|
|
gezaehlt wird also unter dem alten Namen, und der Vergleich
|
|
laeuft noch INNERHALB der Transaktion. Stimmt er nicht, ist
|
|
ein ROLLBACK noch moeglich. */
|
|
const nachher = d.prepare("SELECT COUNT(*) AS n FROM aufgaben").get().n;
|
|
/* Stimmt die Zahl nicht, wird NICHT bestaetigt. Lieber laeuft das
|
|
Abbrechen noch nicht, als dass eine Aufgabe verschwindet. */
|
|
if (nachher !== vorher) {
|
|
d.exec("ROLLBACK");
|
|
console.error(`[workspace] Umstellung abgebrochen: ${vorher} Aufgaben vorher, `
|
|
+ `${nachher} nachher. Sicherung: ${sicherung}`);
|
|
} else {
|
|
d.exec("COMMIT");
|
|
const kaputt = d.prepare("PRAGMA foreign_key_check").all();
|
|
if (kaputt.length) {
|
|
console.error("[workspace] ACHTUNG: nach der Umstellung", kaputt.length,
|
|
"verwaiste Verweise. Sicherung liegt unter", sicherung);
|
|
} else {
|
|
console.log(`[workspace] Status 'abgebrochen' freigeschaltet, ${vorher} Aufgaben, Verweise geprueft.`);
|
|
}
|
|
}
|
|
} catch (fehler) {
|
|
try { d.exec("ROLLBACK"); } catch { /* schon zurueckgerollt */ }
|
|
console.error("[workspace] Umstellung 'abgebrochen' fehlgeschlagen:", fehler?.message);
|
|
} finally {
|
|
d.exec("PRAGMA foreign_keys = ON");
|
|
}
|
|
}
|
|
|
|
/* ---- Der sechste Bereich: "agentur" (06.09.2026) ----
|
|
|
|
Kernprinzip 04 des Konzepts lautet woertlich "Die Agentur bleibt
|
|
angebunden". Fuenf der sechs Bereiche standen (LIVE, Content,
|
|
Technik, Community, Schutz) -- der sechste fehlte vollstaendig. Was
|
|
dadurch nirgends stand: welche Kampagne laeuft und bis wann, welche
|
|
Schulung ansteht, und vor allem der OFFIZIELLE WEG zur Agentur mit
|
|
Stand und Antwort. Das lief bisher ueber private Nachrichten und
|
|
war damit weder auffindbar noch nachvollziehbar.
|
|
|
|
Und die Trennung, die das Konzept ausdruecklich verlangt und die im
|
|
Alltag sonst verschwimmt: Betreuung und Umsetzung sind UNSERE
|
|
Seite, offizielle Wege und Plattformthemen die der Agentur. Wer
|
|
wofuer zustaendig ist, gehoert auf den Bildschirm, nicht ins
|
|
Gedaechtnis.
|
|
|
|
Derselbe Umbau wie bei "abgebrochen" darueber -- ein CHECK laesst
|
|
sich in SQLite nicht aendern, die Tabelle muss neu gebaut werden:
|
|
erst sichern, dann tauschen, Zeilen zaehlen, Verweise pruefen. */
|
|
const eintraegePlan = d.prepare(
|
|
"SELECT sql FROM sqlite_master WHERE type = 'table' AND name = 'eintraege'").get()?.sql || "";
|
|
if (eintraegePlan && !eintraegePlan.includes("'agentur'")) {
|
|
const sicherung = `${DB_PFAD}.vor-agentur-${jetztStempel}`;
|
|
try {
|
|
d.exec(`VACUUM INTO '${sicherung.replace(/'/g, "''")}'`);
|
|
console.log("[workspace] Sicherung vor der Umstellung:", sicherung);
|
|
} catch (fehler) {
|
|
console.error("[workspace] Sicherung fehlgeschlagen, Umstellung abgebrochen:", fehler?.message);
|
|
return;
|
|
}
|
|
|
|
d.exec("PRAGMA foreign_keys = OFF");
|
|
try {
|
|
const vorher = d.prepare("SELECT COUNT(*) AS n FROM eintraege").get().n;
|
|
d.exec("BEGIN");
|
|
d.exec(`
|
|
CREATE TABLE eintraege_neu (
|
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
bereich TEXT NOT NULL
|
|
CHECK (bereich IN ('live','content','technik','community','schutz','agentur')),
|
|
art TEXT NOT NULL,
|
|
titel TEXT NOT NULL,
|
|
text TEXT,
|
|
datum TEXT NOT NULL,
|
|
bewertung INTEGER CHECK (bewertung IS NULL OR (bewertung BETWEEN 1 AND 5)),
|
|
dringlichkeit TEXT NOT NULL DEFAULT 'mittel'
|
|
CHECK (dringlichkeit IN ('hoch','mittel','niedrig')),
|
|
status TEXT NOT NULL DEFAULT 'offen'
|
|
CHECK (status IN ('offen','erledigt')),
|
|
creator_id INTEGER REFERENCES personen(id) ON DELETE SET NULL,
|
|
erstellt TEXT NOT NULL,
|
|
erstellt_von INTEGER REFERENCES personen(id) ON DELETE SET NULL,
|
|
geaendert TEXT,
|
|
hook TEXT,
|
|
format TEXT,
|
|
saeule_id INTEGER,
|
|
geplant TEXT,
|
|
creator_extern TEXT,
|
|
/* Die drei Event-Spalten MUESSEN hier mit stehen (07.09.2026).
|
|
|
|
Der Spalten-Nachtrag weiter oben laeuft frueher als dieser
|
|
Neubau. Auf einer Datenbank, die noch den alten CHECK hat
|
|
-- eine Sicherung von vor dem 06.09., in einen heutigen
|
|
Stand eingespielt --, waeren die drei Spalten also erst
|
|
angelegt und hier sofort wieder weggeworfen worden: mit
|
|
allem, was drinsteht, ohne Fehlermeldung, und die
|
|
Zeilenzahl haette weiterhin gestimmt. Genau die
|
|
Verlustart, vor der der Kommentar zu dieser Umstellung
|
|
warnt. */
|
|
event_ende TEXT,
|
|
event_aufgaben TEXT,
|
|
event_regeln TEXT
|
|
);
|
|
INSERT INTO eintraege_neu
|
|
(id, bereich, art, titel, text, datum, bewertung, dringlichkeit, status,
|
|
creator_id, erstellt, erstellt_von, geaendert,
|
|
hook, format, saeule_id, geplant, creator_extern,
|
|
event_ende, event_aufgaben, event_regeln)
|
|
SELECT id, bereich, art, titel, text, datum, bewertung, dringlichkeit, status,
|
|
creator_id, erstellt, erstellt_von, geaendert,
|
|
hook, format, saeule_id, geplant, creator_extern,
|
|
event_ende, event_aufgaben, event_regeln
|
|
FROM eintraege;
|
|
DROP TABLE eintraege;
|
|
ALTER TABLE eintraege_neu RENAME TO eintraege;
|
|
CREATE INDEX IF NOT EXISTS idx_eintraege_bereich ON eintraege (bereich, creator_id);
|
|
`);
|
|
const nachher = d.prepare("SELECT COUNT(*) AS n FROM eintraege").get().n;
|
|
if (nachher !== vorher) {
|
|
d.exec("ROLLBACK");
|
|
console.error(`[workspace] Umstellung abgebrochen: ${vorher} Eintraege vorher, `
|
|
+ `${nachher} nachher. Sicherung: ${sicherung}`);
|
|
} else {
|
|
d.exec("COMMIT");
|
|
const kaputt = d.prepare("PRAGMA foreign_key_check").all();
|
|
if (kaputt.length) {
|
|
console.error("[workspace] ACHTUNG: nach der Umstellung", kaputt.length,
|
|
"verwaiste Verweise. Sicherung liegt unter", sicherung);
|
|
} else {
|
|
console.log(`[workspace] Bereich 'agentur' freigeschaltet, ${vorher} Eintraege, Verweise geprueft.`);
|
|
}
|
|
}
|
|
} catch (fehler) {
|
|
try { d.exec("ROLLBACK"); } catch { /* schon zurueckgerollt */ }
|
|
console.error("[workspace] Umstellung 'agentur' fehlgeschlagen:", fehler?.message);
|
|
} finally {
|
|
d.exec("PRAGMA foreign_keys = ON");
|
|
}
|
|
}
|
|
|
|
/* ---- Rolle "manager" erlauben ---- */
|
|
const bauplan = d.prepare(
|
|
"SELECT sql FROM sqlite_master WHERE type = 'table' AND name = 'personen'").get()?.sql || "";
|
|
if (!bauplan.includes("'manager'")) {
|
|
const sicherung = `${DB_PFAD}.vor-manager-${jetztStempel}`;
|
|
try {
|
|
d.exec(`VACUUM INTO '${sicherung.replace(/'/g, "''")}'`);
|
|
console.log("[workspace] Sicherung vor der Umstellung:", sicherung);
|
|
} catch (fehler) {
|
|
/* Ohne Sicherung wird NICHT umgestellt. Lieber laeuft der Manager
|
|
noch nicht, als dass Daten ohne Netz angefasst werden. */
|
|
console.error("[workspace] Sicherung fehlgeschlagen, Umstellung abgebrochen:", fehler?.message);
|
|
return;
|
|
}
|
|
|
|
/* Fremdschluessel muessen aus sein, weil andere Tabellen auf
|
|
personen(id) zeigen -- und das laesst sich nicht innerhalb einer
|
|
Transaktion umschalten. */
|
|
d.exec("PRAGMA foreign_keys = OFF");
|
|
try {
|
|
d.exec("BEGIN");
|
|
d.exec(`
|
|
CREATE TABLE personen_neu (
|
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
name TEXT NOT NULL,
|
|
rolle TEXT NOT NULL CHECK (rolle IN ('admin','manager','scout','creator')),
|
|
code_hash TEXT NOT NULL,
|
|
code_salt TEXT NOT NULL,
|
|
code_n INTEGER NOT NULL,
|
|
aktiv INTEGER NOT NULL DEFAULT 1,
|
|
erstellt TEXT NOT NULL,
|
|
letzter_login TEXT
|
|
);
|
|
INSERT INTO personen_neu
|
|
(id, name, rolle, code_hash, code_salt, code_n, aktiv, erstellt, letzter_login)
|
|
SELECT id, name, rolle, code_hash, code_salt, code_n, aktiv, erstellt, letzter_login
|
|
FROM personen;
|
|
DROP TABLE personen;
|
|
ALTER TABLE personen_neu RENAME TO personen;
|
|
`);
|
|
d.exec("COMMIT");
|
|
|
|
/* Nach dem Tausch pruefen, ob die Verweise noch stimmen. Findet
|
|
sich etwas, wird das laut gemeldet -- stillschweigend kaputte
|
|
Verweise waeren das Schlimmste an dieser Stelle. */
|
|
const kaputt = d.prepare("PRAGMA foreign_key_check").all();
|
|
if (kaputt.length) {
|
|
console.error("[workspace] ACHTUNG: nach der Umstellung", kaputt.length,
|
|
"verwaiste Verweise. Sicherung liegt unter", sicherung);
|
|
} else {
|
|
console.log("[workspace] Rolle 'manager' freigeschaltet, Verweise geprueft.");
|
|
}
|
|
} catch (fehler) {
|
|
try { d.exec("ROLLBACK"); } catch { /* schon zurueckgerollt */ }
|
|
console.error("[workspace] Umstellung fehlgeschlagen:", fehler?.message);
|
|
} finally {
|
|
d.exec("PRAGMA foreign_keys = ON");
|
|
}
|
|
}
|
|
|
|
/* =====================================================================
|
|
ROLLE "spicy" (Spicy Media) FREISCHALTEN — 07.09.2026
|
|
|
|
Wunsch Filipe: "ich will dass ueber dogfather auch eine kategorie,
|
|
eine neue rolle entsteht die den namen traegt, spicy media."
|
|
|
|
---------------------------------------------------------------------
|
|
DAS IST DER RISKANTESTE UMBAU IM GANZEN HAUS
|
|
|
|
Eine Rolle ist ein erlaubter Wert in einer Spalte, und der steckt in
|
|
SQLite in einem CHECK. Ein CHECK laesst sich nicht aendern -- die
|
|
Tabelle muss neu gebaut werden. Bei `eintraege` war das schon
|
|
unangenehm; hier geht es um `personen`, und auf die zeigen
|
|
ZWEIUNDFUENFZIG Fremdschluessel aus einem Dutzend Tabellen. Jede
|
|
Sitzung, jede Aufgabe, jeder Termin, jede Datei haengt daran.
|
|
|
|
DESHALB WIRD DER BAUPLAN NICHT ABGESCHRIEBEN, SONDERN GELESEN.
|
|
|
|
Die beiden Umstellungen darueber schreiben ihre Spaltenliste von
|
|
Hand ab. Das ging gut, solange die Liste stimmte -- aber `personen`
|
|
hat seit damals SIEBEN Spalten dazubekommen (bild, ueber_mich,
|
|
tiktok, instagram, youtube, twitch und weitere, siehe den
|
|
Spalten-Nachtrag oben). Wer hier eine Liste abschreibt, verliert
|
|
beim naechsten Mal genau die Spalte, die jemand letzte Woche
|
|
ergaenzt hat -- samt allen Profilbildern.
|
|
|
|
Also: Der vorhandene CREATE-Text wird aus sqlite_master geholt und
|
|
darin AUSSCHLIESSLICH die Rollenliste ersetzt. Alles andere -- jede
|
|
Spalte, jeder Typ, jede Vorgabe -- bleibt woertlich stehen. Und die
|
|
Kopierliste kommt aus PRAGMA table_info, also aus der Tabelle
|
|
selbst.
|
|
|
|
WENN DIE ERSETZUNG NICHT GREIFT, WIRD NICHT UMGESTELLT. Ein
|
|
`replace`, das nichts findet, gibt den Text unveraendert zurueck --
|
|
die Umstellung liefe dann durch, baute dieselbe Tabelle noch einmal
|
|
und meldete Erfolg. Genau die Sorte gruener Haken, die nichts
|
|
geprueft hat. Deshalb wird danach nachgesehen, ob 'spicy' wirklich
|
|
drinsteht.
|
|
===================================================================== */
|
|
/* =====================================================================
|
|
MEHR TERMINARTEN (08.09.2026)
|
|
|
|
Wunsch Filipe: "da sollen auch noch kategorien wie bigmatch,
|
|
turniere, special-live ... informier dich, was man da alles noch
|
|
gebrauchen koennte."
|
|
|
|
Recherchiert (Streams Charts, Kick, StreamerCollabs): Was ein
|
|
Creator-Team im Kalender wirklich unterscheidet, sind neben den
|
|
internen Terminen die AUFTRITTE -- Wettkaempfe (BigMatch, Turnier),
|
|
gemeinsame Formate (Collab, Raid-Train) und die grossen Ausnahmen
|
|
(Special-Live, Charity). Genau diese sechs kommen dazu.
|
|
|
|
WARUM EIN TABELLENUMBAU: Die erlaubten Werte stecken in einem
|
|
CHECK, und ein CHECK laesst sich in SQLite nicht aendern. Dieselbe
|
|
Lage wie bei der Rolle 'spicy' -- und deshalb hier dasselbe,
|
|
bewaehrte Verfahren: Bauplan aus sqlite_master lesen, NUR die Liste
|
|
ersetzen, das Ergebnis pruefen, Spalten aus PRAGMA holen, Zeilen
|
|
INNERHALB der Transaktion zaehlen, Sicherung vorher.
|
|
|
|
Zwei Tabellen statt einer (termine und termin_serien), deshalb
|
|
eine Schleife -- zweimal derselbe Text waere zweimal dieselbe
|
|
Gelegenheit, eine Stelle zu vergessen. */
|
|
const ARTEN_NEU = "'termin','call','review','bigmatch','turnier','special','collab','raid','charity'";
|
|
for (const tabelle of ["termine", "termin_serien"]) {
|
|
const plan = d.prepare(
|
|
"SELECT sql FROM sqlite_master WHERE type = 'table' AND name = ?").get(tabelle)?.sql || "";
|
|
/* Geprueft wird die REGEL, nicht der ganze Bauplan: SQLite hebt ihn
|
|
woertlich auf, samt Kommentaren -- ein erklaerender Satz mit dem
|
|
Wort 'bigmatch' wuerde sonst genuegen, damit die Umstellung sich
|
|
fuer erledigt haelt. Genau dieser Fehler ist bei 'spicy' passiert. */
|
|
const artRegel = plan.match(/art\s+IN\s*\(([^)]*)\)/i)?.[1] || "";
|
|
if (!plan || !artRegel || artRegel.includes("'bigmatch'")) continue;
|
|
|
|
/* DER TABELLENNAME GEHOERT IN DEN DATEINAMEN (08.09.2026).
|
|
|
|
Hier stand `.vor-arten-${jetztStempel}` -- ohne die Tabelle. Der
|
|
Stempel wird EINMAL pro Serverstart gebildet, diese Schleife
|
|
laeuft aber ZWEIMAL. Der zweite Durchlauf wollte also dieselbe
|
|
Datei anlegen, `VACUUM INTO` weigert sich (die Datei ist schon
|
|
da), und das `break` unten beendete daraufhin die ganze Schleife.
|
|
|
|
Ergebnis: `termine` war umgestellt, `termin_serien` NICHT. Und
|
|
zwar still -- die Meldung ging in die Serverausgabe, die niemand
|
|
liest. Aufgefallen waere es erst, wenn jemand eine wiederkehrende
|
|
BigMatch-Reihe anlegt und die Datenbank sie ohne erkennbaren
|
|
Grund ablehnt.
|
|
|
|
Das `break` bleibt richtig: Wenn sich keine Sicherung anlegen
|
|
laesst, wird nicht umgebaut. Falsch war nur der Name. */
|
|
const sicherung = `${DB_PFAD}.vor-arten-${tabelle}-${jetztStempel}`;
|
|
try {
|
|
d.exec(`VACUUM INTO '${sicherung.replace(/'/g, "''")}'`);
|
|
console.log("[workspace] Sicherung vor der Artenumstellung:", sicherung);
|
|
} catch (fehler) {
|
|
console.error("[workspace] Sicherung fehlgeschlagen, Artenumstellung abgebrochen:",
|
|
fehler?.message);
|
|
break;
|
|
}
|
|
|
|
const neuerPlan = plan
|
|
/* DAS MUSTER WIRD ZUSAMMENGESETZT, NICHT IN EINEN TEMPLATE-STRING
|
|
GESCHRIEBEN. Dort wird \s beim Einlesen zu einem blossen "s" --
|
|
aus "CREATE TABLE\s+" wuerde "CREATE TABLEs+", das Muster
|
|
passte auf nichts, und die Umstellung waere STILL ausgeblieben.
|
|
Genau so stand es hier beim ersten Anlauf; nachgemessen mit
|
|
einem Einzeiler, der beide Schreibweisen gegen den echten
|
|
Bauplan haelt. */
|
|
.replace(new RegExp(
|
|
"CREATE TABLE\\s+(?:IF\\s+NOT\\s+EXISTS\\s+)?[\"'`]?" + tabelle + "[\"'`]?", "i"),
|
|
`CREATE TABLE ${tabelle}_neu`)
|
|
.replace(/art\s+IN\s*\([^)]*\)/i, `art IN (${ARTEN_NEU})`);
|
|
if (!neuerPlan.includes("'bigmatch'") || !neuerPlan.includes(`${tabelle}_neu`)) {
|
|
console.error(`[workspace] Artenumstellung abgebrochen: Bauplan von ${tabelle} `
|
|
+ "liess sich nicht umschreiben.");
|
|
continue;
|
|
}
|
|
|
|
const spalten = d.prepare(`PRAGMA table_info(${tabelle})`).all().map((z) => z.name);
|
|
if (!spalten.length) continue;
|
|
const liste = spalten.map((n) => `"${n}"`).join(", ");
|
|
|
|
d.exec("PRAGMA foreign_keys = OFF");
|
|
try {
|
|
const vorher = d.prepare(`SELECT COUNT(*) AS n FROM ${tabelle}`).get().n;
|
|
d.exec("BEGIN");
|
|
d.exec(neuerPlan);
|
|
d.exec(`INSERT INTO ${tabelle}_neu (${liste}) SELECT ${liste} FROM ${tabelle};`);
|
|
const nachher = d.prepare(`SELECT COUNT(*) AS n FROM ${tabelle}_neu`).get().n;
|
|
if (nachher !== vorher) {
|
|
d.exec("ROLLBACK");
|
|
console.error(`[workspace] Artenumstellung abgebrochen: ${vorher} Zeilen vorher, `
|
|
+ `${nachher} nachher. Sicherung: ${sicherung}`);
|
|
} else {
|
|
d.exec(`DROP TABLE ${tabelle};`);
|
|
d.exec(`ALTER TABLE ${tabelle}_neu RENAME TO ${tabelle};`);
|
|
d.exec("COMMIT");
|
|
const kaputt = d.prepare("PRAGMA foreign_key_check").all();
|
|
if (kaputt.length) {
|
|
console.error("[workspace] ACHTUNG: nach der Artenumstellung", kaputt.length,
|
|
"verwaiste Verweise. Sicherung:", sicherung);
|
|
} else {
|
|
console.log(`[workspace] Terminarten erweitert in ${tabelle}: ${nachher} Zeilen, `
|
|
+ `${spalten.length} Spalten uebernommen, Verweise geprueft.`);
|
|
}
|
|
}
|
|
} catch (fehler) {
|
|
try { d.exec("ROLLBACK"); } catch { /* schon zurueckgerollt */ }
|
|
console.error("[workspace] Artenumstellung fehlgeschlagen:", fehler?.message,
|
|
"-- Sicherung:", sicherung);
|
|
} finally {
|
|
d.exec("PRAGMA foreign_keys = ON");
|
|
}
|
|
}
|
|
|
|
/* Welche Rollen die Datenbank zulaesst. Der Ablauf steht in
|
|
rollenRegelUmstellen() -- einmal, nicht je Rolle abgeschrieben.
|
|
Beide Aufrufe stehen hier: Auf einer Datenbank, die 'spicy' schon
|
|
kennt, tut der erste nichts und nur der zweite laeuft. */
|
|
checkListeErweitern(d, "personen", "rolle", "spicy",
|
|
["spicy", "admin", "manager", "scout", "creator"], jetztStempel);
|
|
checkListeErweitern(d, "personen", "rolle", "modi",
|
|
["spicy", "admin", "manager", "scout", "creator", "modi"], jetztStempel);
|
|
checkListeErweitern(d, "personen", "rolle", "hand",
|
|
["spicy", "admin", "manager", "scout", "creator", "modi", "hand"], jetztStempel);
|
|
|
|
/* DER BEREICH FUER DAS IDEEN-BOARD (10.09.2026, Kapitel 5.6).
|
|
|
|
Dieselbe Umstellung, andere Tabelle -- und genau deshalb steht sie
|
|
ab jetzt EINMAL da. Der alte agentur-Block weiter unten schreibt
|
|
den ganzen Bauplan von Hand ab; im Kommentar dort steht, dass dabei
|
|
schon einmal drei Spalten vergessen wurden. Eine vierte Abschrift
|
|
waere die vierte Gelegenheit dazu. */
|
|
checkListeErweitern(d, "eintraege", "bereich", "ideen",
|
|
["live", "content", "technik", "community", "schutz", "agentur", "ideen"], jetztStempel);
|
|
/* Die Angebote (10.09.2026) -- Vorschlaege der Modis, ueber die
|
|
DogFather und VanVan entscheiden. */
|
|
checkListeErweitern(d, "eintraege", "bereich", "angebote",
|
|
["live", "content", "technik", "community", "schutz", "agentur", "ideen", "angebote"],
|
|
jetztStempel);
|
|
/* ZWEI NEUE ZUSTAENDE. Bisher kannte ein Eintrag nur offen/erledigt.
|
|
Ein Angebot hat aber eine ENTSCHEIDUNG: angenommen oder abgelehnt
|
|
-- und das ist etwas anderes als "erledigt". Wer beides in einen
|
|
Topf wirft, kann hinterher nicht mehr sagen, was aus einem
|
|
Vorschlag geworden ist. */
|
|
checkListeErweitern(d, "eintraege", "status", "angenommen",
|
|
["offen", "erledigt", "angenommen", "abgelehnt"], jetztStempel);
|
|
}
|
|
|
|
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')),
|
|
/* Nur Gruppen haben einen Namen. Ein Zweier-Gespraech heisst
|
|
immer nach dem Gegenueber -- und zwar fuer jeden anders. Ein
|
|
gespeicherter Name waere fuer eine der beiden Seiten falsch. */
|
|
name TEXT,
|
|
erstellt TEXT NOT NULL,
|
|
erstellt_von INTEGER REFERENCES personen(id) ON DELETE SET NULL,
|
|
/* Der Zeitpunkt der letzten Nachricht, mitgefuehrt. Ohne ihn
|
|
braeuchte die Gespraechsliste je Raum eine Unterabfrage --
|
|
bei zwanzig Raeumen zwanzig Abfragen fuer eine Liste. */
|
|
letzte_am TEXT
|
|
);
|
|
CREATE INDEX IF NOT EXISTS idx_chat_raeume_letzte ON chat_raeume (letzte_am DESC);
|
|
|
|
CREATE TABLE IF NOT EXISTS chat_teilnehmer (
|
|
raum_id INTEGER NOT NULL REFERENCES chat_raeume(id) ON DELETE CASCADE,
|
|
person_id INTEGER NOT NULL REFERENCES personen(id) ON DELETE CASCADE,
|
|
seit TEXT NOT NULL,
|
|
/* Bis zu welcher Nachricht diese Person gelesen hat. */
|
|
gelesen_bis INTEGER NOT NULL DEFAULT 0,
|
|
/* Wer eine Gruppe angelegt hat, darf Leute hinzufuegen und
|
|
entfernen. In Zweier-Gespraechen bedeutungslos. */
|
|
leitung INTEGER NOT NULL DEFAULT 0,
|
|
/* Verlassene Gruppen: Die Zeile bleibt mit einem Datum stehen,
|
|
damit der Verlauf bis dahin lesbar bleibt und niemand
|
|
rueckwirkend aus der Geschichte verschwindet. */
|
|
raus_am TEXT,
|
|
PRIMARY KEY (raum_id, person_id)
|
|
);
|
|
CREATE INDEX IF NOT EXISTS idx_chat_teilnehmer_person ON chat_teilnehmer (person_id);
|
|
|
|
CREATE TABLE IF NOT EXISTS chat_nachrichten (
|
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
raum_id INTEGER NOT NULL REFERENCES chat_raeume(id) ON DELETE CASCADE,
|
|
person_id INTEGER REFERENCES personen(id) ON DELETE SET NULL,
|
|
text TEXT NOT NULL,
|
|
erstellt TEXT NOT NULL,
|
|
/* Zurueckgenommen: Der Text wird geleert, die Zeile bleibt.
|
|
Ein Loch im Verlauf wirft mehr Fragen auf als der Hinweis,
|
|
dass hier etwas zurueckgenommen wurde. */
|
|
weg_am TEXT
|
|
);
|
|
CREATE INDEX IF NOT EXISTS idx_chat_nachrichten_raum
|
|
ON chat_nachrichten (raum_id, id);
|
|
|
|
/* Creator-Profil (Onboarding aus dem Konzept). Eine Zeile je
|
|
Creator, entsteht erst beim ersten Speichern.
|
|
admin_notiz ist bewusst Teil dieser Tabelle, wird aber nur an
|
|
das Management ausgeliefert -- siehe workspace-profil.js. */
|
|
CREATE TABLE IF NOT EXISTS profile (
|
|
person_id INTEGER PRIMARY KEY REFERENCES personen(id) ON DELETE CASCADE,
|
|
handles TEXT,
|
|
nische TEXT,
|
|
live_zeiten TEXT,
|
|
technik TEXT,
|
|
ziel_live TEXT,
|
|
ziel_content TEXT,
|
|
ziel_community TEXT,
|
|
ziel_technik TEXT,
|
|
plan_start TEXT,
|
|
plan_prio1 TEXT,
|
|
plan_prio2 TEXT,
|
|
plan_prio3 TEXT,
|
|
naechster_review TEXT,
|
|
admin_notiz TEXT,
|
|
geaendert TEXT,
|
|
geaendert_von INTEGER REFERENCES personen(id) ON DELETE SET NULL
|
|
);
|
|
|
|
/* Termine, Calls und Reviews. Der Beginn steht als lokale Zeit
|
|
(JJJJ-MM-TTThh:mm) -- genau so, wie ein datetime-local-Feld sie
|
|
liefert. Bewusst ohne Zeitzone: Alle Beteiligten sitzen in
|
|
derselben, und eine falsch umgerechnete Uhrzeit waere schlimmer
|
|
als gar keine Umrechnung. */
|
|
CREATE TABLE IF NOT EXISTS termine (
|
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
titel TEXT NOT NULL,
|
|
beschreibung TEXT,
|
|
art TEXT NOT NULL DEFAULT 'termin'
|
|
CHECK (art IN ('termin','call','review','bigmatch','turnier','special','collab','raid','charity')),
|
|
beginn TEXT NOT NULL,
|
|
dauer_min INTEGER NOT NULL DEFAULT 30,
|
|
ort TEXT,
|
|
creator_id INTEGER REFERENCES personen(id) ON DELETE SET NULL,
|
|
teilnehmer_id INTEGER REFERENCES personen(id) ON DELETE SET NULL,
|
|
erledigt INTEGER NOT NULL DEFAULT 0,
|
|
erstellt TEXT NOT NULL,
|
|
erstellt_von INTEGER REFERENCES personen(id) ON DELETE SET NULL
|
|
);
|
|
CREATE INDEX IF NOT EXISTS idx_termine_beginn ON termine (beginn);
|
|
|
|
/* MEHRERE TEILNEHMER AN EINEM TERMIN (05.09.2026) ------------------
|
|
|
|
Wunsch Filipe: *"wenn ich im kalender was eintrage will ich dass
|
|
ich auch 2 leute markieren kann mit denen der call ist."*
|
|
|
|
Bis hierher hatte ein Termin GENAU EIN Gegenueber
|
|
(termine.teilnehmer_id). Fuer ein Gespraech zu dritt musste man
|
|
zwei Termine anlegen -- und hatte damit zwei Wahrheiten ueber
|
|
dieselbe halbe Stunde.
|
|
|
|
Gebaut nach demselben Muster wie datei_personen weiter unten:
|
|
eine eigene kleine Tabelle statt weiterer Spalten. Zwei Spalten
|
|
"teilnehmer2_id", "teilnehmer3_id" waeren beim vierten Menschen
|
|
wieder am Ende, und jede Abfrage muesste alle einzeln
|
|
aufzaehlen.
|
|
|
|
WICHTIG -- teilnehmer_id BLEIBT und behaelt seine Bedeutung:
|
|
Es ist das HAUPT-Gegenueber, mit dem der Termin vereinbart
|
|
wurde. Diese Tabelle sagt, WER SONST NOCH dabei ist. Beim
|
|
Umstellen wird der vorhandene Wert mit uebernommen, damit die
|
|
Teilnehmerliste von Anfang an vollstaendig ist und keine
|
|
Abfrage zwei Stellen zusammensuchen muss.
|
|
|
|
An dieser Tabelle haengt die SICHTBARKEIT (siehe
|
|
termineSichtbar): Wer hier steht, sieht den Termin. Ein
|
|
vergessener Eintrag ist deshalb kein Schoenheitsfehler,
|
|
sondern ein Termin, den jemand nicht findet. */
|
|
CREATE TABLE IF NOT EXISTS termin_teilnehmer (
|
|
termin_id INTEGER NOT NULL REFERENCES termine(id) ON DELETE CASCADE,
|
|
person_id INTEGER NOT NULL REFERENCES personen(id) ON DELETE CASCADE,
|
|
PRIMARY KEY (termin_id, person_id)
|
|
);
|
|
CREATE INDEX IF NOT EXISTS idx_termin_teilnehmer ON termin_teilnehmer (person_id);
|
|
|
|
/* =================================================================
|
|
DER WECKER (07.09.2026)
|
|
|
|
Wunsch Filipe: "wie so ein wecker, den man auch in den
|
|
eintraegen aktivieren oder ausschalten kann, den soll man sogar
|
|
so einstellen koennen, dass er einen auch mehrmals informiert,
|
|
einmal eine woche vorher, einmal drei tage vorher und einmal am
|
|
tag selber. das soll jeder fuer sich selbst einstellen koennen."
|
|
|
|
EINE ZEILE IST EIN WECKER. Nicht ein Feld am Termin mit einer
|
|
Liste darin: Mehrere Vorlaufzeiten je Termin sind der ganze
|
|
Zweck, und "jeder fuer sich" heisst, dass zwei Personen am
|
|
SELBEN Termin verschiedene Wecker haben. Beides zusammen ist
|
|
eine klassische n:m-Beziehung, und die gehoert in eine eigene
|
|
Tabelle. Ein Feld mit kommagetrennten Zahlen waere beim ersten
|
|
"zeig mir alle faelligen Wecker" nicht mehr abfragbar.
|
|
|
|
Die Spalte minuten_vorher statt eines Zeitpunkts: Ein Zeitpunkt muesste
|
|
bei JEDER Terminverschiebung nachgezogen werden -- und genau
|
|
das vergisst man. Ein Abstand rechnet sich beim Wecken aus dem
|
|
aktuellen Beginn und ist damit immer richtig, egal wie oft der
|
|
Termin wandert.
|
|
|
|
ON DELETE CASCADE an beiden Seiten: Ein Wecker ohne Termin
|
|
weckt niemanden, und ein Wecker ohne Person auch nicht.
|
|
Zusammengesetzter Primaerschluessel: Dieselbe Person kann
|
|
denselben Abstand nicht zweimal setzen -- sonst klingelt es
|
|
doppelt, und das merkt man erst nachts.
|
|
|
|
Recherche (Google Calendar, Outlook, Morgen): Fuenf Wecker je
|
|
Termin sind das uebliche Maximum, und die verbreitete Empfehlung
|
|
fuer Wichtiges lautet "eine Woche, ein Tag, am Tag selbst".
|
|
Genau diese Staffel bietet die Oberflaeche an. */
|
|
CREATE TABLE IF NOT EXISTS termin_wecker (
|
|
termin_id INTEGER NOT NULL REFERENCES termine(id) ON DELETE CASCADE,
|
|
person_id INTEGER NOT NULL REFERENCES personen(id) ON DELETE CASCADE,
|
|
minuten_vorher INTEGER NOT NULL,
|
|
gesetzt TEXT NOT NULL,
|
|
PRIMARY KEY (termin_id, person_id, minuten_vorher)
|
|
);
|
|
CREATE INDEX IF NOT EXISTS idx_termin_wecker_person
|
|
ON termin_wecker (person_id);
|
|
|
|
/* Dasselbe fuer die Wiederholungen. Eine Serie ist eine Regel --
|
|
die Teilnehmer gehoeren zur Regel, nicht zur einzelnen
|
|
Auspraegung, sonst muesste man sie jede Woche neu eintragen.
|
|
Der Nachfueller kopiert sie beim Anlegen jedes Termins hierher
|
|
hinueber (siehe workspace-serien.js). */
|
|
CREATE TABLE IF NOT EXISTS serie_teilnehmer (
|
|
serie_id INTEGER NOT NULL REFERENCES termin_serien(id) ON DELETE CASCADE,
|
|
person_id INTEGER NOT NULL REFERENCES personen(id) ON DELETE CASCADE,
|
|
PRIMARY KEY (serie_id, person_id)
|
|
);
|
|
CREATE INDEX IF NOT EXISTS idx_serie_teilnehmer ON serie_teilnehmer (person_id);
|
|
|
|
/* Wiederkehrende Termine (02.09.2026) --------------------------------
|
|
|
|
Eine Serie ist eine REGEL, kein Termin: "jeden Dienstag um 18:00
|
|
Uhr, bis ich es abstelle". Aus ihr entstehen echte Zeilen in
|
|
der Tabelle termine.
|
|
|
|
Warum echte Zeilen und nicht bloss gerechnete Ausprägungen:
|
|
ACHT andere Stellen lesen termine direkt -- Calls, Protokolle,
|
|
Berichte, Hinweise/Ampel, Suche, Aufgaben ("nächster Termin"),
|
|
Übersicht, Startseite. Eine nur im Kalender gerechnete
|
|
Wiederholung wäre in all diesen Ansichten unsichtbar gewesen,
|
|
ohne dass es jemandem auffällt, und beim geplanten ICS-Abo
|
|
ebenfalls. Der Nachfüller hält stattdessen einen Horizont echt
|
|
gefüllt -- damit sieht jedes Modul die Wiederholung, ohne dass
|
|
dort eine einzige Zeile geändert werden musste.
|
|
|
|
uhrzeit statt beginn: Eine Regel kennt keinen Zeitpunkt, nur
|
|
eine Uhrzeit und einen Takt. Gerechnet wird auf reinen
|
|
Datumstexten -- deshalb bleibt 18:00 auch über die
|
|
Zeitumstellung hinweg 18:00 und wandert nicht auf 17:00. */
|
|
CREATE TABLE IF NOT EXISTS termin_serien (
|
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
titel TEXT NOT NULL,
|
|
beschreibung TEXT,
|
|
art TEXT NOT NULL DEFAULT 'termin'
|
|
CHECK (art IN ('termin','call','review','bigmatch','turnier','special','collab','raid','charity')),
|
|
takt TEXT NOT NULL
|
|
CHECK (takt IN ('taeglich','werktags','woechentlich',
|
|
'zweiwoechentlich','monatlich_datum',
|
|
'monatlich_letzter','monatlich_wochentag',
|
|
'jaehrlich')),
|
|
start_tag TEXT NOT NULL,
|
|
uhrzeit TEXT NOT NULL,
|
|
ende_tag TEXT,
|
|
dauer_min INTEGER NOT NULL DEFAULT 30,
|
|
ort TEXT,
|
|
creator_id INTEGER REFERENCES personen(id) ON DELETE SET NULL,
|
|
teilnehmer_id INTEGER REFERENCES personen(id) ON DELETE SET NULL,
|
|
aktiv INTEGER NOT NULL DEFAULT 1,
|
|
erstellt TEXT NOT NULL,
|
|
erstellt_von INTEGER REFERENCES personen(id) ON DELETE SET NULL,
|
|
geaendert TEXT
|
|
);
|
|
CREATE INDEX IF NOT EXISTS idx_serien_aktiv ON termin_serien (aktiv);
|
|
|
|
/* Ausgelassene Tage einer Serie. Wer eine einzelne Ausprägung
|
|
löscht, meint "dieses eine Mal nicht" -- ohne diese Tabelle
|
|
legte der Nachfüller sie beim nächsten Aufruf wieder an, und der
|
|
gelöschte Termin wäre kommentarlos zurück. */
|
|
CREATE TABLE IF NOT EXISTS termin_serien_aus (
|
|
serie_id INTEGER NOT NULL REFERENCES termin_serien(id) ON DELETE CASCADE,
|
|
tag TEXT NOT NULL,
|
|
PRIMARY KEY (serie_id, tag)
|
|
);
|
|
|
|
/* Dateiablage. Auf der Platte traegt jede Datei einen erzeugten
|
|
Zufallsnamen (name_datei), der Originalname steht nur hier.
|
|
Dadurch kann ein Dateiname weder Pfade verlassen noch etwas
|
|
ueberschreiben. Der Freigabe-Ablauf aus dem Konzept steckt in
|
|
status: entwurf -> review -> freigegeben. */
|
|
CREATE TABLE IF NOT EXISTS dateien (
|
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
name_original TEXT NOT NULL,
|
|
name_datei TEXT NOT NULL UNIQUE,
|
|
groesse INTEGER NOT NULL,
|
|
typ TEXT,
|
|
status TEXT NOT NULL DEFAULT 'entwurf'
|
|
CHECK (status IN ('entwurf','review','freigegeben')),
|
|
notiz TEXT,
|
|
creator_id INTEGER REFERENCES personen(id) ON DELETE SET NULL,
|
|
hochgeladen_von INTEGER REFERENCES personen(id) ON DELETE SET NULL,
|
|
erstellt TEXT NOT NULL,
|
|
geaendert TEXT
|
|
);
|
|
CREATE INDEX IF NOT EXISTS idx_dateien_creator ON dateien (creator_id);
|
|
|
|
/* Eintraege der Phase-2-Bereiche (LIVE, Content, Technik,
|
|
Community, Schutz). Eine gemeinsame Tabelle statt fuenf fast
|
|
gleicher: Die Bereiche unterscheiden sich nur in den erlaubten
|
|
Arten und darin, ob Bewertung oder Dringlichkeit dazugehoeren --
|
|
das steht in workspace-bereiche.js, nicht in der Datenbank. */
|
|
CREATE TABLE IF NOT EXISTS eintraege (
|
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
bereich TEXT NOT NULL
|
|
CHECK (bereich IN ('live','content','technik','community','schutz','agentur','ideen')),
|
|
art TEXT NOT NULL,
|
|
titel TEXT NOT NULL,
|
|
text TEXT,
|
|
datum TEXT NOT NULL,
|
|
bewertung INTEGER CHECK (bewertung IS NULL OR (bewertung BETWEEN 1 AND 5)),
|
|
dringlichkeit TEXT NOT NULL DEFAULT 'mittel'
|
|
CHECK (dringlichkeit IN ('hoch','mittel','niedrig')),
|
|
status TEXT NOT NULL DEFAULT 'offen'
|
|
CHECK (status IN ('offen','erledigt')),
|
|
creator_id INTEGER REFERENCES personen(id) ON DELETE SET NULL,
|
|
erstellt TEXT NOT NULL,
|
|
erstellt_von INTEGER REFERENCES personen(id) ON DELETE SET NULL,
|
|
geaendert TEXT
|
|
);
|
|
CREATE INDEX IF NOT EXISTS idx_eintraege_bereich ON eintraege (bereich, creator_id);
|
|
|
|
/* Wer darf welche Datei sehen. Bisher hatte eine Datei genau
|
|
EINEN Bereich (dateien.creator_id) -- damit liess sich eine
|
|
Datei nicht zweien geben, ohne sie zweimal hochzuladen.
|
|
Diese Tabelle erlaubt beliebig viele Empfaenger, Creator wie
|
|
Scouts. creator_id bleibt bestehen: Es sagt weiterhin, zu
|
|
wessen BEREICH eine Datei gehoert, waehrend hier steht, WER sie
|
|
sehen darf. Zwei verschiedene Fragen. */
|
|
CREATE TABLE IF NOT EXISTS datei_personen (
|
|
datei_id INTEGER NOT NULL REFERENCES dateien(id) ON DELETE CASCADE,
|
|
person_id INTEGER NOT NULL REFERENCES personen(id) ON DELETE CASCADE,
|
|
PRIMARY KEY (datei_id, person_id)
|
|
);
|
|
CREATE INDEX IF NOT EXISTS idx_datei_personen ON datei_personen (person_id);
|
|
|
|
/* Wissens-Bibliothek: Anleitungen als PDF, hinter dem Login.
|
|
Jede PDF hat GENAU EINE Hauptkategorie -- so gibt es sie nur
|
|
einmal. Zusaetzliche Themen laufen ueber Tags, damit dieselbe
|
|
Anleitung trotzdem ueber mehrere Suchbegriffe auffindbar ist.
|
|
Die Kategorien selbst stehen im Code (workspace-wissen.js),
|
|
nicht hier: Sie sind eine bewusste Gliederung, keine Nutzdaten. */
|
|
CREATE TABLE IF NOT EXISTS wissen (
|
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
titel TEXT NOT NULL,
|
|
beschreibung TEXT,
|
|
kategorie TEXT NOT NULL,
|
|
stufe TEXT NOT NULL DEFAULT 'einsteiger'
|
|
CHECK (stufe IN ('einsteiger','fortgeschritten','profi')),
|
|
geraet TEXT NOT NULL DEFAULT 'egal'
|
|
CHECK (geraet IN ('pc','handy','beide','egal')),
|
|
tags TEXT,
|
|
name_original TEXT NOT NULL,
|
|
name_datei TEXT NOT NULL,
|
|
groesse INTEGER NOT NULL,
|
|
wichtig INTEGER NOT NULL DEFAULT 0,
|
|
veroeffentlicht TEXT NOT NULL,
|
|
aktualisiert TEXT,
|
|
erstellt_von INTEGER REFERENCES personen(id) ON DELETE SET NULL
|
|
);
|
|
CREATE INDEX IF NOT EXISTS idx_wissen_kat ON wissen (kategorie);
|
|
|
|
/* =================================================================
|
|
LEISTUNG — die Zahlen, an denen die Betreuung haengt (06.09.2026)
|
|
|
|
DER GROSSE BEFUND aus dem Plan vom 31.08.2026: Der Workspace
|
|
organisiert ARBEIT sehr gut, aber es gibt keine einzige Zahl
|
|
ueber das GESCHAEFT. Damit haengt die ganze Betreuung in der
|
|
Luft -- der Start-Check bewertet ohne zu messen, der Report
|
|
fragt "was hat funktioniert" ohne Beleg, das 90-Tage-Ziel ist
|
|
ein Satz statt eines Fortschritts.
|
|
|
|
EINE ZEILE JE CREATOR UND TAG. Tag und nicht Woche: Feineres
|
|
laesst sich immer zusammenfassen, Groeberes nie aufteilen. Wer
|
|
mit Wochenwerten anfaengt, kann spaeter nie mehr sagen, ob der
|
|
Einbruch am Dienstag oder am Wochenende lag.
|
|
|
|
WELCHE FELDER -- und warum genau diese (Recherche 31.08. und
|
|
06.09.2026, TikTok LIVE Backstage):
|
|
|
|
diamanten die Zahl, an der TikTok und die Agentur den
|
|
Erfolg messen
|
|
dauer_min Grundlage fuer "gueltige Tage"/"aktive Stunden"
|
|
gueltiger_tag der Zaehler, an dem Ranghochstufungen haengen
|
|
zuschauer_avg sagt, ob es TRAEGT
|
|
zuschauer_max sagt, was MOEGLICH waere
|
|
verweildauer_s DIE LEITZAHL (Recherche 06.09.2026): 2026
|
|
haengt der Algorithmus alles daran, und sie
|
|
gehoert als Betriebskennzahl behandelt --
|
|
sie soll die naechste Runde steuern, nicht
|
|
die letzte erklaeren
|
|
schenker wie BREIT die Unterstuetzung steht, nicht nur
|
|
wie hoch. Ein Grossspender ist ein Risiko,
|
|
zwanzig kleine sind ein Fundament
|
|
follower_neu waechst der Kanal oder dreht er sich im Kreis
|
|
notiz ein Satz Kontext ("PK gegen X", "krank")
|
|
|
|
WARUM DIE NOTIZ DAZUGEHOERT: Ohne sie sieht man in drei Monaten
|
|
einen Einbruch und weiss nicht mehr, dass die Person Grippe
|
|
hatte. Zahlen ohne Umstaende fuehren in die Irre.
|
|
|
|
PRIMAERSCHLUESSEL (creator_id, tag): Derselbe Creator am
|
|
selben Tag kann nur EINE Zeile haben. Das ist die halbe Miete
|
|
beim Import -- wer eine Datei zweimal einliest, ueberschreibt
|
|
dann, statt zu verdoppeln. Verdoppelte Zahlen sind schlimmer
|
|
als fehlende: Sie sehen richtig aus. */
|
|
CREATE TABLE IF NOT EXISTS leistung (
|
|
creator_id INTEGER NOT NULL REFERENCES personen(id) ON DELETE CASCADE,
|
|
tag TEXT NOT NULL, /* JJJJ-MM-TT, Ortszeit */
|
|
diamanten INTEGER,
|
|
dauer_min INTEGER,
|
|
gueltiger_tag INTEGER NOT NULL DEFAULT 0,
|
|
zuschauer_avg INTEGER,
|
|
zuschauer_max INTEGER,
|
|
verweildauer_s INTEGER, /* Sekunden, die Leitzahl */
|
|
schenker INTEGER,
|
|
follower_neu INTEGER,
|
|
notiz TEXT,
|
|
erfasst TEXT NOT NULL,
|
|
erfasst_von INTEGER REFERENCES personen(id) ON DELETE SET NULL,
|
|
/* Woher der Wert kommt. Wichtig, wenn eine Zahl seltsam
|
|
aussieht: von Hand getippt oder aus dem Export? */
|
|
quelle TEXT NOT NULL DEFAULT 'hand'
|
|
CHECK (quelle IN ('hand','import')),
|
|
PRIMARY KEY (creator_id, tag)
|
|
);
|
|
CREATE INDEX IF NOT EXISTS idx_leistung_tag ON leistung (tag DESC);
|
|
|
|
/* ZIELE je Creator. Aus dem 90-Tage-Plan, den es im Profil schon
|
|
als FREITEXT gibt -- daraus wird hier eine pruefbare Groesse.
|
|
"Mehr Reichweite" ist kein Ziel, "4 LIVE-Tage pro Woche" ist
|
|
eines.
|
|
|
|
Eine Zeile je Creator, nicht je Ziel und Zeitraum: Ein Ziel,
|
|
das sich woechentlich aendert, ist keines. Wer es anpasst,
|
|
ueberschreibt -- der Verlauf steht ohnehin in der Tabelle
|
|
"leistung". */
|
|
CREATE TABLE IF NOT EXISTS leistung_ziele (
|
|
creator_id INTEGER PRIMARY KEY REFERENCES personen(id) ON DELETE CASCADE,
|
|
diamanten_woche INTEGER,
|
|
tage_woche INTEGER,
|
|
verweildauer_s INTEGER,
|
|
gesetzt TEXT NOT NULL,
|
|
gesetzt_von INTEGER REFERENCES personen(id) ON DELETE SET NULL
|
|
);
|
|
|
|
/* Start-Check / Erstanalyse (Konzept, Seite 5). Eine Zeile je
|
|
geprueftem Punkt -- ungeprueft heisst: gar keine Zeile. So sieht
|
|
man am Bestand sofort, wie weit die Analyse ist, ohne leere
|
|
Platzhalter mitschleppen zu muessen.
|
|
Die Punkte selbst stehen im Code, nicht hier: Waeren sie
|
|
Datensaetze, koennte jeder eigene anlegen -- und zwei Creator
|
|
waeren nicht mehr vergleichbar, was der ganze Zweck ist. */
|
|
CREATE TABLE IF NOT EXISTS startcheck (
|
|
creator_id INTEGER NOT NULL REFERENCES personen(id) ON DELETE CASCADE,
|
|
feld TEXT NOT NULL,
|
|
punkt TEXT NOT NULL,
|
|
bewertung TEXT CHECK (bewertung IS NULL OR bewertung IN ('ok','mittel','handlung')),
|
|
notiz TEXT,
|
|
geaendert TEXT NOT NULL,
|
|
geaendert_von INTEGER REFERENCES personen(id) ON DELETE SET NULL,
|
|
PRIMARY KEY (creator_id, feld, punkt)
|
|
);
|
|
|
|
/* Betreuung: wer kuemmert sich um welchen Creator.
|
|
Scouts betreuen Creator wie das Management, aber nur die ihnen
|
|
zugeteilten -- deshalb braucht es eine ausdrueckliche Zuordnung
|
|
und keine Rollenregel. Ein Creator hat genau eine zustaendige
|
|
Person (PRIMARY KEY), damit nie unklar ist, wer gefragt ist.
|
|
Das Management sieht ohnehin alle und braucht keinen Eintrag. */
|
|
CREATE TABLE IF NOT EXISTS betreuung (
|
|
creator_id INTEGER PRIMARY KEY REFERENCES personen(id) ON DELETE CASCADE,
|
|
betreuer_id INTEGER NOT NULL REFERENCES personen(id) ON DELETE CASCADE,
|
|
seit TEXT NOT NULL,
|
|
gesetzt_von INTEGER REFERENCES personen(id) ON DELETE SET NULL
|
|
);
|
|
CREATE INDEX IF NOT EXISTS idx_betreuung_betreuer ON betreuung (betreuer_id);
|
|
|
|
/* Welcher Scout gehoert zu welchem Manager (01.09.2026).
|
|
|
|
"er soll nur die Scouts sehen, die ihm zugeteilt sind."
|
|
|
|
WARUM EINE EIGENE TABELLE und nicht ein zweiter Eintrag in
|
|
betreuung: Dort heisst die Spalte creator_id, und der ganze
|
|
uebrige Code liest sie als "das ist ein Creator". Ein Scout in
|
|
dieser Spalte waere technisch moeglich und fachlich eine Luege --
|
|
betreuteIds() gaebe dann Scout-Nummern zurueck, die anderswo als
|
|
Creator behandelt wuerden. Solche Abkuerzungen raechen sich
|
|
genau dann, wenn niemand mehr weiss, dass sie getroffen wurden.
|
|
|
|
Ein Scout gehoert zu hoechstens EINEM Manager -- deshalb ist
|
|
scout_id der Schluessel. Eine Zuteilung an mehrere waere die
|
|
Frage "wer ist zustaendig?" ohne Antwort.
|
|
|
|
ON DELETE CASCADE auf beiden Seiten: Verschwindet der Scout oder
|
|
der Manager, ist die Zuteilung gegenstandslos. Sie stehenzulassen
|
|
hiesse, dass eine geloeschte Person weiter Sichtbarkeit steuert. */
|
|
CREATE TABLE IF NOT EXISTS scout_zuteilung (
|
|
scout_id INTEGER PRIMARY KEY REFERENCES personen(id) ON DELETE CASCADE,
|
|
manager_id INTEGER NOT NULL REFERENCES personen(id) ON DELETE CASCADE,
|
|
seit TEXT NOT NULL,
|
|
gesetzt_von INTEGER REFERENCES personen(id) ON DELETE SET NULL
|
|
);
|
|
CREATE INDEX IF NOT EXISTS idx_scout_zuteilung_manager
|
|
ON scout_zuteilung (manager_id);
|
|
|
|
/* Meeting-Protokoll zu einem Termin (Konzept, Seite 13).
|
|
Bewusst eine eigene Tabelle statt neuer Spalten in termine: Es
|
|
gibt hier keine Migrationen, und ein Protokoll gehoert ohnehin
|
|
nur zu einem Bruchteil der Termine. Ein Termin hat hoechstens
|
|
ein Protokoll -- daher UNIQUE. */
|
|
CREATE TABLE IF NOT EXISTS protokolle (
|
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
termin_id INTEGER NOT NULL UNIQUE REFERENCES termine(id) ON DELETE CASCADE,
|
|
punkte TEXT,
|
|
entscheidungen TEXT,
|
|
naechster_termin_id INTEGER REFERENCES termine(id) ON DELETE SET NULL,
|
|
erstellt TEXT NOT NULL,
|
|
erstellt_von INTEGER REFERENCES personen(id) ON DELETE SET NULL,
|
|
geaendert TEXT
|
|
);
|
|
|
|
/* "Jeder Call endet mit klaren To-dos, die direkt ins Board
|
|
uebernommen werden." Die To-dos leben deshalb NICHT im Protokoll,
|
|
sondern sind echte Aufgaben. Diese Tabelle merkt sich nur, aus
|
|
welchem Gespraech eine Aufgabe entstanden ist. */
|
|
CREATE TABLE IF NOT EXISTS protokoll_aufgaben (
|
|
protokoll_id INTEGER NOT NULL REFERENCES protokolle(id) ON DELETE CASCADE,
|
|
aufgabe_id INTEGER NOT NULL REFERENCES aufgaben(id) ON DELETE CASCADE,
|
|
PRIMARY KEY (protokoll_id, aufgabe_id)
|
|
);
|
|
|
|
/* Scout-CRM: die Pipeline vom ersten Fund bis zur Uebergabe.
|
|
creator_id ist gesetzt, sobald aus einem uebergebenen Lead eine
|
|
echte Person angelegt wurde -- damit bleibt nachvollziehbar, wer
|
|
einen Creator gefunden hat, auch Jahre spaeter. */
|
|
CREATE TABLE IF NOT EXISTS leads (
|
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
name TEXT NOT NULL,
|
|
plattform TEXT,
|
|
handle TEXT,
|
|
status TEXT NOT NULL DEFAULT 'neu'
|
|
CHECK (status IN ('neu','angesprochen','gespraech','interessiert','uebergeben','abgelehnt')),
|
|
prioritaet TEXT NOT NULL DEFAULT 'mittel'
|
|
CHECK (prioritaet IN ('hoch','mittel','niedrig')),
|
|
aktivitaet TEXT,
|
|
potenzial TEXT,
|
|
notizen TEXT,
|
|
letzte_nachricht TEXT,
|
|
naechster_followup TEXT,
|
|
scout_id INTEGER REFERENCES personen(id) ON DELETE SET NULL,
|
|
creator_id INTEGER REFERENCES personen(id) ON DELETE SET NULL,
|
|
erstellt TEXT NOT NULL,
|
|
erstellt_von INTEGER REFERENCES personen(id) ON DELETE SET NULL,
|
|
geaendert TEXT,
|
|
uebergeben_am TEXT
|
|
);
|
|
CREATE INDEX IF NOT EXISTS idx_leads_scout ON leads (scout_id, status);
|
|
|
|
CREATE TABLE IF NOT EXISTS versuche (
|
|
ip TEXT NOT NULL,
|
|
zeitpunkt TEXT NOT NULL
|
|
);
|
|
CREATE INDEX IF NOT EXISTS idx_versuche_ip ON versuche (ip, zeitpunkt);
|
|
CREATE INDEX IF NOT EXISTS idx_sitzungen_gueltig ON sitzungen (gueltig_bis);
|
|
`);
|
|
umstellungen(d);
|
|
_db = d;
|
|
return d;
|
|
} catch (fehler) {
|
|
_dbFehler = fehler;
|
|
console.error("[workspace] Datenbank nicht verfügbar:", fehler?.message);
|
|
throw fehler;
|
|
}
|
|
}
|
|
|
|
/* ---------- Hilfsmittel ---------------------------------------------- */
|
|
|
|
const jetzt = () => new Date().toISOString();
|
|
|
|
/* Die echte Besucher-IP.
|
|
|
|
Warum das nötig ist: Die Kette lautet Besucher -> Cloudflare -> Caddy ->
|
|
Express. `trust proxy` steht auf 1, Express nimmt daher den letzten
|
|
Eintrag aus X-Forwarded-For -- und das ist die Adresse von Cloudflare,
|
|
nicht die des Besuchers. Folge: Im Protokoll stand bei jedem dieselbe
|
|
IP, und viel schlimmer -- ALLE Nutzer teilten sich einen einzigen
|
|
Sperr-Zähler. Ein paar Tippfehler von drei Leuten hätten den Zugang für
|
|
alle gesperrt.
|
|
|
|
Cloudflare setzt die echte Adresse in CF-Connecting-IP und überschreibt
|
|
den Kopf bei jeder Anfrage, ein Besucher kann ihn also nicht fälschen.
|
|
Einschränkung: Wer die Adresse des Servers kennt und ihn unter Umgehung
|
|
von Cloudflare direkt anspricht, könnte den Kopf frei setzen. Deshalb
|
|
wird auf die Peer-Adresse zurückgefallen, sobald der Kopf fehlt -- und
|
|
beide Werte landen im Protokoll, damit so etwas auffällt. */
|
|
export function echteIp(req) {
|
|
const cf = req.get("cf-connecting-ip");
|
|
if (cf && cf.length <= 45) return cf.trim();
|
|
return req.ip || "?";
|
|
}
|
|
|
|
function hashe(code, salt, N) {
|
|
return scryptSync(code, salt, SCRYPT.keylen,
|
|
{ N, r: SCRYPT.r, p: SCRYPT.p, maxmem: 256 * 1024 * 1024 }).toString("hex");
|
|
}
|
|
|
|
/* =====================================================================
|
|
DER SUCHSCHLUESSEL ZU EINEM CODE (09.09.2026)
|
|
|
|
Beantwortet in Mikrosekunden, WEN man pruefen soll -- nicht, ob der
|
|
Code stimmt. Das bleibt Sache von scrypt darunter.
|
|
|
|
WARUM EIN HMAC UND KEIN EINFACHER HASH: Ein einfacher Hash ueber den
|
|
Code waere ueberall gleich. Mit einem Schluessel, der nur in dieser
|
|
einen Datenbank steht, ist der Wert ausserhalb wertlos -- er laesst
|
|
sich nirgends nachschlagen und nirgends wiedererkennen.
|
|
|
|
WARUM DER SCHLUESSEL IN DIE DATENBANK GEHOERT UND NICHT IN DIE
|
|
UMGEBUNG: Eine neue Pflichtangabe in server/.env ist eine Angabe, die
|
|
beim naechsten Aufsetzen fehlen kann. Sie wuerde nicht laut scheitern,
|
|
sondern leise: Der stille Zugang fuende niemanden mehr, und die
|
|
Anmeldung saehe aus wie ein falscher Code. Aus der Datenbank kann sie
|
|
nicht verschwinden -- sie wird mitgesichert wie alles andere.
|
|
|
|
OHNE SCHLUESSEL GIBT ES KEINE ANTWORT, nicht etwa eine schlechtere:
|
|
`null` heisst "konnte nicht nachsehen", und die Anmeldung behandelt
|
|
das wie "nicht gefunden". Ein Rueckfall auf einen ungeschluesselten
|
|
Wert waere die stille Verschlechterung, die niemandem auffaellt. */
|
|
let _kennungSchluessel = null;
|
|
function kennungSchluessel() {
|
|
if (_kennungSchluessel) return _kennungSchluessel;
|
|
try {
|
|
const d = db();
|
|
const lies = () => d.prepare(
|
|
"SELECT wert FROM einstellungen WHERE schluessel = 'code_kennung_schluessel'").get()?.wert;
|
|
let wert = lies();
|
|
if (!wert) {
|
|
/* Bewusst NICHT ueber einstellungSetzen(): Das schriebe einen
|
|
Eintrag ins Protokoll, und ein Protokolleintrag ist genau die
|
|
Spur, die es hier nicht geben soll. */
|
|
d.prepare(`INSERT INTO einstellungen (schluessel, wert, geaendert, von)
|
|
VALUES (?,?,?,NULL) ON CONFLICT(schluessel) DO NOTHING`)
|
|
.run("code_kennung_schluessel", randomBytes(32).toString("hex"),
|
|
new Date().toISOString());
|
|
/* Danach nochmal LESEN statt den erzeugten Wert zu benutzen:
|
|
Legen zwei Vorgaenge ihn im selben Augenblick an, gewinnt einer
|
|
-- und beide muessen mit demselben weiterrechnen. */
|
|
wert = lies();
|
|
}
|
|
_kennungSchluessel = wert || null;
|
|
return _kennungSchluessel;
|
|
} catch {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
export function codeKennung(code) {
|
|
const schluessel = kennungSchluessel();
|
|
if (!schluessel || !code) return null;
|
|
return createHmac("sha256", schluessel).update(String(code)).digest("hex");
|
|
}
|
|
|
|
/* Vergleich in konstanter Zeit. Ein normaler ===-Vergleich bricht beim
|
|
ersten abweichenden Zeichen ab; aus den Laufzeitunterschieden lässt sich
|
|
ein Geheimnis Zeichen für Zeichen erraten. */
|
|
function gleich(a, b) {
|
|
const x = Buffer.from(String(a), "utf8");
|
|
const y = Buffer.from(String(b), "utf8");
|
|
if (x.length !== y.length) return false;
|
|
return timingSafeEqual(x, y);
|
|
}
|
|
|
|
const tokenHash = (t) => createHash("sha256").update(t).digest("hex");
|
|
|
|
export function protokolliere(aktion, { personId = null, rolle = null, detail = null, ip = null } = {}) {
|
|
try {
|
|
db().prepare(
|
|
"INSERT INTO protokoll (zeitpunkt, person_id, rolle, aktion, detail, ip) VALUES (?,?,?,?,?,?)"
|
|
).run(jetzt(), personId, rolle, aktion, detail, ip);
|
|
} catch { /* Ein fehlgeschlagener Protokolleintrag darf nichts blockieren. */ }
|
|
}
|
|
|
|
function zuVieleVersuche(ip) {
|
|
const grenze = new Date(Date.now() - VERSUCHE_FENSTER_MIN * 60_000).toISOString();
|
|
db().prepare("DELETE FROM versuche WHERE zeitpunkt < ?").run(grenze);
|
|
const { anzahl } = db()
|
|
.prepare("SELECT COUNT(*) AS anzahl FROM versuche WHERE ip = ? AND zeitpunkt >= ?")
|
|
.get(ip, grenze);
|
|
return anzahl >= VERSUCHE_MAX;
|
|
}
|
|
|
|
/* ---------- Sitzung ---------------------------------------------------- */
|
|
|
|
export function sitzungLesen(req) {
|
|
const token = req.cookies?.[COOKIE];
|
|
if (!token) return null;
|
|
try {
|
|
const reihe = db().prepare(`
|
|
SELECT s.gueltig_bis, p.id, p.name, p.rolle, p.aktiv
|
|
FROM sitzungen s JOIN personen p ON p.id = s.person_id
|
|
WHERE s.token_hash = ?`).get(tokenHash(token));
|
|
if (!reihe || !reihe.aktiv) return null;
|
|
if (reihe.gueltig_bis < jetzt()) {
|
|
db().prepare("DELETE FROM sitzungen WHERE token_hash = ?").run(tokenHash(token));
|
|
return null;
|
|
}
|
|
/* DIE ADRESSE ENTSCHEIDET MIT (10.09.2026).
|
|
|
|
Seit die Modi-App unter crew.dogfather-universe.com laeuft, gehoert
|
|
jede Sitzung zu genau EINER Adresse: Modis dorthin, alle anderen auf
|
|
den Workspace. Die Regel steht in crew-adresse.js und ist dort ohne
|
|
laufenden Server pruefbar.
|
|
|
|
WARUM HIER UND NICHT IN DEN MODULEN: Durch diese Funktion geht jedes
|
|
der 26 Fachmodule und jede Seitenschranke. Eine Middleware daneben
|
|
koennte man in einem neuen Modul vergessen -- und ein vergessener
|
|
Rechteschutz faellt nicht auf, weil dann alles geht.
|
|
|
|
`null` heisst hier dasselbe wie bei einer abgelaufenen Sitzung:
|
|
nicht angemeldet. Die Seitenschranke schickt zur Zugangswand, die
|
|
Schnittstellen antworten mit 401. Beides ist genau richtig -- auf
|
|
dieser Adresse ist diese Person schlicht niemand. */
|
|
if (!sitzungPasstZurAdresse(reihe.rolle, req.get?.("host"))) return null;
|
|
|
|
return { id: reihe.id, name: reihe.name, rolle: reihe.rolle };
|
|
} catch { return null; }
|
|
}
|
|
|
|
function sitzungSetzen(res, person, req) {
|
|
const token = randomBytes(32).toString("hex");
|
|
const bis = new Date(Date.now() + SITZUNG_STUNDEN * 3600_000).toISOString();
|
|
db().prepare(
|
|
"INSERT INTO sitzungen (token_hash, person_id, erstellt, gueltig_bis, ip, browser) VALUES (?,?,?,?,?,?)"
|
|
).run(tokenHash(token), person.id, jetzt(), bis, echteIp(req),
|
|
(req.get("user-agent") || "").slice(0, 200));
|
|
|
|
res.cookie(COOKIE, token, {
|
|
httpOnly: true, // kein Zugriff aus JavaScript -> kein Diebstahl per XSS
|
|
/* Live immer true: Hinter Caddy ist req.secure dank `trust proxy` das
|
|
Ergebnis von X-Forwarded-Proto. Nur beim lokalen Test über http
|
|
fällt es weg -- sonst wäre der Ablauf gar nicht prüfbar, weil
|
|
Browser und curl ein Secure-Cookie über http verwerfen. */
|
|
secure: req.secure,
|
|
sameSite: "lax", // schützt vor fremden Formularen (CSRF)
|
|
path: "/workspace", // gilt nur hier, nicht auf der ganzen Domain
|
|
maxAge: SITZUNG_STUNDEN * 3600_000,
|
|
});
|
|
}
|
|
|
|
/* ---------- Router ----------------------------------------------------- */
|
|
|
|
export const workspaceRouter = express.Router();
|
|
|
|
/* Schutz der angemeldeten Seiten. Serverseitig, nicht nur im Browser --
|
|
sonst könnte man die Seite einfach direkt aufrufen. */
|
|
/* Pfad -> erlaubte Rollen. `null` heisst: jede angemeldete Rolle.
|
|
|
|
Die Rolle wird hier mitgeprueft und nicht nur in der Schnittstelle.
|
|
Sonst bekommt z. B. ein Creator die Verwaltungsseite zwar ausgeliefert
|
|
(HTTP 200) und sieht ihr Geruest, auch wenn sie danach leer bleibt und
|
|
das Skript ihn wegschickt. Sichtbar sein soll sie gar nicht. */
|
|
const GESCHUETZT = {
|
|
"/workspace/start.html": null,
|
|
"/workspace/uebersicht.html": null,
|
|
/* AUSDRUECKLICH OHNE 'modi' (10.09.2026). Vorher stand hier `null`
|
|
("jede angemeldete Rolle") -- und mit der neuen Rolle hiess das
|
|
auch: sie. Die Seite laedt dann zwar, ihre Schnittstelle antwortet
|
|
ihm aber mit 404, weil `content` nicht zu seinen Bereichen gehoert.
|
|
Eine Seite, die man oeffnen kann und die dann leer bleibt, sieht
|
|
aus wie ein Fehler -- und ist einer.
|
|
|
|
Das ist die Kehrseite von "geschuetzt ist die Regel, nicht die
|
|
Ausnahme": Eine NEUE ROLLE erbt jedes `null` automatisch. Wer eine
|
|
Rolle hinzufuegt, muss diese Liste durchgehen -- der Kachel-Durchgang
|
|
in pruef-rollen findet die Faelle, die dabei auffallen. */
|
|
"/workspace/content.html": ["spicy", "admin", "manager", "scout", "creator"],
|
|
"/workspace/aufgaben.html": null,
|
|
/* Chat (06.09.2026): jede Rolle. WEN man erreicht, entscheidet
|
|
schreibbareIds() -- nicht der Zugang zur Seite. Ein Creator, der
|
|
die Seite nicht öffnen dürfte, könnte auch nicht antworten, und
|
|
genau das Antworten ist der Zweck. */
|
|
"/workspace/chat.html": null,
|
|
/* NUR DogFather (07.09.2026). Wunsch Filipe: "nimm die kategorie
|
|
zahlen bei jedem weg, nur die rolle dogfather soll die sehen."
|
|
|
|
Bis heute stand hier `null` -- jede Rolle durfte die Seite oeffnen,
|
|
und WESSEN Zahlen jemand sah, entschied sichtbareCreatorIds().
|
|
Die Begruendung dafuer war gut (es sind SEINE Zahlen), sie ist
|
|
jetzt aber nicht mehr meine Entscheidung.
|
|
|
|
WICHTIG: Die Kachel wegzunehmen haette NICHT gereicht. Eine Kachel
|
|
ist ein Weg, keine Schranke -- wer die Adresse kennt oder ein altes
|
|
Lesezeichen hat, waere weiterhin hineingekommen. Beides gehoert
|
|
zusammen: bereiche.js zeigt sie nur noch DogFather, diese Zeile
|
|
laesst nur ihn hinein. */
|
|
/* NUR DogFather -- auch nicht Spicy Media (07.09.2026, zweite
|
|
Praezisierung). Beim Anlegen der Rolle stand hier ["spicy","admin"],
|
|
weil Spicy Media "dieselben Rechte ausser Automationen" bekommen
|
|
sollte. Filipe hat es an der laufenden Seite gesehen und
|
|
widersprochen: "bei der spicy rolle, die zahlen diese kategorie
|
|
nicht sehen." */
|
|
"/workspace/leistung.html": ["admin"],
|
|
/* NUR DogFather (02.09.2026). Die Rollenauswahl verspricht das seit
|
|
jeher ("Manager -- dieselben Rechte, ausser der
|
|
Personenverwaltung"), die Schranke hielt sich nur nicht daran.
|
|
Wunsch: "die manager sollen diese kategorien garnicht sehen". */
|
|
/* PERSONEN & ZUGAENGE: jetzt auch fuer Manager und Spicy Media
|
|
(07.09.2026).
|
|
|
|
Filipe: "die manager sehen immer noch nicht die kachel personen &
|
|
zugangscode, wo sie dan die neuen creator hinzufuegen koennen ...
|
|
und die sollen in der kategorie wo die jetzt sehen werden auch noch
|
|
das mit den hinzufuegen sehen, alles andere auf der seite sollen die
|
|
weiterhin nicht sehen."
|
|
|
|
DIE SEITE OEFFNET SICH, DIE SCHNITTSTELLEN NICHT. Das ist der ganze
|
|
Trick und der Grund, warum das hier ungefaehrlich ist: Alles unter
|
|
/workspace/api/verwaltung haengt weiterhin an `nurAdmin` -- Rollen
|
|
aendern, Codes neu setzen, sperren, loeschen, Protokoll lesen
|
|
bleiben zu und antworten mit 404. Wer die Seite oeffnet, bekommt
|
|
also genau die Teile zu sehen, fuer die es auch einen Weg gibt.
|
|
|
|
Eine Seite ist kein Schutz, sie ist ein Weg. Der Schutz steht in den
|
|
Schnittstellen, und der ist unveraendert. */
|
|
"/workspace/personen.html": ["spicy", "admin", "manager"],
|
|
"/workspace/profil.html": ["spicy", "admin", "manager", "scout", "creator"],
|
|
/* Der eigene Steckbrief -- jede Rolle hat einen. Ein Creator wird
|
|
dort nicht hingeschickt (er sieht seinen auf profil.html), darf die
|
|
Seite aber aufrufen: Sie zeigt ihm dasselbe, nur ohne die Akte. */
|
|
/* 'modi' kam am 10.09.2026 dazu. Der Steckbrief ist die EIGENE Seite
|
|
("Dein Bild und deine Kanäle"); profil.html daneben ist der
|
|
Entwicklungsplan eines Creators und fuer ihn sinnlos. Der erste
|
|
Anlauf hatte genau das verwechselt -- die Kachel hiess "Mein
|
|
Profil" und fuehrte auf eine Seite, deren Schnittstelle mit 404
|
|
antwortet, weil sie ausdruecklich nur Creator kennt. Gefunden vom
|
|
neuen Kachel-Durchgang in pruef-rollen. */
|
|
"/workspace/steckbrief.html": ["spicy", "admin", "manager", "scout", "creator", "hand", "modi"],
|
|
"/workspace/kalender.html": null,
|
|
"/workspace/dateien.html": null,
|
|
/* 'modi' kam am 10.09.2026 dazu -- und es war ein echter Fund: Ohne
|
|
diese Zeile leiteten VIER seiner Kacheln (Live-Ablauf, Community,
|
|
Technik, Ideen-Board) wortlos auf die Startseite zurueck. Der
|
|
Rollen-Rundgang meldete sie trotzdem als "ok": Eine Umleitung ist
|
|
kein Fehler, die Seite laedt ja -- nur eben eine andere. Seither
|
|
prueft pruef-rollen fuer JEDE Rolle, dass jede Kachel, die sie
|
|
bekommt, auch wirklich dort ankommt.
|
|
|
|
WELCHE Bereiche ein Modi oeffnen darf, entscheidet die Liste
|
|
seiner Kacheln (MODI_BEREICHE_ERLAUBT) -- sonst kaeme er ueber die
|
|
Adresszeile auch in die Agentur-Ablage. */
|
|
"/workspace/bereich.html": ["spicy", "admin", "manager", "scout", "creator", "hand", "modi"],
|
|
"/workspace/report.html": ["spicy", "admin", "manager", "scout", "creator"],
|
|
"/workspace/scouting.html": ["spicy", "admin", "manager", "scout"],
|
|
/* DIE ARBEITSLAGE DES TEAMS (09.09.2026).
|
|
|
|
Filipe: "so eine kategorie wie ueber die creator will ich dass nur
|
|
fuer die spicy und dogfather rolle auch ueber manager und scouts
|
|
gibt."
|
|
|
|
Nur diese beiden Rollen. Ein Manager sieht seine eigene Zeile hier
|
|
ausdruecklich NICHT -- das ist kein Versehen: Eine Uebersicht ueber
|
|
die Arbeit der Kollegen gehoert der Leitung, nicht der Reihe. Der
|
|
Schutz steht ohnehin in der Schnittstelle (workspace-team.js);
|
|
diese Zeile sorgt nur dafuer, dass niemand vor einer leeren Seite
|
|
steht. */
|
|
"/workspace/team.html": ["spicy", "admin"],
|
|
"/workspace/calls.html": null,
|
|
"/workspace/startcheck.html": null,
|
|
"/workspace/wissen.html": null,
|
|
/* Ebenfalls nur DogFather: Was von allein passiert, bestimmt, was
|
|
allen anderen zugeschoben wird. */
|
|
/* AUTOMATIONEN BLEIBEN BEI DogFather (07.09.2026). Ausdruecklich:
|
|
"bei automationen sollen die nicht sehen." Dahinter liegen
|
|
Sicherungen, die KI und der Zustand des Servers -- das ist Betrieb,
|
|
nicht Betreuung. */
|
|
"/workspace/automation.html": ["admin"],
|
|
/* Die gemeinsame Lage des Teams -- nur die DogFather-Rolle, also
|
|
Filipe und VanVan (10.09.2026). Fuer alle anderen leitet die
|
|
Schranke auf die Startseite; die Schnittstelle dahinter antwortet
|
|
zusaetzlich mit 404. Zwei Schloesser, absichtlich: Faellt eines
|
|
weg, haelt das andere. */
|
|
/* Der Eingang: DogFather und seine rechte Hand. Blueprint 3.1 --
|
|
"Kann im Alltag vertretend Entscheidungen treffen." Genau das
|
|
passiert auf dieser Seite. */
|
|
"/workspace/teamlage.html": ["admin", "hand"],
|
|
};
|
|
|
|
/* Die einzige Seite unter /workspace, die offen sein MUSS -- man kann
|
|
sich schlecht anmelden, wenn die Anmeldeseite eine Anmeldung verlangt. */
|
|
/* DIE ZUGANGSWAENDE. Zwei, seit die Modi-App ihre eigene Adresse hat:
|
|
crew-index.html ist dieselbe Wand in Dogis Farben, ohne Kachelreihe.
|
|
|
|
SIE MUSS HIER STEHEN, sonst dreht sich die Seite im Kreis: crewWeiche
|
|
biegt /workspace/ auf /workspace/crew-index.html um; stuende die
|
|
Datei nicht als offen, schickte die Schranke sie zurueck nach
|
|
/workspace/ -- und von dort wieder hierher. Der Browser bricht das
|
|
nach ein paar Runden mit "zu viele Weiterleitungen" ab, und man
|
|
sucht den Fehler ueberall, nur nicht in dieser Zeile. */
|
|
const OFFEN = new Set(["/workspace/index.html", "/workspace/crew-index.html"]);
|
|
|
|
/** Der Pfad, wie ihn die Schranke ansehen muss.
|
|
*
|
|
* BEFUND VOM 04.09.2026 (Audit). Der erste Entwurf verglich `req.path`
|
|
* direkt gegen die Liste -- ein EXAKTER Vergleich. Express raeumt
|
|
* Punkt-Segmente selbst weg (`/workspace/./start.html` und
|
|
* `/workspace/x/../start.html` kommen bereits zurechtgerueckt an), aber
|
|
* MEHRFACHE SCHRAEGSTRICHE nicht. Gemessen:
|
|
*
|
|
* /workspace/start.html -> req.path /workspace/start.html
|
|
* //workspace/start.html -> req.path //workspace/start.html
|
|
* /workspace//start.html -> req.path /workspace//start.html
|
|
*
|
|
* Der Listenvergleich traf damit nicht, die Anfrage lief ungeprueft an
|
|
* express.static weiter -- und das zieht die Schraegstriche seinerseits
|
|
* zusammen und liefert die Datei aus. Ergebnis: `//workspace/start.html`
|
|
* kam OHNE ANMELDUNG mit HTTP 200, und ein Creator bekam ueber
|
|
* `/workspace//personen.html` die Verwaltungsseite. Das ist derselbe
|
|
* Befund wie am 27.08.2026 ("sichtbar sein soll sie gar nicht"), nur
|
|
* eine Schreibweise weiter -- der Fix von damals deckte genau eine ab.
|
|
*
|
|
* Nicht betroffen waren die DATEN: Bei doppeltem Schraegstrich trifft
|
|
* keine der /workspace/api-Routen mehr (404), und wo sie trifft, greift
|
|
* ihre eigene Schranke (401). Nachgemessen, nicht angenommen.
|
|
*
|
|
* Kleinschreibung kommt dazu, weil ein case-unempfindliches Dateisystem
|
|
* (Windows, macOS) `PERSONEN.HTML` an dieselbe Datei weiterreicht. Auf
|
|
* dem Linux-Server greift das nicht, hier im Test schon -- und eine
|
|
* Schranke, die nur auf einem Dateisystem haelt, ist keine.
|
|
*
|
|
* Abschliessende Punkte und Leerzeichen fallen weg: Windows oeffnet
|
|
* `start.html.` als `start.html`. */
|
|
function schrankenPfad(roh) {
|
|
return String(roh || "")
|
|
.replace(/\/{2,}/g, "/") // // -> /
|
|
.replace(/[.\s]+$/, (ende) => // abschliessende Punkte/Leerzeichen
|
|
ende.includes(".") && !/^\.[a-z]/i.test(ende) ? "" : ende)
|
|
.toLowerCase();
|
|
}
|
|
|
|
/* GESCHUETZT IST JETZT DIE AUSNAHME, NICHT DIE REGEL.
|
|
|
|
Vorher galt: Was in der Liste steht, ist geschuetzt -- alles andere
|
|
nicht. Wer eine neue Seite anlegt und den Eintrag vergisst, stellt sie
|
|
damit offen ins Netz, ohne dass irgendwo etwas rot wird. Genau diese
|
|
Sorte Fehler faellt erst auf, wenn jemand danach sucht.
|
|
|
|
Jetzt gilt: JEDE .html unter /workspace ist geschuetzt, ausser den
|
|
ausdruecklich offenen. Die Liste oben sagt nur noch, WELCHE ROLLEN
|
|
zusaetzlich eingeschraenkt sind. Eine neue Seite ist dadurch von
|
|
selbst mindestens "nur fuer Angemeldete" -- der sichere Rueckfall. */
|
|
workspaceRouter.use((req, res, next) => {
|
|
const pfad = schrankenPfad(req.path);
|
|
if (!pfad.startsWith("/workspace/") || !pfad.endsWith(".html")) return next();
|
|
if (OFFEN.has(pfad)) return next();
|
|
|
|
const person = sitzungLesen(req);
|
|
if (!person) return res.redirect(302, "/workspace/");
|
|
const erlaubt = GESCHUETZT[pfad];
|
|
if (erlaubt && !erlaubt.includes(person.rolle)) {
|
|
return res.redirect(302, "/workspace/start.html");
|
|
}
|
|
return next();
|
|
});
|
|
|
|
/**
|
|
* Gehoert dieser Code zu einem verborgenen Zugang?
|
|
*
|
|
* Gibt die Person zurueck oder null. `null` heisst hier ausdruecklich
|
|
* "nein oder nicht feststellbar" -- ohne Suchschluessel (etwa weil die
|
|
* Einstellung fehlt) gibt codeKennung() null zurueck, und dann wird
|
|
* niemand angemeldet. Das ist die richtige Richtung: Im Zweifel kommt
|
|
* niemand herein, statt dass im Zweifel jemand hereinkommt.
|
|
*/
|
|
function stillerZugang(code) {
|
|
try {
|
|
const kennung = codeKennung(code);
|
|
if (!kennung) return null;
|
|
const k = db().prepare(
|
|
"SELECT id, name, rolle, code_hash, code_salt, code_n FROM personen "
|
|
+ `WHERE code_kennung = ? AND rolle IN (${
|
|
[...TEAM_DOGI_ROLLEN].map((r) => `'${r}'`).join(", ")}) AND aktiv = 1`).get(kennung);
|
|
if (!k) return null;
|
|
/* Trotz Treffer wird geprueft. Der Suchschluessel ist ein
|
|
Wegweiser, kein Ausweis -- und ein Wegweiser, dem man blind
|
|
folgt, ist eine Hintertuer. */
|
|
return gleich(hashe(code, k.code_salt, k.code_n), k.code_hash) ? k : null;
|
|
} catch (fehler) {
|
|
console.error("[workspace] Stiller Zugang nicht pruefbar:", fehler?.message);
|
|
return null;
|
|
}
|
|
}
|
|
|
|
workspaceRouter.post("/workspace/api/anmelden", (req, res) => {
|
|
const ip = echteIp(req);
|
|
try {
|
|
if (zuVieleVersuche(ip)) {
|
|
protokolliere("anmeldung_gesperrt", { ip });
|
|
return res.status(429).json({ fehler: "zu_viele_versuche" });
|
|
}
|
|
|
|
const rolle = String(req.body?.rolle || "");
|
|
const code = String(req.body?.code || "");
|
|
const codeBrauchbar = code.length >= 4 && code.length <= 200;
|
|
|
|
/* =================================================================
|
|
DER STILLE ZUGANG (09.09.2026)
|
|
|
|
Wunsch Filipe: *"dass die keine neue eingangs kachel bekommen wie
|
|
spicy dogfather und so sondern einfach einen code. damit die von
|
|
der workspace auch nicht mal sehen dass die modis von mir einen
|
|
eigenen zugang haben."*
|
|
|
|
Ein Modi oeffnet dieselbe Seite, tippt auf IRGENDEINE vorhandene
|
|
Kachel und gibt seinen Code ein. Welche Kachel, ist gleichgueltig
|
|
-- der Code allein entscheidet. Von aussen sieht das aus wie jede
|
|
andere Anmeldung: dieselbe Seite, dieselbe Anfrage, dieselben
|
|
Felder.
|
|
|
|
ZWEI NAHELIEGENDE WEGE, DIE ABSICHTLICH NICHT GEWAEHLT WURDEN:
|
|
|
|
* Ein Merkmal im Code ("M-..."): waere ein sichtbares Kennzeichen
|
|
auf dem Zettel des Modis. Wer den Code sieht, sieht die Sorte.
|
|
* Eine eigene Adresse (/modi.html): waere eine Seite, die man
|
|
finden kann -- und die Seitenpruefung zaehlt Seiten.
|
|
|
|
WARUM DAS VOR DEM GEWOHNTEN WEG STEHT UND NICHT DAHINTER: Ein
|
|
Rueckfall NACH einem Fehlversuch haette den gewohnten Weg
|
|
verlaengert -- und zwar nur dann, wenn er scheitert. Genau daran
|
|
waere es zu erkennen gewesen: Fehlversuche dauern ploetzlich
|
|
laenger als frueher. Der Suchschluessel kostet Mikrosekunden und
|
|
trifft in aller Regel nichts; fuer alle anderen bleibt der Ablauf
|
|
unveraendert, auch in der Zeit.
|
|
|
|
DER SUCHSCHLUESSEL ALLEIN LAESST NIEMANDEN HEREIN. Er sagt nur,
|
|
WEN man pruefen soll -- danach entscheidet scrypt wie ueberall. */
|
|
/* AUF DEN ALTEN ADRESSEN WIRD DER VERBORGENE ZUGANG GAR NICHT ERST
|
|
GEFRAGT (Entscheidung Filipe, 10.09.2026).
|
|
|
|
Vorher stand hier eine Weiterleitung: Wer seinen Code noch auf
|
|
workspace.dogfather-universe.com eintippte, bekam keine Sitzung,
|
|
aber den Weg zur neuen Adresse. Filipes Entscheidung dagegen:
|
|
"gar nichts -- Code stimmt nicht". Die alte Wand soll sich
|
|
verhalten, als waere der Code erfunden.
|
|
|
|
WARUM DAS NICHT NUR EINE ANDERE ANTWORT IST, SONDERN EIN ANDERER
|
|
WEG: Haette ich hier bloss `return 401` gesetzt, waere der Ablauf
|
|
trotzdem ein anderer geblieben -- ein Suchschluessel-Treffer und
|
|
EIN scrypt-Durchlauf statt der Kandidatenschleife der gewaehlten
|
|
Kachel. Das ist messbar, und Zeitunterschiede sind genau die
|
|
Spur, die dieser ganze Zugang vermeiden soll.
|
|
|
|
Deshalb wird `stillerZugang` auf diesen drei Adressen ueberhaupt
|
|
nicht aufgerufen. Der Code faellt danach durch den gewohnten Weg
|
|
wie jeder unbekannte: dieselbe Schleife, derselbe Eintrag in
|
|
`versuche`, dieselbe Antwort, dieselbe Dauer. Die alte Wand
|
|
verhaelt sich damit exakt so wie vor dem Tag, an dem es diese
|
|
Rollen gab.
|
|
|
|
DER PREIS, und er gehoert genannt: Ein Modi, der die alte
|
|
Verknuepfung auf dem Handy hat, sammelt dort Fehlversuche wie
|
|
jeder andere auch. Acht in zehn Minuten sperren seine IP -- und
|
|
zwar auch fuer die neue Adresse, denn die Sperre haengt an der
|
|
IP, nicht am Hostnamen. Das ist der Preis der Stille; er faellt
|
|
nur an, wenn jemand die alte Adresse mehrfach probiert. */
|
|
const still = codeBrauchbar && !istOhneModiAdresse(req.get("host"))
|
|
? stillerZugang(code)
|
|
: null;
|
|
if (still) {
|
|
db().prepare("DELETE FROM versuche WHERE ip = ?").run(ip);
|
|
db().prepare("UPDATE personen SET letzter_login = ? WHERE id = ?").run(jetzt(), still.id);
|
|
sitzungSetzen(res, still, req);
|
|
protokolliere("anmeldung", {
|
|
personId: still.id, rolle: still.rolle, ip, detail: still.name,
|
|
});
|
|
return res.json({
|
|
weiter: "/workspace/start.html", name: still.name, rolle: still.rolle,
|
|
});
|
|
}
|
|
|
|
/* Ab hier der gewohnte Weg -- unveraendert. 'modi' ist keine
|
|
Kachel-Rolle und faellt deshalb in dieselbe Antwort wie eine
|
|
erfundene. */
|
|
/* AUF DER MODI-ADRESSE ENDET DER GEWOHNTE WEG HIER (10.09.2026).
|
|
|
|
crew.dogfather-universe.com ist nicht die zweite Tuer in den
|
|
Workspace. Wer sie errät und seinen eigenen Code eintippt, soll
|
|
genau das erleben, was er bei einem Tippfehler erlebt: Die
|
|
Zugangswand sieht aus wie immer, der Code stimmt nicht. Wuerde er
|
|
stattdessen hereinkommen, wuesste er im selben Moment, dass es
|
|
hier noch etwas gibt.
|
|
|
|
BEWUSST IN DIESELBE BEDINGUNG GESCHRIEBEN und nicht als eigener
|
|
Block davor: So ist es nicht nur dieselbe ANTWORT, sondern
|
|
derselbe Weg -- ein Eintrag in `versuche`, kein scrypt-Durchlauf,
|
|
401 "ungueltig". Ein eigener Block waere messbar schneller oder
|
|
langsamer gewesen als der Weg daneben, und genau daran erkennt
|
|
man eine Sonderbehandlung. */
|
|
/* JE ADRESSE EIN ANDERER KACHELSATZ (10.09.2026).
|
|
|
|
Bis eben stand hier `fremdAufCrew` -- auf crew. wurde JEDE
|
|
Kachelanmeldung abgewiesen, weil dort nur Modis ueber den
|
|
verborgenen Weg hereinkamen. Seit Filipes "3 rollen. dogfather.
|
|
rechte hand und modis" gibt es dort drei Kacheln, und der
|
|
gewohnte Weg wird gebraucht.
|
|
|
|
Zwei Mengen statt einer Bedingung mit Ausnahmen: Welche Kachel
|
|
auf welcher Wand steht, ist EINE Tatsache -- sie steht oben bei
|
|
ROLLEN_KACHEL und CREW_KACHEL, und hier wird nur gefragt, welche
|
|
Wand gerade dran ist. Ein Manager, der crew. erraet und seinen
|
|
Code eintippt, faellt dadurch in genau dieselbe Antwort wie
|
|
jemand mit einer erfundenen Rolle. */
|
|
const kachelSatz = istCrewAdresse(req.get("host")) ? CREW_KACHEL : ROLLEN_KACHEL;
|
|
|
|
if (!kachelSatz.has(rolle) || !codeBrauchbar) {
|
|
db().prepare("INSERT INTO versuche (ip, zeitpunkt) VALUES (?,?)").run(ip, jetzt());
|
|
return res.status(401).json({ fehler: "ungueltig" });
|
|
}
|
|
|
|
/* Alle aktiven Personen dieser Rolle durchgehen. Es gibt bewusst KEIN
|
|
Benutzerfeld: Der Code allein identifiziert die Person. Bei der
|
|
erwarteten Größenordnung (eine Handvoll Personen je Rolle) ist das
|
|
unkritisch; ab etwa 50 Codes je Rolle sollte hier ein Präfix im Code
|
|
die Vorauswahl übernehmen, sonst wird die Anmeldung spürbar langsam. */
|
|
const kandidaten = db()
|
|
.prepare("SELECT id, name, rolle, code_hash, code_salt, code_n FROM personen WHERE rolle = ? AND aktiv = 1")
|
|
.all(rolle);
|
|
|
|
/* EIN KAPUTTER DATENSATZ DARF NICHT ALLE AUSSPERREN (05.09.2026).
|
|
|
|
Hier stand die Schleife ohne Absicherung. Wirft `hashe()` bei
|
|
EINER Person -- etwa weil ihr code_n keine Zweierpotenz ist und
|
|
scrypt "Invalid scrypt params" meldet --, dann flog die ganze
|
|
Anmeldung in den catch am Ende: 503 "nicht_verfuegbar", für
|
|
JEDEN mit dieser Rolle, auch für die, deren Daten in Ordnung
|
|
sind.
|
|
|
|
Aufgefallen ist es beim Bau der Abbruchpruefung, wo ich zum
|
|
Testen versehentlich eine Person mit code_n = 1 angelegt hatte.
|
|
Ab da kam kein einziger DogFather mehr herein -- und die Meldung
|
|
sagte "nicht verfügbar", nicht "ein Datensatz ist defekt".
|
|
Danach hätte man lange gesucht.
|
|
|
|
Ein Datenfehler bei einer Person ist jetzt ein Problem DIESER
|
|
Person: Sie wird übersprungen, der Rest der Anmeldung läuft
|
|
normal weiter. Und sie wird laut protokolliert, denn sie kann
|
|
sich selbst nicht mehr anmelden -- das muss auffallen. */
|
|
let gefunden = null;
|
|
for (const k of kandidaten) {
|
|
try {
|
|
if (gleich(hashe(code, k.code_salt, k.code_n), k.code_hash)) { gefunden = k; break; }
|
|
} catch (f) {
|
|
console.error(`[workspace] Zugangsdaten von Person #${k.id} (${k.rolle}) sind defekt `
|
|
+ `-- sie kann sich nicht anmelden. Grund: ${f?.message}`);
|
|
protokolliere("zugangsdaten_defekt", {
|
|
personId: k.id, rolle: k.rolle, ip,
|
|
detail: String(f?.message || "").slice(0, 80),
|
|
});
|
|
}
|
|
}
|
|
|
|
if (!gefunden) {
|
|
db().prepare("INSERT INTO versuche (ip, zeitpunkt) VALUES (?,?)").run(ip, jetzt());
|
|
protokolliere("anmeldung_fehlgeschlagen", { rolle, ip });
|
|
/* Bewusst dieselbe Antwort wie bei falscher Rolle: Wer raten will,
|
|
soll nicht erfahren, ob wenigstens die Rolle gestimmt hat. */
|
|
return res.status(401).json({ fehler: "ungueltig" });
|
|
}
|
|
|
|
db().prepare("DELETE FROM versuche WHERE ip = ?").run(ip);
|
|
db().prepare("UPDATE personen SET letzter_login = ? WHERE id = ?").run(jetzt(), gefunden.id);
|
|
sitzungSetzen(res, gefunden, req);
|
|
protokolliere("anmeldung", { personId: gefunden.id, rolle, ip, detail: gefunden.name });
|
|
|
|
return res.json({ weiter: "/workspace/start.html", name: gefunden.name, rolle });
|
|
} catch (fehler) {
|
|
console.error("[workspace] Anmeldung fehlgeschlagen:", fehler?.message);
|
|
return res.status(503).json({ fehler: "nicht_verfuegbar" });
|
|
}
|
|
});
|
|
|
|
workspaceRouter.post("/workspace/api/abmelden", (req, res) => {
|
|
try {
|
|
const token = req.cookies?.[COOKIE];
|
|
if (token) {
|
|
const person = sitzungLesen(req);
|
|
db().prepare("DELETE FROM sitzungen WHERE token_hash = ?").run(tokenHash(token));
|
|
if (person) protokolliere("abmeldung", { personId: person.id, rolle: person.rolle, ip: echteIp(req) });
|
|
}
|
|
} catch { /* Abmelden darf nie scheitern. */ }
|
|
res.clearCookie(COOKIE, { path: "/workspace" });
|
|
res.json({ ok: true });
|
|
});
|
|
|
|
workspaceRouter.get("/workspace/api/ich", (req, res) => {
|
|
const person = sitzungLesen(req);
|
|
if (!person) return res.status(401).json({ fehler: "nicht_angemeldet" });
|
|
/* Die eigene Nummer gehört mit dazu: Ohne sie kann die Oberfläche nicht
|
|
erkennen, welcher Eintrag der eigene ist (etwa "das bin ich" in der
|
|
Personenliste), und das eigene Profil liesse sich gar nicht aufrufen.
|
|
Ein Geheimnis ist sie nicht -- sie beschreibt nur den Angemeldeten. */
|
|
/* Das Profilbild kommt mit: Es steht in der Kopfleiste JEDER Seite und
|
|
in der Begrüßung. Es hier mitzuliefern spart auf jeder Seite eine
|
|
zweite Abfrage -- und verhindert, dass die Plakette erst als
|
|
Buchstabe erscheint und einen Wimpernschlag später zum Bild
|
|
umspringt. */
|
|
let bild = null;
|
|
try {
|
|
const z = db().prepare("SELECT bild FROM personen WHERE id = ?").get(person.id);
|
|
if (z?.bild) bild = `/workspace/api/steckbrief/bild/${z.bild}`;
|
|
} catch { /* ohne Bild ist die Anmeldung trotzdem gültig */ }
|
|
|
|
/* WESSEN ARBEITSPLATZ WIRD GERADE ANGESEHEN (02.09.2026).
|
|
|
|
Ohne diese Angabe war der Umschalter nur halb gebaut: Die LISTEN
|
|
folgten der gewaehlten Sicht, die Seite drumherum nicht. DogFather
|
|
waehlte einen Creator -- und sah weiterhin seine eigene Begruessung,
|
|
seine Rolle und seine Kacheln. Gemeldet mit den Worten "ich seh
|
|
immer noch die Seite genau wie meine", und das stimmte.
|
|
|
|
`sicht` steht NEBEN den eigenen Angaben, nicht an ihrer Stelle. Die
|
|
Oberflaeche braucht beides: WER BIN ICH (fuer die Kopfleiste, fuer
|
|
"das bin ich" in Listen, fuer alles, was schreibt) und WESSEN
|
|
ARBEITSPLATZ SEHE ICH (fuer das, was gezeigt wird). Die beiden zu
|
|
vermischen waere der sichere Weg dazu, dass irgendwann etwas unter
|
|
fremdem Namen gespeichert wird.
|
|
|
|
Null, solange die eigene Sicht laeuft -- dann gibt es nichts zu
|
|
unterscheiden.
|
|
|
|
sichtPerson() wird hier direkt gerufen und nicht ueber req.sicht:
|
|
Die Middleware haengt erst NACH diesem Router (siehe index.js). */
|
|
const angesehen = sichtPerson(req);
|
|
const sicht = angesehen && angesehen.id !== person.id
|
|
? {
|
|
id: angesehen.id,
|
|
name: angesehen.name,
|
|
rolle: angesehen.rolle,
|
|
rolle_name: ROLLEN_NAME[angesehen.rolle] ?? angesehen.rolle,
|
|
}
|
|
: null;
|
|
|
|
res.json({
|
|
id: person.id, name: person.name, rolle: person.rolle,
|
|
rolle_name: ROLLEN_NAME[person.rolle] ?? person.rolle,
|
|
sicht,
|
|
bild,
|
|
/* DIE KACHELN, WENN SIE NICHT IM BROWSER STEHEN DUERFEN.
|
|
|
|
Fuer die fuenf bekannten Rollen steht hier `null`, und die
|
|
Oberflaeche nimmt wie bisher ihre eigene Liste -- der Ablauf
|
|
aendert sich fuer sie also nicht.
|
|
|
|
NACH DER ANGESEHENEN PERSON, nicht nach der eigenen: Sieht sich
|
|
DogFather den Arbeitsplatz eines Modis an, soll er DESSEN Kacheln
|
|
sehen. Genau daran war beim Sicht-Umschalter schon einmal zu
|
|
erkennen, dass er nicht greift ("ich seh immer noch die Seite
|
|
genau wie meine"). */
|
|
bereiche: bereicheFuer(angesehen || person),
|
|
rolle_text: rollentextFuer(angesehen || person),
|
|
marke: markeFuer(angesehen || person),
|
|
/* Kacheln, die zur eigenen Liste DAZUkommen -- im Gegensatz zu
|
|
`bereiche`, das sie ersetzt. Leer fuer alle, die keine haben. */
|
|
bereiche_zusatz: zusatzBereicheFuer(angesehen || person),
|
|
});
|
|
});
|
|
|
|
/* ---------- Verwaltung (nur über die Kommandozeile) --------------------
|
|
Wird von workspace-code.js benutzt. Bewusst nicht über das Netz
|
|
erreichbar: Codes werden auf dem Server erzeugt, einmal angezeigt und
|
|
nie gespeichert -- weder in Git noch in einer Notiz. */
|
|
|
|
/* Alphabet ohne 0/O und 1/I/l: Diese Codes werden abgetippt und
|
|
weitergegeben, Verwechslungen kosten sonst unnötig Nerven. */
|
|
const ALPHABET = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789";
|
|
|
|
export function codeErzeugen(gruppen = 4, laenge = 4) {
|
|
const roh = randomBytes(gruppen * laenge);
|
|
let aus = "";
|
|
for (let i = 0; i < gruppen * laenge; i++) {
|
|
if (i && i % laenge === 0) aus += "-";
|
|
aus += ALPHABET[roh[i] % ALPHABET.length];
|
|
}
|
|
return aus;
|
|
}
|
|
|
|
/* `akteur` ist WER die Aktion ausloest -- nicht, wen sie betrifft. Das
|
|
muss getrennt bleiben: Stand im Protokoll die neu angelegte Person als
|
|
person_id, las sich der Eintrag so, als haette sie sich selbst angelegt.
|
|
Wer betroffen ist, steht im Text. Ohne Akteur (Kommandozeile) bleibt
|
|
das Feld leer. */
|
|
/* =====================================================================
|
|
Betreuung — wer darf sich um welchen Creator kuemmern.
|
|
|
|
Scouts betreuen Creator wie das Management, aber nur die ihnen
|
|
zugeteilten. Diese Regel steht bewusst NUR hier: Sie wird von sechs
|
|
Modulen gebraucht (Profile, Bereiche, Reports, Aufgaben, Kalender,
|
|
Dateien), und eine Rechteregel, die an sechs Stellen steht, ist eine
|
|
Rechteregel, die irgendwann an fuenf Stellen stimmt.
|
|
===================================================================== */
|
|
|
|
/** Welcher Kalendertag ist heute -- nach der ORTSZEIT, nicht nach UTC.
|
|
*
|
|
* Steht hier und nicht in jedem Modul: Sie wurde an vier Stellen
|
|
* gebraucht und an dreien falsch gerechnet (toISOString liefert UTC).
|
|
* Server und Benutzer stehen beide auf Europe/Berlin; zwischen
|
|
* Mitternacht und 2 Uhr lieferte die UTC-Rechnung den Vortag.
|
|
*
|
|
* NICHT verwenden, um mit Datumsangaben zu RECHNEN -- dafuer bleibt es
|
|
* bei UTC-Mittag (siehe kalender.js): Wer mit lokalen Zeiten rechnet,
|
|
* verliert bei der Zeitumstellung einen Tag. */
|
|
export function heuteLokal() {
|
|
const d = new Date();
|
|
const p = (n) => String(n).padStart(2, "0");
|
|
return `${d.getFullYear()}-${p(d.getMonth() + 1)}-${p(d.getDate())}`;
|
|
}
|
|
|
|
/** Derselbe Tag, um n Tage verschoben. */
|
|
export function tagLokal(versatz) {
|
|
const d = new Date(Date.now() + versatz * 86400_000);
|
|
const p = (n) => String(n).padStart(2, "0");
|
|
return `${d.getFullYear()}-${p(d.getMonth() + 1)}-${p(d.getDate())}`;
|
|
}
|
|
|
|
/* Ids der Creator, die diese Person betreut.
|
|
|
|
GILT SEIT DEM 01.09.2026 AUCH FUER MANAGER, nicht mehr nur fuer
|
|
Scouts. Vorher stand hier "das Management sieht ohnehin alles" -- und
|
|
genau das soll nicht mehr sein:
|
|
|
|
"NUR DIE ROLLE DOGFATHER SOLL WIRKLICH WEITERHIN ALLEINE ALLES
|
|
SEHEN KOENNEN. DIE ANDEREN SOLLEN NUR IHRE ZUGETEILTEN AUFGABEN
|
|
VON IHREN CREATOR SEHEN."
|
|
|
|
Die Datenbank konnte das laengst: In `betreuung` steht eine beliebige
|
|
Person als Betreuer, auch ein Manager oder DogFather. Nur diese
|
|
Funktion hat alle ausser Scouts abgewiesen -- ein Manager bekam
|
|
deshalb eine leere Liste und faellt in den Regeln unten auf "sieht
|
|
alles" oder (beim Aufgabenbrett) auf "sieht nichts" zurueck.
|
|
|
|
Ein Creator bleibt aussen vor: Er betreut niemanden, er wird betreut. */
|
|
export function betreuteIds(person) {
|
|
if (!person || (person.rolle !== "scout" && person.rolle !== "manager")) return [];
|
|
try {
|
|
const eigene = db().prepare("SELECT creator_id FROM betreuung WHERE betreuer_id = ?")
|
|
.all(person.id).map((z) => z.creator_id);
|
|
if (person.rolle !== "manager") return eigene;
|
|
|
|
/* DIE KETTE: Manager -> seine Scouts -> deren Creator (01.09.2026).
|
|
|
|
Entschieden auf die Frage "sieht er dann auch die Creator dieser
|
|
Scouts?" -- "ja, alles seiner Scouts". Das ist die uebliche
|
|
Ordnung: Wer einen Scout fuehrt, muss sehen, woran der arbeitet.
|
|
Ohne das muesste jeder Creator einem Manager EINZELN zugewiesen
|
|
werden, und beim ersten vergessenen faende er ein Loch in seiner
|
|
Uebersicht, ohne zu merken, dass es eines ist.
|
|
|
|
Ein Set, weil ein Creator auf beiden Wegen kommen kann: direkt
|
|
zugeteilt UND ueber seinen Scout. Doppelte Nummern wuerden in den
|
|
IN-Listen zu doppelten Fragezeichen -- fachlich harmlos, aber
|
|
jede Abfrage unnoetig laenger. */
|
|
const scouts = scoutsVon(person.id);
|
|
if (!scouts.length) return eigene;
|
|
const ueberScouts = db().prepare(
|
|
`SELECT creator_id FROM betreuung
|
|
WHERE betreuer_id IN (${scouts.map(() => "?").join(",")})`)
|
|
.all(...scouts).map((z) => z.creator_id);
|
|
return [...new Set([...eigene, ...ueberScouts])];
|
|
} catch {
|
|
return []; // im Zweifel nichts sehen, nie mehr
|
|
}
|
|
}
|
|
|
|
/** Die Scouts, die diesem Manager zugeteilt sind. Fuer alle anderen
|
|
* leer -- ein Scout fuehrt keine Scouts, ein Creator schon gar nicht.
|
|
* DogFather braucht sie nicht: Er sieht ohnehin alles. */
|
|
export function scoutsVon(managerId) {
|
|
if (!managerId) return [];
|
|
try {
|
|
return db().prepare("SELECT scout_id FROM scout_zuteilung WHERE manager_id = ?")
|
|
.all(managerId).map((z) => z.scout_id);
|
|
} catch {
|
|
return [];
|
|
}
|
|
}
|
|
|
|
/** Wessen Leads darf diese Person sehen? Gibt die Personennummern
|
|
* zurueck, deren Pipeline sichtbar ist -- der eigene immer dabei.
|
|
* Ein Scout sieht nur sich, ein Manager sich und seine Scouts. */
|
|
function pipelineIdsRoh(person) {
|
|
if (!person) return [];
|
|
if (person.rolle !== "manager") return [person.id];
|
|
return [...new Set([person.id, ...scoutsVon(person.id)])];
|
|
}
|
|
|
|
/** Zuteilung setzen oder loesen (null loest sie).
|
|
* Wer das DARF, entscheidet die Route -- hier steht nur, WIE. */
|
|
export function scoutZuteilungSetzen(scoutId, managerId, akteur = null) {
|
|
const d = db();
|
|
if (managerId === null) {
|
|
d.prepare("DELETE FROM scout_zuteilung WHERE scout_id = ?").run(scoutId);
|
|
return;
|
|
}
|
|
d.prepare(`
|
|
INSERT INTO scout_zuteilung (scout_id, manager_id, seit, gesetzt_von)
|
|
VALUES (?,?,?,?)
|
|
ON CONFLICT(scout_id) DO UPDATE SET
|
|
manager_id = excluded.manager_id,
|
|
seit = excluded.seit,
|
|
gesetzt_von = excluded.gesetzt_von`)
|
|
.run(scoutId, managerId, new Date().toISOString(), akteur);
|
|
}
|
|
|
|
/* =====================================================================
|
|
WEN DARF DIESE PERSON ÜBERHAUPT SEHEN? (03.09.2026)
|
|
|
|
Wunsch: "jeder manager soll auch immer nur seine und die seiner
|
|
scouts zugeteilten creator und creator daten sehen. und nicht die der
|
|
anderen."
|
|
|
|
Die Kette Manager -> Scout -> Creator gab es schon (betreuteIds). Was
|
|
fehlte, war ihre ANWENDUNG: Über zwanzig Stellen prüften die ROLLE
|
|
statt der ZUTEILUNG -- "ist Leitung? dann alles". Eine Lecksuche über
|
|
alle Leseschnittstellen (server/pruef-manager-sicht.mjs) fand am
|
|
03.09.2026 dreizehn davon: der Kalender zeigte fremde Fristen, die
|
|
Personenlisten fremde Namen, Report, Steckbrief, Profil, Start-Check,
|
|
Schulung und die Suche jeweils alles.
|
|
|
|
Deshalb stehen die beiden Antworten jetzt HIER, an einer Stelle, und
|
|
werden überall geholt statt jedes Mal neu formuliert.
|
|
|
|
RÜCKGABE null HEISST "ALLE" -- und zwar nur für DogFather. Das ist
|
|
bewusst kein leeres Feld: Eine leere Liste bedeutet "niemand", und
|
|
die Verwechslung der beiden ist genau der Fehler, der aus einer
|
|
Sperre eine Freigabe macht. Wer null bekommt, lässt die Einschränkung
|
|
ganz weg; wer ein Feld bekommt, schränkt darauf ein -- auch wenn es
|
|
leer ist.
|
|
===================================================================== */
|
|
|
|
/** Die Creator, deren Daten diese Person sehen darf.
|
|
* null = alle (nur DogFather). */
|
|
function sichtbareCreatorIdsRoh(person) {
|
|
if (!person) return [];
|
|
if (siehtAlles(person)) return null;
|
|
if (person.rolle === "creator") return [person.id];
|
|
return betreuteIds(person); // Manager: eigene + die seiner Scouts
|
|
}
|
|
|
|
/** Die PERSONEN, die in Listen und Auswahlfeldern auftauchen dürfen --
|
|
* Namen sind auch Daten. null = alle (nur DogFather).
|
|
*
|
|
* Enthält immer die Person selbst: Wer sich in einer Auswahl nicht
|
|
* findet, kann sich nichts selbst zuweisen. Bei einem Manager kommen
|
|
* seine Scouts dazu -- er führt sie, er muss sie eintragen können. */
|
|
/* =====================================================================
|
|
WEN DARF DIESE PERSON NICHT SEHEN? (09.09.2026)
|
|
|
|
Wunsch Filipe, ausdruecklich und dringlich: *"und noch gaaaaaanz
|
|
wichtig keiner soll vanvan sehen ausser ich, ueberall soll keiner
|
|
vanvan sehen ausser dogfather."*
|
|
|
|
Es gab davon bisher nur eine Haelfte: In der Personenliste wurde der
|
|
zweite Admin-Zugang fuer Spicy Media ausgeblendet (Wunsch vom
|
|
31.08.). Ueberall sonst -- Chat, Kalender, Aufgaben, Dateien, Suche
|
|
-- war er sichtbar, und `sichtbarePersonenIds` hat ihn sogar
|
|
ausdruecklich JEDER Rolle gezeigt, weil sie alle Admins einsammelt
|
|
("DogFather ist fuer alle sichtbar").
|
|
|
|
WORAN DIE BEIDEN AUSEINANDERGEHALTEN WERDEN: In der Datenbank tragen
|
|
beide die Rolle `admin`, es gibt kein unterscheidendes Feld. Der
|
|
Unterschied, den es wirklich gibt, ist das Alter -- DogFather ist
|
|
der erste Zugang des Hauses. Deshalb gilt: der Admin mit der
|
|
KLEINSTEN Nummer ist DogFather, alle weiteren Admin-Zugaenge sind
|
|
verborgen.
|
|
|
|
DREI AUSNAHMEN, und nur diese drei:
|
|
* DogFather selbst sieht alle.
|
|
* Ein verborgener Zugang sieht sich selbst (sonst faende er sein
|
|
eigenes Profil nicht).
|
|
* Gibt es nur EINEN Admin, ist nichts zu verbergen.
|
|
|
|
DIE SCHWACHSTELLE STEHT HIER, damit sie niemand suchen muss: Wuerde
|
|
Zugang 1 geloescht, rueckte der naechste Admin nach und waere
|
|
ploetzlich sichtbar. Sollte das eintreten, gehoert ein
|
|
ausdrueckliches Merkmal in die Tabelle (`haupt`-Feld). Solange
|
|
DogFather der erste Zugang ist, ist die Nummer die ehrlichste
|
|
Antwort ohne Datenbankumbau -- und sie haengt nicht am NAMEN, der
|
|
sich aendern kann.
|
|
|
|
WARUM AN DIESER STELLE UND NICHT IN DEN ABFRAGEN: Es gibt 23
|
|
Abfragen allein in workspace-personen.js, die Personen lesen. Eine
|
|
Regel, die man an 23 Stellen wiederholt, ist 23 Gelegenheiten, sie
|
|
zu vergessen -- und beim Vergessen faellt hier niemand auf die Nase,
|
|
sondern es sieht jemand etwas, das er nicht sehen soll. Deshalb
|
|
sitzt sie in den SECHS Listenfunktionen, durch die alles laeuft.
|
|
===================================================================== */
|
|
export function verborgeneIds(person) {
|
|
try {
|
|
const d = db();
|
|
|
|
/* ERSTER TEIL: die verborgenen Admin-Zugaenge (Regel vom 09.09.2026).
|
|
Der Admin mit der kleinsten Nummer ist DogFather, jeder weitere
|
|
Admin-Zugang ist verborgen. */
|
|
const admins = d.prepare(
|
|
"SELECT id FROM personen WHERE rolle = 'admin' ORDER BY id").all().map((z) => z.id);
|
|
const versteckteAdmins = admins.length >= 2 ? admins.slice(1) : [];
|
|
|
|
/* ZWEITER TEIL: das Modi-Team.
|
|
|
|
Hier haengt die Regel an der ROLLE und nicht an einer Nummer --
|
|
und das ist der Unterschied, auf den es ankommt. Beim ersten Teil
|
|
steht die Schwachstelle im Kommentar oben ausdruecklich da: Wuerde
|
|
Zugang 1 geloescht, rueckte der naechste nach und waere ploetzlich
|
|
sichtbar. Eine Rolle kann das nicht passieren. Wer einen Modi
|
|
verbergen will, muss ihm nichts anhaengen und nichts nachhalten --
|
|
er IST verborgen, solange er ein Modi ist.
|
|
|
|
WER DARF SIE SEHEN, und nur diese zwei:
|
|
* die DogFather-Rolle (admin) -- also auch die rechte Hand,
|
|
denn sie traegt dieselbe Rolle. So gewollt (Kapitel 3 des
|
|
Anforderungsdokuments: gleicher Ueberblick).
|
|
* ein Modi selbst -- sie sind untereinander ein Team.
|
|
|
|
Manager, Scouts, Creator und Spicy Media sehen sie nicht. Nicht
|
|
"sehen sie ohne Einzelheiten", sondern gar nicht: kein Name, kein
|
|
Eintrag, keine Zahl, die sie mitzaehlt. */
|
|
const modis = d.prepare(
|
|
`SELECT id FROM personen WHERE rolle IN (${
|
|
[...TEAM_DOGI_ROLLEN].map((r) => `'${r}'`).join(", ")})`).all().map((z) => z.id);
|
|
|
|
/* Ohne bekannte Person wird BEIDES verborgen. Das ist die strengere
|
|
Antwort, und bei einer Sichtbarkeitsregel ist die strengere die
|
|
richtige: Wer nicht weiss, wer fragt, zeigt nichts. */
|
|
const darfAdminsSehen = !!person
|
|
&& (person.id === admins[0] || versteckteAdmins.includes(person.id));
|
|
const darfModisSehen = !!person
|
|
&& (person.rolle === "admin" || TEAM_DOGI_ROLLEN.has(person.rolle));
|
|
|
|
const weg = [];
|
|
if (!darfAdminsSehen) weg.push(...versteckteAdmins);
|
|
if (!darfModisSehen) weg.push(...modis);
|
|
/* Doppelte heraus: Ein Modi-Zugang koennte theoretisch auch in der
|
|
ersten Liste stehen, wenn jemand die Rollen umhaengt. */
|
|
return [...new Set(weg)];
|
|
} catch {
|
|
/* Ohne Datenbank lieber nichts verbergen als abstuerzen -- diese
|
|
Funktion darf keine Seite lahmlegen. Sie laeuft in jedem
|
|
Listenaufruf. */
|
|
return [];
|
|
}
|
|
}
|
|
|
|
/** Dieselbe Regel als Filter auf eine fertige Liste. */
|
|
export function ohneVerborgene(ids, person) {
|
|
const weg = verborgeneIds(person);
|
|
if (!weg.length) return ids;
|
|
if (ids === null) {
|
|
/* `null` heisst bisher "sieht alles". Sobald es etwas zu verbergen
|
|
gibt, darf das nicht mehr gelten -- die Liste wird deshalb
|
|
AUSGESCHRIEBEN. Das ist strenger, nicht lockerer, und die
|
|
Aufrufer bauen daraus ohnehin ein `IN (...)`. */
|
|
try {
|
|
return db().prepare(
|
|
`SELECT id FROM personen WHERE id NOT IN (${weg.map(() => "?").join(",")})`)
|
|
.all(...weg).map((z) => z.id);
|
|
} catch { return null; }
|
|
}
|
|
return ids.filter((i) => !weg.includes(i));
|
|
}
|
|
|
|
function sichtbarePersonenIdsRoh(person) {
|
|
if (!person) return [];
|
|
if (siehtAlles(person)) return null;
|
|
const creator = sichtbareCreatorIds(person) || [];
|
|
const scouts = person.rolle === "manager" ? scoutsVon(person.id) : [];
|
|
/* DOGFATHER IST FUER ALLE SICHTBAR (07.09.2026, Wunsch Filipe:
|
|
"dogfather und agentur sollen die alle sehen").
|
|
|
|
Das ist keine Aufweichung der Regel, sondern ihre Vervollstaendigung:
|
|
Jede Rolle arbeitet mit DogFather -- er gibt frei, er entscheidet,
|
|
er steht in jedem Protokoll. Ihn aus der Personenliste
|
|
herauszuhalten hiess bisher, dass sein Name in Terminen und
|
|
Aufgaben auftaucht, aber nirgends ein Gesicht dazugehoert.
|
|
|
|
Sichtbar heisst hier NUR: Name, Rolle und Bild -- dieselben drei
|
|
Angaben wie bei jedem anderen in dieser Liste. Alles Weitere (seine
|
|
Eintraege, seine Chats, seine Zahlen) haengt weiterhin an den
|
|
eigenen Sichtbarkeitsregeln der jeweiligen Module und ist davon
|
|
nicht beruehrt. */
|
|
const dogi = [];
|
|
try {
|
|
for (const z of db().prepare(
|
|
"SELECT id FROM personen WHERE rolle = 'admin' AND aktiv = 1").all()) dogi.push(z.id);
|
|
} catch { /* ohne Datenbank bleibt die Liste, wie sie war */ }
|
|
|
|
/* DAS MODI-TEAM SIEHT SICH UNTEREINANDER (Entscheidung Filipe,
|
|
09.09.2026: "ja, sie sind untereinander ein Team").
|
|
|
|
Ohne diese Zeilen waere jeder Modi allein: kein Team-Gruppenchat,
|
|
keine Schicht, die man tauschen kann, keine Eskalation, die man
|
|
weiterreicht, ohne dass DogFather sie einzeln durchstellt.
|
|
|
|
Es ist KEIN Loch in der Verborgenheit, sondern ihre andere Seite.
|
|
Was diese Liste zusaetzlich hergibt, wird gleich danach durch
|
|
ohneVerborgene() wieder eingeschraenkt -- und dort steht, dass ein
|
|
Modi nur fuer die DogFather-Rolle und fuer Modis sichtbar ist. Wer
|
|
kein Modi ist, bekommt diese Nummern hier gar nicht erst. */
|
|
const modis = [];
|
|
if (TEAM_DOGI_ROLLEN.has(person.rolle)) {
|
|
try {
|
|
const liste = [...TEAM_DOGI_ROLLEN].map((r) => `'${r}'`).join(", ");
|
|
for (const z of db().prepare(
|
|
`SELECT id FROM personen WHERE rolle IN (${liste}) AND aktiv = 1`).all()) modis.push(z.id);
|
|
} catch { /* ohne Datenbank bleibt die Liste, wie sie war */ }
|
|
}
|
|
return [...new Set([person.id, ...creator, ...scouts, ...dogi, ...modis])];
|
|
}
|
|
|
|
/** WER BETREUT MICH -- die Blickrichtung nach oben.
|
|
*
|
|
* betreuteIds() fragt "wen führe ich?". Diese Funktion fragt das
|
|
* Gegenteil: "wer führt mich?" -- also der eigene Scout, dessen
|
|
* Manager, und bei einem Scout sein Manager.
|
|
*
|
|
* Es gab die Richtung bisher nicht, weil sie für Sichtbarkeit nie
|
|
* gebraucht wurde: Ein Creator soll die Daten seines Scouts nicht
|
|
* sehen. Für eine EINLADUNG ist die Frage aber eine andere -- siehe
|
|
* einladbareIds(). */
|
|
function betreuerIdsRoh(person) {
|
|
if (!person) return [];
|
|
try {
|
|
const d = db();
|
|
if (person.rolle === "creator") {
|
|
const betreuer = d.prepare("SELECT betreuer_id FROM betreuung WHERE creator_id = ?")
|
|
.all(person.id).map((z) => z.betreuer_id);
|
|
if (!betreuer.length) return [];
|
|
/* Über den Scout auch dessen Manager: Wer mit seinem Scout einen
|
|
Call hat, hat ihn oft mit dessen Manager zusammen. */
|
|
const manager = d.prepare(
|
|
`SELECT manager_id FROM scout_zuteilung
|
|
WHERE scout_id IN (${betreuer.map(() => "?").join(",")})`)
|
|
.all(...betreuer).map((z) => z.manager_id);
|
|
return [...new Set([...betreuer, ...manager])];
|
|
}
|
|
if (person.rolle === "scout") {
|
|
return d.prepare("SELECT manager_id FROM scout_zuteilung WHERE scout_id = ?")
|
|
.all(person.id).map((z) => z.manager_id);
|
|
}
|
|
return [];
|
|
} catch {
|
|
return []; // im Zweifel niemand -- nie mehr, als sicher ist
|
|
}
|
|
}
|
|
|
|
/** WEN DARF ICH ZU EINEM TERMIN DAZUSTELLEN?
|
|
*
|
|
* Eingeführt am 05.09.2026 auf den Wunsch, die Teilnehmerwahl solle
|
|
* *"für jeden verfügbar sein"*.
|
|
*
|
|
* WARUM NICHT EINFACH sichtbarePersonenIds: Die Frage ist eine
|
|
* andere. Sichtbarkeit heißt "wessen Daten darf ich sehen" und zeigt
|
|
* bewusst nach unten -- ein Creator sieht dort nur sich selbst.
|
|
* Einladen heißt "mit wem arbeite ich zusammen" und geht in BEIDE
|
|
* Richtungen: Ein Creator lädt seinen Scout ein, ein Scout seinen
|
|
* Manager.
|
|
*
|
|
* Und warum nicht die eine Funktion erweitern: sichtbarePersonenIds
|
|
* hängt auch an der AUFGABENZUWEISUNG. Wer dort einen Betreuer
|
|
* hinzufügt, gibt einem Creator nebenbei die Möglichkeit, seinem
|
|
* Scout Aufgaben zu erteilen. Zwei Fragen, zwei Funktionen.
|
|
*
|
|
* Was ein Einladen bewirkt, ist eng: Die Person sieht diesen einen
|
|
* Termin in ihrem Kalender. Keine Daten, keine Rechte, kein Zugriff.
|
|
* Deshalb ist die weitere Menge hier vertretbar.
|
|
*
|
|
* null = alle (nur DogFather), wie überall in dieser Datei. */
|
|
function einladbareIdsRoh(person) {
|
|
if (!person) return [];
|
|
if (siehtAlles(person)) return null;
|
|
const sichtbar = sichtbarePersonenIds(person) || [];
|
|
/* DogFather gehört immer dazu: Er ist der Einzige, mit dem jede
|
|
Rolle zu tun hat, und ihn nicht einladen zu können wäre der erste
|
|
Fall, der auffällt. */
|
|
const chefs = db().prepare("SELECT id FROM personen WHERE rolle = 'admin' AND aktiv = 1")
|
|
.all().map((z) => z.id);
|
|
return [...new Set([...sichtbar, ...betreuerIds(person), ...chefs])];
|
|
}
|
|
|
|
/** MIT WEM DARF ICH SCHREIBEN? (06.09.2026, für den Chat)
|
|
*
|
|
* Entschieden von Filipe: entlang der Betreuung -- PLUS Manager und
|
|
* Scouts untereinander, auch ohne gemeinsamen Creator, "für Absprachen
|
|
* im Team".
|
|
*
|
|
* WARUM NICHT einladbareIds() WIEDERVERWENDEN, obwohl es fast passt:
|
|
* Dort geht es darum, wen man zu einem TERMIN dazustellen darf. Das
|
|
* ist harmlos -- die Person sieht einen Eintrag im Kalender. Ein Chat
|
|
* ist etwas anderes: Er legt ein dauerhaftes Gespräch an, schickt eine
|
|
* Benachrichtigung und macht den eigenen Namen sichtbar. Zwei Fragen,
|
|
* zwei Funktionen -- sonst verschiebt eine spätere Änderung an der
|
|
* einen stillschweigend die andere.
|
|
*
|
|
* WAS SICH DADURCH ÄNDERT gegenüber der Terminwahl: Ein Manager
|
|
* erreicht jetzt auch Scouts, die nicht seine sind, und umgekehrt.
|
|
* Ein Creator NICHT -- er bleibt bei seinen Betreuern und DogFather.
|
|
* Creator untereinander bleiben getrennt; sie sollen die Namen der
|
|
* anderen Creator weiterhin nicht sehen.
|
|
*
|
|
* null = alle (nur DogFather). */
|
|
function schreibbareIdsRoh(person) {
|
|
if (!person) return [];
|
|
if (siehtAlles(person)) return null;
|
|
|
|
const basis = new Set(einladbareIds(person) || []);
|
|
|
|
/* Das Team untereinander. Ein Scout, der eine Frage zu einem
|
|
fremden Creator hat, muss den zuständigen Manager erreichen können
|
|
-- sonst läuft alles über DogFather, und das ist genau der
|
|
Flaschenhals, den ein Chat auflösen soll. */
|
|
if (person.rolle === "manager" || person.rolle === "scout") {
|
|
try {
|
|
for (const z of db().prepare(
|
|
"SELECT id FROM personen WHERE rolle IN ('manager','scout') AND aktiv = 1").all()) {
|
|
basis.add(z.id);
|
|
}
|
|
} catch { /* im Zweifel nur die Betreuungskette */ }
|
|
}
|
|
|
|
/* DOGFATHER UND SPICY MEDIA SIND FUER JEDEN ERREICHBAR (07.09.2026).
|
|
|
|
Filipe: "im chat muss die spicy rolle und die dogfather rolle fuer
|
|
jeden zugaenglich sein."
|
|
|
|
DogFather kam bisher ueber einladbareIds() mit hinein; Spicy Media
|
|
nicht -- die Rolle steht in keiner Betreuungskette, sie steht
|
|
daneben. Genau deshalb war sie fuer einen Creator im Chat gar nicht
|
|
vorhanden, obwohl sie fuer alle zustaendig ist.
|
|
|
|
Beide werden hier ausdruecklich hinzugefuegt statt sich auf einen
|
|
Nebeneffekt zu verlassen: Eine Zustaendigkeit, die nur zufaellig
|
|
aus einer anderen Regel herausfaellt, faellt beim naechsten Umbau
|
|
genauso zufaellig wieder heraus.
|
|
|
|
Die Gegenrichtung stimmt ohne Zutun: Beide Rollen haben
|
|
`siehtAlles` und damit ohnehin `null` = jeder. */
|
|
try {
|
|
for (const z of db().prepare(
|
|
"SELECT id FROM personen WHERE rolle IN ('admin','spicy') AND aktiv = 1").all()) {
|
|
basis.add(z.id);
|
|
}
|
|
} catch { /* im Zweifel bleibt die Betreuungskette */ }
|
|
|
|
/* Sich selbst nicht -- ein Gespräch mit sich allein ist keines. */
|
|
basis.delete(person.id);
|
|
return [...basis];
|
|
}
|
|
|
|
/** Darf diese Person mit jener schreiben? Eine Frage, eine Antwort --
|
|
* damit die Regel nicht an fünf Stellen einzeln nachgebaut wird. */
|
|
export function darfSchreibenMit(person, andereId) {
|
|
const ids = schreibbareIds(person);
|
|
if (ids === null) return Number(andereId) !== Number(person.id);
|
|
return ids.includes(Number(andereId));
|
|
}
|
|
|
|
/** SQL-Baustein daraus: "diese Spalte ist eine Person, die ich sehen
|
|
* darf". Gibt null zurück, wenn nicht eingeschränkt werden muss. */
|
|
export function personenWo(person, spalte) {
|
|
const ids = sichtbarePersonenIds(person);
|
|
if (ids === null) return null;
|
|
if (!ids.length) return { wo: "0=1", werte: [] };
|
|
return { wo: `${spalte} IN (${ids.map(() => "?").join(",")})`, werte: ids };
|
|
}
|
|
|
|
/* SQL-Baustein "diese Spalte gehoert zu einem meiner Creator".
|
|
Gibt null zurueck, wenn es nichts zu ergaenzen gibt -- eine leere
|
|
IN-Liste waere ungueltiges SQL. */
|
|
export function betreutWo(person, spalte) {
|
|
const ids = betreuteIds(person);
|
|
if (!ids.length) return null;
|
|
return { wo: `${spalte} IN (${ids.map(() => "?").join(",")})`, werte: ids };
|
|
}
|
|
|
|
/* =====================================================================
|
|
FREI EINGETRAGENE NAMEN (02.09.2026)
|
|
|
|
Ein Feld wie "Mit wem" oder "Creator" nimmt entweder eine Person aus
|
|
dem Workspace ODER einen frei getippten Namen. Beides zugleich waere
|
|
eine Aussage, die niemand aufloesen kann.
|
|
|
|
WAS EIN FREIER NAME NICHT KANN, und warum das hier steht:
|
|
Er ist eine Beschriftung, kein Konto. Er sieht nichts, bekommt nichts
|
|
angezeigt, hat kein Aufgabenbrett. Bei "Creator" heisst das: Der
|
|
Eintrag gehoert zu niemandem im System und taucht deshalb in keiner
|
|
personenbezogenen Auswertung auf. Filipe hat das am 02.09.2026
|
|
ausdruecklich so gewaehlt, nachdem der Nachteil benannt war; das
|
|
Formular sagt es an der Stelle noch einmal.
|
|
|
|
Damit daraus kein STILLER Ausfall wird, ist die Gegenmassnahme
|
|
eingebaut: externSql. Jede Abfrage, die bisher den Namen der
|
|
verknuepften Person las, faellt jetzt auf den freien Text zurueck.
|
|
Der Eintrag verschwindet dadurch aus keiner Liste, keiner Suche und
|
|
keiner Uebersicht -- er ist nur eben als "(extern)" gekennzeichnet.
|
|
===================================================================== */
|
|
|
|
export const EXTERN_MAX = 80;
|
|
|
|
/** Prueft einen frei eingetragenen Namen und schreibt ihn nach `aus`.
|
|
* `feld` ist die Spalte ohne Endung, also "creator" oder "teilnehmer".
|
|
*
|
|
* Ist eine Person gewaehlt, wird der freie Name verworfen statt
|
|
* abgelehnt: Wer erst jemanden auswaehlt und dann tippt, meint das
|
|
* Getippte nicht mehr -- eine Fehlermeldung waere hier nur im Weg. */
|
|
export function externPruefen(körper, aus, feld, fehler) {
|
|
const schluessel = `${feld}_extern`;
|
|
if (körper[schluessel] === undefined) return;
|
|
const t = String(körper[schluessel] ?? "").trim();
|
|
if (!t) { aus[schluessel] = null; return; }
|
|
if (t.length > EXTERN_MAX) { fehler.push("Der eingetragene Name ist zu lang."); return; }
|
|
/* Steuerzeichen raus. Sie waeren unsichtbar und koennten eine Zeile
|
|
in Listen und Ausgaben zerreissen. */
|
|
aus[schluessel] = t.replace(/[\u0000-\u001f\u007f]/g, "");
|
|
aus[`${feld}_id`] = null;
|
|
}
|
|
|
|
/** SQL-Baustein: der Name der Person -- und wenn keine verknuepft ist,
|
|
* der frei eingetragene Text, gekennzeichnet.
|
|
*
|
|
* In SQLite ergibt NULL || 'x' wieder NULL. Ist die Spalte leer, faellt
|
|
* COALESCE deshalb korrekt auf NULL durch und nicht auf " (extern)". */
|
|
export const externSql = (personSpalte, externSpalte) =>
|
|
`COALESCE(${personSpalte}, ${externSpalte} || ' (extern)')`;
|
|
|
|
/* Wer sieht welchen Termin -- und damit auch: welche Wiederholung.
|
|
|
|
NUR DogFather sieht alles (01.09.2026). Vorher stand hier istLeitung()
|
|
-- damit sah auch jeder Manager jeden Creator. Ein Manager faellt
|
|
jetzt in dieselbe Regel wie ein Scout: nur die Creator, die ihm
|
|
zugeteilt sind. Ausdruecklicher Wunsch: "NUR DIE ROLLE DOGFATHER SOLL
|
|
WIRKLICH ALLEINE ALLES SEHEN."
|
|
|
|
Geaendert wird ausschliesslich, wer was SIEHT. Was ein Manager DARF
|
|
(freigeben, aendern, Personen verwalten), haengt weiterhin an
|
|
istLeitung und bleibt unveraendert -- sonst haette dieser eine Wunsch
|
|
stillschweigend seine halben Rechte mitgenommen.
|
|
|
|
Der Praefix ist der Tabellenname im jeweiligen SQL: "t" fuer termine,
|
|
"s" fuer termin_serien. Diese Regel steht bewusst nur EINMAL: Eine
|
|
zweite, fast gleiche Fassung fuer die Serien waere frueher oder
|
|
spaeter auseinandergelaufen -- und dann haette eine Wiederholung
|
|
jemandem etwas gezeigt, was der einzelne Termin ihm verbirgt. */
|
|
export function termineSichtbar(person, praefix = "t") {
|
|
const p = praefix;
|
|
|
|
/* =====================================================================
|
|
JEDER SIEHT NUR SEINE EIGENEN TERMINE (07.09.2026)
|
|
|
|
Wunsch Filipe, zuerst: "calls oder termine soll jeder nur sehen
|
|
die er selber macht oder jeden betrifft." Wenige Stunden spaeter,
|
|
nachdem er das Ergebnis gesehen hat, praeziser: "jeder soll und
|
|
darf im kalender immer nur seine eigenen eintraege nur sehen."
|
|
Die zweite Fassung gilt -- Begruendung unten beim entfallenen
|
|
Fall "geht alle an".
|
|
|
|
DAS GILT AUCH FUER DOGFATHER, und das ist die eigentliche
|
|
Aenderung. Bis hierher bekam er `1=1` -- er sah jeden Termin von
|
|
jedem. Bei einer Handvoll Leuten war das ein Ueberblick; bei
|
|
dreissig ist es eine Wand aus fremden Verabredungen, in der die
|
|
eigenen untergehen. Ein Kalender, in dem alles steht, ist kein
|
|
Kalender mehr.
|
|
|
|
"EIGEN" IST HIER GENAU DEFINIERT, sonst wird es Auslegung: Ich habe
|
|
den Termin angelegt (erstellt_von), er ist mir zugeordnet
|
|
(creator_id), ich bin das Gegenueber (teilnehmer_id) -- oder ich
|
|
stehe in der Teilnehmerliste. Sonst nichts. Kein Rollenrecht, keine
|
|
Betreuung, keine Ausnahme fuer teilnehmerlose Termine.
|
|
|
|
Diese Regel ersetzt die Rollenfrage vollstaendig: Es gibt hier
|
|
nichts mehr, was DogFather sieht und ein Creator nicht. Was ein
|
|
Betreuer ueber seine Creator wissen muss, steht in deren Bereichen
|
|
und Aufgaben -- nicht in ihrem Kalender.
|
|
===================================================================== */
|
|
|
|
/* MITGEZAEHLT WIRD AUCH DIE TEILNEHMERLISTE (05.09.2026).
|
|
|
|
Seit ein Termin mehrere Teilnehmer haben kann, reicht
|
|
`teilnehmer_id` nicht mehr: Das ist nur das Haupt-Gegenueber. Wer
|
|
als zweiter oder dritter dabei ist, steht in termin_teilnehmer --
|
|
und ohne diese Zeile saehe er den Termin nicht, an dem er
|
|
teilnimmt.
|
|
|
|
Die beiden Aufrufer arbeiten auf verschiedenen Tabellen: der
|
|
Kalender auf `termine` (praefix t), die Wiederholungen auf
|
|
`termin_serien` (praefix s). Deshalb wird hier die passende
|
|
Nebentabelle gewaehlt statt einer festen -- eine falsche Zuordnung
|
|
waere kein Fehler, den man sieht, sondern eine Liste, in der
|
|
jemandem etwas fehlt. */
|
|
const nebenTabelle = p === "s"
|
|
? { tabelle: "serie_teilnehmer", spalte: "serie_id" }
|
|
: { tabelle: "termin_teilnehmer", spalte: "termin_id" };
|
|
const dabei = `EXISTS (SELECT 1 FROM ${nebenTabelle.tabelle} tn`
|
|
+ ` WHERE tn.${nebenTabelle.spalte} = ${p}.id AND tn.person_id = ?)`;
|
|
|
|
const eigen = `(${p}.creator_id = ? OR ${p}.teilnehmer_id = ? OR ${p}.erstellt_von = ?`
|
|
+ ` OR ${dabei})`;
|
|
const werte = [person.id, person.id, person.id, person.id];
|
|
|
|
/* HIER STAND EIN ZWEITER FALL, UND ER IST AM 07.09.2026 ENTFALLEN.
|
|
|
|
Er hiess "geht alle an": ein Termin ohne Gegenueber und ohne
|
|
Teilnehmer war fuer jeden sichtbar, nach der Ueberlegung "wer
|
|
niemanden eintraegt, meint alle".
|
|
|
|
Die Ueberlegung war falsch, und Filipe hat es an einem echten Fall
|
|
gezeigt: In Cigdems Kalender stand sein "Manager Meeting" -- SEIN
|
|
Termin, den er fuer sich eingetragen hatte. Wer keinen Teilnehmer
|
|
eintraegt, meint eben meistens nicht "alle", sondern "mich". Und
|
|
die Auslegung lag nicht bei dem, der den Termin anlegt, sondern
|
|
hier im Code -- das ist die eigentliche Schwaeche gewesen.
|
|
|
|
Filipe: "jeder soll und darf im kalender immer nur seine eigenen
|
|
eintraege nur sehen."
|
|
|
|
Was alle angeht, geht sie jetzt an, weil jemand sie EINTRAEGT:
|
|
"bei calls und protocoll will ich dass jeder seine termine von
|
|
seinem kalender sieht und die wenn sie markiert werden im termin."
|
|
Die Teilnehmerliste ist damit die einzige Antwort auf die Frage,
|
|
wen ein Termin etwas angeht -- eine sichtbare Entscheidung im
|
|
Formular statt einer unsichtbaren Regel im Server.
|
|
|
|
DIESELBE REGEL GILT FUER CALLS & PROTOKOLLE: Die Call-Liste ruft
|
|
genau diese Funktion auf (workspace-calls.js -> sichtbar). Beide
|
|
Seiten koennen deshalb gar nicht auseinanderlaufen. */
|
|
return { wo: eigen, werte };
|
|
}
|
|
|
|
/* =====================================================================
|
|
DIE SICHT EINES ANDEREN — nur fuer DogFather.
|
|
|
|
Wunsch vom 01.09.2026: "ich will, dass ich alles von den Scouts und
|
|
Managern einsehen kann, dass ich auswaehlen kann, was und von wem ich
|
|
sehen will. Und das soll ich als DogFather-Rolle ueberall haben, auf
|
|
jeder Seite. NUR WIR BEIDE SOLLEN DIESE OPTION HABEN."
|
|
|
|
WIE ES GEBAUT IST, und warum genau so.
|
|
|
|
Die naheliegende Loesung waere ein neuer Filter je Seite gewesen:
|
|
"zeige mir Aufgaben von Person X". Das haette bedeutet, in zwoelf
|
|
Modulen eine zweite Rechteregel zu schreiben -- und eine Rechteregel,
|
|
die an zwoelf Stellen steht, stimmt irgendwann an elf.
|
|
|
|
Stattdessen wird die PERSON ausgetauscht, nicht die Regel: Waehlt
|
|
DogFather einen Scout, laeuft jede vorhandene sichtbar()-Funktion
|
|
unveraendert mit diesem Scout als Person. Er sieht dann exakt das,
|
|
was der Scout sieht -- nicht mehr und nicht weniger. Keine einzige
|
|
neue Rechteregel, und was heute richtig ist, bleibt es auch, wenn
|
|
sich eine Regel spaeter aendert.
|
|
|
|
DREI SICHERUNGEN, die nicht verhandelbar sind:
|
|
|
|
1. NUR ADMIN. Die Rolle wird HIER geprueft, nicht in der Oberflaeche.
|
|
Wer kein DogFather ist, kann den Wert anhaengen, so oft er will --
|
|
er wird stillschweigend ignoriert. Ein Manager duerfte sonst durch
|
|
Anhaengen von ?sicht=<Nummer> in fremde Bereiche sehen.
|
|
|
|
2. NUR LESEN. Ersetzt wird ausschliesslich req.sicht, niemals
|
|
req.person. Alles, was schreibt oder Rechte prueft, arbeitet
|
|
weiterhin mit dem echten Angemeldeten. Sonst haette ein
|
|
Schreibvorgang plotzlich im Namen eines anderen stattgefunden --
|
|
und im Protokoll staende der falsche Name.
|
|
|
|
3. NUR AKTIVE, ECHTE PERSONEN. Eine geloeschte oder abgeschaltete
|
|
Nummer faellt auf die eigene Sicht zurueck, statt eine leere Seite
|
|
zu zeigen, die aussieht wie ein Fehler.
|
|
===================================================================== */
|
|
|
|
/** Durch wessen Augen wird gerade gesehen? Gibt IMMER eine Person
|
|
* zurueck -- im Normalfall den Angemeldeten selbst. */
|
|
export function sichtPerson(req) {
|
|
/* req.person setzt jedes Modul in seiner eigenen angemeldet()-Huerde.
|
|
Diese Funktion laeuft aber schon davor (global, siehe index.js) --
|
|
deshalb liest sie die Sitzung notfalls selbst. */
|
|
const ich = req?.person || sitzungLesen(req);
|
|
if (!ich) return null;
|
|
if (ich.rolle !== "admin") return ich;
|
|
|
|
const id = Number(req.query?.sicht || 0);
|
|
if (!Number.isInteger(id) || id <= 0 || id === ich.id) return ich;
|
|
|
|
try {
|
|
const z = db().prepare("SELECT id, name, rolle FROM personen WHERE id = ? AND aktiv = 1").get(id);
|
|
/* Nicht gefunden oder abgeschaltet: zurueck auf die eigene Sicht.
|
|
Eine leere Seite waere hier die schlechtere Antwort -- sie sieht
|
|
aus wie ein Fehler, obwohl nur die Auswahl veraltet ist. */
|
|
return z || ich;
|
|
} catch {
|
|
return ich; /* im Zweifel die eigene Sicht, nie eine fremde */
|
|
}
|
|
}
|
|
|
|
/** Middleware: setzt req.sicht. Steht danach jedem Modul zur Verfuegung.
|
|
* Module, die sie nicht benutzen, arbeiten unveraendert weiter -- req.sicht
|
|
* ist dort schlicht dasselbe wie req.person. */
|
|
export function sichtSetzen(req, res, next) {
|
|
const s = sichtPerson(req);
|
|
if (s) req.sicht = s;
|
|
next();
|
|
}
|
|
|
|
/* Darf diese Person den Bereich dieses Creators sehen und bearbeiten? */
|
|
export function darfCreator(person, creatorId) {
|
|
if (!person || !creatorId) return false;
|
|
/* NUR DogFather pauschal (03.09.2026). Hier stand istLeitung -- und
|
|
damit war jede Managerin fuer JEDEN Creator zustaendig, auch fuer
|
|
die einer fremden Managerin.
|
|
|
|
Diese eine Zeile hing an mehreren Wegen gleichzeitig: Profil,
|
|
Start-Check und die Uebersicht je Creator liessen sich damit ueber
|
|
die blosse Kenntnis einer Nummer abfragen. Man musste nichts
|
|
umgehen, es reichte, eine Zahl in die Adresse zu schreiben.
|
|
|
|
Ein Manager faellt jetzt in dieselbe Zeile wie ein Scout -- die
|
|
Kette "eigene plus die meiner Scouts" steckt in betreuteIds. */
|
|
if (siehtAlles(person)) return true;
|
|
if (person.rolle === "creator") return person.id === Number(creatorId);
|
|
return betreuteIds(person).includes(Number(creatorId));
|
|
}
|
|
|
|
/* Zustaendige Person setzen oder entfernen (null loest die Zuordnung). */
|
|
export function betreuungSetzen(creatorId, betreuerId, akteur = null) {
|
|
const d = db();
|
|
if (betreuerId === null) {
|
|
d.prepare("DELETE FROM betreuung WHERE creator_id = ?").run(creatorId);
|
|
} else {
|
|
d.prepare(`
|
|
INSERT INTO betreuung (creator_id, betreuer_id, seit, gesetzt_von)
|
|
VALUES (?,?,?,?)
|
|
ON CONFLICT(creator_id) DO UPDATE SET
|
|
betreuer_id = excluded.betreuer_id,
|
|
seit = excluded.seit,
|
|
gesetzt_von = excluded.gesetzt_von`).run(
|
|
creatorId, betreuerId, new Date().toISOString(), akteur?.id ?? null);
|
|
}
|
|
protokolliere("betreuung_gesetzt", {
|
|
personId: akteur?.id ?? null, rolle: akteur?.rolle ?? null, ip: akteur?.ip ?? null,
|
|
detail: `Creator #${creatorId} -> ${betreuerId === null ? "niemand" : "#" + betreuerId}`,
|
|
});
|
|
}
|
|
|
|
/* ---------- Einstellungen -------------------------------------------------
|
|
Bewusst mit Vorgabewert und ohne Fehlerfall: Faellt die Datenbank aus,
|
|
soll eine fehlende Einstellung nicht die Seite mitreissen. */
|
|
|
|
export function einstellung(schluessel, vorgabe = null) {
|
|
try {
|
|
return db().prepare("SELECT wert FROM einstellungen WHERE schluessel = ?")
|
|
.get(schluessel)?.wert ?? vorgabe;
|
|
} catch {
|
|
return vorgabe;
|
|
}
|
|
}
|
|
|
|
export function einstellungSetzen(schluessel, wert, akteur = null) {
|
|
db().prepare(`
|
|
INSERT INTO einstellungen (schluessel, wert, geaendert, von) VALUES (?,?,?,?)
|
|
ON CONFLICT(schluessel) DO UPDATE SET
|
|
wert = excluded.wert, geaendert = excluded.geaendert, von = excluded.von`).run(
|
|
schluessel, String(wert), new Date().toISOString(), akteur?.id ?? null);
|
|
protokolliere("einstellung_geaendert", {
|
|
personId: akteur?.id ?? null, rolle: akteur?.rolle ?? null, ip: akteur?.ip ?? null,
|
|
detail: `${schluessel} = ${wert}`.slice(0, 120),
|
|
});
|
|
}
|
|
|
|
export function personAnlegen(name, rolle, akteur = null) {
|
|
if (!ROLLEN.has(rolle)) throw new Error(`Unbekannte Rolle: ${rolle}`);
|
|
const code = codeErzeugen();
|
|
const salt = randomBytes(16).toString("hex");
|
|
const hash = hashe(code, salt, SCRYPT.N);
|
|
const { lastInsertRowid } = db().prepare(
|
|
"INSERT INTO personen (name, rolle, code_hash, code_salt, code_n, code_kennung, aktiv, erstellt)"
|
|
+ " VALUES (?,?,?,?,?,?,1,?)"
|
|
).run(name, rolle, hash, salt, SCRYPT.N, codeKennung(code), jetzt());
|
|
protokolliere("person_angelegt", {
|
|
personId: akteur?.id ?? null, rolle: akteur?.rolle ?? null,
|
|
ip: akteur?.ip ?? null, detail: `${name} (${rolle})`,
|
|
});
|
|
return { id: Number(lastInsertRowid), name, rolle, code };
|
|
}
|
|
|
|
/** Das Sitzungs-Merkmal aus der Anfrage -- roh, wie es im Keks steht.
|
|
* Wird gebraucht, um GENAU DIESE eine Sitzung am Leben zu lassen. */
|
|
export const sitzungToken = (req) => req?.cookies?.[COOKIE] || null;
|
|
|
|
/**
|
|
* Neuen Code setzen.
|
|
*
|
|
* @param behalteToken Die eine Sitzung, die NICHT beendet wird.
|
|
* Gedacht für den Fall "ich erneuere meinen eigenen Code": Wer das tut,
|
|
* ist gerade angemeldet und hält diese Sitzung in der Hand.
|
|
*/
|
|
export function codeNeu(id, akteur = null, behalteToken = null) {
|
|
const person = db().prepare("SELECT id, name, rolle FROM personen WHERE id = ?").get(id);
|
|
if (!person) throw new Error(`Keine Person mit Nummer ${id}`);
|
|
const code = codeErzeugen();
|
|
const salt = randomBytes(16).toString("hex");
|
|
db().prepare(
|
|
"UPDATE personen SET code_hash = ?, code_salt = ?, code_n = ?, code_kennung = ? WHERE id = ?")
|
|
.run(hashe(code, salt, SCRYPT.N), salt, SCRYPT.N, codeKennung(code), id);
|
|
|
|
/* Offene Sitzungen beenden -- ein neuer Code soll den alten Zugang
|
|
wirklich beenden, nicht nur die nächste Anmeldung betreffen.
|
|
|
|
MIT EINER AUSNAHME, seit dem 02.09.2026: die Sitzung, aus der heraus
|
|
der Code gerade erneuert wird.
|
|
|
|
Vorher flog man beim eigenen Code sofort hinaus -- und zwar in
|
|
derselben Sekunde, in der der neue Code auf dem Bildschirm erschien.
|
|
Der nächste Aufruf der Seite lief in ein "nicht angemeldet" und
|
|
leitete zur Anmeldung um; der Code war weg, bevor man ihn kopieren
|
|
konnte. Genau so ist es Filipe am 02.09.2026 passiert, und danach kam
|
|
er nur noch über einen Eingriff auf dem Server wieder hinein.
|
|
|
|
Sicherheitlich kostet die Ausnahme nichts: Wer den Code erneuert, hat
|
|
sich mit genau dieser Sitzung soeben ausgewiesen und hält sie in
|
|
Händen. Alle ANDEREN Geräte fliegen weiterhin hinaus -- das ist der
|
|
Sinn der Übung. */
|
|
const behalten = behalteToken ? tokenHash(behalteToken) : null;
|
|
if (behalten) {
|
|
db().prepare("DELETE FROM sitzungen WHERE person_id = ? AND token_hash <> ?")
|
|
.run(id, behalten);
|
|
} else {
|
|
db().prepare("DELETE FROM sitzungen WHERE person_id = ?").run(id);
|
|
}
|
|
protokolliere("code_erneuert", {
|
|
personId: akteur?.id ?? null, rolle: akteur?.rolle ?? null,
|
|
ip: akteur?.ip ?? null, detail: `für ${person.name}`,
|
|
});
|
|
return { ...person, code };
|
|
}
|
|
|
|
export function personSperren(id, aktiv = 0, akteur = null) {
|
|
const person = db().prepare("SELECT name FROM personen WHERE id = ?").get(id);
|
|
db().prepare("UPDATE personen SET aktiv = ? WHERE id = ?").run(aktiv ? 1 : 0, id);
|
|
if (!aktiv) db().prepare("DELETE FROM sitzungen WHERE person_id = ?").run(id);
|
|
protokolliere(aktiv ? "person_entsperrt" : "person_gesperrt", {
|
|
personId: akteur?.id ?? null, rolle: akteur?.rolle ?? null,
|
|
ip: akteur?.ip ?? null, detail: person?.name ?? `#${id}`,
|
|
});
|
|
}
|
|
|
|
export function personenListe() {
|
|
return db().prepare(`
|
|
SELECT p.id, p.name, p.rolle, p.aktiv, p.erstellt, p.letzter_login,
|
|
(SELECT COUNT(*) FROM sitzungen s WHERE s.person_id = p.id) AS sitzungen
|
|
FROM personen p ORDER BY ${ROLLEN_SORTIERUNG.replace("rolle", "p.rolle")}, p.name`).all();
|
|
}
|
|
|
|
export function protokollLesen(anzahl = 20) {
|
|
return db().prepare(
|
|
"SELECT zeitpunkt, rolle, aktion, detail, ip FROM protokoll ORDER BY id DESC LIMIT ?"
|
|
).all(anzahl);
|
|
}
|
|
|
|
/* =====================================================================
|
|
DER MANTEL UM DIE SECHS LISTENFUNKTIONEN (09.09.2026)
|
|
|
|
Jede von ihnen hat mehrere Rueckgabewege -- `sichtbarePersonenIds`
|
|
allein drei. Die Verbergungsregel an jedem einzelnen anzubringen
|
|
waere achtzehn Gelegenheiten, einen zu vergessen; und wer hier einen
|
|
vergisst, merkt es nicht an einem Fehler, sondern daran, dass jemand
|
|
etwas sieht, das er nicht sehen soll.
|
|
|
|
Deshalb bleiben die Funktionen unveraendert (jetzt mit `Roh` im
|
|
Namen) und werden EINMAL ummantelt. Der Mantel ist die einzige
|
|
Stelle, an der die Regel steht -- und er kann keinen Rueckgabeweg
|
|
uebersehen, weil er hinter allen sitzt.
|
|
|
|
Die Namen nach aussen bleiben gleich: Kein Aufrufer muss geaendert
|
|
werden, und niemand kann versehentlich die ungefilterte Fassung
|
|
benutzen -- die `Roh`-Funktionen werden nicht exportiert.
|
|
===================================================================== */
|
|
export const sichtbareCreatorIds = (person) => ohneVerborgene(sichtbareCreatorIdsRoh(person), person);
|
|
export const sichtbarePersonenIds = (person) => ohneVerborgene(sichtbarePersonenIdsRoh(person), person);
|
|
export const betreuerIds = (person) => ohneVerborgene(betreuerIdsRoh(person), person);
|
|
export const einladbareIds = (person) => ohneVerborgene(einladbareIdsRoh(person), person);
|
|
export const schreibbareIds = (person) => ohneVerborgene(schreibbareIdsRoh(person), person);
|
|
export const pipelineIds = (person) => ohneVerborgene(pipelineIdsRoh(person), person);
|