diff --git a/server/anleitung-tabellen.js b/server/anleitung-tabellen.js new file mode 100644 index 00000000..a86c3650 --- /dev/null +++ b/server/anleitung-tabellen.js @@ -0,0 +1,376 @@ +/* ===================================================================== + DIE ANLEITUNG — Tabellen und Inhalte + + Bauplan: "Kachel Anleitung — Bauplan fuer die Einweisung neuer + Leute", Stand 06.10.2026. Geprueft und ergaenzt in + `02 Projekte/Anleitung-Kachel – Bauplan.md` (Obsidian). + + --------------------------------------------------------------------- + WAS HIER *NICHT* STEHT, UND WARUM DAS DER KERN IST + + Eine Karte der Anleitung zeigt Symbol, Farbe, Name und Ziel der + echten Kachel. Nichts davon steht in dieser Datenbank. + + Es steht in `workspace/assets/js/bereiche.js`, und zwar genau + einmal. Wuerde man es hier noch einmal hinschreiben, haette man zwei + Listen: Die eine wird gepflegt, wenn eine Kachel umbenannt wird, die + andere nicht — und ab da erklaert die Anleitung eine Kachel, die es + so nicht mehr gibt. Das ist derselbe Fehler, der am 11.09.2026 beim + Spaltenumbau drei Spalten gekostet hat: eine abgeschriebene Liste. + + Gespeichert wird deshalb nur, was der Bauplan NEU hinzufuegt: + die Stufe und die drei Saetze (Wozu / Was du hier tust / Merke). + Verbunden wird ueber den Kachel-Schluessel. + + --------------------------------------------------------------------- + DER KACHEL-SCHLUESSEL wird aus dem Ziel ABGELEITET, nicht vergeben: + + uebersicht.html -> uebersicht + bereich.html?b=live -> bereich:live + + Das muss sein, weil zwei Faelle sonst zusammenfallen: + + * `bereich.html` ist FUENF Kacheln (live, community, technik, + schutz, agentur). Ueber die Seite allein waeren sie eine. + * `profil.html` heisst beim Creator "Mein Profil" und beim Manager + "Creator-Profile" — zwei Karten, eine Adresse. Hier trennt sie + die Rolle im Schluessel (haus, rolle, kachel). + + --------------------------------------------------------------------- + DIE HAUS-SPALTE ist leer gelaufene Vorsorge und soll es bleiben. + + Entscheidung Filipe, 06.10.2026: "Jetzt nur Agentur, aber + vorbereitet." Team Dogi bekommt heute keine Anleitung. Die Spalte + kostet jetzt ein Feld; sie spaeter nachzuruesten hiesse, jede Zeile + und jeden Schluessel anzufassen. + ===================================================================== */ + +/** Die drei Stufen. Reihenfolge ist die Anzeigereihenfolge. */ +export const STUFEN = [ + { schluessel: "muss", name: "Muss", + titel: "Das brauchst du sofort", + unter: "Ohne das laeuft der Alltag nicht.", + offen: true }, + { schluessel: "regelmaessig", name: "Regelmäßig", + titel: "Das kommt regelmäßig", + unter: "Woechentlich, monatlich oder bei jedem neuen Creator.", + offen: false }, + { schluessel: "bedarf", name: "Bei Bedarf", + titel: "Das brauchst du nur bei Bedarf", + unter: "Nur oeffnen, wenn es einen Anlass gibt.", + offen: false }, +]; + +export const STUFEN_SCHLUESSEL = STUFEN.map((s) => s.schluessel); + +/** Die Rollen, die eine eigene Fassung bekommen. + * + * OHNE 'admin'. DogFather bekommt keine Einweisung (Bauplan, + * Kapitel 11) — bei ihm oeffnet dieselbe Adresse ab Etappe 4 die + * Pflege-Ansicht. */ +export const FASSUNGEN = ["creator", "scout", "manager", "spicy"]; + +/** Wer die Texte aendern darf. + * + * DogFather UND Spicy — Entscheidung Filipe am 06.10.2026, und zwar + * ausdruecklich dieselbe Regel wie bei den Manager-Zielen am + * 02.10.2026 ("dogfather und spicy rolle sollen gleichzeitig es immer + * aendern koennen fuer alle"). Der Bauplan hatte hier "nur DogFather" + * stehen; zwei verschiedene Regeln fuer dieselbe Frage sind eine + * Regel, die man sich nicht merken kann. */ +export const PFLEGE_ROLLEN = ["admin", "spicy"]; + +/** Die Arten von Listenzeilen in `anl_zeile`. + * + * EINE TABELLE STATT FUENF. Satz, Schritt, Nicht-deins, Takt und Frage + * sind alle dasselbe: eine geordnete Zeile mit hoechstens zwei Texten + * und manchmal einem Ziel. Fuenf Tabellen waeren fuenfmal dieselbe + * Spaltenliste, fuenf Indizes und fuenf Stellen, an denen die + * Haus-Spalte vergessen werden kann. */ +export const ZEILEN_ARTEN = { + /* "In 60 Sekunden" — drei Saetze. Nur `text`. */ + satz: { titel: false, ziel: false }, + /* "Erste Schritte" — `titel` ist die Kachel, `ziel` der Sprung. */ + schritt: { titel: true, ziel: true }, + /* "Das kannst du liegen lassen" — `titel` die Taetigkeit, + `text` wer sie erledigt. */ + nicht: { titel: true, ziel: false }, + /* "Dein Rhythmus" — `titel` ist das Wann, `text` das Was. */ + takt: { titel: true, ziel: false }, + /* "Haeufige Fragen" — `titel` die Frage, `text` die Antwort. */ + frage: { titel: true, ziel: false }, +}; + +export const ARTEN_SCHLUESSEL = Object.keys(ZEILEN_ARTEN); + +/* ===================================================================== + WO DIE UMRECHNUNG "Ziel -> Schluessel" STEHT + + NICHT hier. Der Server bildet nie einen Schluessel: Die Schluessel + stehen woertlich in INHALTE weiter unten, er schreibt sie nur weg. + Gebraucht wird die Rechnung ausschliesslich im Browser, wo die + Kachelliste liegt — also steht sie dort, als + `window.Bereiche.schluesselVonZiel`. + + Ich hatte sie zuerst hier gebaut und exportiert. Benutzt haette sie + niemand. Eine Funktion, die nur dasteht, liest sich beim naechsten + Mal wie eine Zusage ("der Server kennt die Kacheln") und ist keine. + Wenn die Pflege-Ansicht in Etappe 4 Schreibzugriffe pruefen muss, + kommt sie zurueck — dann aber mit einem Aufrufer. + ===================================================================== */ + +const TABELLEN = ` +CREATE TABLE IF NOT EXISTS anl_karte ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + haus TEXT NOT NULL DEFAULT 'agentur', + rolle TEXT NOT NULL, + kachel TEXT NOT NULL, + stufe TEXT NOT NULL, + wozu TEXT NOT NULL DEFAULT '', + tust TEXT NOT NULL DEFAULT '', + merke TEXT NOT NULL DEFAULT '', + reihe INTEGER NOT NULL DEFAULT 0, + geaendert TEXT NOT NULL, + geaendert_von INTEGER REFERENCES personen(id) ON DELETE SET NULL, + UNIQUE (haus, rolle, kachel) +); +CREATE INDEX IF NOT EXISTS idx_anl_karte_sicht ON anl_karte (haus, rolle, stufe, reihe); + +CREATE TABLE IF NOT EXISTS anl_zeile ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + haus TEXT NOT NULL DEFAULT 'agentur', + rolle TEXT NOT NULL, + art TEXT NOT NULL, + reihe INTEGER NOT NULL DEFAULT 0, + titel TEXT NOT NULL DEFAULT '', + text TEXT NOT NULL DEFAULT '', + ziel TEXT, + geaendert TEXT NOT NULL, + geaendert_von INTEGER REFERENCES personen(id) ON DELETE SET NULL +); +CREATE INDEX IF NOT EXISTS idx_anl_zeile_sicht ON anl_zeile (haus, rolle, art, reihe); + +CREATE TABLE IF NOT EXISTS anl_rolle ( + haus TEXT NOT NULL DEFAULT 'agentur', + rolle TEXT NOT NULL, + leitgedanke TEXT NOT NULL DEFAULT '', + geaendert TEXT NOT NULL, + geaendert_von INTEGER REFERENCES personen(id) ON DELETE SET NULL, + PRIMARY KEY (haus, rolle) +); +`; + +/* ===================================================================== + DIE INHALTE + + Woertlich aus dem Bauplan vom 06.10.2026, Kapitel 06. Gewonnen mit + `pdftotext -raw`, NICHT mit `-layout`: Der Layout-Auszug verschiebt + Tabellenzellen. Dort stand "Schulung und Community-Call laufen + woechentlich von allein" beim Eintrag *Mein Profil* statt bei + *Kalender* — eine Zeile, die man ohne Verdacht uebernommen und nie + wiedergefunden haette. + + Die Stufen stammen aus Kapitel 05. Sie sind ab Etappe 4 in der + Pflege-Ansicht aenderbar, ohne dass hier jemand etwas anfasst — + deshalb ist diese Liste eine ERSTBEFUELLUNG und keine Wahrheit. + ===================================================================== */ + +export const INHALTE = { + creator: { + leitgedanke: + "Das ist dein eigener Bereich. Du siehst nur, was zu dir gehört. " + + "Deine Betreuung trägt ein, du liest mit, antwortest und hakst ab.", + saetze: [ + "Du musst nicht alles kennen. Fünf Kacheln reichen für den Anfang.", + "Was heute dran ist, steht immer auf der Zentrale unter „Was ist dran“.", + "Fast alles andere füllt deine Betreuung für dich. Du liest und antwortest.", + ], + schritte: [ + { titel: "Mein Profil", ziel: "profil.html", + text: "Bild wählen, einen Satz über dich schreiben, TikTok-Namen eintragen, speichern." }, + { titel: "Schutz & Regeln", ziel: "bereich.html?b=schutz", + text: "Unterweisungen lesen und zusammen mit deiner Betreuung bestätigen." }, + { titel: "Aufgaben", ziel: "aufgaben.html", + text: "Nachsehen, ob schon etwas für dich offen ist." }, + { titel: "Kalender", ziel: "kalender.html", + text: "Termine in deinen eigenen Kalender holen (Google, Apple, Outlook)." }, + /* KEIN ZIEL. "Installieren" ist keine Kachel, sondern der Knopf + in der Kopfleiste. Ein Sprung auf eine Seite waere hier falsch + -- und ein Sprung ins Leere erst recht. */ + { titel: "Installieren", ziel: null, + text: "Oben antippen, dann hast du den Workspace als App auf dem Handy." }, + ], + karten: [ + { kachel: "aufgaben", stufe: "muss", + wozu: "Was du zu tun hast.", + tust: "Annehmen, bearbeiten, zur Freigabe geben, Rückmeldung schreiben.", + merke: "„Zur Freigabe“ heißt: Jemand schaut noch drauf." }, + { kachel: "chat", stufe: "muss", + wozu: "Nachrichten mit deinem Team.", + tust: "Mit „Neu“ ein Gespräch beginnen.", + merke: "Fehler an der Seite gehören in Support, nicht in den Chat." }, + { kachel: "kalender", stufe: "muss", + wozu: "Termine und Calls.", + tust: "Termine ansehen, eigene anlegen.", + merke: "Schulung und Community-Call laufen wöchentlich von allein." }, + { kachel: "profil", stufe: "muss", + wozu: "Wer du bist und wohin du willst.", + tust: "Einmal ausfüllen: Bild, Kanäle, Stammdaten, Ziele, 90-Tage-Plan.", + merke: "Nur du schreibst deinen Eintrag." }, + { kachel: "bereich:schutz", stufe: "muss", + wozu: "Regeln, die du kennen musst.", + tust: "Einmal: Dokument öffnen, durchgehen, bestätigen.", + merke: "Nach der Bestätigung von beiden Seiten nicht mehr änderbar." }, + + { kachel: "bereich:live", stufe: "regelmaessig", + wozu: "Dein LIVE besser machen.", + tust: "Vor und nach dem LIVE: Bewertungen und Notizen lesen, an jedem Punkt antworten.", + merke: "Geordnet nach vor, während, nach der Sendung." }, + { kachel: "leistung", stufe: "regelmaessig", + wozu: "Diamanten, LIVE-Tage, Verweildauer.", + tust: "Wochenzahlen und Ziele ansehen.", + merke: "Kommen aus Backstage und werden eingetragen, nicht automatisch geholt." }, + { kachel: "calls", stufe: "regelmaessig", + wozu: "Gespräche mit To-dos.", + tust: "Nachlesen, was besprochen wurde. Der Call-Raum ist verlinkt.", + merke: "To-dos landen direkt bei deinen Aufgaben." }, + { kachel: "bereich:agentur", stufe: "regelmaessig", + wozu: "Events und offizielle Wege.", + tust: "Event-Aufgaben ansehen, Rückmeldung geben, Anliegen an die Agentur stellen.", + merke: "Teilnahmebedingungen stehen beim Event." }, + { kachel: "content", stufe: "regelmaessig", + wozu: "Von der Idee bis zum Upload.", + tust: "Sehen, was geplant ist, und antworten.", + merke: "Fünf Stufen: Idee, Hook & Skript, Gedreht, Fertig & geplant, Veröffentlicht." }, + { kachel: "report", stufe: "regelmaessig", + wozu: "Rückblick und nächste Schritte.", + tust: "Einmal im Monat: Zeitraum wählen (7, 30 oder 90 Tage).", + merke: "Ein Review endet mit einer Entscheidung." }, + + { kachel: "uebersicht", stufe: "bedarf", + wozu: "Dein Stand auf einen Blick.", + tust: "Nachsehen: nächster Termin, offene Aufgaben, Start-Check.", + merke: "Was dran ist, zeigt dir schon die Zentrale." }, + { kachel: "startcheck", stufe: "bedarf", + wozu: "Erstanalyse deines Kanals.", + tust: "Ansehen, was passt und was nicht, in 16 Punkten.", + merke: "Grundlage für deinen 90-Tage-Plan." }, + { kachel: "bereich:community", stufe: "bedarf", + wozu: "Moderation und Konflikte.", + tust: "Nachlesen, wenn es im Chat unruhig wird.", + merke: "Ruhig statt Drama." }, + { kachel: "bereich:technik", stufe: "bedarf", + wozu: "Setup, Fehler, Lösungen.", + tust: "Nachschlagen, wenn etwas klemmt.", + merke: "Erst hier schauen, dann fragen." }, + { kachel: "dateien", stufe: "bedarf", + wozu: "Ablage für dich.", + tust: "Dateien öffnen, die für dich abgelegt wurden.", + merke: "Du kannst hier nichts hochladen." }, + { kachel: "wissen", stufe: "bedarf", + wozu: "Anleitungen zu TikTok und Technik.", + tust: "Nach Stufe, Gerät oder Tag filtern.", + merke: "Hier geht es um Streaming, nicht um den Workspace." }, + { kachel: "support", stufe: "bedarf", + wozu: "Etwas ist kaputt.", + tust: "Kurz beschreiben, bis zu drei Bilder anhängen, abschicken.", + merke: "DogFather und die rechte Hand kümmern sich." }, + ], + nicht: [ + { titel: "Zahlen eintragen.", text: "Das macht deine Betreuung." }, + { titel: "Start-Check und LIVE-Analyse bewerten.", text: "Du liest und antwortest nur." }, + { titel: "Protokolle schreiben.", text: "Du liest sie nach." }, + { titel: "Dateien hochladen.", text: "Geht in deiner Ansicht nicht." }, + { titel: "Jede Kachel täglich öffnen.", text: "Die Zentrale sagt dir, was dran ist." }, + ], + takt: [ + { titel: "Täglich", text: "Zentrale („Was ist dran“), Aufgaben, Chat." }, + { titel: "Vor und nach dem LIVE", text: "LIVE-Analyse und Zahlen." }, + { titel: "Wöchentlich", text: "Kalender, Calls & Protokolle, Agentur." }, + { titel: "Monatlich", text: "Reports & Review und dein 90-Tage-Plan in Mein Profil." }, + ], + fragen: [ + { titel: "Muss ich jede Kachel kennen?", + text: "Nein. Fang mit den fünf Muss-Kacheln an. Der Rest kommt, wenn du ihn brauchst." }, + { titel: "Ich habe meinen Code verloren.", + text: "Sag deiner Betreuung oder DogFather Bescheid. Du bekommst einen neuen, " + + "der alte gilt dann nicht mehr." }, + { titel: "Warum sehe ich keine Zahlen?", + text: "Für die Woche wurde noch kein Tag eingetragen. Sprich deine Betreuung an." }, + { titel: "Wer sieht mein Profil?", + text: "Dein Team: Bild, Name und was du über dich geschrieben hast." }, + ], + }, +}; + +/* ===================================================================== + ANLEGEN UND ERSTBEFUELLEN + ===================================================================== */ + +/** Spielt eine Rolle ein — aber nur, wenn fuer sie noch GAR NICHTS da ist. + * + * DAS IST DIE WICHTIGSTE ZEILE DIESER DATEI. Ein Einspielen bei jedem + * Serverstart wuerde jede Aenderung aus der Pflege-Ansicht beim + * naechsten Neustart ueberschreiben — lautlos, und man wuerde es erst + * Wochen spaeter merken, wenn jemand fragt, warum sein Text wieder + * der alte ist. + * + * Gepruefte Bedingung ist "keine einzige Karte dieser Rolle", nicht + * "Tabelle leer": Sonst bekaeme Rolle zwei nichts mehr, sobald Rolle + * eins drin ist. */ +function rolleEinspielen(d, haus, rolle, inhalt, jetzt) { + const schon = d.prepare( + "SELECT COUNT(*) n FROM anl_karte WHERE haus = ? AND rolle = ?").get(haus, rolle)?.n ?? 0; + if (schon > 0) return 0; + + const karte = d.prepare( + `INSERT INTO anl_karte (haus, rolle, kachel, stufe, wozu, tust, merke, reihe, geaendert) + VALUES (?,?,?,?,?,?,?,?,?)`); + const zeile = d.prepare( + `INSERT INTO anl_zeile (haus, rolle, art, reihe, titel, text, ziel, geaendert) + VALUES (?,?,?,?,?,?,?,?)`); + + let n = 0; + inhalt.karten.forEach((k, i) => { + karte.run(haus, rolle, k.kachel, k.stufe, k.wozu, k.tust, k.merke, i, jetzt); + n++; + }); + const listen = [ + ["satz", inhalt.saetze.map((t) => ({ titel: "", text: t, ziel: null }))], + ["schritt", inhalt.schritte], + ["nicht", inhalt.nicht], + ["takt", inhalt.takt], + ["frage", inhalt.fragen], + ]; + for (const [art, liste] of listen) { + liste.forEach((z, i) => { + zeile.run(haus, rolle, art, i, z.titel ?? "", z.text ?? "", z.ziel ?? null, jetzt); + n++; + }); + } + d.prepare( + `INSERT INTO anl_rolle (haus, rolle, leitgedanke, geaendert) VALUES (?,?,?,?) + ON CONFLICT(haus, rolle) DO NOTHING`).run(haus, rolle, inhalt.leitgedanke, jetzt); + return n; +} + +export function anleitungTabellen(d) { + d.exec(TABELLEN); + + const jetzt = new Date().toISOString(); + let gesamt = 0; + const neu = []; + for (const rolle of FASSUNGEN) { + const inhalt = INHALTE[rolle]; + /* Eine Rolle ohne Inhalte ist kein Fehler, sondern der Stand der + Arbeit: Etappe 1 bringt nur den Creator, die drei anderen kommen + in Etappe 3. Still uebergehen — aber die Zuordnungspruefung + weiss davon und meldet es als offen, nicht als in Ordnung. */ + if (!inhalt) continue; + const n = rolleEinspielen(d, "agentur", rolle, inhalt, jetzt); + if (n) { gesamt += n; neu.push(`${rolle} ${n}`); } + } + if (gesamt) { + console.log(`[workspace] Anleitung: ${gesamt} Zeilen erstbefuellt (${neu.join(", ")}).`); + } +} diff --git a/server/index.js b/server/index.js index 10566a3f..176f23c8 100644 --- a/server/index.js +++ b/server/index.js @@ -72,6 +72,7 @@ import { videoRouter, videoWacheStarten } from "./workspace-video.js"; import { bewerbungRouter } from "./workspace-bewerbung.js"; import { leistungRouter } from "./workspace-leistung.js"; import { managerZieleRouter } from "./workspace-manager-ziele.js"; +import { anleitungRouter } from "./workspace-anleitung.js"; import { fruehwarnungRouter } from "./workspace-fruehwarnung.js"; import { icsRouter } from "./workspace-ics.js"; @@ -356,6 +357,7 @@ app.use(scoutRouter); ueberschneiden sich nicht), aber wer suchen muss, sucht sie nebeneinander. */ app.use(managerZieleRouter); +app.use(anleitungRouter); app.use(callRouter); app.use(hinweisRouter); app.use(zentraleRouter); diff --git a/server/mess-anleitung.mjs b/server/mess-anleitung.mjs new file mode 100644 index 00000000..66a04618 --- /dev/null +++ b/server/mess-anleitung.mjs @@ -0,0 +1,190 @@ +/* ===================================================================== + WIE DIE ANLEITUNG AUSSIEHT (06.10.2026, Etappe 1) + + SIE PRUEFT NICHTS und meldet keinen Fehlschlag -- sie macht Bilder, + auf denen man nachsehen kann, und misst vier Dinge, die man auf + einem Bild schlecht sieht: waagerechtes Schieben, Beruehrziele unter + 40 px, Fehler in der Browserkonsole und ob der Filter wirklich etwas + tut. + + Deshalb `mess-` und nicht `pruef-`. + + ZWEI BREITEN, und die kleinere ist die wichtigere: 412 px ist die + haeufigste Android-Breite. Am 06.09.2026 hat genau diese Breite im + Creator Workspace eine Ueberdeckung aufgedeckt, bei der ein Knopf + ueber einem anderen lag -- auf dem Computer war nichts zu sehen. + + EIGENE WEGWERF-DATENBANK in einem mktemp-Ordner. In einem laufenden + System waeren das Testdaten, und die sind verboten. + + Aufrufen mit: node server/mess-anleitung.mjs + ===================================================================== */ +import { mkdtempSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { request as httpAnfrage } from "node:http"; + +import { eigenerPort } from "./helfer-port.mjs"; +const PORT = await eigenerPort(import.meta, "mess-anleitung"); +const ordner = mkdtempSync(join(tmpdir(), "ws-anl-bild-")); +process.env.WORKSPACE_DB = join(ordner, "workspace.db"); +process.env.PORT = String(PORT); +process.env.SITE_ACCESS_SECRET = "lokaler-test"; +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(240_000, "mess-anleitung"); +await new Promise((r) => setTimeout(r, 900)); +process.on("uncaughtException", (f) => { console.error("ABSTURZ:", f); process.exit(7); }); +process.on("unhandledRejection", (f) => { console.error("ABSTURZ (Versprechen):", f); process.exit(7); }); + +/* ---------- Leute ----------------------------------------------------- */ +const { DatabaseSync } = await import("node:sqlite"); +const { scryptSync, randomBytes, createHmac } = await import("node:crypto"); +const d = new DatabaseSync(process.env.WORKSPACE_DB); +const jetztIso = new Date().toISOString(); +const kenn = randomBytes(32).toString("hex"); +d.prepare("INSERT INTO einstellungen (schluessel, wert, geaendert, von)" + + " VALUES (?,?,?,NULL) ON CONFLICT(schluessel) DO NOTHING") + .run("code_kennung_schluessel", kenn, jetztIso); +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", kenn).update(code).digest("hex"), jetztIso); +} +anlegen("Luna", "creator", "CODE-LUNA-0001"); +anlegen("Filipe", "admin", "CODE-DOGI-0001"); +d.close(); + +function anfrage(pfad, methode, koerper) { + return new Promise((fertig, schief) => { + const daten = koerper ? JSON.stringify(koerper) : null; + const a = httpAnfrage({ + host: "127.0.0.1", port: PORT, path: pfad, method: methode, + headers: { + Origin: `http://127.0.0.1:${PORT}`, + ...(daten ? { "Content-Type": "application/json", + "Content-Length": Buffer.byteLength(daten) } : {}), + }, + }, (antwort) => { + let text = ""; + antwort.on("data", (t) => { text += t; }); + antwort.on("end", () => fertig({ code: antwort.statusCode, kopf: antwort.headers, text })); + }); + a.on("error", schief); + if (daten) a.write(daten); + a.end(); + }); +} + +async function keksFuer(rolle, code) { + const an = await anfrage("/workspace/api/anmelden", "POST", { rolle, code }); + if (an.code !== 200) { console.log(`ABBRUCH: Anmeldung ${rolle} ${an.code}`); process.exit(2); } + return (an.kopf["set-cookie"] || []).map((x) => x.split(";")[0]).join("; "); +} +const keksCreator = await keksFuer("creator", "CODE-LUNA-0001"); +const keksDogi = await keksFuer("admin", "CODE-DOGI-0001"); + +/* ---------- Bilder ---------------------------------------------------- */ +const pw = await import( + "file:///C:/Users/qciga/Documents/Obelix/Analyse/node_modules/playwright/index.mjs"); +const browser = await pw.chromium.launch(); + +const nachmessen = () => ({ + schiebtQuer: document.documentElement.scrollWidth > window.innerWidth + 2, + zuKlein: [...document.querySelectorAll("button, a[href], summary")] + .filter((e) => { + const k = e.getBoundingClientRect(); + return k.width > 0 && k.height > 0 && k.height < 40; + }) + .map((e) => `${e.tagName.toLowerCase()}.${(e.className || "-")}: ${Math.round(e.getBoundingClientRect().height)}px`), + karten: document.querySelectorAll(".an-karte").length, + /* Je Stufe: wie viele Karten, und ist der Kasten offen? */ + stufen: [...document.querySelectorAll(".an-stufe")].map((s) => ({ + stufe: s.dataset.stufe, + zahl: s.querySelector(".an-stufe__zahl")?.textContent, + offen: !!s.querySelector(".an-stufe__falte")?.open, + sichtbar: !s.hidden, + })), + /* Tragen alle Karten den Ton ihrer Kachel? Eine ohne waere grau. */ + ohneTon: [...document.querySelectorAll(".an-karte")] + .filter((k) => !k.dataset.ton) + .map((k) => k.querySelector(".an-karte__name")?.textContent), + schritte: document.querySelectorAll(".an-schritt").length, + saetze: document.querySelectorAll(".an-saetze__zeile").length, + nicht: document.querySelectorAll(".an-nicht__zeile").length, + takt: document.querySelectorAll(".an-takt__wann").length, + fragen: document.querySelectorAll(".an-frage").length, + ohne: document.getElementById("ohne-fassung")?.hidden === false + ? document.getElementById("ohne-fassung").textContent.slice(0, 70) : null, +}); + +async function bild(name, keks, breite, hoehe, vorher) { + const kontext = await browser.newContext({ + viewport: { width: breite, height: hoehe }, hasTouch: breite <= 860, + }); + await kontext.addCookies(keks.split("; ").map((k) => { + const [n, ...r] = k.split("="); + return { name: n, value: r.join("="), domain: "127.0.0.1", path: "/" }; + })); + const seite = await kontext.newPage(); + const konsole = []; + seite.on("console", (e) => { + if (e.type() === "error" && !/favicon|net::ERR/i.test(e.text())) konsole.push(e.text().slice(0, 160)); + }); + seite.on("pageerror", (e) => konsole.push("Absturz: " + e.message)); + + await seite.goto(`http://127.0.0.1:${PORT}/workspace/anleitung.html`, + { waitUntil: "networkidle" }); + await seite.waitForTimeout(1000); + if (vorher) await vorher(seite); + await seite.waitForTimeout(400); + + const blick = await seite.evaluate(nachmessen); + await seite.screenshot({ path: `bild-anleitung-${name}.png`, fullPage: true }); + + console.log(`\n--- ${name} (${breite}px) ---`); + if (blick.ohne) console.log(` Ohne Fassung: ${blick.ohne}…`); + console.log(` Karten: ${blick.karten} Schritte: ${blick.schritte} ` + + `Saetze: ${blick.saetze} Nicht deins: ${blick.nicht} ` + + `Takt: ${blick.takt} Fragen: ${blick.fragen}`); + for (const s of blick.stufen) { + console.log(` ${String(s.stufe).padEnd(13)} ${String(s.zahl).padEnd(3)} ` + + `${s.offen ? "offen" : "zu "} ${s.sichtbar ? "" : "(ausgeblendet)"}`); + } + console.log(` Karten ohne Farbe: ${blick.ohneTon.length ? blick.ohneTon.join(", ") : "keine"}`); + console.log(` Quer schieben: ${blick.schiebtQuer ? "JA — Befund!" : "nein"}`); + console.log(` Zu kleine Ziele: ${blick.zuKlein.length ? blick.zuKlein.join(", ") : "keine"}`); + if (konsole.length) console.log(` KONSOLE: ${konsole.join(" | ")}`); + await kontext.close(); + return blick; +} + +await bild("creator-handy", keksCreator, 412, 950); +await bild("creator-rechner", keksCreator, 1440, 1000); + +/* Der Filter muss etwas TUN. Auf einem Bild sieht man nur den + gedrueckten Knopf -- ob die anderen Kaesten verschwinden, nicht. */ +await bild("creator-nur-muss", keksCreator, 412, 950, async (seite) => { + await seite.click('.an-filter__knopf[data-wert="muss"]'); +}); + +/* DogFather ohne gewaehlte Sicht: Er bekommt keine Fassung, sondern + eine Auskunft. Das Bild zeigt, ob die wie eine Erklaerung aussieht + und nicht wie ein Fehler. */ +await bild("dogfather-ohne-sicht", keksDogi, 412, 950); + +await browser.close(); +try { rmSync(ordner, { recursive: true, force: true }); } catch { /* egal */ } +console.log("\nBilder geschrieben: bild-anleitung-*.png"); +process.exit(0); diff --git a/server/pruef-anleitung.mjs b/server/pruef-anleitung.mjs new file mode 100644 index 00000000..60c1bab4 --- /dev/null +++ b/server/pruef-anleitung.mjs @@ -0,0 +1,394 @@ +/* ===================================================================== + DIE ANLEITUNG — stimmt sie mit den Kacheln ueberein? (Etappe 1) + ===================================================================== + + DIE EIGENTLICHE FRAGE DIESER DATEI steht im Bauplan gar nicht; sie + kam beim Nachmessen dazu, und sie ist der Grund, warum es diese + Pruefung ueberhaupt gibt: + + Erklaert die Anleitung genau die Kacheln, die es gibt -- + und zwar JEDE und KEINE DARUEBER HINAUS? + + Beides kann lautlos kaputtgehen, und beides auf dieselbe Weise: Wer + eine Kachel hinzufuegt, vergisst die Karte -- dann steht auf der + Startseite etwas, das die Einweisung nicht kennt. Wer eine Kachel + entfernt, laesst die Karte stehen -- dann erklaert die Einweisung + etwas, das es nicht mehr gibt, mit einem Knopf, der ins Leere + fuehrt. + + Der Bauplan sieht dafuer nur einen Hinweis in der Pflege-Ansicht vor + ("Fuer diese Kachel fehlt ein Eintrag"). Der hilft nur, wenn jemand + hinsieht. Diese Pruefung sieht immer hin. + + --------------------------------------------------------------------- + WOHER DIE KACHELLISTE KOMMT + + Aus `workspace/assets/js/bereiche.js`, und zwar indem die Datei + AUSGEFUEHRT wird -- nicht, indem jemand sie mit einem Muster + durchsucht. Ein Muster waere eine zweite, ungenaue Nachbildung der + Datei; sie wuerde bei der ersten ungewohnten Schreibweise still + danebenliegen und dann "alles in Ordnung" melden. + + Der Server kennt diese Liste nicht (bereicheFuer gibt fuers + Agenturhaus `null` zurueck). Deshalb muss die Pruefung sie sich + selbst holen -- genau wie der Browser es tut. + ===================================================================== */ + +import { mkdtempSync, rmSync, readFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import vm from "node:vm"; +import { eigenerPort } from "./helfer-port.mjs"; + +const PORT = await eigenerPort(import.meta, "pruef-anleitung"); +const ordner = mkdtempSync(join(tmpdir(), "ws-anl-")); +process.env.WORKSPACE_DB = join(ordner, "workspace.db"); +process.env.PORT = String(PORT); +process.env.SITE_ACCESS_SECRET = "lokaler-test"; +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-anleitung"); +await new Promise((r) => setTimeout(r, 800)); + +const BASIS = `http://127.0.0.1:${PORT}`; +let fehler = 0, geprueft = 0; +const melde = (t) => console.log(t); +const ok = (b, t) => { geprueft++; console.log((b ? " ok " : " FEHL ") + t); if (!b) fehler++; }; +function braucht(bedingung, was) { + if (bedingung) return; + console.log(`\nABBRUCH: konnte nicht nachsehen — ${was}`); + console.log("Das ist KEIN Befund am Code. Die Pruefung konnte ihre"); + console.log("Voraussetzung nicht herstellen und sagt das, statt zu schweigen.\n"); + try { rmSync(ordner, { recursive: true, force: true }); } catch { /* egal */ } + process.exit(2); +} + +/* ===================================================================== + DIE KACHELLISTE — bereiche.js wirklich ausfuehren + ===================================================================== */ +function bereicheLaden() { + const quelle = readFileSync( + new URL("../workspace/assets/js/bereiche.js", import.meta.url), "utf8"); + const fenster = {}; + const tot = () => ({ setAttribute() {}, append() {}, style: { setProperty() {} } }); + const umgebung = { window: fenster, URLSearchParams, + document: { createElement: tot, createElementNS: tot } }; + umgebung.globalThis = umgebung; + vm.createContext(umgebung); + vm.runInContext(quelle, umgebung, { filename: "bereiche.js" }); + return fenster.Bereiche; +} + +const B = bereicheLaden(); +braucht(B && Array.isArray(B.GRUPPEN) && B.GRUPPEN.length > 0, + "bereiche.js liefert keine Kachelliste — ohne sie ist nichts zu vergleichen"); +braucht(typeof B.schluesselVonZiel === "function", + "bereiche.js kennt schluesselVonZiel nicht"); + +melde("\n=== Der Kachel-Schluessel rechnet richtig ==="); +const SCHLUESSEL_PROBEN = [ + ["uebersicht.html", "uebersicht"], + ["bereich.html?b=live", "bereich:live"], + ["bereich.html?b=agentur", "bereich:agentur"], + ["profil.html", "profil"], + ["chat.html?raum=treff", "chat"], + ["", ""], +]; +for (const [ziel, erwartet] of SCHLUESSEL_PROBEN) { + const ist = B.schluesselVonZiel(ziel); + ok(ist === erwartet, `"${ziel || "(leer)"}" -> "${ist}" (erwartet "${erwartet}")`); +} +/* GEGENPROBE: Die Rechnung muss auch TRENNEN koennen. Lieferte sie fuer + alles dasselbe, waeren die sechs Zeilen oben trotzdem teils gruen. */ +ok(B.schluesselVonZiel("bereich.html?b=live") + !== B.schluesselVonZiel("bereich.html?b=technik"), + "Gegenprobe: zwei Bereiche derselben Seite bekommen verschiedene Schluessel"); + +function kachelnFuer(rolle) { + const nach = new Map(); + for (const g of B.GRUPPEN) { + for (const k of (g.bereiche || [])) { + if (Array.isArray(k.rollen) && !k.rollen.includes(rolle)) continue; + const s = B.schluesselVonZiel(k.ziel); + if (s) nach.set(s, k); + } + } + return nach; +} + +/* ===================================================================== + LEUTE UND ANMELDUNG + ===================================================================== */ +const { DatabaseSync } = await import("node:sqlite"); +const { scryptSync, randomBytes, createHmac } = await import("node:crypto"); +const d = new DatabaseSync(process.env.WORKSPACE_DB); +const jetztIso = new Date().toISOString(); +const kennSchluessel = randomBytes(32).toString("hex"); +d.prepare("INSERT INTO einstellungen (schluessel, wert, geaendert, von)" + + " VALUES (?,?,?,NULL) ON CONFLICT(schluessel) DO NOTHING") + .run("code_kennung_schluessel", kennSchluessel, jetztIso); + +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", kennSchluessel).update(code).digest("hex"), jetztIso); + return d.prepare("SELECT last_insert_rowid() AS id").get().id; +} + +const ROLLEN = [["admin", "Filipe"], ["spicy", "Spicy Media"], ["manager", "Patrick"], + ["scout", "Kevin"], ["creator", "Luna"], ["modi", "Miss"], ["hand", "VanVan"]]; +const CODES = {}; +for (const [i, [rolle, name]] of ROLLEN.entries()) { + CODES[rolle] = `CODE-A${String(i).padStart(2, "0")}-000001`; + anlegen(name, rolle, CODES[rolle]); +} + +/* Wie viele Zeilen hat die Erstbefuellung angelegt? Direkt in der + Datenbank nachgezaehlt und nicht ueber die Schnittstelle: Sonst + pruefte man die Schnittstelle mit sich selbst. */ +const inDb = d.prepare( + "SELECT COUNT(*) n FROM anl_karte WHERE haus = 'agentur' AND rolle = 'creator'").get().n; +d.close(); + +const { request } = await import("node:http"); +const CREW_HOST = "crew.dogfather-universe.com"; + +function roh(pfad, host, koerper, keks) { + return new Promise((fertig, schief) => { + const daten = koerper ? JSON.stringify(koerper) : null; + const a = request({ + host: "127.0.0.1", port: PORT, path: pfad, + method: daten ? "POST" : "GET", + headers: { + ...(host ? { Host: host } : {}), + Origin: host ? `https://${host}` : BASIS, + ...(keks ? { Cookie: keks } : {}), + ...(daten ? { "Content-Type": "application/json", + "Content-Length": Buffer.byteLength(daten) } : {}), + }, + }, (antwort) => { + let text = ""; + antwort.on("data", (t) => { text += t; }); + antwort.on("end", () => fertig({ code: antwort.statusCode, kopf: antwort.headers, text })); + }); + a.on("error", schief); + if (daten) a.write(daten); + a.end(); + }); +} + +async function anmelden(rolle, host = null) { + const r = await roh("/workspace/api/anmelden", host, { rolle, code: CODES[rolle] }); + if (r.code !== 200) return null; + return [].concat(r.kopf["set-cookie"] || []).map((z) => z.split(";")[0]).join("; ") || null; +} + +melde("\n=== Alle Rollen kommen herein ==="); +const kekse = {}; +for (const [rolle] of ROLLEN) { + const haus = (rolle === "modi" || rolle === "hand") ? CREW_HOST : null; + kekse[rolle] = await anmelden(rolle, haus); + ok(!!kekse[rolle], `${rolle}: angemeldet`); +} +braucht(Object.values(kekse).every(Boolean), "nicht alle Rollen konnten sich anmelden"); + +const holen = async (rolle, host = null) => { + const r = await roh("/workspace/api/anleitung", host, null, kekse[rolle]); + let j = null; + try { j = JSON.parse(r.text); } catch { /* bleibt null */ } + return { code: r.code, j }; +}; + +/* ===================================================================== + DIE ERSTBEFUELLUNG + ===================================================================== */ +melde("\n=== Die Inhalte sind eingespielt ==="); +ok(inDb === 18, `Creator hat 18 Karten in der Datenbank (${inDb})`); + +const creator = await holen("creator"); +ok(creator.code === 200, `Creator bekommt die Anleitung (HTTP ${creator.code})`); +braucht(creator.code === 200 && creator.j, "die Schnittstelle antwortet dem Creator nicht"); +ok(creator.j.hat_fassung === true, "Creator hat eine eigene Fassung"); +ok(creator.j.anzahl === 18, `18 Karten ueber die Schnittstelle (${creator.j.anzahl})`); +ok((creator.j.saetze || []).length === 3, `drei Saetze „In 60 Sekunden" (${(creator.j.saetze || []).length})`); +ok((creator.j.schritte || []).length === 5, `fuenf Erste Schritte (${(creator.j.schritte || []).length})`); +ok((creator.j.nicht || []).length === 5, `fuenf Zeilen „Nicht deins" (${(creator.j.nicht || []).length})`); +ok((creator.j.takt || []).length === 4, `vier Rhythmus-Zeilen (${(creator.j.takt || []).length})`); +ok((creator.j.fragen || []).length === 4, `vier haeufige Fragen (${(creator.j.fragen || []).length})`); +ok(!!creator.j.leitgedanke && creator.j.leitgedanke.length > 40, + "der Leitgedanke steht da"); + +/* DIE ZAHLEN DER ABNAHMELISTE — GERECHNET, NICHT ABGESCHRIEBEN. + Der Bauplan nennt 5/6/7. Diese Zeile prueft nicht gegen die Zahl, + sondern gegen die Summe: Zusammen muessen es so viele sein, wie es + Karten gibt, und keine Karte darf eine unbekannte Stufe tragen. */ +melde("\n=== Die Stufen ==="); +const jeStufe = {}; +for (const k of creator.j.karten) jeStufe[k.stufe] = (jeStufe[k.stufe] || 0) + 1; +const bekannt = new Set((creator.j.stufen || []).map((s) => s.schluessel)); +ok([...Object.keys(jeStufe)].every((s) => bekannt.has(s)), + `jede Karte traegt eine bekannte Stufe (${Object.keys(jeStufe).join(", ")})`); +ok(Object.values(jeStufe).reduce((a, b) => a + b, 0) === creator.j.karten.length, + "die Stufen zusammen ergeben alle Karten"); +ok(jeStufe.muss === 5 && jeStufe.regelmaessig === 6 && jeStufe.bedarf === 7, + `Verteilung 5/6/7 wie im Bauplan (${jeStufe.muss}/${jeStufe.regelmaessig}/${jeStufe.bedarf})`); + +/* ===================================================================== + DIE ZUORDNUNG — IN BEIDE RICHTUNGEN + ===================================================================== */ +melde("\n=== Jede Kachel hat eine Karte, jede Karte eine Kachel ==="); +const kacheln = kachelnFuer("creator"); +/* Die Anleitung selbst bekommt keine Karte. Eine Karte, die die Seite + erklaert, auf der man gerade steht, ist Fuellmaterial. */ +kacheln.delete("anleitung"); +braucht(kacheln.size > 0, "fuer den Creator findet sich keine einzige Kachel"); + +const habenKarte = new Set(creator.j.karten.map((k) => k.kachel)); +const ohneKarte = [...kacheln.keys()].filter((s) => !habenKarte.has(s)); +const ohneKachel = [...habenKarte].filter((s) => !kacheln.has(s)); + +ok(ohneKarte.length === 0 && kacheln.size > 0, + ohneKarte.length + ? `${ohneKarte.length} Kacheln OHNE Karte: ${ohneKarte.join(", ")}` + : `alle ${kacheln.size} Kacheln des Creators haben eine Karte`); +ok(ohneKachel.length === 0 && habenKarte.size > 0, + ohneKachel.length + ? `${ohneKachel.length} Karten OHNE Kachel: ${ohneKachel.join(", ")}` + : `alle ${habenKarte.size} Karten zeigen auf eine Kachel, die es gibt`); + +/* GEGENPROBE. Beide Zeilen oben koennen "in Ordnung" sagen. Koennen sie + auch "nicht in Ordnung"? Ohne diesen Beweis waere eine leere Menge + auf beiden Seiten gruen -- und genau so sieht ein kaputter Abgleich + aus. */ +const erfundeneKachel = new Set([...kacheln.keys(), "gibtesnicht"]); +ok([...erfundeneKachel].filter((s) => !habenKarte.has(s)).length === 1, + "Gegenprobe: eine Kachel ohne Karte wird gefunden"); +const erfundeneKarte = new Set([...habenKarte, "auchnicht"]); +ok([...erfundeneKarte].filter((s) => !kacheln.has(s)).length === 1, + "Gegenprobe: eine Karte ohne Kachel wird gefunden"); + +/* Jede Karte muss alle drei Saetze haben. Eine halb gefuellte Karte + sieht aus wie ein Ladefehler. */ +const luecken = creator.j.karten.filter((k) => !k.wozu || !k.tust || !k.merke); +ok(luecken.length === 0, + luecken.length ? `${luecken.length} Karten ohne vollstaendige drei Zeilen: ` + + luecken.map((k) => k.kachel).join(", ") + : "jede Karte hat Wozu, Was du hier tust und Merke"); + +/* Die Erste-Schritte-Ziele muessen Kacheln dieser Rolle sein -- oder + gar kein Ziel haben ("Installieren" ist ein Knopf, keine Seite). + Ein Sprung auf eine Seite, die ein Creator nicht oeffnen darf, waere + ein Knopf, der auf die Startseite zurueckwirft. */ +const schrittFehler = (creator.j.schritte || []) + .filter((s) => s.ziel) + .filter((s) => !kachelnFuer("creator").has(B.schluesselVonZiel(s.ziel))); +ok(schrittFehler.length === 0, + schrittFehler.length + ? `Erste Schritte zeigen ins Leere: ${schrittFehler.map((s) => s.ziel).join(", ")}` + : "jeder Erste Schritt mit Ziel fuehrt auf eine Kachel dieser Rolle"); + +/* ===================================================================== + WER DARF, WER NICHT + ===================================================================== */ +melde("\n=== Die drei Schranken ==="); + +const admin = await holen("admin"); +ok(admin.code === 200 && admin.j?.hat_fassung === false, + `DogFather bekommt keine Fassung, sondern eine Auskunft (HTTP ${admin.code}, ` + + `hat_fassung ${admin.j?.hat_fassung})`); +ok(!!admin.j?.satz && admin.j.satz.length > 30, + "…und die Auskunft sagt ihm, wie er trotzdem hineinsieht"); +ok(admin.j?.darf_pflegen === true, "DogFather darf pflegen"); + +const spicy = await holen("spicy"); +ok(spicy.code === 200, `Spicy Media bekommt eine Antwort (HTTP ${spicy.code})`); +ok(spicy.j?.darf_pflegen === true, + "Spicy Media darf pflegen (Entscheidung 06.10.2026, wie bei den Manager-Zielen)"); +/* Etappe 1 bringt nur den Creator. Scout, Manager und Spicy haben noch + keine Inhalte -- das ist der Stand der Arbeit und kein Fehler. Es + wird aber AUSGESPROCHEN, nicht uebersprungen. */ +melde(` -- Scout, Manager und Spicy Media haben noch keine Inhalte ` + + `(Etappe 3) — Spicy meldet ${spicy.j?.anzahl ?? 0} Karten`); + +const creatorPflege = creator.j.darf_pflegen; +ok(creatorPflege === false, "ein Creator darf NICHT pflegen"); + +const modi = await holen("modi", CREW_HOST); +ok(modi.code === 404, + `ein Modi bekommt die Anleitung nicht (HTTP ${modi.code}, erwartet 404)`); +const hand = await holen("hand", CREW_HOST); +ok(hand.code === 404, + `die rechte Hand bekommt sie auf der Crew-Adresse nicht (HTTP ${hand.code}, erwartet 404)`); + +/* Die Seite selbst, nicht nur die Schnittstelle. + ===================================================================== + GEPRUEFT WIRD DAS ZIEL, NICHT NUR DIE 302. + + Das Haus beantwortet "darfst du nicht" mit einer Umleitung und nicht + mit 403 (workspace.js, bei `darfSeite`) -- ausdruecklich, damit es + auf "nein" nur EINE Antwort gibt und niemand aus dem Unterschied + etwas ablesen kann. + + Genau deshalb reicht hier "Status ist 302" nicht: NICHT ANGEMELDET + gibt ebenfalls 302. Eine Pruefung auf die blosse Zahl waere auch + dann gruen, wenn die Sitzung des Modi gar nicht erkannt wuerde -- + sie haette dann die Anmeldung gemessen und nicht die Schranke. + Unterscheidbar sind die beiden nur am Ziel: + + nicht angemeldet -> /workspace/ + angemeldet, aber gesperrt -> /workspace/start.html + ===================================================================== */ +const seiteCreator = await roh("/workspace/anleitung.html", null, null, kekse.creator); +ok(seiteCreator.code === 200, `anleitung.html oeffnet fuer den Creator (${seiteCreator.code})`); + +const seiteModi = await roh("/workspace/anleitung.html", CREW_HOST, null, kekse.modi); +ok(seiteModi.code === 302 && seiteModi.kopf.location === "/workspace/start.html", + `ein angemeldeter Modi wird zur Startseite umgeleitet ` + + `(${seiteModi.code} -> ${seiteModi.kopf.location})`); + +const seiteOhne = await roh("/workspace/anleitung.html", null, null, null); +ok(seiteOhne.code === 302 && seiteOhne.kopf.location === "/workspace/", + `ohne Anmeldung zur Anmeldung, nicht zur Startseite ` + + `(${seiteOhne.code} -> ${seiteOhne.kopf.location})`); + +/* GEGENPROBE ZU DEN BEIDEN: Sie unterscheiden wirklich. Zeigten beide + Umleitungen auf dasselbe, waeren die Zeilen oben zusammen trotzdem + gruen zu bekommen -- und die Schranke waere ungeprueft. */ +ok(seiteModi.kopf.location !== seiteOhne.kopf.location, + "Gegenprobe: gesperrt und nicht angemeldet fuehren an verschiedene Orte"); + +/* ===================================================================== + DIE ERSTBEFUELLUNG DARF NICHTS UEBERSCHREIBEN + ===================================================================== + Die wichtigste Zeile in anleitung-tabellen.js: Eingespielt wird nur, + wenn fuer eine Rolle noch gar nichts da ist. Liefe sie bei jedem + Start, waere jede Aenderung aus der Pflege-Ansicht beim naechsten + Neustart weg -- lautlos, und man merkte es Wochen spaeter. */ +melde("\n=== Ein zweiter Lauf laesst Aenderungen in Ruhe ==="); +const d2 = new DatabaseSync(process.env.WORKSPACE_DB); +d2.prepare("UPDATE anl_karte SET merke = ? WHERE haus='agentur' AND rolle='creator' AND kachel='chat'") + .run("VON HAND GEAENDERT"); +const { anleitungTabellen } = await import("./anleitung-tabellen.js"); +anleitungTabellen(d2); +const nachher = d2.prepare( + "SELECT merke FROM anl_karte WHERE haus='agentur' AND rolle='creator' AND kachel='chat'").get(); +const anzahlNachher = d2.prepare( + "SELECT COUNT(*) n FROM anl_karte WHERE haus='agentur' AND rolle='creator'").get().n; +d2.close(); +ok(nachher?.merke === "VON HAND GEAENDERT", + `der geaenderte Text steht noch da ("${(nachher?.merke || "").slice(0, 24)}")`); +ok(anzahlNachher === 18, `und es sind keine Karten dazugekommen (${anzahlNachher})`); + +/* ---------- Abschluss ------------------------------------------------ */ +try { rmSync(ordner, { recursive: true, force: true }); } catch { /* egal */ } +console.log(`\n${fehler ? "FEHLER: " + fehler : "Alles in Ordnung"} — ${geprueft} Prüfungen`); +process.exit(fehler ? 1 : 0); diff --git a/server/pruef-rollen.mjs b/server/pruef-rollen.mjs index cfe0743f..9b63b9b6 100644 --- a/server/pruef-rollen.mjs +++ b/server/pruef-rollen.mjs @@ -178,11 +178,30 @@ d.close(); /* ---------- Der Rundgang ------------------------------------------------ */ +/* ZWEI SEITEN FEHLTEN HIER (06.10.2026). + + `manager-ziele.html` (seit 02.10.) und `anleitung.html` (seit heute) + standen nicht in dieser Liste. Ganz ungeprueft waren sie nicht -- der + Kachel-Durchgang weiter unten oeffnet jede Kachel jeder Rolle und + haette gemerkt, wenn eine ins Leere fuehrt. Er prueft aber nur, WO + man landet. Die eigentliche Arbeit dieser Liste -- Fehler in der + Browserkonsole, 4xx-Antworten vom Server, tote Verweise, leere + Seiten, waagerechtes Ueberstehen -- lief fuer beide nie. + + Das ist dieselbe Sorte Luecke wie am 28.08.2026 bei RunOne: Der Lauf + war gruen, und acht Pruefungen je Geraet fielen still weg. Hier + waren es zwei Seiten mal acht Rollen. + + Eine abgeschriebene Liste bleibt es trotzdem, und sie wird wieder + veralten. Richtig waere, sie aus `rechte.js` abzuleiten -- dort + steht jede Seite des Hauses genau einmal. Das ist ein eigener + Umbau; bis dahin gilt: Wer eine Seite anlegt, traegt sie hier ein. */ const SEITEN = [ "start.html", "uebersicht.html", "aufgaben.html", "kalender.html", "calls.html", "content.html", "bereich.html?b=live", "dateien.html", "personen.html", "scouting.html", "steckbrief.html", "profil.html", "report.html", "wissen.html", "startcheck.html", "automation.html", + "manager-ziele.html", "anleitung.html", ]; /* Fünf Durchgänge, nicht vier. diff --git a/server/rechte.js b/server/rechte.js index ca9f3240..aa94c836 100644 --- a/server/rechte.js +++ b/server/rechte.js @@ -203,6 +203,32 @@ export const SEITEN = { ihre Daten offen zu lassen waere dieselbe Luecke wie am 11.09.2026 bei content.html, nur andersherum. */ "/workspace/manager-ziele.html": ["spicy", "admin", "manager", "scout"], + /* ==================================================================== + DIE ANLEITUNG -- MIT 'admin', OBWOHL DOGFATHER KEINE KACHEL HAT + (06.10.2026) + + Das sieht nach einem Widerspruch zu bereiche.js aus, wo 'admin' + bei der Kachel fehlt, und ist keiner. Zwei verschiedene Fragen: + + bereiche.js -- WAS WIRD ANGEBOTEN. DogFather bekommt keine + Einweisung (so der Bauplan), also keine Kachel. + hier -- WER DARF HEREIN, wenn er die Adresse kennt. + + Er muss herein duerfen: Ab Etappe 4 oeffnet dieselbe Adresse fuer + ihn die Pflege-Ansicht, und schon vorher muss er nachsehen koennen, + was seine Leute zu lesen bekommen -- sonst pflegt er blind. + + DIE CREW BLEIBT DRAUSSEN. 'hand', 'modi', 'linke' und 'gast' stehen + nicht in dieser Zeile und bekommen damit 403. Die Anleitung ist + eine Sache des Agenturhauses; Team Dogi bekommt spaeter eine + eigene oder keine (Entscheidung 06.10.2026: erst einmal keine, + aber die Daten sind haus-bewusst angelegt). + + UND SIE IST NICHT DIE EINZIGE SCHRANKE: workspace-anleitung.js + prueft Haus und Rolle noch einmal selbst, und die Hinweise tun es + ein drittes Mal. Am 15.09.2026 ist genau dort ein Weg fuer die + Community durchgerutscht, der in dieser Tafel sauber gesperrt war. */ + "/workspace/anleitung.html": ["spicy", "admin", "manager", "scout", "creator"], /* NUR DogFather (02.09.2026). Die Rollenauswahl verspricht das seit jeher ("Manager -- dieselben Rechte, ausser der Personenverwaltung"), die Schranke hielt sich nur nicht daran. diff --git a/server/workspace-anleitung.js b/server/workspace-anleitung.js new file mode 100644 index 00000000..07f38800 --- /dev/null +++ b/server/workspace-anleitung.js @@ -0,0 +1,133 @@ +/* ===================================================================== + workspace-anleitung.js — die Einweisung je Rolle (Etappe 1) + + Bauplan: „Kachel Anleitung", Stand 06.10.2026, geprueft in + `02 Projekte/Anleitung-Kachel – Bauplan.md`. + + --------------------------------------------------------------------- + WAS DIESE SCHNITTSTELLE LIEFERT UND WAS NICHT + + Sie liefert die TEXTE und die STUFE je Kachel. Sie liefert NICHT den + Namen, das Symbol, die Farbe oder die Adresse einer Kachel — die + stehen in `workspace/assets/js/bereiche.js`, und nur dort. Die Seite + verbindet beides ueber den Kachel-Schluessel + (`window.Bereiche.schluesselVonZiel`). + + Das ist Absicht und der Grund, warum diese Datei so kurz ist: Der + Server kennt die Kacheln des Agenturhauses gar nicht. `bereicheFuer` + gibt dort `null` zurueck — „nimm die Liste aus dem Browser". Haette + ich sie hier noch einmal hingeschrieben, um „alles an einer Stelle" + zu haben, waere genau das die zweite Liste, die beim naechsten Umbau + veraltet und dann eine Kachel erklaert, die es nicht mehr gibt. + + --------------------------------------------------------------------- + DREI SCHRANKEN, NICHT EINE + + 1. `rechte.js` sperrt die Seite fuer die Crew. + 2. Diese Datei prueft Haus und Rolle noch einmal selbst. + 3. Die Hinweise (ab Etappe 2) pruefen es ein drittes Mal. + + Am 15.09.2026 ist ein Weg fuer die Community genau durch Luecke 3 + gerutscht, obwohl Tafel und Seite sauber gesperrt waren. Die + Hinweise sind eine eigene Tuer. + ===================================================================== */ + +import express from "express"; +import { db, sitzungLesen } from "./workspace.js"; +import { + STUFEN, FASSUNGEN, PFLEGE_ROLLEN, +} from "./anleitung-tabellen.js"; + +export const anleitungRouter = express.Router(); + +const HAUS = "agentur"; + +function angemeldet(req, res, next) { + const person = sitzungLesen(req); + if (!person) return res.status(401).json({ fehler: "nicht_angemeldet" }); + req.person = person; + next(); +} + +anleitungRouter.use("/workspace/api/anleitung", angemeldet); + +/** Wessen Fassung wird gezeigt? + * + * `req.sicht` ist der HAUSEIGENE Umschalter: DogFather kann die Sicht + * eines anderen einnehmen, und dann zeigt jede Seite das, was dieser + * andere sieht. Hier gilt dasselbe — und genau deshalb braucht die + * Anleitung keine eigene „Vorschau": Die gibt es schon, sie heisst + * „Meine Sicht" und steht in der Kopfleiste. + * + * Ohne gewaehlte Sicht ist DogFather `admin`, und fuer `admin` gibt es + * keine Fassung (Bauplan, Kapitel 11). Dann sagt die Seite das, statt + * ihm irgendeine fremde Rolle unterzuschieben. */ +function fassungFuer(req) { + const person = req.sicht || req.person; + return { person, rolle: String(person?.rolle || "") }; +} + +anleitungRouter.get("/workspace/api/anleitung", (req, res) => { + try { + const { person, rolle } = fassungFuer(req); + + /* DIE CREW HAT HIER NICHTS ZU SUCHEN. 404 und nicht 403: Auf der + Crew-Adresse gibt es diese Seite nicht, und „gibt es nicht" ist + die richtige Auskunft — 403 waere der Hinweis, dass es sie + woanders doch gibt. */ + if (person?.haus === "crew") return res.status(404).json({ fehler: "nicht_hier" }); + + const d = db(); + const darfPflegen = PFLEGE_ROLLEN.includes(String(req.person?.rolle || "")); + + if (!FASSUNGEN.includes(rolle)) { + /* DogFather ohne gewaehlte Sicht. Kein Fehler, sondern eine + Auskunft: Er bekommt keine Einweisung, kann aber jede ansehen. + Die Rollen stehen MIT Namen dabei, damit die Seite keine + zweite Rollenliste braucht. */ + return res.json({ + rolle, hat_fassung: false, darf_pflegen: darfPflegen, + fassungen: FASSUNGEN, + satz: "Für dich gibt es keine Einweisung – du kennst den Laden. " + + "Wähle oben unter „Meine Sicht“ eine Rolle, dann siehst du genau das, " + + "was diese Person zu lesen bekommt.", + }); + } + + const kopf = d.prepare( + "SELECT leitgedanke FROM anl_rolle WHERE haus = ? AND rolle = ?").get(HAUS, rolle); + + const karten = d.prepare( + `SELECT kachel, stufe, wozu, tust, merke, reihe, geaendert + FROM anl_karte WHERE haus = ? AND rolle = ? + ORDER BY reihe, id`).all(HAUS, rolle); + + const zeilen = d.prepare( + `SELECT art, titel, text, ziel, reihe + FROM anl_zeile WHERE haus = ? AND rolle = ? + ORDER BY art, reihe, id`).all(HAUS, rolle); + + const nachArt = (art) => zeilen.filter((z) => z.art === art); + + /* DIE ZAHL GEHOERT IN DIE ANTWORT, nicht nur in die Liste. Die + Seite kann damit sagen „18 Karten" — und eine Pruefung kann + nachrechnen, ohne die Liste selbst zu zaehlen. */ + res.json({ + rolle, + hat_fassung: true, + darf_pflegen: darfPflegen, + leitgedanke: kopf?.leitgedanke || "", + stufen: STUFEN, + saetze: nachArt("satz").map((z) => z.text), + schritte: nachArt("schritt").map((z) => ({ titel: z.titel, text: z.text, ziel: z.ziel })), + karten, + nicht: nachArt("nicht").map((z) => ({ titel: z.titel, text: z.text })), + takt: nachArt("takt").map((z) => ({ titel: z.titel, text: z.text })), + fragen: nachArt("frage").map((z) => ({ titel: z.titel, text: z.text })), + anzahl: karten.length, + }); + } catch (fehler) { + console.error("[workspace] Anleitung:", fehler?.message); + res.status(500).json({ fehler: "serverfehler" }); + } +}); diff --git a/server/workspace.js b/server/workspace.js index 2ff3aef2..171ca6e2 100644 --- a/server/workspace.js +++ b/server/workspace.js @@ -36,6 +36,7 @@ import { hilfeTabellen } from "./hilfe-tabellen.js"; import { supportTabellen } from "./support-tabellen.js"; import { notizTabellen } from "./notiz-tabellen.js"; import { managerZieleTabellen } from "./manager-ziele-tabellen.js"; +import { anleitungTabellen } from "./anleitung-tabellen.js"; import { reaktionTabellen } from "./reaktion-tabellen.js"; import { spendenTabellen } from "./spenden-tabellen.js"; import { unterstuetzungTabellen } from "./unterstuetzung-tabellen.js"; @@ -6448,6 +6449,11 @@ export function db() { muss diese Zeile danach kommen. Stünde sie davor, legte SQLite die Fremdschlüssel auf eine Tabelle an, die es noch nicht gibt. */ managerZieleTabellen(d); + /* DIE ANLEITUNG nach den Personen -- sie verweist mit + `geaendert_von` darauf. Dieselbe Begruendung wie eine Zeile + hoeher: Stuende sie davor, legte SQLite den Fremdschluessel + auf eine Tabelle, die es noch nicht gibt. */ + anleitungTabellen(d); reaktionTabellen(d); spendenTabellen(d); /* Die Wege zum Unterstützen (24.09.2026). Sie hängen an personen diff --git a/workspace/anleitung.html b/workspace/anleitung.html new file mode 100644 index 00000000..d9293022 --- /dev/null +++ b/workspace/anleitung.html @@ -0,0 +1,153 @@ + + + + + +Anleitung · Creator Workspace + + + + + + + + + + + + + + + + + +
+ +

Creator Workspace · Anleitung

+
+ … + +
+
+ +
+ +
+
+

Einweisung

+

Anleitung

+

+ Was du brauchst und was nicht. Fang oben an – der Rest ist + zugeklappt, bis du ihn brauchst. +

+
+
+ + + + + + + + +
+ + + + + + + + + + + + diff --git a/workspace/anruf-probe.html b/workspace/anruf-probe.html index ab291add..6eb64f00 100644 --- a/workspace/anruf-probe.html +++ b/workspace/anruf-probe.html @@ -16,8 +16,8 @@ verdeckt. Gefunden hat es pruef-struktur. Richtig sind `gate.css` (die Farbwerte des Hauses) und `haus.css`. --> - - + + - +