diff --git a/server/pruef-modi-kategorien.mjs b/server/pruef-modi-kategorien.mjs
new file mode 100644
index 00000000..b4430794
--- /dev/null
+++ b/server/pruef-modi-kategorien.mjs
@@ -0,0 +1,305 @@
+/* =====================================================================
+ KATEGORIEN AN AUFGABEN -- aber nur bei den Modis
+
+ Kapitel 6.1 des Anforderungsdokuments verlangt eine Kategorie an jeder
+ Aufgabe. Filipes Entscheidung dazu (10.09.2026): nur bei den Modis.
+ Fuer Creator, Scouts und Manager bleibt das Formular, wie es war.
+
+ DREI FRAGEN, und die dritte wird gern vergessen:
+
+ 1. Kann ein Modi eine Kategorie setzen und wiederfinden?
+ 2. Bekommt sie sonst niemand zu Gesicht?
+ 3. Was passiert, wenn jemand sie trotzdem SCHICKT?
+
+ Die dritte entscheidet ueber die Verborgenheit. Eine Absage
+ ("Unbekannte Kategorie") waere die Auskunft, dass es das Feld gibt --
+ also muss sie stillschweigend fallen. Wer sie benutzen darf, bekommt
+ bei einem Tippfehler dagegen sehr wohl eine Absage: Fuer ihn ist er
+ ein Fehler und kein Geheimnis.
+ ===================================================================== */
+import { mkdtempSync, rmSync } from "node:fs";
+import { tmpdir } from "node:os";
+import { join } from "node:path";
+
+const ordner = mkdtempSync(join(tmpdir(), "ws-kat-"));
+process.env.WORKSPACE_DB = join(ordner, "workspace.db");
+process.env.PORT = "4361";
+process.env.SITE_ACCESS_SECRET = "lokaler-test";
+process.env.SITE_ACCESS_CODE_DOGI = "DOGI-TEST-1";
+process.env.SITE_PUBLIC_LAUNCH_AT = "2020-01-01T00:00:00+01:00";
+
+const express = (await import("express")).default;
+const ec = express.response.cookie;
+express.response.cookie = function (n, w, o) { return ec.call(this, n, w, { ...(o || {}), secure: false }); };
+import { notbremse } from "./helfer-notbremse.mjs";
+await import("./index.js");
+notbremse(180_000, "pruef-modi-kategorien");
+await new Promise((r) => setTimeout(r, 700));
+const BASIS = "http://127.0.0.1:4361";
+
+let fehler = 0;
+let geprueft = 0;
+const ok = (b, t) => { geprueft++; console.log((b ? " ok " : " FEHL ") + t); if (!b) fehler++; };
+let offen = 0;
+const unklar = (t) => { offen++; console.log(" -- " + t); };
+
+/* ---------- Personen ------------------------------------------------- */
+const { DatabaseSync } = await import("node:sqlite");
+const { scryptSync, randomBytes, createHmac } = await import("node:crypto");
+const d = new DatabaseSync(process.env.WORKSPACE_DB);
+const jetzt = new Date().toISOString();
+
+const schluessel = randomBytes(32).toString("hex");
+d.prepare("INSERT INTO einstellungen (schluessel, wert, geaendert, von)"
+ + " VALUES (?,?,?,NULL) ON CONFLICT(schluessel) DO NOTHING")
+ .run("code_kennung_schluessel", schluessel, jetzt);
+
+function anlegen(name, rolle, code) {
+ const salt = randomBytes(16).toString("hex");
+ const hash = scryptSync(code, salt, 64,
+ { N: 32768, r: 8, p: 1, maxmem: 96 * 1024 * 1024 }).toString("hex");
+ d.prepare("INSERT INTO personen"
+ + " (name, rolle, code_hash, code_salt, code_n, code_kennung, aktiv, erstellt)"
+ + " VALUES (?,?,?,?,?,?,1,?)")
+ .run(name, rolle, hash, salt, 32768,
+ createHmac("sha256", schluessel).update(code).digest("hex"), jetzt);
+ return d.prepare("SELECT last_insert_rowid() AS id").get().id;
+}
+
+const idDogi = anlegen("Filipe", "admin", "CODE-DOGI-0001");
+const idMan = anlegen("Schulle", "manager", "CODE-MAN-0001");
+const idScout = anlegen("Patrick", "scout", "CODE-SCOU-0001");
+const idCreat = anlegen("Tili", "creator", "CODE-ANNA-0001");
+const idModi = anlegen("Marina", "modi", "CODE-MODI-0001");
+d.prepare("INSERT INTO betreuung (creator_id, betreuer_id, seit) VALUES (?,?,?)")
+ .run(idCreat, idScout, jetzt);
+d.prepare("INSERT INTO scout_zuteilung (scout_id, manager_id, seit) VALUES (?,?,?)")
+ .run(idScout, idMan, jetzt);
+d.close();
+void idDogi;
+
+async function anmelden(rolle, code) {
+ const a = await fetch(BASIS + "/workspace/api/anmelden", {
+ method: "POST", headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({ rolle, code }),
+ });
+ const keks = (a.headers.getSetCookie?.() || []).map((z) => z.split(";")[0]).join("; ");
+ return { status: a.status, keks };
+}
+async function hole(keks, pfad, aufruf = {}) {
+ const a = await fetch(BASIS + pfad, {
+ ...aufruf,
+ headers: { ...(aufruf.headers || {}), cookie: keks },
+ });
+ return { status: a.status, daten: await a.json().catch(() => null) };
+}
+
+/* ---------- 1. Wer bekommt die Liste ueberhaupt? --------------------- */
+console.log("\n=== 1. Wer bekommt die Kategorien zu sehen? ===\n");
+
+const modiAn = await anmelden("creator", "CODE-MODI-0001");
+const dogiAn = await anmelden("admin", "CODE-DOGI-0001");
+const manAn = await anmelden("manager", "CODE-MAN-0001");
+const creatAn = await anmelden("creator", "CODE-ANNA-0001");
+
+let kategorien = [];
+{
+ const a = await hole(modiAn.keks, "/workspace/api/aufgaben");
+ kategorien = a.daten?.kategorien || [];
+ /* Die ZAHL gehoert in die Bedingung: Eine leere Liste wuerde sonst
+ als "hat Kategorien bekommen" durchgehen. */
+ ok(a.status === 200 && kategorien.length >= 6,
+ `der Modi bekommt ${kategorien.length} Kategorien`);
+ ok(kategorien.every((k) => k.wert && k.name),
+ "jede hat einen Schluessel und einen lesbaren Namen");
+}
+{
+ const a = await hole(dogiAn.keks, "/workspace/api/aufgaben");
+ ok(a.status === 200 && (a.daten?.kategorien || []).length === kategorien.length,
+ "DogFather ebenso -- er legt den Modis Aufgaben an");
+}
+for (const [name, an] of [["Manager", manAn], ["Creator", creatAn]]) {
+ const a = await hole(an.keks, "/workspace/api/aufgaben");
+ const k = a.daten?.kategorien;
+ /* LEER und nicht "fehlt": Die Oberflaeche baut das Feld nur, wenn
+ etwas ankommt. Waere das Feld gar nicht da, faellt es auf dasselbe
+ hinaus -- aber eine leere Liste sagt ausdruecklich "nichts fuer
+ dich" statt "vergessen". */
+ ok(a.status === 200 && Array.isArray(k) && k.length === 0,
+ `${name}: keine Kategorien (${JSON.stringify(k)})`);
+}
+
+/* ---------- 2. Setzen, lesen, aendern, leeren ------------------------ */
+console.log("\n=== 2. Der Modi setzt eine Kategorie ===\n");
+
+const jsonKopf = { "Content-Type": "application/json" };
+let aufgabeId = null;
+{
+ const a = await hole(modiAn.keks, "/workspace/api/aufgaben", {
+ method: "POST", headers: jsonKopf,
+ body: JSON.stringify({ titel: "Clip vom Freitags-Live schneiden", kategorie: "clipping" }),
+ });
+ aufgabeId = a.daten?.id ?? null;
+ ok(a.status === 201 && Number.isInteger(aufgabeId), `angelegt (${a.status})`);
+}
+if (aufgabeId) {
+ const a = await hole(modiAn.keks, "/workspace/api/aufgaben");
+ const seine = (a.daten?.aufgaben || []).find((x) => x.id === aufgabeId);
+ ok(seine?.kategorie === "clipping",
+ `und steht mit ihrer Kategorie in der Liste (${seine?.kategorie})`);
+
+ const b = await hole(modiAn.keks, `/workspace/api/aufgaben/${aufgabeId}`, {
+ method: "PATCH", headers: jsonKopf, body: JSON.stringify({ kategorie: "social" }),
+ });
+ const c = await hole(modiAn.keks, "/workspace/api/aufgaben");
+ ok(b.status === 200
+ && (c.daten?.aufgaben || []).find((x) => x.id === aufgabeId)?.kategorie === "social",
+ "sie laesst sich aendern");
+
+ /* LEEREN MUSS GEHEN. Ohne diesen Weg bliebe eine einmal gesetzte
+ Kategorie fuer immer stehen -- und ein Feld, das man nicht
+ zuruecknehmen kann, ist eine Falle. */
+ const e = await hole(modiAn.keks, `/workspace/api/aufgaben/${aufgabeId}`, {
+ method: "PATCH", headers: jsonKopf, body: JSON.stringify({ kategorie: "" }),
+ });
+ const f = await hole(modiAn.keks, "/workspace/api/aufgaben");
+ ok(e.status === 200
+ && (f.daten?.aufgaben || []).find((x) => x.id === aufgabeId)?.kategorie === null,
+ "und wieder leeren");
+}
+
+/* ---------- 3. Ein Tippfehler ist ein Fehler ------------------------- */
+console.log("\n=== 3. Was bei einem falschen Wert passiert ===\n");
+{
+ const a = await hole(modiAn.keks, "/workspace/api/aufgaben", {
+ method: "POST", headers: jsonKopf,
+ body: JSON.stringify({ titel: "Irgendwas", kategorie: "gibtesnicht" }),
+ });
+ ok(a.status === 400 && /Kategorie/i.test(a.daten?.fehler || ""),
+ `dem Modi wird es gesagt (${a.status} ${a.daten?.fehler || ""})`);
+}
+
+/* ---------- 4. Und wenn ein Manager sie schickt? --------------------- */
+console.log("\n=== 4. Ein Manager schickt eine Kategorie mit ===\n");
+{
+ /* DER HEIKELSTE FALL. Eine Absage waere die Auskunft, dass es das
+ Feld gibt. Also: Die Aufgabe entsteht ganz normal, der Wert faellt
+ lautlos weg. */
+ const a = await hole(manAn.keks, "/workspace/api/aufgaben", {
+ method: "POST", headers: jsonKopf,
+ body: JSON.stringify({
+ titel: "Ganz normale Manager-Aufgabe", kategorie: "clipping",
+ verantwortlich_id: idMan,
+ }),
+ });
+ ok(a.status === 201, `die Aufgabe entsteht ganz normal (${a.status})`);
+ if (a.daten?.id) {
+ const b = await hole(dogiAn.keks, "/workspace/api/aufgaben");
+ const seine = (b.daten?.aufgaben || []).find((x) => x.id === a.daten.id);
+ ok(seine && seine.kategorie === null,
+ `aber ohne Kategorie (${JSON.stringify(seine?.kategorie)})`);
+ } else {
+ unklar("ohne Nummer laesst sich das Ergebnis nicht nachsehen");
+ }
+}
+
+/* ---------- 5. Im Browser -------------------------------------------- */
+console.log("\n=== 5. Das Feld im echten Formular ===\n");
+{
+ const { chromium } = await import(
+ "file:///C:/Users/qciga/Documents/Obelix/Analyse/node_modules/playwright/index.mjs");
+ const browser = await chromium.launch();
+ try {
+ async function formular(kachel, code) {
+ const k = await browser.newContext({ viewport: { width: 1280, height: 900 } });
+ const s = await k.newPage();
+ const konsole = [];
+ s.on("console", (e) => { if (e.type() === "error") konsole.push(e.text()); });
+ s.on("pageerror", (e) => konsole.push("Absturz: " + e.message));
+ await s.goto(BASIS + "/workspace/", { waitUntil: "domcontentloaded" });
+ await s.click(`.rolle[data-rolle="${kachel}"]`);
+ await s.fill("#code", code);
+ await s.click("button[type=submit]");
+ await s.waitForURL("**/start.html", { timeout: 15000 });
+ await s.goto(BASIS + "/workspace/aufgaben.html", { waitUntil: "networkidle" });
+ await s.waitForTimeout(900);
+ await s.click("#neu-oeffnen").catch(() => {});
+ await s.waitForTimeout(500);
+ return { kontext: k, seite: s, konsole };
+ }
+
+ /* Der Modi: Feld da, Auswahl gefuellt. */
+ {
+ const { kontext, seite, konsole } = await formular("creator", "CODE-MODI-0001");
+ const z = await seite.evaluate(() => {
+ const h = document.getElementById("feld-kategorie");
+ const f = document.getElementById("f-kategorie");
+ return {
+ sichtbar: !!h && !h.hidden,
+ auswahl: f ? [...f.options].map((o) => o.textContent) : [],
+ };
+ });
+ ok(z.sichtbar, "der Modi sieht das Feld");
+ ok(z.auswahl.length >= 7,
+ `mit ${z.auswahl.length} Eintraegen (${z.auswahl.slice(1, 4).join(", ")} …)`);
+ /* KEIN CREATOR-VORLAGENBRETT. Es haelt 80 Aufgaben fuer den Aufbau
+ eines Kanals bereit -- fuer jemanden, der moderiert, ist davon
+ nichts gedacht. Gefunden auf dem Bildschirmfoto, nicht im Code:
+ Die Kachel laedt fehlerfrei, sie zeigt nur das Falsche. */
+ const vorlagen = await seite.evaluate(() => {
+ const v = document.getElementById("vorlagenbrett");
+ return { da: !!v && !v.hidden, text: document.body.innerText };
+ });
+ ok(!vorlagen.da, "und kein Creator-Vorlagenbrett");
+ ok(!/vier Bereichen und vier Stufen/.test(vorlagen.text),
+ "auch nicht als Text auf der Seite");
+ ok(konsole.length === 0,
+ konsole.length ? `Fehler in der Konsole: ${konsole[0]}` : "keine Fehler in der Konsole");
+ await seite.screenshot({ path: "pruef-modi-kategorie.png", fullPage: false });
+ await kontext.close();
+ }
+
+ /* Der Manager: kein Feld -- und kein Wort davon im Formular. */
+ {
+ const { kontext, seite, konsole } = await formular("manager", "CODE-MAN-0001");
+ const z = await seite.evaluate(() => {
+ const h = document.getElementById("feld-kategorie");
+ const f = document.getElementById("f-kategorie");
+ return {
+ sichtbar: !!h && !h.hidden,
+ /* Nur der Platzhalter darf dastehen. Waeren die Namen
+ eingesetzt und das Feld bloss verborgen, stuenden sie im
+ Seitenquelltext -- und waeren zu finden. */
+ eintraege: f ? f.options.length : 0,
+ text: document.body.innerText,
+ };
+ });
+ ok(!z.sichtbar, "der Manager sieht das Feld nicht");
+ ok(z.eintraege <= 1,
+ `und die Auswahl ist gar nicht erst gefuellt (${z.eintraege} Eintrag)`);
+ ok(!/Chat-Moderation|Clipping & Schnitt/.test(z.text),
+ "kein Kategoriename steht auf seiner Seite");
+ ok(konsole.length === 0,
+ konsole.length ? `Fehler in der Konsole: ${konsole[0]}` : "keine Fehler in der Konsole");
+ await kontext.close();
+ }
+
+ /* GEGENPROBE ZUM BROWSERTEIL: Findet die Suche die Namen ueberhaupt,
+ wenn sie dastehen? Ohne diesen Schritt bewiese der Haken oben
+ nichts -- er waere auch gruen, wenn innerText leer waere. */
+ {
+ const { kontext, seite } = await formular("creator", "CODE-MODI-0001");
+ const text = await seite.evaluate(() => document.body.innerText);
+ ok(/Clipping & Schnitt/.test(text),
+ "beim Modi findet dieselbe Suche den Namen sehr wohl");
+ await kontext.close();
+ }
+ } finally {
+ await browser.close();
+ }
+}
+
+console.log(`\n${fehler ? "FEHLGESCHLAGEN" : "BESTANDEN"} -- `
+ + `${geprueft} geprueft, ${fehler} Fehler, ${offen} nicht nachsehbar`);
+try { rmSync(ordner, { recursive: true, force: true }); } catch { /* egal */ }
+process.exit(fehler ? 1 : 0);
diff --git a/server/pruef-modi-verborgen.mjs b/server/pruef-modi-verborgen.mjs
index 5ec2ced9..add60da4 100644
--- a/server/pruef-modi-verborgen.mjs
+++ b/server/pruef-modi-verborgen.mjs
@@ -290,13 +290,116 @@ console.log("\n=== 5. Anlegen wie im Betrieb: DogFather legt einen Modi an ===\n
`der Manager sieht ihn nicht (${namen.length} Personen: ${namen.join(", ")})`);
}
}
-/* ---------- 6. Die Gegenprobe ----------------------------------------
+/* ---------- 6. Die Startseite eines Modis ---------------------------- */
+console.log("\n=== 6. Was ein Modi auf seiner Startseite sieht ===\n");
+{
+ const an = await anmelden("creator", "CODE-MODI-0001");
+ const { status, daten } = await hole(an.keks, "/workspace/api/ich");
+ if (status !== 200) {
+ unklar(`/api/ich nicht lesbar (${status})`);
+ } else {
+ const b = daten?.bereiche;
+ ok(Array.isArray(b) && b.length > 0,
+ `der Server schickt ihm ${Array.isArray(b) ? b.length : "keine"} Kacheln`);
+ /* Nichts aus der Agentur. Nicht "moeglichst wenig", sondern keine
+ einzige -- diese Seiten drehen sich um betreute Creator, um
+ Rechte oder um Automationen. */
+ const verboten = ["personen.html", "scouting.html", "automation.html",
+ "report.html", "calls.html", "uebersicht.html", "content.html"];
+ const drin = (b || []).map((x) => x.ziel).filter((z) => verboten.includes(z));
+ ok(Array.isArray(b) && drin.length === 0,
+ drin.length ? `ABER AUCH: ${drin.join(", ")} -- FEHLER` : "und nichts aus der Agentur");
+ /* Die Beschriftung gehoert zum Empfaenger: "Alle Creator auf einen
+ Blick" waere fuer jemanden ohne Creator schlicht falsch. */
+ ok((b || []).every((x) => x.name && x.unter && x.zeichen && x.ziel),
+ "jede Kachel hat Beschriftung, Zeichen und Ziel");
+ }
+
+ /* GEGENPROBE ZUR QUELLE: Fuer eine bekannte Rolle muss `null`
+ herauskommen -- nicht eine leere Liste. Die Oberflaeche
+ unterscheidet die beiden, und verwechselte man sie, haetten alle
+ anderen ab sofort eine leere Startseite. */
+ const m = await anmelden("manager", "CODE-MAN-0001");
+ const mi = await hole(m.keks, "/workspace/api/ich");
+ ok(mi.status === 200 && mi.daten?.bereiche === null,
+ `beim Manager steht dort null, nicht [] (${JSON.stringify(mi.daten?.bereiche)})`);
+}
+
+/* ---------- 7. Und im echten Browser? -------------------------------- */
+console.log("\n=== 7. Dieselbe Seite mit einem echten Browser ===\n");
+{
+ const { chromium } = await import(
+ "file:///C:/Users/qciga/Documents/Obelix/Analyse/node_modules/playwright/index.mjs");
+ const browser = await chromium.launch();
+ try {
+ const kontext = await browser.newContext({ viewport: { width: 390, height: 844 } });
+ const seite = await kontext.newPage();
+ const konsole = [];
+ seite.on("console", (e) => { if (e.type() === "error") konsole.push(e.text()); });
+ seite.on("pageerror", (e) => konsole.push("Absturz: " + e.message));
+
+ await seite.goto(BASIS + "/workspace/", { waitUntil: "domcontentloaded" });
+ /* Irgendeine Kachel -- fuer einen Modi gibt es keine eigene. */
+ await seite.click('.rolle[data-rolle="creator"]');
+ await seite.fill("#code", "CODE-MODI-0001");
+ await seite.click("button[type=submit]");
+ await seite.waitForURL("**/start.html", { timeout: 15000 });
+ await seite.waitForTimeout(1200);
+
+ const bild = await seite.evaluate(() => ({
+ kacheln: [...document.querySelectorAll(".gruppe a[href]")].map((a) => a.getAttribute("href")),
+ gruppen: [...document.querySelectorAll(".gruppe__kopf")].map((k) => k.textContent.trim().slice(0, 40)),
+ rolle: (document.getElementById("zgruss-rolle") || {}).textContent || "",
+ rollentext: (document.getElementById("rollentext") || {}).textContent || "",
+ marke: (document.querySelector(".zmarke") || {}).textContent || "",
+ rauten: document.querySelectorAll(".zmarke .zraute").length,
+ }));
+
+ /* Die ZAHL gehoert in die Bedingung, nicht nur in den Text: Eine
+ leere Seite wuerde sonst als "nichts Verbotenes dabei" durchgehen. */
+ ok(bild.kacheln.length >= 8,
+ `${bild.kacheln.length} Kacheln stehen wirklich auf der Seite (${bild.gruppen.join(" | ")})`);
+ ok(bild.kacheln.some((h) => h.startsWith("aufgaben.html")),
+ "darunter das Aufgabenbrett -- das Herzstueck aus Kapitel 6");
+ ok(!bild.kacheln.some((h) => /personen|scouting|automation|report/.test(h)),
+ "und nichts aus der Agentur");
+ /* Der Anzeigename kommt aus /api/ich, nicht aus einer Datei im
+ Browser -- sonst stuende das Wort in einer Datei, die jeder
+ bekommt. Dass er hier steht, ist der Nachweis, dass der Weg geht. */
+ ok(bild.rolle.trim() === "MODI",
+ `die Plakette sagt "${bild.rolle.trim()}" (kommt vom Server)`);
+ /* EIN SATZ, KEIN WORT. Die fuenf bekannten Rollen bekommen unter
+ der Begruessung eine Erklaerung ("Creator · dein eigener
+ Bereich"). Stuende dort nur "Modi", saehe die Seite nicht
+ verborgen aus, sondern unfertig -- und genau das stand da, bis
+ diese Pruefung es gemeldet hat. */
+ ok(bild.rollentext.includes("·") && bild.rollentext.trim().length > 12,
+ `und die Zeile darunter ist ein ganzer Satz ("${bild.rollentext.trim()}")`);
+ /* DIE MARKE UEBER DEM TITEL. Ein Modi gehoert nicht zur Agentur --
+ stand dort "Spicy Media", war die Seite fuer ihn schlicht falsch
+ beschriftet. Gefunden auf dem Bildschirmfoto, nicht im Code. */
+ ok(bild.marke.includes("Team Dogi") && !bild.marke.includes("Spicy"),
+ `die Zierzeile nennt seinen Betrieb ("${bild.marke.trim()}")`);
+ /* Und die beiden Zierrauten sind noch da: Sie beim Austausch des
+ Textes zu verlieren waere lautlos passiert. */
+ ok(bild.rauten === 2, `die beiden Zierrauten stehen noch (${bild.rauten})`);
+ ok(konsole.length === 0,
+ konsole.length ? `Fehler in der Konsole: ${konsole[0]}` : "keine Fehler in der Konsole");
+
+ await seite.screenshot({ path: "pruef-modi-start.png", fullPage: true });
+ console.log(" -- Bildschirmfoto: server/pruef-modi-start.png");
+ await kontext.close();
+ } finally {
+ await browser.close();
+ }
+}
+/* ---------- 8. Die Gegenprobe ----------------------------------------
Sie steht ABSICHTLICH GANZ UNTEN: Sie haengt eine Person
absichtlich aus der Regel aus. Lief sie weiter oben, tauchte
diese Person in jeder Liste DANACH auf -- und jeder spaetere
Abschnitt maesse einen Zustand, den es im Betrieb nicht gibt.
-------------------------------------------------------------------- */
-console.log("\n=== 6. Gegenprobe: kann diese Pruefung ueberhaupt anschlagen? ===\n");
+console.log("\n=== 8. Gegenprobe: kann diese Pruefung ueberhaupt anschlagen? ===\n");
{
const d2 = new DatabaseSync(process.env.WORKSPACE_DB);
d2.prepare("UPDATE personen SET rolle = 'creator' WHERE id = ?").run(idModi1);
diff --git a/server/pruef-rollen.mjs b/server/pruef-rollen.mjs
index 5871316e..018b0975 100644
--- a/server/pruef-rollen.mjs
+++ b/server/pruef-rollen.mjs
@@ -63,17 +63,30 @@ const ok = (b, t) => { geprueft++; console.log((b ? " ok " : " FEHL ") + t);
machen selten Fehler. Jede Rolle bekommt deshalb etwas zu sehen. */
const { DatabaseSync } = await import("node:sqlite");
-const { scryptSync, randomBytes } = await import("node:crypto");
+const { scryptSync, randomBytes, createHmac } = await import("node:crypto");
const d = new DatabaseSync(process.env.WORKSPACE_DB);
const jetzt = new Date().toISOString();
/* ORTSZEIT, nicht UTC -- Begruendung in helfer-zeit.mjs. */
const tag = (v) => tagLokal(v);
+/* DER SUCHSCHLUESSEL ZUM CODE (09.09.2026).
+
+ Ohne ihn kaeme ein Modi nicht herein: Er hat keine Kachel, der stille
+ Zugang findet ihn allein ueber diesen Wert. Er muss dastehen, BEVOR
+ die erste Person angelegt wird -- die Personen entstehen hier von
+ Hand und nicht ueber personAnlegen(), das ihn sonst selbst schreibt. */
+const kennungSchluessel = randomBytes(32).toString("hex");
+d.prepare("INSERT INTO einstellungen (schluessel, wert, geaendert, von)"
+ + " VALUES (?,?,?,NULL) ON CONFLICT(schluessel) DO NOTHING")
+ .run("code_kennung_schluessel", kennungSchluessel, jetzt);
+
function person(name, rolle, code) {
const salt = randomBytes(16).toString("hex");
const hash = scryptSync(code, salt, 64, { N: 32768, r: 8, p: 1, maxmem: 96 * 1024 * 1024 }).toString("hex");
- d.prepare("INSERT INTO personen (name, rolle, code_hash, code_salt, code_n, aktiv, erstellt) VALUES (?,?,?,?,?,1,?)")
- .run(name, rolle, hash, salt, 32768, jetzt);
+ const kennung = createHmac("sha256", kennungSchluessel).update(code).digest("hex");
+ d.prepare("INSERT INTO personen (name, rolle, code_hash, code_salt, code_n, code_kennung, aktiv, erstellt)"
+ + " VALUES (?,?,?,?,?,?,1,?)")
+ .run(name, rolle, hash, salt, 32768, kennung, jetzt);
return d.prepare("SELECT last_insert_rowid() AS id").get().id;
}
const idDogi = person("Filipe", "admin", "CODE-DOGI-0001");
@@ -83,6 +96,10 @@ const idTili = person("Tili", "creator", "CODE-TILI-0001");
/* Zwei Neue ohne jede Zuteilung -- der Zustand am ersten Tag. */
person("Neu-Scout", "scout", "CODE-NEU-000001");
person("Neu-Manager", "manager", "CODE-NEUM-00001");
+/* Ein Modi -- verborgen fuer alle ausser der DogFather-Rolle und den
+ anderen Modis. Er laeuft hier ueber JEDE Seite, denn "verborgen"
+ heisst nicht "darf kaputt sein": Was er sieht, muss auch gehen. */
+person("Marina", "modi", "CODE-MODI-0001");
/* Cigdem betreut Tili, Ben ebenfalls -- so hat jede betreuende Rolle
etwas zu sehen. Genau diese Zuteilung fehlte im echten Bestand, und
@@ -138,6 +155,14 @@ const ROLLEN = [
{ rolle: "creator", code: "CODE-TILI-0001", name: "Creator (Tili)" },
{ rolle: "scout", code: "CODE-NEU-000001", name: "Scout (OHNE Creator)" },
{ rolle: "manager", code: "CODE-NEUM-00001", name: "Manager (OHNE Creator)" },
+ /* DER SIEBTE DURCHGANG: ein Modi (09.09.2026).
+
+ `rolle` ist hier die KACHEL, auf die geklickt wird -- nicht die
+ Rolle, die dabei herauskommt. Fuer einen Modi gibt es keine Kachel;
+ er tippt auf irgendeine vorhandene, und der Code entscheidet. Dass
+ ausgerechnet "creator" dasteht, ist Absicht und beweist nebenbei,
+ dass die Kachel wirklich gleichgueltig ist. */
+ { rolle: "creator", code: "CODE-MODI-0001", name: "Modi (Marina)" },
];
const { chromium } = await import(
diff --git a/server/pruef-start-ansicht.mjs b/server/pruef-start-ansicht.mjs
index edf8fd92..7468ff5d 100644
--- a/server/pruef-start-ansicht.mjs
+++ b/server/pruef-start-ansicht.mjs
@@ -176,7 +176,25 @@ for (const [breite, wie] of [[1440, "Computer"], [390, "Handy"]]) {
console.log(" Gruppen:", r.gruppen.map((g) => `${g.name} (${g.kacheln})`).join(" · "));
console.log(" Zahlen: ", r.kacheln.filter((k) => k.zahl).map((k) => `${k.name}=${k.zahl}${k.warn ? "!" : ""}`).join(" · "));
- ok(r.gruppen.length === 3, `drei Gruppen (${r.gruppen.length})`);
+ /* VIER, nicht drei (berichtigt 10.09.2026).
+
+ Die Erwartung stand auf drei, seit b45de94 eine vierte Gruppe
+ hinzugefuegt hat ("Rund um das Team -- die Arbeitslage von Managern
+ und Scouts"). Die Anwendung war richtig, die Zahl hier alt -- und
+ diese Pruefung damit rot, ohne dass es jemandem aufgefallen ist.
+
+ Gefunden hat sie nicht ein Gesamtlauf, sondern die Frage "was kann
+ an meiner Aenderung kaputtgehen?" -- und danach die Gegenprobe auf
+ dem unveraenderten Stand, die zeigte: schon vorher rot, nicht von
+ mir.
+
+ GEPRUEFT WERDEN JETZT DIE NAMEN, nicht nur die Anzahl. Vier Gruppen
+ koennten auch drei richtige und eine doppelte sein, und dann stuende
+ hier wieder eine gruene Zahl ueber einem falschen Bildschirm. */
+ const ERWARTETE_GRUPPEN = ["Täglich", "Rund um den Creator",
+ "Rund um das Team", "Team & System"];
+ ok(JSON.stringify(r.gruppen.map((g) => g.name)) === JSON.stringify(ERWARTETE_GRUPPEN),
+ `vier Gruppen in dieser Reihenfolge (${r.gruppen.map((g) => g.name).join(" · ")})`);
/* WIE VIELE KACHELN? Nicht abgeschrieben, sondern aus bereiche.js
gezaehlt (06.09.2026).
diff --git a/server/workspace-aufgaben.js b/server/workspace-aufgaben.js
index 45e7e6af..54472efc 100644
--- a/server/workspace-aufgaben.js
+++ b/server/workspace-aufgaben.js
@@ -12,6 +12,7 @@ import express from "express";
import {
db, protokolliere, echteIp, sitzungLesen, betreutWo, istLeitung, ohneDogFather, heuteLokal, ROLLEN_SORTIERUNG, betreuteIds,
externPruefen, externSql, sichtbarePersonenIds, sichtbareCreatorIds,
+ MODI_KATEGORIEN, kategorienFuer,
} from "./workspace.js";
export const aufgabenRouter = express.Router();
@@ -99,6 +100,46 @@ export function sichtbar(person) {
? { wo: `(${eigen} OR ${b.wo})`, werte: [person.id, person.id, ...b.werte] }
: { wo: eigen, werte: [person.id, person.id] };
}
+ /* =================================================================
+ MODI (10.09.2026) -- UND FAST WAERE DERSELBE FEHLER PASSIERT WIE
+ AM 01.09.2026 BEIM MANAGER.
+
+ Er stand hier nicht, fiel in den Default und sah damit auf dem
+ Aufgabenbrett GAR NICHTS -- eigene Aufgaben eingeschlossen. Und
+ wie der Kommentar oben schon sagt: Ein leeres Brett sieht aus wie
+ "nichts zu tun", nicht wie ein Fehler. Der Rollen-Rundgang
+ (pruef-rollen) meldete fuer den Modi brav "aufgaben.html ok",
+ denn die Seite laedt ja, sie ist nur leer. Gefunden hat es erst
+ eine Pruefung, die eine Aufgabe ANLEGT und sie danach
+ wiederzufinden versucht.
+
+ WAS ER SIEHT: die Aufgaben des ganzen Modi-Teams. Filipes
+ Entscheidung vom 09.09.2026 -- "ja, sie sind untereinander ein
+ Team". Ohne das koennte niemand eine Schicht tauschen oder eine
+ Eskalation weiterreichen, ohne dass DogFather sie einzeln
+ durchstellt.
+
+ AENDERN DARF ER TROTZDEM NUR SEINE EIGENEN. Das entscheidet
+ darfAendern() weiter unten, und es bleibt dabei: Sehen, um sich
+ abzustimmen, ist etwas anderes als in fremder Arbeit schreiben.
+
+ WARUM DIE NUMMERN FRISCH GELESEN WERDEN und nicht einmal beim
+ Start: Kommt ein Modi dazu, muessen ihn die anderen sofort sehen.
+ Eine Liste, die beim Serverstart entsteht, waere bis zum naechsten
+ Neustart falsch -- und niemand wuesste, warum. */
+ case "modi": {
+ const modis = db().prepare(
+ "SELECT id FROM personen WHERE rolle = 'modi'").all().map((z) => z.id);
+ /* Kann nicht leer sein -- diese Person IST ein Modi. Die Abfrage
+ faende sonst `IN ()`, und das ist in SQLite ein Syntaxfehler. */
+ if (!modis.length) return { wo: "a.verantwortlich_id = ?", werte: [person.id] };
+ const platz = modis.map(() => "?").join(",");
+ return {
+ wo: `(a.creator_id IN (${platz}) OR a.verantwortlich_id IN (${platz})`
+ + ` OR a.erstellt_von IN (${platz}))`,
+ werte: [...modis, ...modis, ...modis],
+ };
+ }
default:
return { wo: "0=1", werte: [] }; // unbekannte Rolle sieht nichts
}
@@ -123,6 +164,13 @@ const SPALTEN = `
/* Die Vorlage, aus der sie entstanden ist (09.09.2026) -- damit das
Vorlagenbrett zeigen kann, was schon geholt wurde. */
a.vorlage,
+ /* Die Kategorie (10.09.2026, Kapitel 6.1 des Anforderungsdokuments).
+ Sie kommt fuer JEDE Aufgabe mit -- wer sie nicht benutzen darf,
+ hat ohnehin keine Aufgabe mit einem Wert darin. Sie hier je nach
+ Rolle wegzulassen waere eine zweite Sichtbarkeitsregel neben der,
+ die es schon gibt, und damit eine zweite Gelegenheit, sie falsch
+ zu ziehen. */
+ a.kategorie,
pa.name AS abbruch_von_name`;
const VERBUND = `
@@ -163,7 +211,15 @@ aufgabenRouter.get("/workspace/api/aufgaben", (req, res) => {
CASE WHEN a.frist IS NULL THEN 1 ELSE 0 END, a.frist,
CASE a.prioritaet WHEN 'hoch' THEN 0 WHEN 'mittel' THEN 1 ELSE 2 END,
a.id DESC`).all(...werte, heuteLokal());
- res.json({ aufgaben: reihen });
+ res.json({
+ aufgaben: reihen,
+ /* Die Kategorien kommen mit der Liste, nicht ueber einen eigenen
+ Aufruf: Das Brett braucht beides im selben Augenblick, und ein
+ zweiter Aufruf waere ein zweiter Weg, der einzeln scheitern
+ kann. Wer sie nicht benutzen darf, bekommt eine leere Liste --
+ und die Oberflaeche zeigt dann gar kein Feld. */
+ kategorien: kategorienFuer(req.person),
+ });
} catch (fehler) {
console.error("[workspace] Aufgaben lesen:", fehler?.message);
res.status(503).json({ fehler: "nicht_verfuegbar" });
@@ -278,7 +334,9 @@ aufgabenRouter.get("/workspace/api/personen", (req, res) => {
/* ---------- Anlegen und Ändern ------------------------------------------ */
-function pruefeFelder(körper, { neu }) {
+const KATEGORIEN = new Set(MODI_KATEGORIEN.map((k) => k.wert));
+
+function pruefeFelder(körper, { neu, person }) {
const fehler = [];
const aus = {};
@@ -316,6 +374,24 @@ function pruefeFelder(körper, { neu }) {
else aus[feld] = zahl;
}
+ /* DIE KATEGORIE (10.09.2026).
+
+ STILLSCHWEIGEND FALLEN GELASSEN, NICHT ABGELEHNT, wenn jemand sie
+ schickt, der sie nicht benutzen darf. Eine Fehlermeldung
+ ("Unbekannte Kategorie") waere die Auskunft, dass es das Feld gibt
+ -- und genau die soll niemand bekommen. Wer sie benutzen darf,
+ bekommt bei einem falschen Wert dagegen sehr wohl eine Absage:
+ Fuer ihn ist ein Tippfehler ein Fehler und kein Geheimnis.
+
+ Leer heisst ausdruecklich `null` und nicht "unveraendert" -- sonst
+ liesse sich eine einmal gesetzte Kategorie nie wieder entfernen. */
+ if (körper.kategorie !== undefined && kategorienFuer(person).length) {
+ const k = String(körper.kategorie ?? "").trim();
+ if (!k) aus.kategorie = null;
+ else if (!KATEGORIEN.has(k)) fehler.push("Unbekannte Kategorie.");
+ else aus.kategorie = k;
+ }
+
/* Frei eingetragene Namen statt einer Person -- eine Agentur, eine
Marke, jemand ohne Konto. Sie schlagen die Auswahl: Wer tippt,
meint das Getippte, und die Verknuepfung faellt weg. */
@@ -496,7 +572,7 @@ aufgabenRouter.delete("/workspace/api/notizen/:id", gleicheHerkunft, (req, res)
aufgabenRouter.post("/workspace/api/aufgaben", gleicheHerkunft, (req, res) => {
try {
- const { aus, fehler } = pruefeFelder(req.body || {}, { neu: true });
+ const { aus, fehler } = pruefeFelder(req.body || {}, { neu: true, person: req.person });
if (fehler.length) return res.status(400).json({ fehler: fehler.join(" ") });
/* Wer nicht Management ist, darf ausschließlich für sich selbst
@@ -515,12 +591,12 @@ aufgabenRouter.post("/workspace/api/aufgaben", gleicheHerkunft, (req, res) => {
const { lastInsertRowid } = db().prepare(`
INSERT INTO aufgaben
(titel, beschreibung, status, prioritaet, creator_id, verantwortlich_id,
- creator_extern, verantwortlich_extern, frist, erstellt, erstellt_von)
- VALUES (?,?,?,?,?,?,?,?,?,?,?)`).run(
+ creator_extern, verantwortlich_extern, frist, kategorie, erstellt, erstellt_von)
+ VALUES (?,?,?,?,?,?,?,?,?,?,?,?)`).run(
aus.titel, aus.beschreibung ?? null, aus.status ?? "offen",
aus.prioritaet ?? "mittel", aus.creator_id ?? null, aus.verantwortlich_id ?? null,
aus.creator_extern ?? null, aus.verantwortlich_extern ?? null,
- aus.frist ?? null, jetzt(), req.person.id);
+ aus.frist ?? null, aus.kategorie ?? null, jetzt(), req.person.id);
protokolliere("aufgabe_angelegt", {
personId: req.person.id, rolle: req.person.rolle, ip: echteIp(req),
@@ -547,7 +623,7 @@ aufgabenRouter.patch("/workspace/api/aufgaben/:id", gleicheHerkunft, (req, res)
if (!aufgabe) return res.status(404).json({ fehler: "nicht_gefunden" });
if (!darfAendern(req.person, aufgabe)) return res.status(403).json({ fehler: "nicht_erlaubt" });
- const { aus, fehler } = pruefeFelder(req.body || {}, { neu: false });
+ const { aus, fehler } = pruefeFelder(req.body || {}, { neu: false, person: req.person });
if (fehler.length) return res.status(400).json({ fehler: fehler.join(" ") });
/* Zuordnungen darf nur das Management verschieben. */
diff --git a/server/workspace-zentrale.js b/server/workspace-zentrale.js
index 1cd97e7d..07f799be 100644
--- a/server/workspace-zentrale.js
+++ b/server/workspace-zentrale.js
@@ -247,7 +247,23 @@ zentraleRouter.get("/workspace/api/zentrale", (req, res) => {
zahlen: [
{ wert: meine.length, schild: "heute" },
{ wert: [...mitTermin].filter((s) => s >= stundeJetzt).length, schild: "kommt noch" },
- { wert: betreuteIds(zeigt).length, schild: "betreut" },
+ /* "BETREUT" IST FUER EINEN MODI IMMER NULL (10.09.2026).
+
+ Er betreut keine Creator -- das ist die Arbeit der Scouts und
+ Manager. Auf seiner Startseite stand deshalb dauerhaft "0
+ betreut": eine Zahl, die nie etwas anderes sagen kann, und
+ damit schlimmer als keine. Wer sie sieht, sucht nach dem
+ Fehler.
+
+ Stattdessen zaehlt sie, wie viele im Modi-Team sind. Das ist
+ die Zahl, die an dieser Stelle etwas bedeutet -- und sie
+ bekommt nur zu sehen, wer ohnehin weiss, dass es die Runde
+ gibt. */
+ zeigt.rolle === "modi"
+ ? { wert: db().prepare(
+ "SELECT COUNT(*) AS n FROM personen WHERE rolle = 'modi' AND aktiv = 1").get().n,
+ schild: "im Team" }
+ : { wert: betreuteIds(zeigt).length, schild: "betreut" },
],
});
} catch (fehler) {
diff --git a/server/workspace.js b/server/workspace.js
index 2492d224..fafcddcd 100644
--- a/server/workspace.js
+++ b/server/workspace.js
@@ -186,6 +186,173 @@ export const ROLLEN_NAME = {
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: "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 Profil", unter: "Deine Angaben und dein Bild", zeichen: "profil",
+ ton: 20, ziel: "profil.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;
+}
+
+/* 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)
+
+ Kapitel 6.1 des Anforderungsdokuments nennt sechs Beispiele, der
+ Aufgabenkatalog in Teil 2 benutzt dreizehn. Dreizehn Knoepfe in einer
+ Filterleiste sind auf einem Handy unbedienbar -- deshalb diese acht,
+ und hier steht, wohin die uebrigen fallen:
+
+ Branding, Team, Kommunikation -> Planung & Organisation
+ Wachstum -> Community & Events
+
+ Das ist eine Entscheidung und keine Uebersetzung. Wer sie spaeter
+ anders will, aendert sie hier, an einer Stelle.
+
+ SIE STEHEN AUF DEM SERVER, nicht in assets/js/aufgaben.js. Nicht weil
+ die Woerter etwas verraten wuerden -- "Clipping" sagt nichts ueber
+ Modis --, 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: "clipping", name: "Clipping & Schnitt" },
+ { wert: "social", name: "Social Media" },
+ { wert: "community", name: "Community & Events" },
+ { wert: "technik", name: "Technik" },
+ { wert: "planung", name: "Planung & Organisation" },
+ { wert: "analyse", name: "Analyse & Zahlen" },
+ { 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 : [];
+}
+
+export function rollentextFuer(person) {
+ return person?.rolle === "modi" ? MODI_ROLLENTEXT : null;
+}
+
+export function markeFuer(person) {
+ return person?.rolle === "modi" ? MODI_MARKE : null;
+}
+
/* 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
@@ -614,6 +781,20 @@ function umstellungen(d) {
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"],
]) {
try {
const vorhanden = d.prepare(`PRAGMA table_info(${tabelle})`).all().map((s) => s.name);
@@ -2439,6 +2620,20 @@ workspaceRouter.get("/workspace/api/ich", (req, res) => {
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),
});
});
diff --git a/workspace/app.webmanifest b/workspace/app.webmanifest
index a40f1d1c..f9928569 100644
--- a/workspace/app.webmanifest
+++ b/workspace/app.webmanifest
@@ -13,25 +13,25 @@
"theme_color": "#06090f",
"icons": [
{
- "src": "/assets/img/app-symbole/workspace-192.png?v=202609100010",
+ "src": "/assets/img/app-symbole/workspace-192.png?v=202609100051",
"sizes": "192x192",
"type": "image/png",
"purpose": "any"
},
{
- "src": "/assets/img/app-symbole/workspace-512.png?v=202609100010",
+ "src": "/assets/img/app-symbole/workspace-512.png?v=202609100051",
"sizes": "512x512",
"type": "image/png",
"purpose": "any"
},
{
- "src": "/assets/img/app-symbole/workspace-192-maskable.png?v=202609100010",
+ "src": "/assets/img/app-symbole/workspace-192-maskable.png?v=202609100051",
"sizes": "192x192",
"type": "image/png",
"purpose": "maskable"
},
{
- "src": "/assets/img/app-symbole/workspace-512-maskable.png?v=202609100010",
+ "src": "/assets/img/app-symbole/workspace-512-maskable.png?v=202609100051",
"sizes": "512x512",
"type": "image/png",
"purpose": "maskable"
diff --git a/workspace/assets/js/aufgaben.js b/workspace/assets/js/aufgaben.js
index f8407c0e..19c14578 100644
--- a/workspace/assets/js/aufgaben.js
+++ b/workspace/assets/js/aufgaben.js
@@ -200,6 +200,23 @@
(faellig ? 'überfällig ' : 'bis ') + datumHuebsch(a.frist));
zeile.append(f);
}
+ /* Die Kategorie als Wort in derselben Zeile wie Frist und Person.
+ Kein eigener Streifen und keine eigene Farbe: Sie ist eine
+ Einordnung, keine Warnung -- und eine Karte, auf der alles
+ hervorsticht, hebt nichts hervor. Der Name kommt aus der Liste
+ des Servers; steht die Aufgabe in einer Kategorie, die es nicht
+ mehr gibt, wird nichts angezeigt statt eines Schluessels.
+
+ KEINE EIGENE CSS-KLASSE. Sie muesste in sieben gleichlautenden
+ Kopien der Modulliste gepflegt werden (pruef-css-klassen wacht
+ darueber, dass die sieben Zeichen fuer Zeichen gleich sind) --
+ viel Aufwand und sieben Gelegenheiten fuer einen Unterschied,
+ fuer eine Zeile Text. Dieselbe schlichte Form wie "· Bereich X"
+ daneben tut es. */
+ if (a.kategorie) {
+ const name = kategorien.find((k) => k.wert === a.kategorie)?.name;
+ if (name) zeile.append(el('span', null, '· ' + name));
+ }
if (a.verantwortlich_name) zeile.append(el('span', null, '· ' + a.verantwortlich_name));
if (LEITUNG.has(ich.rolle) && a.creator_name && a.creator_name !== a.verantwortlich_name) {
zeile.append(el('span', null, '· Bereich ' + a.creator_name));
@@ -668,11 +685,71 @@
window.zielMarkieren?.(treffer, ZEIG_TEXT[was]);
}
+ /* =====================================================================
+ DIE KATEGORIE EINER AUFGABE (10.09.2026, Kapitel 6.1)
+
+ Filipes Entscheidung: nur bei den Modis. Fuer Creator, Scouts und
+ Manager bleibt das Formular unveraendert -- sie bekommen das Feld
+ nicht ausgeblendet, sondern gar nicht: Der Server schickt ihnen
+ keine Kategorien, und ohne Kategorien wird nichts gebaut.
+
+ WARUM DIE NAMEN NICHT HIER STEHEN: Diese Datei bekommt jeder
+ ausgeliefert, der die Seite oeffnet. Eine Liste, die im Browser
+ jedes Menschen liegt und nur bei einem einzigen benutzt wird, wirft
+ die Frage auf, fuer wen sie da ist. Genau die soll niemand stellen.
+ ===================================================================== */
+ let kategorien = [];
+ /* Wer ein Modi ist -- gefuellt aus der Personenliste, die ohnehin
+ geladen wird. Sie enthaelt Modis nur fuer den, der sie sehen darf;
+ fuer alle anderen bleibt diese Menge leer. */
+ const modis = new Set();
+
+ function kategorienEinsetzen() {
+ if (!kategorien.length) return;
+ for (const ziel of ['f-kategorie', 'b-kategorie']) {
+ const feld = $(ziel);
+ if (!feld || feld.dataset.fertig) continue;
+ feld.dataset.fertig = 'ja';
+ for (const k of kategorien) {
+ const o = el('option', null, k.name);
+ o.value = k.wert;
+ feld.append(o);
+ }
+ }
+ }
+
+ /* Gehoert diese Aufgabe zu einem Modi?
+
+ Fuer einen Modi immer (es sind seine eigenen). Fuer die
+ DogFather-Rolle nur dann, wenn die gewaehlte Person eine ist --
+ sonst stuende das Feld auch an jeder Creator-Aufgabe, und genau das
+ wollte Filipe nicht ("nur bei den Modis"). */
+ function gehoertModi(creatorId, verantId) {
+ if (ich?.rolle === 'modi') return true;
+ return modis.has(Number(creatorId)) || modis.has(Number(verantId));
+ }
+
+ function kategorieFeldZeigen(praefix) {
+ const huelle = $(praefix === 'f' ? 'feld-kategorie' : 'b-feld-kategorie');
+ if (!huelle) return;
+ const zeigen = kategorien.length
+ && gehoertModi($(`${praefix}-creator`)?.value, $(`${praefix}-verant`)?.value);
+ huelle.hidden = !zeigen;
+ /* Verborgen heisst auch: nichts mitschicken. Ein Wert, der in einem
+ unsichtbaren Feld stehenbleibt, wandert sonst beim naechsten
+ Speichern mit -- und niemand sieht, woher er kommt. */
+ if (!zeigen && $(`${praefix}-kategorie`)) $(`${praefix}-kategorie`).value = '';
+ }
+
async function laden() {
try {
const antwort = await hole('/workspace/api/aufgaben');
if (!antwort.ok) { melde('Aufgaben konnten nicht geladen werden.'); return; }
- alleAufgaben = (await antwort.json()).aufgaben || [];
+ const daten = await antwort.json();
+ alleAufgaben = daten.aufgaben || [];
+ kategorien = daten.kategorien || [];
+ kategorienEinsetzen();
+ kategorieFeldZeigen('f');
lageZeigen(alleAufgaben);
neuZeichnen();
/* Erst NACH dem Zeichnen -- vorher gibt es die Karten nicht, und
@@ -713,6 +790,7 @@
daten.creator_extern = $('f-creator').dataset.extern || null;
daten.verantwortlich_extern = $('f-verant').dataset.extern || null;
}
+ if (!$('feld-kategorie')?.hidden) daten.kategorie = $('f-kategorie').value || '';
knopfEl.disabled = true;
try {
@@ -749,6 +827,8 @@
$('b-verant').value = a.verantwortlich_id ? String(a.verantwortlich_id) : '';
$('b-loeschen').hidden = false;
}
+ kategorieFeldZeigen('b');
+ if ($('b-kategorie')) $('b-kategorie').value = a.kategorie || '';
dialog.showModal();
$('b-titel').focus();
}
@@ -774,6 +854,7 @@
daten.creator_extern = $('b-creator').dataset.extern || null;
daten.verantwortlich_extern = $('b-verant').dataset.extern || null;
}
+ if (!$('b-feld-kategorie')?.hidden) daten.kategorie = $('b-kategorie').value || '';
$('b-speichern').disabled = true;
try {
const a = await hole(`/workspace/api/aufgaben/${bearbeitet.id}`, {
@@ -824,6 +905,11 @@ Das laesst sich nicht rueckgaengig machen. Erledigte Aufgaben sollten normalerwe
try {
const { personen } = await (await hole('/workspace/api/personen')).json();
for (const p of personen) {
+ /* Wer ein Modi ist, entscheidet spaeter, ob das Kategorie-Feld
+ ueberhaupt erscheint. Die Liste enthaelt Modis nur fuer den,
+ der sie sehen darf -- fuer alle anderen bleibt die Menge
+ leer, und das Feld bleibt weg. */
+ if (p.rolle === 'modi') modis.add(Number(p.id));
for (const ziel of ['f-creator', 'f-verant', 'b-creator', 'b-verant']) {
const o = el('option', null, `${p.name} (${p.rolle})`);
o.value = String(p.id);
@@ -849,6 +935,15 @@ Das laesst sich nicht rueckgaengig machen. Erledigte Aufgaben sollten normalerwe
window.personenwahl?.(ziel, { hinweis: "Kein Konto im Workspace – der Name steht nur an diesem Eintrag."
+ ' Die Aufgabe steht dann auf keinem persönlichen Aufgabenbrett.' });
}
+ /* Das Kategorie-Feld folgt der gewaehlten Person. Auf `change` und
+ nicht einmalig beim Oeffnen: Wer erst den Titel tippt und dann
+ die Person aussucht, bekaeme das Feld sonst nie zu sehen. */
+ for (const ziel of ['f-creator', 'f-verant']) {
+ $(ziel)?.addEventListener('change', () => kategorieFeldZeigen('f'));
+ }
+ for (const ziel of ['b-creator', 'b-verant']) {
+ $(ziel)?.addEventListener('change', () => kategorieFeldZeigen('b'));
+ }
}
await laden();
@@ -915,6 +1010,23 @@ Das laesst sich nicht rueckgaengig machen. Erledigte Aufgaben sollten normalerwe
async function vorlagenbrettZeigen() {
const ziel = $('vorlagenbrett');
if (!ziel || !ich) return;
+ /* NICHT FUER EINEN MODI (10.09.2026).
+
+ Das Vorlagenbrett haelt 80 fertige Aufgaben in vier Bereichen und
+ vier Stufen bereit -- den Begleitplan fuer einen CREATOR, von
+ "Ton aufnehmen und anhoeren" bis "Monatsbericht mit drei festen
+ Zahlen". Fuer einen Modi ist davon nichts gedacht: Er moderiert,
+ er baut keinen Kanal auf.
+
+ Aufgefallen ist das auf dem Bildschirmfoto der Browserpruefung,
+ nicht im Code -- die Kachel laedt fehlerfrei, sie zeigt nur das
+ Falsche. Ein Vorschlag, der nicht passt, ist schlimmer als
+ keiner: Man raeumt ihn nicht weg, man arbeitet um ihn herum.
+
+ SEIN Katalog steht in Teil 2 des Anforderungsdokuments (die 60+
+ Aufgaben aus Phase 1 bis 6). Bis der da ist, steht hier nichts --
+ das ist ehrlicher als etwas Fremdes. */
+ if (ich.rolle === 'modi') { ziel.hidden = true; return; }
if (!vorlagen) {
try {
const a = await hole('/workspace/api/vorlagen');
diff --git a/workspace/assets/js/start.js b/workspace/assets/js/start.js
index acf9b636..dac122ab 100644
--- a/workspace/assets/js/start.js
+++ b/workspace/assets/js/start.js
@@ -133,13 +133,52 @@
zahlen.set(kachel, bisher);
}
+ /* =================================================================
+ WELCHE KACHELN DIESE PERSON SIEHT (10.09.2026)
+
+ ZWEI QUELLEN, und die Reihenfolge ist die Aussage:
+
+ 1. Schickt der Server eine Liste, gilt sie. Das ist der Weg fuer
+ Rollen, die in bereiche.js gar nicht vorkommen duerfen --
+ diese Datei bekommt jeder ausgeliefert, der die Seite
+ oeffnet, und ein Rollenname darin waere mit einem Blick in
+ den Quelltext gefunden.
+ 2. Sonst wie bisher: die Rollenliste an der Kachel selbst.
+
+ Nach der ANGESEHENEN Rolle, nicht der eigenen -- sonst stuenden
+ bei der Sicht auf einen Creator weiterhin "Personen & Zugaenge"
+ und "Automationen" da, die er gar nicht hat. Genau daran war zu
+ sehen, dass die Sicht nicht greift. Der Server richtet sich beim
+ Bauen seiner Liste nach derselben Person.
+
+ `null` heisst "nimm deine eigene Liste", eine LEERE Liste hiesse
+ "keine Kacheln". Die beiden auseinanderzuhalten ist kein
+ Feinschliff: Verwechselte man sie, haetten die fuenf bekannten
+ Rollen ab sofort eine leere Startseite. */
+ function eigeneGruppen(wer) {
+ const vomServer = ich && ich.bereiche;
+ if (Array.isArray(vomServer)) {
+ /* Nach Gruppen buendeln, in der Reihenfolge, in der sie kommen
+ -- eine Map behaelt sie, ein Objekt sortierte Zahlenschluessel
+ um. Hier sind es Woerter, aber darauf zu bauen waere die Art
+ Annahme, die spaeter jemanden Zeit kostet. */
+ const nach = new Map();
+ for (const b of vomServer) {
+ if (!nach.has(b.gruppe)) {
+ nach.set(b.gruppe, { name: b.gruppe, unter: b.gruppeUnter || '', bereiche: [] });
+ }
+ nach.get(b.gruppe).bereiche.push(b);
+ }
+ return [...nach.values()];
+ }
+ return GRUPPEN
+ .map((g) => ({ ...g, bereiche: g.bereiche.filter((b) => b.rollen.includes(wer.rolle)) }))
+ .filter((g) => g.bereiche.length);
+ }
+
let laufNr = 0;
- for (const gruppe of GRUPPEN) {
- /* Nach der ANGESEHENEN Rolle -- sonst stuenden bei der Sicht auf
- einen Creator weiterhin "Personen & Zugaenge" und "Automationen"
- da, die er gar nicht hat. Genau daran war zu sehen, dass die
- Sicht nicht greift. */
- const meine = gruppe.bereiche.filter((b) => b.rollen.includes(zeigt.rolle));
+ for (const gruppe of eigeneGruppen(zeigt)) {
+ const meine = gruppe.bereiche;
if (!meine.length) continue;
const abschnitt = el('section', 'gruppe');
@@ -681,9 +720,34 @@
Liste (mit dem erklaerenden Zusatz dahinter), dann der Name vom
Server, zuletzt der nackte Schluessel. So aendert sich fuer die
fuenf bekannten Rollen nichts. */
+ /* DIE ZIERZEILE UEBER DEM TITEL (10.09.2026).
+
+ Sie nennt den Betrieb -- fest "Spicy Media" in start.html. Fuer
+ einen Modi stimmt das nicht, er gehoert zu Team Dogi; der Server
+ schickt dann den passenden Namen mit.
+
+ ERSETZT WIRD NUR DER TEXTKNOTEN, nicht der ganze Inhalt: In dem
+ Element sitzen zwei als Zierrauten links und
+ rechts. Ein textContent darauf haette sie geloescht -- und zwar
+ lautlos, denn eine fehlende Raute sieht nicht nach Fehler aus,
+ sondern nach Absicht. Ein neues Element drumherum waere die
+ andere naheliegende Loesung und aendert die Flex-Aufteilung; das
+ hier aendert am Aufbau gar nichts. */
+ if (ich.marke) {
+ const marke = document.querySelector('.zmarke');
+ if (marke) {
+ for (const knoten of marke.childNodes) {
+ if (knoten.nodeType === 3 && knoten.textContent.trim()) {
+ knoten.textContent = ` ${ich.marke} `;
+ }
+ }
+ }
+ }
+
$('rollentext').textContent = ich.sicht
- ? `Arbeitsplatz von ${ich.sicht.name} · ${ROLLENTEXT[zeigt.rolle] || zeigt.rolle}`
- : (ROLLENTEXT[ich.rolle] || ich.rolle_name || ich.rolle);
+ ? `Arbeitsplatz von ${ich.sicht.name} · `
+ + `${ROLLENTEXT[zeigt.rolle] || ich.sicht.rolle_name || zeigt.rolle}`
+ : (ROLLENTEXT[ich.rolle] || ich.rolle_text || ich.rolle_name || ich.rolle);
/* Datum und Rolle in der Anrede -- wie im Vorbild, wo unter "Noch
wach, Dogi" das Datum steht und daneben "ADMIN & PARTNER".
diff --git a/workspace/aufgaben.html b/workspace/aufgaben.html
index 6f49a90e..d2dc01a4 100644
--- a/workspace/aufgaben.html
+++ b/workspace/aufgaben.html
@@ -12,12 +12,12 @@
Ton wie die Kopfleiste darunter (#06090f), damit Leiste und Seite
eine Flaeche sind statt zweier. -->
-
-
-
-
-
-
+
+
+
+
+
+
@@ -97,6 +97,16 @@
+
+