Filipe: "ich will dass es eine eigene app wird" -- und dann "subdomain
machen jetzt sofort". Name von ihm gewaehlt: crew. (unauffaellig).
Auf einem Ursprung laesst sich genau EINE App installieren (w3c/manifest
Nr. 1180). Derselbe Grund, aus dem der Workspace am 06.09. umgezogen ist.
Getrennt wird ueber den HOSTNAMEN: ein Dienst, eine Datenbank, ein
Verzeichnis wie bisher.
DIE REGEL STEHT AN EINER STELLE
crew-adresse.js beantwortet: Darf diese Rolle auf dieser Adresse
angemeldet sein? Eingehaengt in sitzungLesen() -- durch die Funktion geht
jedes der 26 Fachmodule und jede Seitenschranke. Eine Middleware daneben
kann man in einem neuen Modul vergessen, und ein vergessener Rechteschutz
faellt nicht auf, weil dann alles geht.
crew. nur Modis; jeder andere bekommt 401 wie bei einem Tippfehler
workspace. keine Modis mehr; sie bekommen den Weg zur neuen Adresse
localhost UNVERAENDERT
Die dritte Zeile ist der Kern: Die Regel ist eine AUFZAEHLUNG der drei
echten Adressen, nicht "alles ausser crew". Sonst wuerden sieben andere
Pruefdateien ab sofort messen, dass ein Modi nirgends hereinkommt -- gruen,
weil sie nichts mehr finden.
ZWEI APPS, ZWEI NAMEN
Beide Adressen liefern dieselben HTML-Dateien. Auf crew. wird
/workspace/app.webmanifest serverseitig auf crew.webmanifest umgebogen --
so braucht keine der 30 Seiten eine zweite Zeile, die man bei der 31.
vergisst. "Team Dogi" statt "Creator Workspace", eigenes Zeichen:
derselbe Husky, aber ohne Chili (die gehoert zu Spicy Media, nicht zu
ihnen) und im Modi-Ton #5f8a9f.
tools/crew-symbol.mjs erzeugt die sechs Symbole und bricht ab, wenn sie
sich zu weniger als 10 % vom Workspace-Symbol unterscheiden. Gemessen:
43 bis 53 %.
GEMESSEN
pruef-crew-adresse 74 Pruefungen, 0 Fehler -- mit Gegenprobe: der Modi
bekommt in der Datenbank die Rolle 'manager', danach
MUSS dieselbe Sitzung auf crew. ins Leere laufen
pruef-rollen 245, unveraendert (die Regel ist lokal wirkungslos)
pruef-workspace-umzug 31 Faelle
pruef-zwischenspeicher 21 -- ein Stempel im Haus, jetzt ueber 23 Dateien
Der Server kennt die Adresse damit. DNS und Caddy bleiben Filipes Schritt.
Co-Authored-By: Claude Opus 5 <[email protected]>
3850 lines
183 KiB
JavaScript
3850 lines
183 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, CREW_ADRESSE, istOhneModiAdresse, istCrewAdresse }
|
|
from "./crew-adresse.js";
|
|
|
|
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", "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", "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"]);
|
|
|
|
/* 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 ELSE 5 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 ohneModi(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 = 'modi'))`)
|
|
.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" || person.rolle === "modi");
|
|
|
|
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",
|
|
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.
|
|
*/
|
|
export function bereicheFuer(person) {
|
|
return person?.rolle === "modi" ? MODI_BEREICHE : 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(
|
|
MODI_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 person.rolle === "modi" || 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") return [];
|
|
try {
|
|
return db().prepare(
|
|
"SELECT id FROM personen WHERE rolle = 'modi' AND aktiv = 1").all().map((z) => z.id);
|
|
} catch {
|
|
/* Ohne Datenbank lieber kein Feld als ein falsches. */
|
|
return [];
|
|
}
|
|
}
|
|
|
|
export function rollentextFuer(person) {
|
|
return person?.rolle === "modi" ? MODI_ROLLENTEXT : null;
|
|
}
|
|
|
|
export function markeFuer(person) {
|
|
return person?.rolle === "modi" ? 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.
|
|
===================================================================== */
|
|
const ZUSATZ_BEREICHE = {
|
|
admin: [{
|
|
gruppe: "Rund um das Team",
|
|
name: "Team-Lage", unter: "Was noch nicht besprochen ist",
|
|
zeichen: "teamlage", ton: 22, ziel: "teamlage.html", szene: "halle",
|
|
}],
|
|
};
|
|
|
|
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 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);
|
|
|
|
/* 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", "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", "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. */
|
|
"/workspace/teamlage.html": ["admin"],
|
|
};
|
|
|
|
/* Die einzige Seite unter /workspace, die offen sein MUSS -- man kann
|
|
sich schlecht anmelden, wenn die Anmeldeseite eine Anmeldung verlangt. */
|
|
const OFFEN = new Set(["/workspace/index.html"]);
|
|
|
|
/** Der Pfad, wie ihn die Schranke ansehen muss.
|
|
*
|
|
* BEFUND VOM 04.09.2026 (Audit). Der erste Entwurf verglich `req.path`
|
|
* direkt gegen die Liste -- ein EXAKTER Vergleich. Express raeumt
|
|
* Punkt-Segmente selbst weg (`/workspace/./start.html` und
|
|
* `/workspace/x/../start.html` kommen bereits zurechtgerueckt an), aber
|
|
* MEHRFACHE SCHRAEGSTRICHE nicht. Gemessen:
|
|
*
|
|
* /workspace/start.html -> req.path /workspace/start.html
|
|
* //workspace/start.html -> req.path //workspace/start.html
|
|
* /workspace//start.html -> req.path /workspace//start.html
|
|
*
|
|
* Der Listenvergleich traf damit nicht, die Anfrage lief ungeprueft an
|
|
* express.static weiter -- und das zieht die Schraegstriche seinerseits
|
|
* zusammen und liefert die Datei aus. Ergebnis: `//workspace/start.html`
|
|
* kam OHNE ANMELDUNG mit HTTP 200, und ein Creator bekam ueber
|
|
* `/workspace//personen.html` die Verwaltungsseite. Das ist derselbe
|
|
* Befund wie am 27.08.2026 ("sichtbar sein soll sie gar nicht"), nur
|
|
* eine Schreibweise weiter -- der Fix von damals deckte genau eine ab.
|
|
*
|
|
* Nicht betroffen waren die DATEN: Bei doppeltem Schraegstrich trifft
|
|
* keine der /workspace/api-Routen mehr (404), und wo sie trifft, greift
|
|
* ihre eigene Schranke (401). Nachgemessen, nicht angenommen.
|
|
*
|
|
* Kleinschreibung kommt dazu, weil ein case-unempfindliches Dateisystem
|
|
* (Windows, macOS) `PERSONEN.HTML` an dieselbe Datei weiterreicht. Auf
|
|
* dem Linux-Server greift das nicht, hier im Test schon -- und eine
|
|
* Schranke, die nur auf einem Dateisystem haelt, ist keine.
|
|
*
|
|
* Abschliessende Punkte und Leerzeichen fallen weg: Windows oeffnet
|
|
* `start.html.` als `start.html`. */
|
|
function schrankenPfad(roh) {
|
|
return String(roh || "")
|
|
.replace(/\/{2,}/g, "/") // // -> /
|
|
.replace(/[.\s]+$/, (ende) => // abschliessende Punkte/Leerzeichen
|
|
ende.includes(".") && !/^\.[a-z]/i.test(ende) ? "" : ende)
|
|
.toLowerCase();
|
|
}
|
|
|
|
/* GESCHUETZT IST JETZT DIE AUSNAHME, NICHT DIE REGEL.
|
|
|
|
Vorher galt: Was in der Liste steht, ist geschuetzt -- alles andere
|
|
nicht. Wer eine neue Seite anlegt und den Eintrag vergisst, stellt sie
|
|
damit offen ins Netz, ohne dass irgendwo etwas rot wird. Genau diese
|
|
Sorte Fehler faellt erst auf, wenn jemand danach sucht.
|
|
|
|
Jetzt gilt: JEDE .html unter /workspace ist geschuetzt, ausser den
|
|
ausdruecklich offenen. Die Liste oben sagt nur noch, WELCHE ROLLEN
|
|
zusaetzlich eingeschraenkt sind. Eine neue Seite ist dadurch von
|
|
selbst mindestens "nur fuer Angemeldete" -- der sichere Rueckfall. */
|
|
workspaceRouter.use((req, res, next) => {
|
|
const pfad = schrankenPfad(req.path);
|
|
if (!pfad.startsWith("/workspace/") || !pfad.endsWith(".html")) return next();
|
|
if (OFFEN.has(pfad)) return next();
|
|
|
|
const person = sitzungLesen(req);
|
|
if (!person) return res.redirect(302, "/workspace/");
|
|
const erlaubt = GESCHUETZT[pfad];
|
|
if (erlaubt && !erlaubt.includes(person.rolle)) {
|
|
return res.redirect(302, "/workspace/start.html");
|
|
}
|
|
return next();
|
|
});
|
|
|
|
/**
|
|
* 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 = 'modi' 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. */
|
|
const still = codeBrauchbar ? stillerZugang(code) : null;
|
|
if (still) {
|
|
/* AUF DER ALTEN ADRESSE GIBT ES KEINE SITZUNG MEHR (10.09.2026).
|
|
|
|
Die Modi-App hat seit heute eine eigene Adresse. Wer seinen Code
|
|
noch auf workspace.dogfather-universe.com eintippt, bekommt hier
|
|
den Weg dorthin -- und ausdruecklich KEINE Anmeldung: zwei
|
|
Sitzungen auf zwei Adressen waeren zwei Apps auf dem
|
|
Startbildschirm und eine zweite Spur genau dort, wo alle anderen
|
|
arbeiten.
|
|
|
|
DASS DIE ANTWORT DIE NEUE ADRESSE NENNT, IST KEIN LECK. Sie
|
|
kommt nur nach Eingabe eines gueltigen Modi-Codes; wer den hat,
|
|
ist Modi. Fuer jeden anderen -- auch fuer einen Manager mit
|
|
gueltigem eigenen Code -- veraendert sich hier gar nichts.
|
|
|
|
Kein Eintrag in `versuche`: Der Code war richtig. Ein Modi, der
|
|
die alte Verknuepfung auf dem Handy hat, wuerde sich sonst mit
|
|
jedem Versuch selbst aussperren. */
|
|
if (istOhneModiAdresse(req.get("host"))) {
|
|
protokolliere("anmeldung_alte_adresse", {
|
|
personId: still.id, rolle: still.rolle, ip, detail: still.name,
|
|
});
|
|
return res.json({
|
|
weiter: `${CREW_ADRESSE}/workspace/start.html`,
|
|
name: still.name, rolle: still.rolle, umgezogen: true,
|
|
});
|
|
}
|
|
|
|
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. */
|
|
const fremdAufCrew = istCrewAdresse(req.get("host"));
|
|
|
|
if (!ROLLEN_KACHEL.has(rolle) || !codeBrauchbar || fremdAufCrew) {
|
|
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 = 'modi'").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" || person.rolle === "modi");
|
|
|
|
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 (person.rolle === "modi") {
|
|
try {
|
|
for (const z of db().prepare(
|
|
"SELECT id FROM personen WHERE rolle = 'modi' 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);
|