Files
dogfather-universe/server/workspace-anleitung.js
T
DogFatherGitandClaude Opus 5 88a8d302b9 Kapitel 11 auf der Crew-Adresse: die Community gehoert nicht in die Pflegeliste
ZWEITER BEFUND DES ABENDS, und wieder einer, den es im Agenturhaus
gar nicht geben konnte.

Bauplan Kapitel 11, woertlich: "Stand der Ersten Schritte: nur fuer
Team-Rollen, 'fertig / offen' mit Datum. Fuer die Community wird
nichts je Person angezeigt."

Gemessen: Auf der Crew-Adresse nahm die Liste schlicht alle Fassungen
des Hauses -- und seit die Community seit heute frueh eine eigene
Fassung hat, stand jedes Mitglied mit Namen, Fortschritt und Datum
darin. Im Agenturhaus konnte das nie auffallen, dort ist jede Fassung
eine Teamrolle.

Das ist kein Schoenheitsfehler: Ein Mitglied hat sich zum Mitlesen
angemeldet, nicht zu einer Fortschrittsakte. Jetzt siebt
`standRollen()` ueber AUSSEN_ROLLEN -- abgeleitet und nicht als zweite
Liste, damit die naechste Rolle von aussen automatisch heraus ist.
Gegenprobe gefahren: Sieb ausgebaut -> die Zeile wird rot und nennt
"1 von 4".

DAZU GEPRUEFT, was Kapitel 11 sonst verlangt (pruef-anleitung
173 -> 181): DogFather bekommt auf der Crew-Adresse die Pflege statt
einer Fassung, mit den VIER Fassungen dieses Hauses (nicht denen der
Agentur); ein Modi kommt an die Liste gar nicht heran (403); und die
"Vorschau in der Sicht dieser Rolle" liefert ihm die 29 Karten des
Modi, mit `darf_pflegen: true` und `darf_abhaken: false` -- er soll
pflegen koennen, aber nicht in fremdem Namen abhaken. Nachgestellt
wird dabei genau die Anfrage, die kopf.js stellt (`sicht=<Nummer>`
an jede lesende Anfrage), nicht eine nachgebaute.

AUSSERDEM: ZWEI DINGE HIESSEN `an-stufe`
Der Kasten einer EINORDNUNG ("Das brauchst du sofort") und der Kasten
"Deine Stufe" mit Neu, Dabei, Stamm trugen dieselbe Klasse. Der
Bauplan warnt in Kapitel 14 ausdruecklich davor, die beiden zu
verwechseln -- im Quelltext taten sie es.

Aufgefallen beim Messen des Filters "Muss": Er musste seine Suche von
Hand auf `#stufen` eingrenzen, sonst haette er die Karte "Deine Stufe"
mit weggeblendet. Eine Regel wie `.an-stufe { display: none }` haette
denselben Schaden angerichtet, und man haette sie nicht kommen sehen.
Die Mitglieder-Stufe heisst jetzt `an-rang`; in der Oberflaeche aendert
sich kein Wort.

IM BROWSER NACHGEMESSEN (390 px, echte Anmeldung)
  Filter "Muss"  -> Community 5 Karten, rechte Hand 8. Genau die
                    Zahlen der Abnahme-Checkliste, und nach dem
                    Umbenennen blendet er nur noch den einen
                    Abschnitt aus statt zwei.
  "Deine Stufe"  -> Community ja ("Du bist gerade: Neu"), rechte Hand
                    nein. Steht und faellt mit dem neuen Namen.
  Keine Browsermeldungen, kein Querscrollen.

pruef-css-klassen und pruef-anleitung (181) gruen.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-10-06 17:49:18 +02:00

857 lines
38 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/* =====================================================================
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, AUSSEN_ROLLEN, bereicheFuer } from "./workspace.js";
import { darfSeite } from "./rechte.js";
import {
STUFEN, PFLEGE_ROLLEN, fassungenFuer,
} from "./anleitung-tabellen.js";
import {
stufeVon, STUFEN_NAME, DABEI_TAGE, STAMM_TAGE, STAMM_BEITRAEGE,
TREFF_SCHREIBEN, STUFEN as MITGLIED_STUFEN,
} from "./workspace-treff.js";
export const anleitungRouter = express.Router();
/** In welchem Haus sind wir?
*
* DIE ADRESSE ENTSCHEIDET, NICHT DIE ANGESEHENE ROLLE (06.10.2026).
* Hier stand `const HAUS = "agentur"` -- die Anleitung gab es nur
* im Agenturhaus, und die Crew bekam an fuenf Stellen ein 404
* ("nicht_hier"). Seit heute hat auch Team Dogi ihre vier
* Fassungen; das Haus kommt deshalb aus der Anmeldung.
*
* GEFRAGT WIRD IMMER `req.person`, NIE `req.sicht`: Nimmt DogFather
* die Sicht einer anderen Rolle ein, wechselt er damit die ROLLE,
* nicht die ADRESSE. Wer hier die angesehene Person naehme, bekaeme
* auf der Crew-Adresse Agenturinhalte, sobald eine Sicht gesetzt
* ist -- und genau das ist die Trennung, die seit dem 24.09. gilt. */
const hausVon = (p) => (p?.haus === "crew" ? "crew" : "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 || "") };
}
/** WESSEN Haken werden gezeigt?
*
* Die der angesehenen Person. DogFather, der die Sicht eines Creators
* einnimmt, soll sehen, wie weit DIESER Creator ist — das ist der
* Sinn der Sicht. Setzen darf er sie nicht (`darf_abhaken`).
*
* Ohne gewaehlte Sicht ist es die eigene Person, und das ist der
* Normalfall. */
function wessenStand(req) {
return (req.sicht || req.person)?.id ?? 0;
}
/** Darf diese Person die Seite ueberhaupt oeffnen, die die Karte
* erklaert?
*
* Bauplan Kapitel 04 und 13: „Leite die sichtbaren Karten aus
* derselben Rechte-Tafel ab, die rechte.html verwendet. Eine Rolle
* sieht nur Karten zu Seiten, die sie oeffnen darf; pruefe das
* SERVERSEITIG."
*
* GEMESSEN AM 06.10.2026, UND ES STIMMTE NICHT: Wer in „Wer sieht
* was" fuer einen Modi `bereich.html` sperrte, bekam vom Server
* weiterhin alle elf Bereichs-Karten geliefert. Im Browser fielen sie
* zwar weg (die Seite verbindet jede Karte mit einer Kachel, und die
* Kachelliste ist gefiltert) -- aber die TEXTE gingen trotzdem
* hinaus, und eine Oberflaeche ist keine Schranke. Genau deshalb
* steht im Bauplan „serverseitig".
*
* DER PFAD WIRD AUS DEM SCHLUESSEL GERECHNET, nicht aus einer
* Zuordnungsliste: Der Schluessel ist der Seitenname ohne `.html`,
* bei Unterseiten mit einem Doppelpunkt dahinter
* (`bereich:highlight`, `chat:treff`). Das ist die Umkehrung von
* `Bereiche.schluesselVonZiel`; eine gepflegte Tabelle daneben waere
* die, die beim naechsten neuen Brett fehlt.
*
* OHNE EINTRAG IN DER TAFEL HEISST NEIN -- das entscheidet
* `darfSeite` selbst, und zwar fuer das ganze Haus gleich. Hier wird
* nichts davon nachgebaut. */
function darfKarte(person, schluessel) {
const name = String(schluessel || "").split(":")[0];
if (!name) return false;
return darfSeite(person, `/workspace/${name}.html`);
}
/** Kann diese Person das, was die Karte beschreibt, wegen ihrer
* Mitglieder-Stufe noch NICHT?
*
* Bauplan Kapitel 04: "Kann ein Community-Mitglied etwas wegen seiner
* Stufe noch nicht, steht das an der Karte („ab Dabei", „ab Stamm")."
*
* ABGELEITET AUS DER TAFEL, DIE ES ENTSCHEIDET. `TREFF_SCHREIBEN`
* sagt je Brett, welche Stufe zum Schreiben noetig ist -- dieselbe
* Tafel, die der Server beim Schreiben durchsetzt. Eine zweite Liste
* hier waere die, die eines Tages "ab Dabei" an eine Karte schreibt,
* bei der laengst jeder darf (oder umgekehrt, und das waere
* schlimmer: eine Karte ohne Marke, die beim Antippen abweist).
*
* `null` HAT ZWEI BEDEUTUNGEN IN DER TAFEL, und beide fuehren hier
* richtigerweise zu keiner Marke: Ein Brett ohne Eintrag kennt die
* Tafel nicht (dann geht es nicht ums Schreiben), und ein Brett mit
* `null` darf von aussen ueberhaupt niemand beschreiben -- das ist
* keine Stufe, die man erreicht, sondern eine dauerhafte Aufteilung.
* "ab Stamm" daranzuschreiben waere ein Versprechen, das nie
* eingeloest wird.
*
* DER SCHLUESSEL TRAEGT DAS BRETT SCHON IN SICH: `bereich:highlight`
* und `chat:treff` -- beide Formen entstehen in
* `Bereiche.schluesselVonZiel`, und beide tragen hinter dem
* Doppelpunkt genau den Namen, den die Tafel kennt. */
function erstAbFuer(person, schluessel) {
const hat = stufeVon(person);
if (!hat) return null; // Teamrolle: keine Mitglieder-Stufe
const brett = String(schluessel || "").split(":")[1] || "";
const noetig = TREFF_SCHREIBEN[brett];
if (!noetig) return null;
if (MITGLIED_STUFEN.indexOf(hat) >= MITGLIED_STUFEN.indexOf(noetig)) return null;
return STUFEN_NAME[noetig] || noetig;
}
/** „Deine Stufe" — nur fuer die Community (Bauplan, Kapitel 04).
*
* WARUM DIE ZAHLEN NICHT HIER STEHEN: `DABEI_TAGE` und die beiden
* anderen kommen aus workspace-treff.js, wo die Stufe auch BERECHNET
* wird. Haette ich „ab 7 Tagen" hier hingeschrieben, stuende die
* Regel an zwei Stellen -- und ausgerechnet der Text, der den Leuten
* erklaert, woran sie sind, waere der erste, der veraltet.
*
* `null` FUER ALLE ANDEREN. Eine Teamrolle hat keine Mitglieder-Stufe;
* eine Karte mit „Neu" waere dort schlicht falsch. Die Seite laesst
* den Abschnitt dann weg.
*
* NICHT MIT DER EINORDNUNG VERWECHSELN (Bauplan, Kapitel 02): Muss,
* Regelmaessig und Bei Bedarf sagen, was WICHTIG ist. Neu, Dabei und
* Stamm sagen, was jemand DARF. Deshalb heisst die Einordnung in der
* Oberflaeche nie „Stufe". */
function stufenkarte(person) {
const jetzt = stufeVon(person);
if (!jetzt) return null;
const erstellt = db().prepare("SELECT erstellt FROM personen WHERE id = ?")
.get(person.id)?.erstellt;
/* Ab wann geht mehr? Aus dem Beitrittstag gerechnet, nicht
geschaetzt -- "in ein paar Tagen" beantwortet die Frage nicht. */
const abTag = (tage) => {
if (!erstellt) return null;
const t = Date.parse(erstellt);
if (!Number.isFinite(t)) return null;
return new Date(t + tage * 86400_000).toISOString().slice(0, 10);
};
return {
jetzt,
name: STUFEN_NAME[jetzt] || jetzt,
reihen: [
{ schluessel: "neu", name: STUFEN_NAME.neu, ab: "ab dem ersten Tag",
kann: "Lesen und bei Wünschen mitstimmen." },
{ schluessel: "dabei", name: STUFEN_NAME.dabei, ab: `ab ${DABEI_TAGE} Tagen`,
kann: "Im Rudel schreiben, Wünsche einstellen, dich bei „Mitmachen“ melden.",
datum: abTag(DABEI_TAGE) },
{ schluessel: "stamm", name: STUFEN_NAME.stamm,
ab: `ab ${STAMM_TAGE} Tagen und ${STAMM_BEITRAEGE} eigenen Beiträgen`,
kann: "Clips und Bilder einstellen. Sie erscheinen, sobald das Team sie freigegeben hat.",
datum: abTag(STAMM_TAGE) },
],
};
}
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. */
const d = db();
const darfPflegen = PFLEGE_ROLLEN.includes(String(req.person?.rolle || ""));
if (!fassungenFuer(hausVon(req.person)).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: fassungenFuer(hausVon(req.person)),
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(hausVon(req.person), rolle);
const karten = d.prepare(
`SELECT id, kachel, stufe, wozu, tust, merke, reihe, geaendert
FROM anl_karte WHERE haus = ? AND rolle = ?
ORDER BY reihe, id`).all(hausVon(req.person), rolle)
/* DIE RECHTETAFEL SIEBT MIT -- siehe darfKarte(). Gefiltert wird
an der Quelle und nicht erst beim Senden: Darunter haengen der
Zaehler, die Stufen-Abschnitte und die Luecken-Anzeige der
Pflege, und die sollen alle dieselbe Menge meinen. */
.filter((k) => darfKarte(person, k.kachel));
/* `erledigt` kommt aus derselben Abfrage und nicht aus einer
zweiten: Zwei Abfragen koennten zwischen den Zeilen
auseinanderlaufen, und dann zeigte der Fortschritt eine andere
Zahl als die Haken darunter. */
const zeilen = d.prepare(
`SELECT z.id, z.art, z.titel, z.text, z.ziel, z.reihe,
(s.person_id IS NOT NULL) AS erledigt
FROM anl_zeile z
LEFT JOIN anl_stand s ON s.zeile_id = z.id AND s.person_id = ?
WHERE z.haus = ? AND z.rolle = ?
ORDER BY z.art, z.reihe, z.id`).all(wessenStand(req), hausVon(req.person), rolle);
const nachArt = (art) => zeilen.filter((z) => z.art === art);
const schritte = nachArt("schritt");
const fertig = schritte.filter((z) => z.erledigt).length;
/* ==================================================================
DIE MARKE "NEU" -- UND WARUM SIE EIN ENDE BRAUCHT
Der Bauplan sagt: "Aendert DogFather einen Eintrag, traegt er
fuer alle die Marke Neu." Er sagt nicht, wann sie verschwindet.
Ohne Ende ist nach einem halben Jahr alles neu, und die Marke
sagt nichts mehr -- dieselbe Krankheit wie eine Warnung, die
immer kommt.
Sie gilt deshalb, solange BEIDES zutrifft:
* die Aenderung ist hoechstens 14 Tage her, UND
* die Person hat die Seite seit der Aenderung nicht geoeffnet.
Der zweite Teil ist der wichtigere: Wer nachgesehen hat, hat es
gesehen. Der erste faengt den ab, der monatelang nicht
hereinschaut -- fuer den waere nach der Rueckkehr sonst die
halbe Seite "neu", und das hilft niemandem.
`zuletzt` wird NACH dem Rechnen fortgeschrieben, nicht davor --
sonst waere die Marke schon beim ersten Laden weg. */
const besuch = d.prepare(
"SELECT gesehen_am FROM anl_besuch WHERE person_id = ?").get(person?.id ?? 0);
const zuletzt = besuch?.gesehen_am || "";
const FRIST = new Date(Date.now() - 14 * 86400_000).toISOString();
const istNeu = (geaendert) => !!geaendert && geaendert > FRIST && geaendert > zuletzt;
/* 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: schritte.map((z) => ({
id: z.id, titel: z.titel, text: z.text, ziel: z.ziel, erledigt: !!z.erledigt,
})),
/* DER FORTSCHRITT ZAEHLT NUR DIE ERSTEN SCHRITTE -- so steht es
im Bauplan, und das ist der Grund, warum es ihn gibt: "Niemand
muss 18 Karten abhaken. Fertig ist, wer die Ersten Schritte
erledigt hat." */
fortschritt: { erledigt: fertig, gesamt: schritte.length },
/* Abhaken darf nur, wer diese Fassung auch IST. DogFather, der
sich die Sicht eines Creators ansieht, bekommt die Haken zu
sehen, kann sie aber nicht setzen -- es waere sonst sein
eigener Fortschritt an fremden Schritten. */
darf_abhaken: String(req.person?.rolle || "") === rolle,
karten: karten.map((k) => ({
...k, neu: istNeu(k.geaendert), erst_ab: erstAbFuer(person, k.kachel),
})),
nicht: nachArt("nicht").map((z) => ({ id: z.id, titel: z.titel, text: z.text })),
takt: nachArt("takt").map((z) => ({ id: z.id, titel: z.titel, text: z.text })),
fragen: nachArt("frage").map((z) => ({ id: z.id, titel: z.titel, text: z.text })),
/* "DREI DINGE VORWEG" -- nur die Community bringt sie mit
(Bauplan, Kapitel 04). Bei allen anderen Rollen ist die Liste
leer, und die Seite laesst den Abschnitt dann weg. Kein
Sonderfall im Code, nur eine leere Liste. */
vorweg: nachArt("vorweg").map((z) => ({ id: z.id, titel: z.titel, text: z.text })),
stufe: stufenkarte(person),
anzahl: karten.length,
});
/* Erst antworten, dann den Besuch vermerken. Haette die Zeile
davor gestanden, waere jede Aenderung schon beim ersten Laden
nicht mehr "neu" gewesen -- die Marke haette nie jemand gesehen. */
if (person?.id) {
d.prepare(
`INSERT INTO anl_besuch (person_id, gesehen_am) VALUES (?,?)
ON CONFLICT(person_id) DO UPDATE SET gesehen_am = excluded.gesehen_am`)
.run(person.id, new Date().toISOString());
}
} catch (fehler) {
console.error("[workspace] Anleitung:", fehler?.message);
res.status(500).json({ fehler: "serverfehler" });
}
});
/* =====================================================================
EINEN ERSTEN SCHRITT ABHAKEN — ODER DEN HAKEN WEGNEHMEN
=====================================================================
PUT und nicht POST: Der Aufruf setzt einen Zustand auf einen Wert.
Zweimal „erledigt" nacheinander ist derselbe Zustand wie einmal —
bei einem wackeligen Mobilfunknetz ist das der Unterschied zwischen
„nochmal tippen hilft" und „jetzt ist es doppelt".
WER DARF: nur die Person selbst, und nur an Schritten IHRER Rolle.
Beides wird hier geprueft und nicht nur in der Oberflaeche — eine
ausgeblendete Schaltflaeche hat noch nie etwas geschuetzt.
===================================================================== */
anleitungRouter.put("/workspace/api/anleitung/schritt/:id", (req, res) => {
try {
const person = req.person;
const id = Number(req.params.id);
if (!Number.isInteger(id) || id <= 0) {
return res.status(400).json({ fehler: "keine_nummer" });
}
const erledigt = req.body?.erledigt === true;
const d = db();
/* DIE ZEILE MUSS EIN SCHRITT SEIN UND DER EIGENEN ROLLE GEHOEREN.
Ohne `art = 'schritt'` liesse sich jede Zeile abhaken — auch eine
Frage oder eine Rhythmus-Zeile. Das faellt niemandem auf, bis
der Fortschritt eines Tages 7 von 5 meldet. */
const zeile = d.prepare(
`SELECT id FROM anl_zeile
WHERE id = ? AND art = 'schritt' AND haus = ? AND rolle = ?`)
.get(id, hausVon(req.person), String(person?.rolle || ""));
if (!zeile) return res.status(404).json({ fehler: "kein_schritt" });
if (erledigt) {
d.prepare(
`INSERT INTO anl_stand (person_id, zeile_id, erledigt_am) VALUES (?,?,?)
ON CONFLICT(person_id, zeile_id) DO NOTHING`)
.run(person.id, id, new Date().toISOString());
} else {
d.prepare("DELETE FROM anl_stand WHERE person_id = ? AND zeile_id = ?")
.run(person.id, id);
}
/* Der neue Stand kommt zurueck, damit die Seite ihn nicht selbst
hochrechnen muss. Eine Seite, die mitzaehlt, zaehlt irgendwann
anders als der Server. */
const stand = d.prepare(
`SELECT COUNT(*) AS gesamt,
SUM(CASE WHEN s.person_id IS NOT NULL THEN 1 ELSE 0 END) AS erledigt
FROM anl_zeile z
LEFT JOIN anl_stand s ON s.zeile_id = z.id AND s.person_id = ?
WHERE z.haus = ? AND z.rolle = ? AND z.art = 'schritt'`)
.get(person.id, hausVon(req.person), String(person.rolle));
res.json({
erledigt,
fortschritt: { erledigt: Number(stand?.erledigt || 0), gesamt: Number(stand?.gesamt || 0) },
});
} catch (fehler) {
console.error("[workspace] Anleitung abhaken:", fehler?.message);
res.status(500).json({ fehler: "serverfehler" });
}
});
/* =====================================================================
DER HINWEIS: „Erste Schritte noch offen"
=====================================================================
EINE QUELLE, ZWEI ANZEIGEN. Diese Funktion speist sowohl die Zeile
unter „Was ist dran" als auch die Zahl an der Kachel — und ab dieser
Etappe zusaetzlich die breite Kachel oben auf der Startseite.
Das ist kein Nebeneffekt, sondern die Regel des Hauses: Der Bauplan
wollte einen Punkt bei „Was ist dran" UND einen eigenen Zaehler an
der Kachel. Zwei getrennt gebaute Zaehler zeigen irgendwann zwei
verschiedene Zahlen uebereinander auf einem Bildschirm, und dann
glaubt man keiner mehr.
`try` um alles: Ein Fehler hier wuerde sonst die GANZE Hinweisliste
mitnehmen — Aufgaben, Termine, Reviews. Ein kaputter Hinweis darf
nicht die zwoelf gesunden daneben loeschen.
===================================================================== */
export function offeneHinweise(person) {
try {
if (!person) return [];
const rolle = String(person.rolle || "");
if (!fassungenFuer(hausVon(person)).includes(rolle)) return [];
const stand = db().prepare(
`SELECT COUNT(*) AS gesamt,
SUM(CASE WHEN s.person_id IS NOT NULL THEN 1 ELSE 0 END) AS erledigt
FROM anl_zeile z
LEFT JOIN anl_stand s ON s.zeile_id = z.id AND s.person_id = ?
WHERE z.haus = ? AND z.rolle = ? AND z.art = 'schritt'`)
.get(person.id, hausVon(person), rolle);
const gesamt = Number(stand?.gesamt || 0);
const offen = gesamt - Number(stand?.erledigt || 0);
/* Gibt es fuer diese Rolle noch gar keine Schritte (Etappe 3 steht
aus), ist `gesamt` 0 und `offen` 0 — kein Hinweis. Richtig: Ein
„0 Schritte offen" waere eine Meldung ueber nichts. */
if (offen <= 0) return [];
return [{
art: "anl_schritte", stufe: "offen",
text: "Erste Schritte noch offen", ziel: "anleitung.html", anzahl: offen,
}];
} catch (fehler) {
console.error("[workspace] Anleitung-Hinweise:", fehler?.message);
return [];
}
}
/* =====================================================================
DIE PFLEGE — NUR DOGFATHER UND SPICY (Etappe 4)
=====================================================================
Bauplan Kapitel 11: Rolle waehlen und Vorschau, Stufe aendern, Texte
bearbeiten, Stand je Person, Hinweis bei fehlendem Eintrag.
DIE VORSCHAU IST NICHT GEBAUT WORDEN -- es gibt sie schon. Der
Sicht-Umschalter in der Kopfleiste zeigt jede Seite mit den Augen
einer anderen Person, auch diese. Eine zweite Rollenauswahl waere
dieselbe Funktion an einer Stelle, an der sie niemand sucht.
WER DARF: `PFLEGE_ROLLEN` = admin und spicy. Entscheidung Filipe am
06.10.2026, dieselbe Regel wie bei den Manager-Zielen. Geprueft wird
`req.person`, NICHT `req.sicht`: Wer die Sicht eines Creators
einnimmt, soll sehen, was dieser sieht -- und in dem Moment nichts
aendern koennen, ohne es zu merken.
===================================================================== */
function nurPflege(req, res, next) {
if (!PFLEGE_ROLLEN.includes(String(req.person?.rolle || ""))) {
return res.status(403).json({ fehler: "nicht_erlaubt" });
}
next();
}
const text = (v, max) => String(v ?? "").trim().slice(0, max);
/** Eine Karte aendern: Stufe und die drei Saetze. */
anleitungRouter.put("/workspace/api/anleitung/karte/:id", nurPflege, (req, res) => {
try {
const id = Number(req.params.id);
if (!Number.isInteger(id) || id <= 0) return res.status(400).json({ fehler: "keine_nummer" });
const stufe = String(req.body?.stufe ?? "");
if (!STUFEN.some((s) => s.schluessel === stufe)) {
return res.status(400).json({ fehler: "unbekannte_stufe" });
}
const d = db();
const n = d.prepare(
`UPDATE anl_karte SET stufe = ?, wozu = ?, tust = ?, merke = ?,
geaendert = ?, geaendert_von = ?
WHERE id = ? AND haus = ?`)
.run(stufe, text(req.body?.wozu, 300), text(req.body?.tust, 400),
text(req.body?.merke, 400), new Date().toISOString(), req.person.id, id, hausVon(req.person));
if (!n.changes) return res.status(404).json({ fehler: "keine_karte" });
res.json({ ok: true });
} catch (fehler) {
console.error("[workspace] Anleitung Karte:", fehler?.message);
res.status(500).json({ fehler: "serverfehler" });
}
});
/** Eine fehlende Karte anlegen.
*
* Gebraucht fuer genau den Fall, den der Bauplan "Fuer diese Kachel
* fehlt ein Eintrag" nennt: Eine neue Kachel kommt ins Haus, und die
* Anleitung kennt sie noch nicht. Ohne diesen Weg muesste jemand in
* die Datenbank. */
anleitungRouter.post("/workspace/api/anleitung/karte", nurPflege, (req, res) => {
try {
const rolle = String(req.body?.rolle ?? "");
const kachel = text(req.body?.kachel, 80);
const stufe = String(req.body?.stufe ?? "bedarf");
if (!fassungenFuer(hausVon(req.person)).includes(rolle)) return res.status(400).json({ fehler: "unbekannte_rolle" });
if (!kachel) return res.status(400).json({ fehler: "keine_kachel" });
if (!STUFEN.some((s) => s.schluessel === stufe)) {
return res.status(400).json({ fehler: "unbekannte_stufe" });
}
const d = db();
const platz = d.prepare(
"SELECT COALESCE(MAX(reihe), -1) + 1 AS n FROM anl_karte WHERE haus = ? AND rolle = ?")
.get(hausVon(req.person), rolle).n;
try {
d.prepare(
`INSERT INTO anl_karte (haus, rolle, kachel, stufe, wozu, tust, merke,
reihe, geaendert, geaendert_von)
VALUES (?,?,?,?,?,?,?,?,?,?)`)
.run(hausVon(req.person), rolle, kachel, stufe, text(req.body?.wozu, 300),
text(req.body?.tust, 400), text(req.body?.merke, 400), platz,
new Date().toISOString(), req.person.id);
} catch {
/* Die Tabelle hat UNIQUE(haus, rolle, kachel) -- eine zweite
Karte zur selben Kachel kann es nicht geben, und das ist der
Grund, warum die Zuordnungspruefung "genau eine" sagen darf. */
return res.status(409).json({ fehler: "gibt_es_schon" });
}
res.json({ ok: true });
} catch (fehler) {
console.error("[workspace] Anleitung Karte anlegen:", fehler?.message);
res.status(500).json({ fehler: "serverfehler" });
}
});
/** Eine Karte entfernen -- fuer Kacheln, die es nicht mehr gibt. */
anleitungRouter.delete("/workspace/api/anleitung/karte/:id", nurPflege, (req, res) => {
try {
const id = Number(req.params.id);
if (!Number.isInteger(id) || id <= 0) return res.status(400).json({ fehler: "keine_nummer" });
const n = db().prepare("DELETE FROM anl_karte WHERE id = ? AND haus = ?").run(id, hausVon(req.person));
if (!n.changes) return res.status(404).json({ fehler: "keine_karte" });
res.json({ ok: true });
} catch (fehler) {
console.error("[workspace] Anleitung Karte loeschen:", fehler?.message);
res.status(500).json({ fehler: "serverfehler" });
}
});
/** Eine Listenzeile aendern (Satz, Schritt, Nicht deins, Takt, Frage). */
anleitungRouter.put("/workspace/api/anleitung/zeile/:id", nurPflege, (req, res) => {
try {
const id = Number(req.params.id);
if (!Number.isInteger(id) || id <= 0) return res.status(400).json({ fehler: "keine_nummer" });
const d = db();
const alt = d.prepare("SELECT art FROM anl_zeile WHERE id = ? AND haus = ?").get(id, hausVon(req.person));
if (!alt) return res.status(404).json({ fehler: "keine_zeile" });
/* Ein Ziel gibt es nur beim Schritt. Bei einer Frage waere es ein
Feld, das nirgends angezeigt wird -- und beim naechsten Umbau
haelt es jemand fuer eine Zusage. */
const ziel = alt.art === "schritt" ? (text(req.body?.ziel, 200) || null) : null;
d.prepare(
`UPDATE anl_zeile SET titel = ?, text = ?, ziel = ?, geaendert = ?, geaendert_von = ?
WHERE id = ? AND haus = ?`)
.run(text(req.body?.titel, 200), text(req.body?.text, 600), ziel,
new Date().toISOString(), req.person.id, id, hausVon(req.person));
res.json({ ok: true });
} catch (fehler) {
console.error("[workspace] Anleitung Zeile:", fehler?.message);
res.status(500).json({ fehler: "serverfehler" });
}
});
/** Den Leitgedanken einer Rolle aendern. */
anleitungRouter.put("/workspace/api/anleitung/rolle/:rolle", nurPflege, (req, res) => {
try {
const rolle = String(req.params.rolle || "");
if (!fassungenFuer(hausVon(req.person)).includes(rolle)) return res.status(404).json({ fehler: "unbekannte_rolle" });
db().prepare(
`INSERT INTO anl_rolle (haus, rolle, leitgedanke, geaendert, geaendert_von)
VALUES (?,?,?,?,?)
ON CONFLICT(haus, rolle) DO UPDATE SET leitgedanke = excluded.leitgedanke,
geaendert = excluded.geaendert, geaendert_von = excluded.geaendert_von`)
.run(hausVon(req.person), rolle, text(req.body?.leitgedanke, 600),
new Date().toISOString(), req.person.id);
res.json({ ok: true });
} catch (fehler) {
console.error("[workspace] Anleitung Rolle:", fehler?.message);
res.status(500).json({ fehler: "serverfehler" });
}
});
/** Wer ist wie weit? (Bauplan: "Stand je Person: fertig/offen mit Datum")
*
* Es ist eine ARBEITSLAGE, keine Ueberwachung -- dieselbe
* Unterscheidung wie bei der Team-Lage. Hier steht, wer seine
* Einweisung noch offen hat, damit jemand nachfragen kann. Nicht,
* was er gelesen hat oder wie lange er gebraucht hat. */
/** Welche Rollen stehen im „Stand der Ersten Schritte"?
*
* NUR TEAMROLLEN -- Bauplan Kapitel 11, woertlich: „Stand der Ersten
* Schritte: nur fuer Team-Rollen, ‚fertig / offen' mit Datum. Fuer die
* Community wird nichts je Person angezeigt."
*
* GEMESSEN AM 06.10.2026, UND ES STIMMTE NICHT: Auf der Crew-Adresse
* nahm die Liste schlicht alle Fassungen des Hauses -- und seit die
* Community eine eigene Fassung hat, stand jedes Mitglied mit Namen
* und Fortschritt darin. Das ist kein Schoenheitsfehler: Ein Mitglied
* hat sich zum Mitlesen angemeldet, nicht zu einer Fortschrittsakte.
* Im Agenturhaus fiel es nicht auf, weil dort jede Fassung eine
* Teamrolle ist.
*
* ABGELEITET AUS AUSSEN_ROLLEN und nicht aus einer zweiten Liste:
* Kommt eines Tages eine weitere Rolle von aussen dazu, ist sie damit
* automatisch heraus. Eine Liste hier waere die, die man beim
* Anlegen der neuen Rolle nicht anfasst. */
function standRollen(haus) {
return fassungenFuer(haus).filter((r) => !AUSSEN_ROLLEN.has(r));
}
anleitungRouter.get("/workspace/api/anleitung/stand", nurPflege, (req, res) => {
try {
const rollen = standRollen(hausVon(req.person));
/* Keine Teamrolle in diesem Haus? Dann eine leere Liste und kein
SQL mit leerem IN () -- das waere ein Syntaxfehler und damit ein
500 an einer Stelle, an der „es gibt niemanden" die richtige
Antwort ist. */
if (!rollen.length) return res.json({ leute: [] });
const leute = db().prepare(
`SELECT p.id, p.name, p.rolle,
(SELECT COUNT(*) FROM anl_zeile z
WHERE z.haus = ? AND z.rolle = p.rolle AND z.art = 'schritt') AS gesamt,
(SELECT COUNT(*) FROM anl_stand s JOIN anl_zeile z2 ON z2.id = s.zeile_id
WHERE s.person_id = p.id AND z2.art = 'schritt') AS erledigt,
(SELECT MAX(s2.erledigt_am) FROM anl_stand s2 WHERE s2.person_id = p.id) AS zuletzt
FROM personen p
WHERE p.aktiv = 1 AND p.rolle IN (${rollen.map(() => "?").join(",")})
ORDER BY p.rolle, p.name`).all(hausVon(req.person), ...rollen);
res.json({
leute: leute.map((p) => ({
name: p.name, rolle: p.rolle,
erledigt: Number(p.erledigt || 0), gesamt: Number(p.gesamt || 0),
fertig: Number(p.gesamt || 0) > 0 && Number(p.erledigt || 0) >= Number(p.gesamt || 0),
zuletzt: p.zuletzt || null,
})),
});
} catch (fehler) {
console.error("[workspace] Anleitung Stand:", fehler?.message);
res.status(500).json({ fehler: "serverfehler" });
}
});
/* =====================================================================
DIE EINWEISUNG BEIM ERSTEN ANMELDEN (Etappe 6)
=====================================================================
Zwei Wege, und beide sind klein: "Soll ich?" und "Ich habe sie
gezeigt." Alles andere -- die fuenf Schritte, das Hervorheben, der
Text -- steht im Browser, weil nur er weiss, wo die Dinge auf dem
Bildschirm liegen.
DIE ANTWORT IST IMMER 200, auch fuer die Crew und fuer DogFather --
dann eben mit `zeigen: false`. Ein 404 waere hier das Falsche: Das
Skript laeuft auf JEDER Startseite, und ein Fehlschlag in der
Konsole bei jedem Aufruf ist genau die Meldung, die die naechste
echte verdeckt.
===================================================================== */
anleitungRouter.get("/workspace/api/anleitung/einweisung", (req, res) => {
try {
/* NICHT `req.sicht`: Die Einweisung gehoert der Person, die
angemeldet ist. DogFather, der die Sicht eines Creators
einnimmt, soll nicht dessen Begruessung bekommen -- und erst
recht nicht fuer ihn abhaken, dass sie gezeigt wurde. */
const person = req.person;
const rolle = String(person?.rolle || "");
if (!fassungenFuer(hausVon(person)).includes(rolle)) {
return res.json({ zeigen: false, rolle });
}
const d = db();
const schon = d.prepare(
"SELECT 1 AS da FROM anl_einweisung WHERE person_id = ? AND rolle = ?")
.get(person.id, rolle);
/* Die Muss-Kacheln als SCHLUESSEL. Die Adressen kennt nur der
Browser (siehe oben); er rechnet sie mit derselben Funktion aus,
mit der auch die Karten verbunden werden. */
const muss = d.prepare(
`SELECT kachel FROM anl_karte
WHERE haus = ? AND rolle = ? AND stufe = 'muss' ORDER BY reihe`)
.all(hausVon(req.person), rolle).map((z) => z.kachel);
res.json({
zeigen: !schon,
rolle,
name: person.name || "",
muss,
/* IST DAS EINE TEAMROLLE? (06.10.2026, Bauplan Kapitel 10)
"Community drei Schritte, Team-Rollen fuenf."
Als Ja/Nein und nicht als Rollenname: rundgang.js laeuft im
Browser, und in dieser Datei steht bewusst kein einziger
Rollenname -- sie waere sonst am Tag nach der naechsten
Aenderung falsch, und zwar bei jedem, der sie schon geladen
hat. Gefragt wird dieselbe Menge, die auch sonst entscheidet,
wer von aussen kommt. */
team: !AUSSEN_ROLLEN.has(rolle),
});
} catch (fehler) {
console.error("[workspace] Einweisung:", fehler?.message);
/* Auch im Fehlerfall kein 500: Eine kaputte Begruessung darf die
Startseite nicht mit einer roten Zeile begruessen. */
res.json({ zeigen: false });
}
});
/* =====================================================================
„NEU FUER DICH" — DIE KARTE BEIM AUFSTIEG (Bauplan Kapitel 10)
=====================================================================
„Erreicht jemand ‚Dabei' (ab 7 Tagen) oder ‚Stamm' (ab 30 Tagen und
3 eigenen Beitraegen), zeigt die Startseite einmal die Karte ‚Neu
fuer dich: Du kannst jetzt …' mit Sprung zur passenden Karte."
WAS JETZT GEHT, WIRD NICHT AUFGESCHRIEBEN, SONDERN ABGELEITET —
aus derselben Tafel, die es auch ENTSCHEIDET (`TREFF_SCHREIBEN`:
Brett → noetige Stufe). Haette ich hier eine Liste gepflegt („ab
Dabei darfst du schreiben, Wuensche einstellen, dich melden"), waere
sie beim naechsten neuen Brett still falsch — und zwar in der
Richtung, die weh tut: Jemand bekommt eine Glueckwunschkarte, die
ihm etwas verspricht, das er gar nicht darf, oder sie verschweigt
ihm, was er jetzt duerfte.
DIE KACHELN KOMMEN AUS SEINER EIGENEN LISTE, nicht aus einer
zweiten. `bereicheFuer` hat die Rechtetafel schon angewandt; eine
Kachel, die diese Person nicht sehen darf, kann hier gar nicht erst
auftauchen. Gefunden werden sie ueber das Ziel (`b=` und `raum=`),
weil Namen in diesem Haus wandern und Ziele nicht.
DREI EXIT-WEGE, und der dritte ist wichtig: Gibt es zu einer frisch
freigeschalteten Taetigkeit KEINE Kachel, erscheint die Karte
trotzdem — mit dem Satz, aber ohne Sprung. Die Karte wegzulassen,
weil ein Verweis fehlt, hiesse den Aufstieg zu verschweigen. */
const AUFSTIEGS_STUFEN = new Set(["dabei", "stamm"]);
function neuFuerDich(person) {
const jetzt = stufeVon(person);
if (!jetzt || !AUFSTIEGS_STUFEN.has(jetzt)) return null;
const schon = db().prepare(
"SELECT 1 AS da FROM anl_stufe WHERE person_id = ? AND stufe = ?")
.get(person.id, jetzt);
if (schon) return null;
/* Welche Bretter wurden GERADE freigeschaltet? Die, deren noetige
Stufe genau diese ist — nicht die, die schon vorher gingen. */
const frisch = Object.entries(TREFF_SCHREIBEN)
.filter(([, noetig]) => noetig === jetzt).map(([brett]) => brett);
const liste = bereicheFuer(person);
const kacheln = [];
for (const brett of frisch) {
const k = (Array.isArray(liste) ? liste : []).find((x) => {
const z = String(x?.ziel || "");
return z.includes(`b=${brett}`) || z.includes(`raum=${brett}`);
});
if (k) kacheln.push({ name: k.name, ziel: k.ziel, zeichen: k.zeichen, ton: k.ton });
}
return {
zeigen: true,
stufe: jetzt,
name: STUFEN_NAME[jetzt] || jetzt,
kacheln,
};
}
anleitungRouter.get("/workspace/api/anleitung/aufstieg", (req, res) => {
try {
/* `req.person` und nicht `req.sicht`: Der Aufstieg gehoert der
Person, die angemeldet ist. Wer die Sicht eines Mitglieds
einnimmt, soll dessen Glueckwunsch nicht wegklicken. */
res.json(neuFuerDich(req.person) || { zeigen: false });
} catch (fehler) {
console.error("[workspace] Aufstieg:", fehler?.message);
/* Kein 500: Eine kaputte Glueckwunschkarte darf die Zentrale
nicht mit einer roten Zeile begruessen. */
res.json({ zeigen: false });
}
});
/** Die Karte wurde gezeigt — gemerkt beim ZEIGEN, nicht beim
* Wegklicken. Dieselbe Entscheidung wie bei der Begruessung, und aus
* demselben gemessenen Grund: Wer die Seite schliesst, ohne etwas
* anzuklicken, hat sie trotzdem gesehen. Sonst kommt sie wieder. Und
* beim uebernaechsten Mal auch. */
anleitungRouter.post("/workspace/api/anleitung/aufstieg/gesehen", (req, res) => {
try {
const person = req.person;
const jetzt = stufeVon(person);
if (!jetzt || !AUFSTIEGS_STUFEN.has(jetzt)) return res.json({ ok: true });
db().prepare(
`INSERT INTO anl_stufe (person_id, stufe, gezeigt_am) VALUES (?,?,?)
ON CONFLICT(person_id, stufe) DO NOTHING`)
.run(person.id, jetzt, new Date().toISOString());
res.json({ ok: true });
} catch (fehler) {
console.error("[workspace] Aufstieg merken:", fehler?.message);
res.status(500).json({ fehler: "serverfehler" });
}
});
/** Gezeigt -- egal ob durchgeklickt oder "Spaeter".
*
* BEIDES ZAEHLT, und das ist Absicht. Der Bauplan sagt: "Spaeter ist
* erlaubt. Dann steht unter 'Was ist dran': Erste Schritte noch
* offen." Wer "Spaeter" waehlt, hat die Begruessung gesehen und soll
* sie nicht bei jedem Aufruf wieder bekommen -- der Hinweis
* uebernimmt. Ein Fenster, das so lange wiederkommt, bis man das
* Richtige anklickt, ist keine Einladung, sondern eine Sperre. */
anleitungRouter.post("/workspace/api/anleitung/einweisung/gesehen", (req, res) => {
try {
const person = req.person;
const rolle = String(person?.rolle || "");
if (!fassungenFuer(hausVon(person)).includes(rolle)) {
return res.json({ ok: true });
}
db().prepare(
`INSERT INTO anl_einweisung (person_id, rolle, gezeigt_am) VALUES (?,?,?)
ON CONFLICT(person_id, rolle) DO NOTHING`)
.run(person.id, rolle, new Date().toISOString());
res.json({ ok: true });
} catch (fehler) {
console.error("[workspace] Einweisung merken:", fehler?.message);
res.status(500).json({ fehler: "serverfehler" });
}
});