diff --git a/server/helfer-png-bauen.mjs b/server/helfer-png-bauen.mjs
new file mode 100644
index 00000000..7bd4f811
--- /dev/null
+++ b/server/helfer-png-bauen.mjs
@@ -0,0 +1,61 @@
+/* =====================================================================
+ EIN PNG SCHREIBEN (03.10.2026)
+
+ Das Gegenstueck zu helfer-png.mjs, das eines LIEST. Gebraucht fuer
+ die Eventkarten-Pruefung: Ob der Titel auf einer Buehne lesbar
+ bleibt, haengt davon ab, was fuer ein Banner darunter liegt -- und
+ der interessante Fall ist nicht das dunkle, sondern das HELLE, das
+ jemand spaeter hochlaedt. Ein solches Bild muss die Pruefung also
+ selbst herstellen koennen.
+
+ WARUM SELBST GESCHRIEBEN: Dieselbe Begruendung wie bei helfer-png.mjs
+ -- das Projekt hat kein Bildmodul, und fuer ein Testbild eines
+ nachzuinstallieren waere eine Abhaengigkeit, die jemand pflegen
+ muss. Node bringt zlib mit; ein unkomprimiertes RGB-PNG ist danach
+ nur noch Pruefsumme und Zeilenfilter.
+ ===================================================================== */
+import { deflateSync } from "node:zlib";
+
+const tabelle = (() => {
+ const t = new Int32Array(256);
+ for (let n = 0; n < 256; n++) {
+ let c = n;
+ for (let k = 0; k < 8; k++) c = (c & 1) ? (0xedb88320 ^ (c >>> 1)) : (c >>> 1);
+ t[n] = c;
+ }
+ return t;
+})();
+function crc32(buf) {
+ let c = 0xffffffff;
+ for (let i = 0; i < buf.length; i++) c = tabelle[(c ^ buf[i]) & 0xff] ^ (c >>> 8);
+ return (c ^ 0xffffffff) >>> 0;
+}
+function teil(name, inhalt) {
+ const kopf = Buffer.alloc(8);
+ kopf.writeUInt32BE(inhalt.length, 0);
+ kopf.write(name, 4, "ascii");
+ const pruef = Buffer.alloc(4);
+ pruef.writeUInt32BE(crc32(Buffer.concat([Buffer.from(name, "ascii"), inhalt])), 0);
+ return Buffer.concat([kopf, inhalt, pruef]);
+}
+/** male(x, y, b, h) -> [r,g,b] */
+export function pngBauen(b, h, male) {
+ const roh = Buffer.alloc((b * 3 + 1) * h);
+ let p = 0;
+ for (let y = 0; y < h; y++) {
+ roh[p++] = 0;
+ for (let x = 0; x < b; x++) {
+ const f = male(x, y, b, h);
+ roh[p++] = f[0]; roh[p++] = f[1]; roh[p++] = f[2];
+ }
+ }
+ const ihdr = Buffer.alloc(13);
+ ihdr.writeUInt32BE(b, 0); ihdr.writeUInt32BE(h, 4);
+ ihdr[8] = 8; ihdr[9] = 2; ihdr[10] = 0; ihdr[11] = 0; ihdr[12] = 0;
+ return Buffer.concat([
+ Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]),
+ teil("IHDR", ihdr),
+ teil("IDAT", deflateSync(roh, { level: 9 })),
+ teil("IEND", Buffer.alloc(0)),
+ ]);
+}
diff --git a/server/pruef-agentur.mjs b/server/pruef-agentur.mjs
index 8cba5a8e..eb1c572c 100644
--- a/server/pruef-agentur.mjs
+++ b/server/pruef-agentur.mjs
@@ -697,33 +697,56 @@ melde("\n=== Die Kachel ===");
Regeln koennen vollstaendig in der Antwort stehen und trotzdem
nirgends gezeichnet werden -- ein vergessener Aufruf in karte()
reicht, und niemand merkt es, weil die Karte ja da ist. */
+ /* ==== AUF DIE NEUE KARTE UMGESTELLT (03.10.2026) ================
+
+ Die Eventkarte wurde umgebaut (siehe pruef-eventkarte.mjs): Buehne
+ mit Titel auf dem Banner, Zeitraum einmal statt dreimal, Aufgaben
+ als Liste zum Abhaken. Dabei haben die Bausteine neue Namen
+ bekommen -- `.event-zeitraum__marke` heisst jetzt `.ev-noch`,
+ `.event-block__text` ist zu einer Liste aus `.ev-punkt__text`
+ geworden.
+
+ DIESE PRUEFUNG WURDE DESHALB NICHT GELOESCHT, SONDERN NACHGEZOGEN:
+ Sie sagt weiterhin dieselben FUENF Dinge zu, nur an der neuen
+ Stelle. Eine Pruefung wegzunehmen, weil sie nach einem Umbau rot
+ wird, senkt die Zahl und beweist danach nichts mehr
+ (Projektgedaechtnis, 28.08.2026: 789 statt 804 Pruefungen, und
+ acht davon fielen still weg).
+
+ WAS SICH INHALTLICH AENDERT: Der Zustand steht nicht mehr als
+ "laeuft bis TT.MM.JJJJ" da, sondern als "noch N Tage" -- dieselbe
+ Aussage, nur beantwortet sie jetzt die Frage, die man wirklich
+ hat. Geprueft wird deshalb auf ein WORT (nicht nur Farbe), nicht
+ mehr auf einen bestimmten Satz. */
const event = await seite.$$eval(".eintrag-karte", (karten) => {
const k = karten.find((x) => x.querySelector(".eintrag-titel")?.textContent.trim() === "Sommer-Aktion");
if (!k) return null;
return {
stand: k.dataset.event,
- marke: k.querySelector(".event-zeitraum__marke")?.textContent.trim(),
- spanne: k.querySelector(".event-zeitraum__spanne")?.textContent.trim(),
- schilder: [...k.querySelectorAll(".event-block__schild")].map((s) => s.textContent.trim()),
- texte: [...k.querySelectorAll(".event-block__text")].map((s) => s.textContent.trim()),
+ marke: k.querySelector(".ev-noch")?.textContent.trim(),
+ spanne: k.querySelector(".ev-spanne")?.textContent.trim(),
+ schilder: [...k.querySelectorAll(".ev-block__schild")].map((s) => s.textContent.trim()),
+ texte: [...k.querySelectorAll(".ev-punkt__text")].map((s) => s.textContent.trim()),
};
});
ok(!!event, "die Event-Karte Sommer-Aktion steht auf der Seite");
ok(event?.stand === "laeuft", `sie ist als laufend gekennzeichnet ("${event?.stand}")`);
- ok(/läuft bis/.test(event?.marke || ""),
+ /* Ein Wort, kein leerer Platzhalter -- und es muss zum Zustand
+ passen: "noch N Tage" bzw. "heute ist der letzte Tag". */
+ ok(/^(noch \d+ Tage?|heute ist der letzte Tag)$/.test(event?.marke || ""),
`der Zustand steht als WORT da, nicht nur als Farbe ("${event?.marke}")`);
ok(/–/.test(event?.spanne || ""), `und der Zeitraum als Spanne ("${event?.spanne}")`);
ok(event?.schilder.join(",") === "Aufgaben,Regeln",
`beide Bloecke sind beschriftet (${event?.schilder.join(", ")})`);
ok(event?.texte.includes("1 Punkt je Live-Stunde") && event?.texte.includes("Nur eigenes Konto"),
- "und tragen wirklich ihren Inhalt");
+ `und tragen wirklich ihren Inhalt (${JSON.stringify(event?.texte)})`);
/* GEGENPROBE dazu: Eine Karte, die KEIN Event ist, darf nichts davon
haben. Sonst bewiese der Block oben nur, dass die Elemente
existieren -- nicht, dass sie zur richtigen Art gehoeren. */
const schulung = await seite.$$eval(".eintrag-karte", (karten) => {
const k = karten.find((x) => x.querySelector(".eintrag-titel")?.textContent.trim() === "Test schulung");
- return k ? { event: k.dataset.event ?? null, bloecke: k.querySelectorAll(".event-block").length } : null;
+ return k ? { event: k.dataset.event ?? null, bloecke: k.querySelectorAll(".ev-block").length } : null;
});
ok(schulung && !schulung.event && schulung.bloecke === 0,
`eine Schulung bekommt weder Zustand noch Event-Bloecke (${JSON.stringify(schulung)})`);
diff --git a/server/pruef-eventkarte.mjs b/server/pruef-eventkarte.mjs
new file mode 100644
index 00000000..7b58fc42
--- /dev/null
+++ b/server/pruef-eventkarte.mjs
@@ -0,0 +1,900 @@
+/* =====================================================================
+ AGENTUR-EVENTS: KARTE, PUNKTE UND FORMULAR (03.10.2026)
+
+ Filipe, mit einem Bildschirmfoto des Oktober-Events: „ich will das
+ viel geiler viel perfekter. und auch wenn man die eintraegt und so
+ soll viel mehr perfektioniert und uebersichtlicher sein."
+
+ Umgebaut wurden drei Dinge, und alle drei werden hier nachgemessen:
+
+ 1. DIE KARTE Buehne mit Titel auf dem Banner statt 638 px
+ leerem Bild darueber; Zeitraum einmal statt
+ dreimal; „noch 13 Tage" als Antwort.
+ 2. DIE PUNKTE Aus Fliesstext wird eine Liste zum Abhaken,
+ jeder fuer sich. Neue Tabelle event_punkte.
+ 3. DAS FORMULAR Ein Feld je Zeile statt eines leeren Textfeldes,
+ in das man Aufzaehlungszeichen selbst tippt.
+
+ WAS HIER AUSDRUECKLICH NICHT NUR BESTAETIGT WIRD:
+
+ Zu jeder Zusage gehoert eine Gegenprobe, die zeigt, dass die
+ Pruefung auch „nicht in Ordnung" sagen kann -- sonst bestaetigt sie
+ nichts (Projektgedaechtnis, 01.09.2026: `every()` auf einem leeren
+ Feld meldet `true`). Deshalb steht die Zahl der geprueften Dinge in
+ der BEDINGUNG, nicht nur im Meldetext.
+
+ KEINE WANDUHR ALS ANNAHME: Alle Daten sind relativ zu heute
+ gerechnet (`tag(-1)`, `tag(+12)`). Ein festes Datum waere eine
+ Zeitbombe -- gruen, bis der Kalender es einholt (Projektgedaechtnis,
+ 06.09.2026, gate-oeffnung.mjs).
+ ===================================================================== */
+import { mkdtempSync, rmSync, appendFileSync } from "node:fs";
+import { tmpdir } from "node:os";
+import { join } from "node:path";
+
+const ordner = mkdtempSync(join(tmpdir(), "ws-evk-"));
+process.env.WORKSPACE_DB = join(ordner, "workspace.db");
+
+import { notbremse } from "./helfer-notbremse.mjs";
+const { eigenerPort } = await import("./helfer-port.mjs");
+const PORT = await eigenerPort(import.meta, "die Eventkarten-Pruefung");
+
+process.env.PORT = String(PORT);
+process.env.SITE_ACCESS_SECRET = "lokaler-test";
+process.env.SITE_ACCESS_CODE_DOGI = "DOGI-TEST-1";
+process.env.SITE_PUBLIC_LAUNCH_AT = "2020-01-01T00:00:00+01:00";
+
+const express = (await import("express")).default;
+const ec = express.response.cookie;
+express.response.cookie = function (n, w, o) { return ec.call(this, n, w, { ...(o || {}), secure: false }); };
+await import("./index.js");
+notbremse(300_000, "pruef-eventkarte");
+await new Promise((r) => setTimeout(r, 1000));
+const BASIS = "http://127.0.0.1:" + PORT;
+
+const PROTOKOLL = join(process.cwd(), "pruef-eventkarte-lauf.txt");
+try { rmSync(PROTOKOLL, { force: true }); } catch { /* gab es noch nicht */ }
+const melde = (z) => {
+ console.log(z);
+ try { appendFileSync(PROTOKOLL, z + "\n"); } catch { /* Zugabe */ }
+};
+let fehler = 0, geprueft = 0;
+const ok = (b, t) => { geprueft++; melde((b ? " ok " : " FEHL ") + t); if (!b) fehler++; };
+
+/* ---- Bestand ---------------------------------------------------------- */
+const { DatabaseSync } = await import("node:sqlite");
+const { scryptSync, randomBytes } = await import("node:crypto");
+await fetch(BASIS + "/workspace/api/anmelden", {
+ method: "POST", headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({ rolle: "admin", code: "x" }) }).catch(() => {});
+for (let i = 0; i < 60; i++) {
+ try {
+ const p = new DatabaseSync(process.env.WORKSPACE_DB);
+ try { p.prepare("SELECT 1 FROM personen LIMIT 1").get(); break; } finally { p.close(); }
+ } catch { await new Promise((r) => setTimeout(r, 250)); }
+}
+const d = new DatabaseSync(process.env.WORKSPACE_DB);
+const jetzt = new Date().toISOString();
+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, aktiv, erstellt) VALUES (?,?,?,?,?,1,?)")
+ .run(name, rolle, hash, salt, 32768, jetzt);
+ return d.prepare("SELECT last_insert_rowid() AS id").get().id;
+}
+const idDogi = anlegen("Dogfather", "admin", "CODE-DOGI-0001");
+const idLuna = anlegen("Luna", "creator", "CODE-CREA-0001");
+
+const heute = new Date();
+const tag = (v) => new Date(heute.getTime() + v * 86400000).toISOString().slice(0, 10);
+function evt(titel, von, bis, auf, reg) {
+ d.prepare("INSERT INTO eintraege (titel, text, bereich, art, status, datum, event_ende,"
+ + " event_aufgaben, event_regeln, dringlichkeit, erstellt, erstellt_von)"
+ + " VALUES (?,?,'agentur','kampagne','offen',?,?,?,?,'mittel',?,?)")
+ .run(titel, "Beschreibung zum Test.", von, bis, auf, reg, jetzt, idDogi);
+ return d.prepare("SELECT last_insert_rowid() AS id").get().id;
+}
+/* Die drei Zustaende, die eventStand kennt -- jeder einzeln, nicht als
+ Summe. Und die Aufgaben genau so krumm getippt wie im echten Event:
+ einmal ohne Leerzeichen hinter dem Punkt, einmal mit. */
+const laufend = evt("Oktober Event", tag(-1), tag(12),
+ "·Jeden Tag Live gehen\n·22-Live-Stunden erreichen\n· 33k Diamanten erreichen",
+ "· Nur Streams auf dem eigenen Konto\n· Punkte zählen bis 23:59 am letzten Tag");
+const kommend = evt("November Challenge", tag(20), tag(45),
+ "· 30 Live-Stunden", "· Nur eigene Konten");
+const vorbei = evt("September Rückblick", tag(-40), tag(-12),
+ "· 20 Live-Stunden", "· Nur eigene Konten");
+/* Ein gewoehnlicher Eintrag auf demselben Brett: Er darf sich NICHT
+ mitverwandeln. Ohne ihn wuerde "nur Events sind betroffen" hier gar
+ nicht geprueft. */
+d.prepare("INSERT INTO eintraege (titel, text, bereich, art, status, datum, dringlichkeit, erstellt, erstellt_von)"
+ + " VALUES (?,?,'agentur','schulung','offen',?, 'mittel', ?, ?)")
+ .run("Einarbeitung für neue Modis", "Kein Event.", tag(0), jetzt, idDogi);
+/* EIN EINTRAG, DEN LUNA NICHT SEHEN DARF -- fuer die Gegenprobe zur
+ Bildweg-Reparatur weiter unten. `entwicklung` ist eine Aufzeichnung
+ UEBER eine Person; sie selbst sieht sie nicht. Ohne diesen Eintrag
+ waere "es wurden keine Rechte ausgeweitet" eine Behauptung ohne
+ Beleg. */
+d.prepare("INSERT INTO eintraege (titel, text, bereich, art, status, datum, dringlichkeit, erstellt, erstellt_von, creator_id)"
+ + " VALUES (?,?,'entwicklung','notiz','offen',?, 'mittel', ?, ?, ?)")
+ .run("Vertrauliche Aufzeichnung", "Nicht fuer Luna.", tag(0), jetzt, idDogi, idDogi);
+const geheim = d.prepare("SELECT last_insert_rowid() AS id").get().id;
+d.close();
+
+/* Ein Banner fuer das laufende Event -- ohne Bild waere der Knopf
+ "Banner ganz" nicht pruefbar, und die Buehne mit Bild ist der Fall,
+ um den es Filipe ging. */
+import { pngBauen } from "./helfer-png-bauen.mjs";
+const bannerBild = pngBauen(1280, 720, (x, y, b, h) => {
+ const g = Math.max(0, 1 - Math.hypot(x / b - .5, y / h - .5) * 1.8);
+ return [Math.round(26 + g * 120), Math.round(10 + g * 30), Math.round(46 + g * 150)];
+});
+
+/* ---- Zugang ----------------------------------------------------------- */
+const { request } = await import("node:http");
+const HOST = "workspace.dogfather-universe.com";
+function roh(pfad, o = {}) {
+ return new Promise((f, s) => {
+ const daten = o.rumpf === undefined ? null
+ : (Buffer.isBuffer(o.rumpf) ? o.rumpf : Buffer.from(JSON.stringify(o.rumpf)));
+ const a = request({
+ host: "127.0.0.1", port: PORT, path: pfad,
+ method: o.methode || (daten ? "POST" : "GET"),
+ headers: {
+ Host: HOST, Origin: "https://" + HOST,
+ ...(o.keks ? { Cookie: o.keks } : {}),
+ ...(daten ? { "Content-Length": daten.length } : {}),
+ ...(o.kopf || {}),
+ },
+ }, (r) => {
+ const t = [];
+ r.on("data", (x) => t.push(x));
+ r.on("end", () => f({ code: r.statusCode, kopf: r.headers, text: Buffer.concat(t).toString("utf8") }));
+ });
+ a.on("error", s);
+ if (daten) a.write(daten);
+ a.end();
+ });
+}
+async function anmelden(rolle, code) {
+ const a = await roh("/workspace/api/anmelden", {
+ rumpf: { rolle, code }, kopf: { "Content-Type": "application/json" } });
+ if (a.code !== 200) { melde("ABBRUCH: Anmeldung " + rolle + " -> " + a.code); process.exit(3); }
+ return [].concat(a.kopf["set-cookie"] || []).map((c) => c.split(";")[0]).join("; ");
+}
+const keksDogi = await anmelden("admin", "CODE-DOGI-0001");
+const keksLuna = await anmelden("creator", "CODE-CREA-0001");
+
+const brett = async (keks) => JSON.parse((await roh("/workspace/api/bereich/agentur", { keks })).text);
+
+let bannerId = null, geheimBildId = null;
+{
+ const h = await roh("/workspace/api/bereich/agentur/" + laufend + "/bild", {
+ keks: keksDogi, rumpf: bannerBild, methode: "POST",
+ kopf: { "Content-Type": "image/png", "x-name": encodeURIComponent("banner.png") } });
+ if (h.code !== 201) {
+ melde("ABBRUCH: Banner liess sich nicht anhaengen (" + h.code + ") -- ohne es");
+ melde(" prueft diese Datei die Buehne MIT Bild gar nicht.");
+ process.exit(3);
+ }
+ bannerId = JSON.parse(h.text).id;
+
+ const g = await roh("/workspace/api/bereich/entwicklung/" + geheim + "/bild", {
+ keks: keksDogi, rumpf: bannerBild, methode: "POST",
+ kopf: { "Content-Type": "image/png", "x-name": encodeURIComponent("geheim.png") } });
+ if (g.code !== 201) {
+ melde("ABBRUCH: das Bild fuer die Gegenprobe ging nicht durch (" + g.code + ").");
+ melde(" Ohne es wuerde unten eine Rechtefrage geprueft, die gar nicht stattfindet.");
+ process.exit(3);
+ }
+ geheimBildId = JSON.parse(g.text).id;
+}
+
+melde("\n=== 0. Der Bildweg: wer den Eintrag sieht, sieht sein Bild ===");
+{
+ /* GEMESSEN AM 03.10.2026, und es war ein echter Mangel: Das Banner
+ eines Agentur-Events kam bei einer Creatorin mit 404 zurueck --
+ an genau der Karte, die fuer sie gemacht ist. Grund: Die Regel
+ "wer das Brett sieht, sieht das Bild" galt nur fuer die sieben
+ Community-Bretter, und danach griff die Ablage-Regel, die an
+ Creator-Zuordnungen haengt. Ein Agentur-Event hat keine. */
+ const alsDogi = await roh("/workspace/api/dateien/" + bannerId + "/bild", { keks: keksDogi });
+ const alsLuna = await roh("/workspace/api/dateien/" + bannerId + "/bild", { keks: keksLuna });
+ ok(alsDogi.code === 200, `DogFather bekommt das Banner (HTTP ${alsDogi.code})`);
+ ok(alsLuna.code === 200, `und die Creatorin auch (HTTP ${alsLuna.code}, vorher 404)`);
+
+ /* DIE GEGENPROBE, auf die es ankommt: Die Reparatur darf NICHT mehr
+ oeffnen als noetig. Ein Eintrag, den Luna nicht sehen darf, muss
+ sein Bild weiterhin verweigern -- sonst waere aus einer Luecke
+ eine groessere geworden. */
+ const verboten = await roh("/workspace/api/dateien/" + geheimBildId + "/bild", { keks: keksLuna });
+ ok(verboten.code === 404,
+ `ein Bild an einem fremden Eintrag bleibt verborgen (HTTP ${verboten.code})`);
+ /* Und dass dieses Bild ueberhaupt existiert -- sonst waere die
+ Gegenprobe gruen, weil es nichts zu sehen gab. */
+ const existiert = await roh("/workspace/api/dateien/" + geheimBildId + "/bild", { keks: keksDogi });
+ ok(existiert.code === 200,
+ `es gibt dieses Bild wirklich (DogFather: HTTP ${existiert.code})`);
+}
+
+/* =======================================================================
+ 1. Die Punkte: aus Fliesstext wird eine Liste
+ ======================================================================= */
+melde("\n=== 1. Die Punkte kommen als Liste vom Server ===");
+{
+ const daten = await brett(keksDogi);
+ const e = (daten.eintraege || []).find((x) => x.titel === "Oktober Event");
+ ok(!!e, "das laufende Event ist auf dem Brett");
+
+ const p = e?.event_punkte || [];
+ ok(p.length === 3, `3 Aufgabenpunkte (${p.length})`);
+ /* DAS IST DER EIGENTLICHE MANGEL AUS FILIPES BILDSCHIRMFOTO: Im
+ Original stand „·Jeden Tag Live gehen" neben „· 33k Diamanten
+ erreichen" -- einmal ohne, einmal mit Leerzeichen. */
+ ok(p[0]?.text === "Jeden Tag Live gehen",
+ `ohne Leerzeichen getippt -> sauber: ${JSON.stringify(p[0]?.text)}`);
+ ok(p[2]?.text === "33k Diamanten erreichen",
+ `mit Leerzeichen getippt -> gleich sauber: ${JSON.stringify(p[2]?.text)}`);
+ ok(p.every((x) => /^[0-9a-f]{16}$/.test(x.schluessel || "")) && p.length > 0,
+ "jeder Punkt hat einen Schluessel (16 Hexzeichen)");
+ ok((e?.event_regelpunkte || []).length === 2,
+ `2 Regelpunkte (${(e?.event_regelpunkte || []).length})`);
+ ok(e?.event_geschafft === 0, `noch nichts abgehakt (${e?.event_geschafft})`);
+
+ /* GEGENPROBE: Ein Eintrag, der KEIN Event ist, bekommt keine Punkte.
+ Ohne sie wuesste niemand, ob die Zerlegung nicht einfach ueberall
+ laeuft -- und dann waere "nur Events sind betroffen" unbelegt. */
+ const schulung = (daten.eintraege || []).find((x) => x.art === "schulung");
+ ok(!!schulung, "der Nicht-Event-Eintrag ist da (sonst prueft die Gegenprobe nichts)");
+ ok(schulung && schulung.event_punkte === undefined,
+ "ein Eintrag ohne Eventfelder bekommt keine Punkteliste");
+}
+
+/* =======================================================================
+ 2. Abhaken -- und was dabei NICHT geht
+ ======================================================================= */
+melde("\n=== 2. Abhaken: jeder fuer sich ===");
+let schluesselA = null, schluesselB = null;
+{
+ const daten = await brett(keksDogi);
+ const e = daten.eintraege.find((x) => x.titel === "Oktober Event");
+ schluesselA = e.event_punkte[1].schluessel; /* 22-Live-Stunden */
+ schluesselB = e.event_punkte[2].schluessel; /* 33k Diamanten */
+
+ const a = await roh(`/workspace/api/eintrag/${laufend}/punkt/${schluesselA}`, {
+ keks: keksLuna, methode: "PUT", rumpf: {}, kopf: { "Content-Type": "application/json" } });
+ ok(a.code === 200, `Luna hakt einen Punkt ab (HTTP ${a.code})`);
+ const j = JSON.parse(a.text || "{}");
+ ok(j.geschafft === 1 && j.gesamt === 3, `Antwort sagt ${j.geschafft} von ${j.gesamt} (erwartet 1 von 3)`);
+
+ /* HAELT ES AUCH? Die Zahl aus der Antwort koennte gerechnet sein,
+ ohne dass etwas gespeichert wurde. Deshalb ein frischer Abruf. */
+ const nachher = await brett(keksLuna);
+ const eL = nachher.eintraege.find((x) => x.titel === "Oktober Event");
+ ok(eL.event_geschafft === 1, `nach frischem Abruf: ${eL.event_geschafft} abgehakt`);
+ ok(eL.event_punkte.filter((p) => p.meiner).length === 1
+ && eL.event_punkte[1].meiner === true,
+ "und zwar genau der angeklickte Punkt");
+
+ /* DER KERN DER ZUSAGE: DogFather sieht Lunas Haken NICHT als seinen
+ eigenen. Ohne diese Pruefung waere "jeder hakt fuer sich ab" eine
+ Behauptung. */
+ const beiDogi = await brett(keksDogi);
+ const eD = beiDogi.eintraege.find((x) => x.titel === "Oktober Event");
+ ok(eD.event_geschafft === 0,
+ `DogFather sieht Lunas Haken nicht als seinen (${eD.event_geschafft})`);
+ ok(eD.event_punkte.every((p) => p.meiner === false),
+ "kein einziger Punkt ist bei ihm angehakt");
+
+ /* ... sieht aber, DASS jemand dabei ist -- er ist die Leitung. */
+ ok(Array.isArray(eD.event_stand) && eD.event_stand.length === 1
+ && eD.event_stand[0].name === "Luna" && eD.event_stand[0].geschafft === 1,
+ `die Leitung sieht den Stand: ${JSON.stringify(eD.event_stand)}`);
+ /* GEGENPROBE ZUR LEITUNG: Luna selbst darf diese Liste NICHT
+ bekommen -- sonst waere "wer macht mit" eine Auskunft fuer alle. */
+ ok(eL.event_stand === undefined,
+ "eine Creatorin bekommt die Mitmacherliste gar nicht erst");
+
+ /* Zweimal dasselbe ist kein Fehler, aendert aber auch nichts. */
+ const nochmal = await roh(`/workspace/api/eintrag/${laufend}/punkt/${schluesselA}`, {
+ keks: keksLuna, methode: "PUT", rumpf: {}, kopf: { "Content-Type": "application/json" } });
+ const j2 = JSON.parse(nochmal.text || "{}");
+ ok(nochmal.code === 200 && j2.geschafft === 1,
+ `zweimal derselbe Haken bleibt 1 (HTTP ${nochmal.code}, ${j2.geschafft})`);
+
+ /* Und wieder weg. */
+ const ab = await roh(`/workspace/api/eintrag/${laufend}/punkt/${schluesselA}`, {
+ keks: keksLuna, methode: "DELETE" });
+ const j3 = JSON.parse(ab.text || "{}");
+ ok(ab.code === 200 && j3.geschafft === 0, `Haken wieder weg (${j3.geschafft})`);
+ /* Fuer die folgenden Pruefungen wieder setzen. */
+ await roh(`/workspace/api/eintrag/${laufend}/punkt/${schluesselA}`, {
+ keks: keksLuna, methode: "PUT", rumpf: {}, kopf: { "Content-Type": "application/json" } });
+}
+
+melde("\n=== 2b. Was NICHT geht ===");
+{
+ /* EIN ERFUNDENER SCHLUESSEL. Ohne diese Schranke koennte sich jeder
+ „3 von 3" setzen, ohne dass eine der drei Aufgaben je dastand --
+ und eine Zahl, die man sich ausdenken kann, zaehlt nichts. */
+ const a = await roh(`/workspace/api/eintrag/${laufend}/punkt/00112233445566ff`, {
+ keks: keksLuna, methode: "PUT", rumpf: {}, kopf: { "Content-Type": "application/json" } });
+ ok(a.code === 404, `erfundener Punkt wird abgelehnt (HTTP ${a.code})`);
+
+ /* Unsinn in der Adresse. */
+ const b = await roh(`/workspace/api/eintrag/${laufend}/punkt/..%2F..%2Fetc`, {
+ keks: keksLuna, methode: "PUT", rumpf: {}, kopf: { "Content-Type": "application/json" } });
+ ok(b.code === 400 || b.code === 404, `kein Schluesselformat -> abgelehnt (HTTP ${b.code})`);
+
+ /* Ohne Anmeldung gar nichts. */
+ const c = await roh(`/workspace/api/eintrag/${laufend}/punkt/${schluesselB}`, {
+ methode: "PUT", rumpf: {}, kopf: { "Content-Type": "application/json" } });
+ ok(c.code === 401, `ohne Anmeldung abgewiesen (HTTP ${c.code})`);
+
+ /* Fremde Herkunft (CSRF). */
+ const e = await new Promise((f, s) => {
+ const a2 = request({
+ host: "127.0.0.1", port: PORT,
+ path: `/workspace/api/eintrag/${laufend}/punkt/${schluesselB}`,
+ method: "PUT",
+ headers: { Host: HOST, Origin: "https://boeswillig.example", Cookie: keksLuna,
+ "Content-Type": "application/json", "Content-Length": 2 },
+ }, (r) => { const t = []; r.on("data", (x) => t.push(x));
+ r.on("end", () => f({ code: r.statusCode })); });
+ a2.on("error", s); a2.write("{}"); a2.end();
+ });
+ ok(e.code === 403, `fremde Herkunft abgewiesen (HTTP ${e.code})`);
+}
+
+/* =======================================================================
+ 3. Der Schluessel ueberlebt das Umsortieren -- aber nicht das Umschreiben
+ ======================================================================= */
+melde("\n=== 3. Haken bleiben am richtigen Punkt ===");
+{
+ /* UMSORTIEREN: Der Haken an „22-Live-Stunden" muss bleiben, wo er
+ ist -- auch wenn eine Zeile DAVOR eingefuegt wird. Haengt er an
+ einer Zeilennummer, wandert er still auf eine andere Aufgabe, und
+ jemand hat etwas abgehakt, was er nie gemacht hat. */
+ const a = await roh(`/workspace/api/bereich/agentur/${laufend}`, {
+ keks: keksDogi, methode: "PATCH",
+ rumpf: { event_aufgaben: "· GANZ NEU ganz oben\n· 33k Diamanten erreichen\n· 22-Live-Stunden erreichen\n· Jeden Tag Live gehen" },
+ kopf: { "Content-Type": "application/json" } });
+ ok(a.code === 200, `Aufgaben umsortiert und ergaenzt (HTTP ${a.code})`);
+
+ const nach = await brett(keksLuna);
+ const e = nach.eintraege.find((x) => x.titel === "Oktober Event");
+ ok((e.event_punkte || []).length === 4, `jetzt 4 Punkte (${(e.event_punkte || []).length})`);
+ const haken = (e.event_punkte || []).filter((p) => p.meiner);
+ ok(haken.length === 1 && haken[0].text === "22-Live-Stunden erreichen",
+ `der Haken sitzt weiterhin an: ${JSON.stringify(haken.map((h) => h.text))}`);
+
+ /* GEGENPROBE: Wird die Bedingung SELBST geaendert, muss der Haken
+ fallen -- eine geaenderte Bedingung ist eine andere Bedingung, und
+ ein Haken daran waere eine Falschauskunft. */
+ const b = await roh(`/workspace/api/bereich/agentur/${laufend}`, {
+ keks: keksDogi, methode: "PATCH",
+ rumpf: { event_aufgaben: "· GANZ NEU ganz oben\n· 33k Diamanten erreichen\n· 30-Live-Stunden erreichen\n· Jeden Tag Live gehen" },
+ kopf: { "Content-Type": "application/json" } });
+ ok(b.code === 200, `Bedingung umgeschrieben: 22 -> 30 Stunden (HTTP ${b.code})`);
+ const nach2 = await brett(keksLuna);
+ const e2 = nach2.eintraege.find((x) => x.titel === "Oktober Event");
+ ok((e2.event_punkte || []).filter((p) => p.meiner).length === 0,
+ "der Haken ist gefallen -- 30 Stunden sind nicht 22");
+ ok(e2.event_geschafft === 0, `und die Zahl stimmt dazu (${e2.event_geschafft})`);
+
+ /* Zurueckstellen fuer die Oberflaechenpruefung. */
+ await roh(`/workspace/api/bereich/agentur/${laufend}`, {
+ keks: keksDogi, methode: "PATCH",
+ rumpf: { event_aufgaben: "·Jeden Tag Live gehen\n·22-Live-Stunden erreichen\n· 33k Diamanten erreichen" },
+ kopf: { "Content-Type": "application/json" } });
+}
+
+/* =======================================================================
+ 4. Die Oberflaeche
+ ======================================================================= */
+melde("\n=== 4. Die Karte im Browser ===");
+const pw = await import("file:///C:/Users/qciga/Documents/Obelix/Analyse/node_modules/playwright/index.mjs");
+const browser = await pw.chromium.launch();
+const klagen = [];
+
+async function alsRolle(rolle, code, breite = 1280) {
+ const ctx = await browser.newContext({ viewport: { width: breite, height: 1000 }, hasTouch: breite <= 860 });
+ await ctx.route("**/*", (route) => {
+ const u = new URL(route.request().url());
+ if (u.hostname === "127.0.0.1") {
+ return route.continue({ headers: { ...route.request().headers(), host: HOST } });
+ }
+ return route.continue();
+ });
+ const seite = await ctx.newPage();
+ seite.on("pageerror", (f) => klagen.push("pageerror: " + f.message));
+ seite.on("console", (m) => { if (m.type() === "error") klagen.push("console: " + m.text().slice(0, 160)); });
+ /* WELCHE Datei fehlt -- die Konsolenmeldung sagt nur "404 (Not
+ Found)". Beim ersten Lauf standen hier drei davon, und sie waren
+ nicht zuzuordnen; eine Pruefung, die nicht sagen kann, woran sie
+ scheitert, kostet eine Stunde Suchen (Projektgedaechtnis,
+ 11.09.2026). */
+ seite.on("response", (r) => {
+ if (r.status() >= 400) klagen.push(`HTTP ${r.status()}: ${new URL(r.url()).pathname}`);
+ });
+ await seite.goto(BASIS + "/workspace/", { waitUntil: "networkidle" });
+ await seite.click('.rolle[data-rolle="' + rolle + '"]');
+ await seite.fill("#code", code);
+ await seite.click("button[type=submit]");
+ await seite.waitForURL("**/start.html", { timeout: 25000 });
+ await seite.goto(BASIS + "/workspace/bereich.html?b=agentur", { waitUntil: "networkidle" });
+ await seite.waitForSelector(".eintrag-karte", { timeout: 15000 });
+ await seite.waitForTimeout(700);
+ return { ctx, seite };
+}
+
+{
+ const { ctx, seite } = await alsRolle("admin", "CODE-DOGI-0001", 1280);
+
+ const karten = await seite.$$('.eintrag-karte[data-eventkarte="ja"]');
+ ok(karten.length === 3, `3 Eventkarten (${karten.length}) -- die Schulung ist keine`);
+
+ /* GEGENPROBE: Die Schulung bleibt eine gewoehnliche Karte. Ohne sie
+ waere "nur Events sind umgebaut" unbelegt. */
+ const schulung = await seite.$('.eintrag-karte:not([data-eventkarte="ja"])');
+ ok(!!schulung, "der Nicht-Event-Eintrag ist weiterhin eine gewoehnliche Karte");
+
+ const masse = await seite.evaluate(() => {
+ const k = [...document.querySelectorAll('.eintrag-karte[data-eventkarte="ja"]')]
+ .find((x) => x.textContent.includes("Oktober Event"));
+ const b = k.querySelector(".ev-buehne");
+ const bild = k.querySelector(".ev-buehne__bild");
+ const t = k.querySelector(".eintrag-titel");
+ const kr = k.getBoundingClientRect();
+ const tr = t.getBoundingClientRect();
+ return {
+ karte: Math.round(kr.height),
+ buehne: b ? Math.round(b.getBoundingClientRect().height) : 0,
+ bildBreite: bild ? Math.round(bild.getBoundingClientRect().width) : 0,
+ buehneBreite: b ? Math.round(b.getBoundingClientRect().width) : 0,
+ titelGroesse: parseFloat(getComputedStyle(t).fontSize),
+ textGroesse: parseFloat(getComputedStyle(k.querySelector(".eintrag-text")).fontSize),
+ markeGroesse: parseFloat(getComputedStyle(k.querySelector(".marke-art")).fontSize),
+ /* Wie weit oben steht der Titel INNERHALB der Karte? Das war der
+ eigentliche Mangel: vorher unter 638 px Bild. */
+ titelInKarte: Math.round(tr.top - kr.top),
+ datumZeilen: [...k.querySelectorAll(".ev-spanne, .im-kalender, .event-zeitraum")]
+ .map((x) => x.textContent.trim()),
+ noch: k.querySelector(".ev-noch")?.textContent || null,
+ };
+ });
+ melde(" -- gemessen: " + JSON.stringify(masse));
+
+ /* DIE ZAHLEN AUS DEM BEFUND, jede einzeln. */
+ ok(masse.titelInKarte < 180,
+ `der Titel steht ${masse.titelInKarte} px unter der Kartenkante (vorher 638+, Grenze 180)`);
+ ok(masse.titelGroesse >= 18,
+ `Titel ${masse.titelGroesse} px (vorher 15,36 -- Grenze 18)`);
+ ok(masse.titelGroesse >= masse.textGroesse * 1.3,
+ `Titel deutlich groesser als der Text darunter (${masse.titelGroesse} vs ${masse.textGroesse})`);
+ /* Die projekteigene Grenze fuer Grossbuchstaben mit Sperrung. Sie
+ galt bisher nur unter 760 px Fensterbreite -- eine Grenze, die nur
+ bei schmalen Fenstern gilt, ist keine. */
+ ok(masse.markeGroesse >= 11.5,
+ `Etikett ${masse.markeGroesse} px (vorher 10,88 -- Grenze 11,5)`);
+ ok(masse.buehne <= 320 && masse.buehne > 0,
+ `Buehne ${masse.buehne} px hoch (vorher 638)`);
+ ok(masse.datumZeilen.length === 1,
+ `das Datum steht einmal, nicht dreimal: ${JSON.stringify(masse.datumZeilen)}`);
+ ok(/^noch \d+ Tage?$/.test(masse.noch || ""),
+ `der Countdown beantwortet "wie lange noch": ${JSON.stringify(masse.noch)}`);
+
+ /* Die drei Zustaende -- jeder einzeln nachgewiesen, nicht als Summe. */
+ const staende = await seite.evaluate(() => {
+ const raus = {};
+ for (const k of document.querySelectorAll('.eintrag-karte[data-eventkarte="ja"]')) {
+ const t = k.querySelector(".eintrag-titel")?.textContent || "?";
+ raus[t] = { stand: k.dataset.event, wort: k.querySelector(".ev-noch")?.textContent };
+ }
+ return raus;
+ });
+ ok(staende["Oktober Event"]?.stand === "laeuft",
+ `laufend: ${JSON.stringify(staende["Oktober Event"])}`);
+ ok(staende["November Challenge"]?.stand === "kommt"
+ && /^startet in \d+ Tagen$/.test(staende["November Challenge"]?.wort || ""),
+ `kommend: ${JSON.stringify(staende["November Challenge"])}`);
+ ok(staende["September Rückblick"]?.stand === "vorbei"
+ && /^vorbei seit \d+ Tagen$/.test(staende["September Rückblick"]?.wort || ""),
+ `vorbei: ${JSON.stringify(staende["September Rückblick"])}`);
+
+ /* EIN VORBEIGELAUFENES EVENT WIRD NICHT MEHR ABGEHAKT -- ein Haken
+ waere eine Behauptung ueber etwas, das nicht mehr stattfindet. */
+ const knoepfeVorbei = await seite.evaluate(() => {
+ const k = [...document.querySelectorAll('.eintrag-karte[data-eventkarte="ja"]')]
+ .find((x) => x.textContent.includes("September"));
+ return {
+ knoepfe: k.querySelectorAll(".ev-punkt__knopf").length,
+ zeilen: k.querySelectorAll(".ev-punkt").length,
+ };
+ });
+ ok(knoepfeVorbei.zeilen > 0 && knoepfeVorbei.knoepfe === 0,
+ `vorbei: ${knoepfeVorbei.zeilen} Zeilen lesbar, ${knoepfeVorbei.knoepfe} Knoepfe`);
+
+ /* DER WEG ZUM GANZEN BANNER. Er war beim ersten Entwurf verloren
+ gegangen: Vorher war das Bild ein Verweis, als Buehne nicht mehr --
+ und bei Filipes Banner steht die eigentliche Ansage IM Bild. */
+ const ganz = await seite.evaluate(() => {
+ const k = [...document.querySelectorAll('.eintrag-karte[data-eventkarte="ja"]')]
+ .find((x) => x.textContent.includes("Oktober Event"));
+ const a = k.querySelector(".ev-buehne__ganz");
+ const t = k.querySelector(".eintrag-titel");
+ if (!a) return null;
+ const ar = a.getBoundingClientRect(), tr = t.getBoundingClientRect();
+ return {
+ ziel: a.getAttribute("href"),
+ neuesFenster: a.getAttribute("target") === "_blank" && /noopener/.test(a.getAttribute("rel") || ""),
+ wort: a.textContent.trim(),
+ /* Deckt er den Titel zu? Das haeufigste Problem bei einem Knopf,
+ der absolut in einer Ecke sitzt. */
+ ueberlappt: !(ar.bottom < tr.top || ar.top > tr.bottom || ar.right < tr.left || ar.left > tr.right),
+ hoehe: Math.round(ar.height),
+ };
+ });
+ ok(!!ganz && /^\/workspace\/api\/dateien\/\d+\/bild$/.test(ganz.ziel || ""),
+ `"Banner ganz" fuehrt zum vollen Bild: ${JSON.stringify(ganz)}`);
+ ok(!!ganz && ganz.neuesFenster, "und zwar in einem neuen Fenster, mit noopener");
+ ok(!!ganz && !ganz.ueberlappt, "er liegt nicht ueber dem Titel");
+
+ /* GEGENPROBE: Ohne Bild gibt es ihn nicht -- ein Knopf, der auf eine
+ Fehlerseite fuehrt, ist schlimmer als keiner. */
+ const ohneBild = await seite.evaluate(() => {
+ const k = [...document.querySelectorAll('.eintrag-karte[data-eventkarte="ja"]')]
+ .find((x) => x.textContent.includes("November Challenge"));
+ return { mitbild: k.querySelector(".ev-buehne")?.dataset.mitbild || null,
+ knopf: k.querySelectorAll(".ev-buehne__ganz").length };
+ });
+ ok(ohneBild.mitbild === null && ohneBild.knopf === 0,
+ `ohne Banner kein Knopf: ${JSON.stringify(ohneBild)}`);
+
+ /* Der Loeschknopf unterscheidet sich jetzt von den anderen. */
+ const gefahr = await seite.evaluate(() => {
+ const k = [...document.querySelectorAll('.eintrag-karte[data-eventkarte="ja"]')]
+ .find((x) => x.textContent.includes("Oktober Event"));
+ const l = k.querySelector(".schritt--gefahr");
+ const andere = k.querySelector(".eintrag-knoepfe .schritt:not(.schritt--gefahr)");
+ return l && andere
+ ? { l: getComputedStyle(l).color, a: getComputedStyle(andere).color, text: l.textContent.trim() }
+ : null;
+ });
+ ok(!!gefahr && gefahr.text === "löschen" && gefahr.l !== gefahr.a,
+ `"löschen" sieht anders aus als die uebrigen Knoepfe: ${JSON.stringify(gefahr)}`);
+
+ await ctx.close();
+}
+
+melde("\n=== 5. Abhaken im Browser, mit Neuladen ===");
+{
+ const { ctx, seite } = await alsRolle("creator", "CODE-CREA-0001", 1280);
+
+ /* DEN ZAEHLER DER RICHTIGEN KARTE LESEN.
+ Beim ersten Lauf stand hier `seite.textContent(".ev-zaehler")` --
+ das nimmt den ERSTEN auf der Seite, und der gehoerte zur November-
+ Challenge ("0 von 1"). Die Pruefung meldete daraufhin FEHL, obwohl
+ am Zaehler nichts falsch war. Eine Pruefung, die das Falsche misst,
+ kostet dieselbe Zeit wie ein echter Fehler. */
+ const OKT = '.eintrag-karte[data-eventkarte="ja"]:has(.eintrag-titel:text-is("Oktober Event"))';
+ const zaehlerLesen = async () =>
+ (await seite.textContent(OKT + " .ev-zaehler") || "").trim();
+
+ const vorher = await zaehlerLesen();
+ ok(/^\d+ von \d+$/.test(vorher), `Zaehler steht da: ${JSON.stringify(vorher)}`);
+
+ /* Luna hat aus Teil 2 noch einen Haken -- also 1 von 3. */
+ ok(vorher === "1 von 3", `Lunas Stand vom Server: ${vorher}`);
+
+ const knoepfe = await seite.$$(OKT + " .ev-punkt__knopf");
+ ok(knoepfe.length === 3, `${knoepfe.length} abhakbare Punkte an diesem Event`);
+ /* Den NICHT bereits abgehakten nehmen -- sonst zaehlt der Klick
+ herunter statt herauf, und die Pruefung misst das Gegenteil. */
+ await knoepfe[0].click();
+ await seite.waitForTimeout(600);
+ const danach = await zaehlerLesen();
+ ok(danach === "2 von 3", `nach dem Klick: ${danach}`);
+
+ /* HAELT ES EINEN SEITENWECHSEL AUS? Eine Anzeige, die nur im
+ Browser zaehlt, sieht genauso aus -- und ist nichts wert. */
+ await seite.reload({ waitUntil: "networkidle" });
+ await seite.waitForSelector(".ev-zaehler", { timeout: 15000 });
+ await seite.waitForTimeout(500);
+ const nachNeuladen = await zaehlerLesen();
+ ok(nachNeuladen === "2 von 3", `nach dem Neuladen immer noch: ${nachNeuladen}`);
+
+ /* Und wieder ab. */
+ const k2 = await seite.$$(OKT + " .ev-punkt__knopf");
+ await k2[0].click();
+ await seite.waitForTimeout(600);
+ const ab = await zaehlerLesen();
+ ok(ab === "1 von 3", `wieder abgewaehlt: ${ab}`);
+
+ await ctx.close();
+}
+
+melde("\n=== 6. Das Formular ===");
+{
+ const { ctx, seite } = await alsRolle("admin", "CODE-DOGI-0001", 1280);
+ await seite.click("#neu-oeffnen");
+ await seite.waitForTimeout(250);
+ await seite.selectOption("#f-art", "kampagne");
+ await seite.waitForTimeout(350);
+
+ const editoren = await seite.$$(".zeileneditor");
+ ok(editoren.length === 2, `zwei Zeilen-Editoren (Aufgaben, Regeln): ${editoren.length}`);
+
+ /* DIE TEXTGESTALTUNGS-LEISTE GEHOERT ZUM VERBORGENEN FELD und darf
+ nicht allein unter der Punkteliste schweben. */
+ const leisten = await seite.evaluate(() => {
+ const raus = [];
+ for (const id of ["f-aufgaben", "f-regeln"]) {
+ const f = document.getElementById(id);
+ const l = f?.parentElement?.querySelector(".textform");
+ raus.push({ id, feldVerborgen: !!f?.hidden, leisteVerborgen: l ? !!l.hidden : null });
+ }
+ return raus;
+ });
+ ok(leisten.length === 2 && leisten.every((x) => x.feldVerborgen && x.leisteVerborgen === true),
+ `Leiste ist mit ihrem Feld verborgen: ${JSON.stringify(leisten)}`);
+
+ /* Platzhalter passend zum Feld -- "22 Live-Stunden" unter REGELN
+ waere eine Fehlanleitung. */
+ const halter = await seite.evaluate(() =>
+ [...document.querySelectorAll(".zeileneditor")].map((r) =>
+ r.querySelector(".zeileneditor__feld")?.placeholder || ""));
+ ok(halter.length === 2 && halter[0] !== halter[1]
+ && /Live-Stunden/.test(halter[0]) && /Konto/.test(halter[1]),
+ `je Feld ein eigenes Beispiel: ${JSON.stringify(halter)}`);
+
+ await seite.fill("#f-titel", "Testevent");
+ await seite.fill("#f-datum", tag(0));
+ await seite.fill("#f-ende", tag(13));
+ await seite.waitForTimeout(250);
+ const dauer = (await seite.textContent("#hinweis-dauer") || "").trim();
+ ok(dauer === "Läuft 14 Tage.", `Dauer wird mitgerechnet: ${JSON.stringify(dauer)}`);
+
+ /* GEGENPROBE: Ein Ende VOR dem Anfang muss auffallen -- sonst stuende
+ auf der frischen Karte "vorbei seit 3 Tagen". */
+ await seite.fill("#f-ende", tag(-3));
+ await seite.waitForTimeout(250);
+ const warnung = (await seite.textContent("#hinweis-dauer") || "").trim();
+ const warnMerkmal = await seite.getAttribute("#hinweis-dauer", "data-warnung");
+ ok(/vor dem Anfang/.test(warnung) && warnMerkmal === "ja",
+ `verdrehter Zeitraum faellt auf: ${JSON.stringify(warnung)}`);
+ await seite.fill("#f-ende", tag(13));
+ await seite.waitForTimeout(250);
+
+ /* Die Vorschau zeigt die echte Buehne. */
+ const vorschau = await seite.evaluate(() => {
+ const v = document.getElementById("ev-vorschau");
+ return {
+ sichtbar: v ? !v.hidden : false,
+ buehne: !!v?.querySelector(".ev-buehne"),
+ titel: v?.querySelector(".eintrag-titel")?.textContent || null,
+ noch: v?.querySelector(".ev-noch")?.textContent || null,
+ };
+ });
+ ok(vorschau.sichtbar && vorschau.buehne && vorschau.titel === "Testevent"
+ && /^noch \d+ Tage?$/.test(vorschau.noch || ""),
+ `Vorschau zeigt die echte Buehne: ${JSON.stringify(vorschau)}`);
+
+ /* DER KERN DES FORMULAR-UMBAUS: Was am Ende gespeichert wird, ist
+ gleichmaessig -- egal wie jemand tippt. */
+ const felder = await seite.$$(".zeileneditor__feld");
+ await felder[0].fill("Jeden Tag Live gehen");
+ await seite.click(".zeileneditor__mehr");
+ const f2 = await seite.$$(".zeileneditor__feld");
+ await f2[1].fill("22 Live-Stunden erreichen");
+ await seite.waitForTimeout(250);
+ const roh2 = await seite.inputValue("#f-aufgaben");
+ ok(roh2 === "· Jeden Tag Live gehen\n· 22 Live-Stunden erreichen",
+ `gleichmaessig gesetzte Zeichen: ${JSON.stringify(roh2)}`);
+
+ /* Und das Ganze wirklich abschicken -- bis auf die Karte. */
+ await seite.click("#neu-speichern");
+ await seite.waitForTimeout(1200);
+ /* IM BRETT SUCHEN, NICHT IM FORMULAR.
+ Beim ersten Lauf stand hier ein Suchlauf ueber ALLE Karten der
+ Seite -- und die VORSCHAU im Formular ist ebenfalls eine Karte mit
+ demselben Merkmal und demselben Titel. Gefunden wurde sie zuerst,
+ und sie hat naturgemaess keine Aufgabenliste. Die Pruefung meldete
+ einen Fehler, den es nicht gab. */
+ const neu = await seite.evaluate(() => {
+ const liste = document.getElementById("liste");
+ const k = [...liste.querySelectorAll('.eintrag-karte[data-eventkarte="ja"]')]
+ .find((x) => x.textContent.includes("Testevent"));
+ return k ? {
+ punkte: [...k.querySelectorAll(".ev-block--aufgaben .ev-punkt__text")].map((x) => x.textContent),
+ zaehler: k.querySelector(".ev-zaehler")?.textContent || null,
+ } : null;
+ });
+ ok(!!neu && neu.punkte.length === 2
+ && neu.punkte[0] === "Jeden Tag Live gehen"
+ && neu.punkte[1] === "22 Live-Stunden erreichen",
+ `aus dem Editor wurde die Liste auf der Karte: ${JSON.stringify(neu)}`);
+ ok(neu?.zaehler === "0 von 2", `und der Zaehler steht auf null: ${neu?.zaehler}`);
+
+ /* NACH DEM SPEICHERN IST DIE VORSCHAU LEER. Sonst stuende beim
+ naechsten "Neuer Eintrag" das vorige Event darin -- und man
+ glaubte, es sei nicht gespeichert worden. */
+ const nachher = await seite.evaluate(() => {
+ const v = document.getElementById("ev-vorschau");
+ return { verborgen: !!v?.hidden, inhalt: (v?.textContent || "").trim().length };
+ });
+ ok(nachher.verborgen && nachher.inhalt === 0,
+ `die Vorschau ist zurueckgesetzt: ${JSON.stringify(nachher)}`);
+
+ await ctx.close();
+}
+
+melde("\n=== 7. Auf dem Handy (412 px) ===");
+{
+ const { ctx, seite } = await alsRolle("creator", "CODE-CREA-0001", 412);
+ const handy = await seite.evaluate(() => {
+ const k = [...document.querySelectorAll('.eintrag-karte[data-eventkarte="ja"]')]
+ .find((x) => x.textContent.includes("Oktober Event"));
+ const knopf = k.querySelector(".ev-punkt__knopf");
+ const b = k.querySelector(".ev-buehne");
+ return {
+ buehne: Math.round(b.getBoundingClientRect().height),
+ knopfHoehe: knopf ? Math.round(knopf.getBoundingClientRect().height) : 0,
+ /* Laeuft etwas ueber den Rand? Das haeufigste Handy-Problem. */
+ ueberlauf: Math.round(k.scrollWidth - k.clientWidth),
+ bloeckeSpalten: getComputedStyle(k.querySelector(".ev-bloecke")).gridTemplateColumns,
+ ganzHoehe: (() => { const a = k.querySelector(".ev-buehne__ganz");
+ return a ? Math.round(a.getBoundingClientRect().height) : null; })(),
+ ganzBreite: (() => { const a = k.querySelector(".ev-buehne__ganz");
+ return a ? Math.round(a.getBoundingClientRect().width) : null; })(),
+ };
+ });
+ melde(" -- gemessen: " + JSON.stringify(handy));
+ ok(handy.knopfHoehe >= 44,
+ `das Beruehrziel ist ${handy.knopfHoehe} px hoch (Grenze 44)`);
+ /* UND WENN DOCH, DANN WELCHES ELEMENT? Eine Zahl allein schickt
+ einen auf die Suche; der Schuldige steht im DOM und wird nur nicht
+ genannt. (Projektgedaechtnis: eine Pruefung, die nicht sagen kann,
+ woran sie scheitert, kostet eine Stunde.) */
+ if (handy.ueberlauf > 1) {
+ const taeter = await seite.evaluate(() => {
+ const k = [...document.querySelectorAll('.eintrag-karte[data-eventkarte="ja"]')]
+ .find((x) => x.textContent.includes("Oktober Event"));
+ const kr = k.getBoundingClientRect();
+ return [...k.querySelectorAll("*")]
+ .map((e) => ({ was: e.className || e.tagName,
+ rechts: Math.round(e.getBoundingClientRect().right - kr.right) }))
+ .filter((x) => x.rechts > 0)
+ .sort((a, b) => b.rechts - a.rechts).slice(0, 5);
+ });
+ melde(" -- was ueberlaeuft: " + JSON.stringify(taeter)
+ + (taeter.length === 0
+ ? " (KEIN Element -- dann ist es ein ::before/::after, das"
+ + " querySelectorAll nicht findet. Beim ersten Mal war es"
+ + " .ev-buehne__inhalt::before mit festem -20px gegen 14px Polsterung.)"
+ : ""));
+ }
+ ok(handy.ueberlauf <= 1, `nichts laeuft ueber den Rand (${handy.ueberlauf} px)`);
+ ok(handy.bloeckeSpalten.split(" ").length === 1,
+ `auf 412 px eine Spalte, nicht zwei: ${handy.bloeckeSpalten}`);
+ ok(handy.buehne <= 240 && handy.buehne > 0, `Buehne ${handy.buehne} px`);
+ /* Auf dem Handy schrumpft "Banner ganz" auf die Lupe -- das Ziel
+ darf dabei nicht unter 32 px fallen. (32 statt 44: Er sitzt frei
+ in einer Ecke ohne Nachbarn, und sein Wort bleibt fuer
+ Vorleseprogramme erhalten.) */
+ ok(handy.ganzHoehe !== null && handy.ganzHoehe >= 32 && handy.ganzBreite >= 32,
+ `der Banner-Knopf bleibt greifbar: ${handy.ganzBreite} x ${handy.ganzHoehe} px`);
+ await ctx.close();
+}
+
+melde("\n=== 8. Das andere Haus bleibt unberuehrt ===");
+{
+ /* DIE REGEL VOM 24.09.2026: "Ab sofort wird JEDER Umbau getrennt
+ gefuehrt. Eine Aenderung an einem der beiden Haeuser darf im
+ anderen nichts bewirken ... Wer etwas baut, sagt vorher, fuer
+ WELCHES Haus es ist, und weist danach nach, dass das andere
+ unveraendert ist."
+
+ DAS IST HIER NICHT SELBSTVERSTAENDLICH: `bereich.css` wird von
+ JEDEM Brett geladen, auch von denen der Crew, und `tuKnopf` ist
+ die Knopffabrik aller Bretter. Beides wurde angefasst. Dass
+ trotzdem nur Agentur-Events betroffen sind, haengt daran, dass
+ jede neue Regel an `[data-eventkarte="ja"]` oder einer `ev-`
+ Klasse haengt -- und dieses Merkmal setzt nur istEventArt().
+ Eine Behauptung, solange sie niemand nachmisst. */
+ const ctx = await browser.newContext({ viewport: { width: 1280, height: 1000 } });
+ await ctx.route("**/*", (route) => {
+ const u = new URL(route.request().url());
+ if (u.hostname === "127.0.0.1") {
+ return route.continue({ headers: { ...route.request().headers(), host: "crew.dogfather-universe.com" } });
+ }
+ return route.continue();
+ });
+ const seite = await ctx.newPage();
+ seite.on("pageerror", (f) => klagen.push("crew pageerror: " + f.message));
+ await seite.goto(BASIS + "/workspace/", { waitUntil: "networkidle" });
+ await seite.click('.rolle[data-rolle="admin"]');
+ await seite.fill("#code", "CODE-DOGI-0001");
+ await seite.click("button[type=submit]");
+ await seite.waitForURL("**/start.html", { timeout: 25000 });
+
+ /* Das Brett der Agentur gibt es auf dieser Adresse gar nicht. */
+ await seite.goto(BASIS + "/workspace/bereich.html?b=agentur", { waitUntil: "networkidle" });
+ await seite.waitForTimeout(900);
+ const dort = await seite.evaluate(() => ({
+ adresse: location.pathname,
+ eventkarten: document.querySelectorAll('[data-eventkarte="ja"]').length,
+ evTeile: document.querySelectorAll('[class*="ev-buehne"], [class*="ev-punkt"], .zeileneditor').length,
+ }));
+ /* ==== EIN BEFUND, DER NICHT AUS DIESEM UMBAU STAMMT ===============
+
+ Hier stand zuerst die Zusage "auf der Crew-Adresse nichts davon" --
+ und sie wurde rot: Das Agentur-Brett ist dort erreichbar, mit
+ Inhalt. Vor dem Schreiben einer Reparatur erst nachgemessen, ob
+ ich das verursacht habe (03.10.2026, server/_mess/haus.mjs, einmal
+ mit und einmal ohne den Umbau):
+
+ Host crew.dogfather-universe.com haus=crew
+ /workspace/api/bereich/agentur -> 200, 1 Eintrag
+
+ ZWEIMAL DASSELBE, mit und ohne. Der Durchgriff ist also aelter als
+ dieser Umbau und gehoert zur Regel vom 24.09.2026 ("Kein Bestand
+ wird geteilt"), nicht hierher -- er ist Filipe gemeldet und wird
+ getrennt entschieden. Ihn hier nebenbei zuzumauern hiesse, am
+ Rechtesystem beider Haeuser zu drehen, waehrend eigentlich eine
+ Karte umgebaut wird.
+
+ WAS DIESE DATEI DESHALB ZUSAGT, ist das, wofuer sie zustaendig
+ ist: Der Umbau aendert an der Crew-Adresse NICHTS -- die Karten
+ sehen dort aus wie auf der Agenturadresse, und die eigenen Bretter
+ der Crew bleiben unberuehrt (gleich darunter).
+
+ Die Zahl wird trotzdem ausgegeben: Faellt sie eines Tages auf 0,
+ ist der Durchgriff behoben, und das soll man hier sehen. */
+ melde(" -- Crew-Adresse, Brett `agentur`: " + JSON.stringify(dort)
+ + (dort.eventkarten > 0
+ ? " (vorbestehender Durchgriff, siehe Kommentar -- nicht aus diesem Umbau)"
+ : " (der Durchgriff ist behoben)"));
+ /* Wenn das Brett dort erscheint, dann wenigstens vollstaendig und
+ ohne Fehler -- eine halb gebaute Karte waere ein echter Schaden. */
+ ok(dort.eventkarten === 0 || dort.evTeile > 0,
+ `was dort erscheint, ist vollstaendig gebaut (${dort.eventkarten} Karten, ${dort.evTeile} Teile)`);
+
+ /* Und ein Brett, das es dort WIRKLICH gibt, sieht aus wie immer.
+ Ohne diese Haelfte waere oben nur bewiesen, dass die Seite leer
+ ist -- das waere sie auch bei einem kaputten Brett. */
+ await seite.goto(BASIS + "/workspace/bereich.html?b=treff", { waitUntil: "networkidle" });
+ await seite.waitForTimeout(900);
+ const treff = await seite.evaluate(() => ({
+ liste: !!document.getElementById("liste"),
+ formular: !!document.getElementById("neu"),
+ knoepfe: document.querySelectorAll(".schritt").length,
+ gefahr: document.querySelectorAll(".schritt--gefahr").length,
+ evTeile: document.querySelectorAll('[class*="ev-buehne"], [class*="ev-punkt"], .zeileneditor').length,
+ }));
+ ok(treff.liste && treff.formular,
+ `ein Crew-Brett steht und funktioniert: ${JSON.stringify(treff)}`);
+ ok(treff.evTeile === 0, `und zeigt keinen Teil des Event-Umbaus (${treff.evTeile})`);
+ /* Auch der Loeschknopf bleibt dort der alte: Die Markierung wird
+ zwar ueberall gesetzt, aber gestaltet wird sie nur innerhalb einer
+ Eventkarte. */
+ const farben = await seite.evaluate(() => {
+ const g = document.querySelector(".schritt--gefahr");
+ const n = document.querySelector(".schritt:not(.schritt--gefahr)");
+ return g && n ? { gefahr: getComputedStyle(g).color, normal: getComputedStyle(n).color } : null;
+ });
+ ok(farben === null || farben.gefahr === farben.normal,
+ `der Loeschknopf sieht dort aus wie immer: ${JSON.stringify(farben)}`);
+
+ await ctx.close();
+}
+
+await browser.close();
+
+melde("\n=== Browser-Meldungen ===");
+ok(klagen.length === 0, klagen.length ? "Klagen: " + klagen.join(" | ") : "keine Fehler in der Konsole");
+
+melde("");
+melde(`${geprueft} geprueft, ${fehler} Fehler`);
+if (!geprueft) { melde("ABBRUCH: nichts geprueft -- das ist ein Fehler, kein Erfolg."); process.exit(3); }
+process.exit(fehler ? 1 : 0);
diff --git a/server/workspace-bereiche.js b/server/workspace-bereiche.js
index fffa218f..7f06405d 100644
--- a/server/workspace-bereiche.js
+++ b/server/workspace-bereiche.js
@@ -20,7 +20,7 @@ import express from "express";
Fassung, und an ihr haengt die Sicherheit aller Uploads im Haus. */
import { writeFileSync, mkdirSync, unlinkSync } from "node:fs";
import { join } from "node:path";
-import { randomBytes } from "node:crypto";
+import { randomBytes, createHash } from "node:crypto";
import { dateiErkennen } from "./workspace-chat.js";
import { TREFF_START, VORSCHLAG_HINWEIS } from "./workspace-treff-start.js";
import { vorlagenFuer } from "./workspace-treff-vorlagen.js";
@@ -752,6 +752,89 @@ const MIT_ANTWORTEN = new Set(["treff", "wunsch"]);
const TITEL_MAX = 160;
const TEXT_MAX = 6000;
+/* =====================================================================
+ DIE PUNKTE EINES AGENTUR-EVENTS (03.10.2026)
+
+ `event_aufgaben` und `event_regeln` sind Fliesstext, den DogFather
+ tippt -- eine Zeile je Punkt, meistens mit einem Aufzaehlungszeichen
+ davor. Dass er von Hand gesetzt wird, sieht man dem Ergebnis an: Im
+ echten Oktober-Event stand „·Jeden Tag Live gehen" neben
+ „· 33k Diamanten erreichen", einmal mit Leerzeichen, einmal ohne.
+
+ HIER WIRD DARAUS EINE LISTE. Die Oberflaeche bekommt Punkte, keinen
+ Absatz -- damit sie Haken daran haengen kann und die Einrueckung
+ nicht mehr davon abhaengt, wie jemand getippt hat.
+
+ DER SPEICHERTEXT BLEIBT UNVERAENDERT. Zerlegt wird beim LESEN, nicht
+ beim Schreiben: Wer das Event spaeter bearbeitet, soll genau das
+ wiederfinden, was er geschrieben hat. Und ein Textfeld, das beim
+ Speichern umformatiert wird, ist ein Textfeld, dem man nicht traut.
+ ===================================================================== */
+
+/* Die Zeichen, mit denen Menschen Aufzaehlungen anfangen. Sie gehoeren
+ nicht zum Inhalt des Punktes -- und wer heute „·" tippt und morgen
+ „-", meint denselben Punkt. */
+const PUNKT_ZEICHEN = /^[\s]*[-–—•·*▪◦‣>+]+[\s]*/u;
+
+/** Der Kern einer Aufgabenzeile, ohne Zierde.
+ * Nur fuer den Vergleich gedacht -- angezeigt wird der Originaltext. */
+function punktKern(zeile) {
+ return String(zeile || "")
+ .replace(PUNKT_ZEICHEN, "")
+ /* Eine fuehrende Nummerierung („1.", „2)") ebenfalls weg: Wer
+ einen Punkt davorsetzt, verschiebt sonst alle Nummern und damit
+ alle Schluessel -- genau der stille Haken-Umzug, den die Tabelle
+ verhindern soll. */
+ .replace(/^\s*\d+[.)]\s*/, "")
+ .replace(/\s+/gu, " ")
+ .trim()
+ .toLowerCase();
+}
+
+/** Der Schluessel, unter dem ein Haken gespeichert wird.
+ *
+ * AUS DEM TEXT, NICHT AUS DER ZEILENNUMMER -- die Begruendung steht
+ * bei der Tabelle `event_punkte` in workspace.js. Hier nur das Wie:
+ * Kern bilden, davon die ersten 16 Zeichen des SHA-256. 16 Zeichen
+ * sind 64 Bit; bei hoechstens ein paar Dutzend Punkten je Event ist
+ * eine Verwechslung ausgeschlossen, und der Schluessel bleibt kurz
+ * genug, um in einer Adresse zu stehen. */
+export function punktSchluessel(zeile) {
+ return createHash("sha256").update(punktKern(zeile), "utf8")
+ .digest("hex").slice(0, 16);
+}
+
+/** Zerlegt ein Textfeld in Punkte.
+ *
+ * LEERE ZEILEN FALLEN WEG, aber nicht die Absicht dahinter: Wer eine
+ * Leerzeile zwischen zwei Bloecke setzt, trennt -- und das ergibt in
+ * einer Liste ohnehin keinen eigenen Punkt.
+ *
+ * GLEICHE PUNKTE NUR EINMAL: Stuende „22 Live-Stunden" zweimal da,
+ * haetten beide denselben Schluessel. Ein Haken wuerde dann an zwei
+ * Stellen gleichzeitig erscheinen und die Zaehlung „2 von 3" waere
+ * falsch. Der zweite faellt deshalb weg -- zusammen mit der Zeile,
+ * denn eine doppelte Bedingung ist ohnehin ein Tippfehler. */
+export function punkteAusText(text) {
+ const raus = [];
+ const gesehen = new Set();
+ for (const zeile of String(text || "").split(/\r?\n/)) {
+ const kern = punktKern(zeile);
+ if (!kern) continue;
+ const schluessel = punktSchluessel(zeile);
+ if (gesehen.has(schluessel)) continue;
+ gesehen.add(schluessel);
+ raus.push({
+ schluessel,
+ /* Angezeigt wird der Text OHNE das getippte Aufzaehlungszeichen:
+ Die Liste setzt ihr eigenes, und zwei davon nebeneinander
+ sehen nach einem Fehler aus. */
+ text: String(zeile).replace(PUNKT_ZEICHEN, "").trim(),
+ });
+ }
+ return raus;
+}
+
const jetzt = () => new Date().toISOString();
function angemeldet(req, res, next) {
@@ -1525,6 +1608,73 @@ bereicheRouter.get("/workspace/api/bereich/:bereich", (req, res) => {
}
for (const e of eintraege) e.antworten = jeE.get(e.id) || [];
}
+
+ /* ==== DIE PUNKTE EINES EVENTS (03.10.2026) ====================
+
+ Aus dem Fliesstext in event_aufgaben/event_regeln wird eine
+ Liste, und an die Aufgaben kommen die Haken der eigenen
+ Person. Zerlegt wird HIER und nicht im Browser: Der Schluessel
+ eines Punktes entscheidet, wo ein Haken landet -- eine zweite
+ Rechenvorschrift dafuer waere die, die beim naechsten Umbau
+ auslaeuft, und zwar still.
+
+ IN EINER ABFRAGE FUER DIE GANZE LISTE, wie bei den Bildern und
+ den Antworten darueber. Ein Event hat ein Dutzend Punkte und
+ ein Brett ein Dutzend Events; einzeln gefragt waere das bei
+ jedem Seitenaufbau ein Schwall kleiner Abfragen.
+
+ NUR DORT, WO ES EVENTS GIBT. Auf den uebrigen Brettern sind
+ diese Felder leer, und eine Abfrage mit leerer Liste ist eine
+ Abfrage ohne Zweck. */
+ const events = eintraege.filter((e) => e.event_aufgaben || e.event_regeln);
+ if (events.length) {
+ const ichId = (req.sicht || req.person)?.id;
+ const p5 = events.map(() => "?").join(", ");
+ /* Meine eigenen Haken ... */
+ const meine = new Map();
+ for (const z of db().prepare(
+ `SELECT eintrag_id, punkt FROM event_punkte
+ WHERE eintrag_id IN (${p5}) AND person_id = ?`)
+ .all(...events.map((e) => e.id), ichId)) {
+ if (!meine.has(z.eintrag_id)) meine.set(z.eintrag_id, new Set());
+ meine.get(z.eintrag_id).add(z.punkt);
+ }
+
+ /* ... und fuer die Leitung: wer macht mit, und wie weit?
+
+ NUR FUER DIE LEITUNG. „Wer hat was geschafft" ist eine
+ Auskunft ueber andere Personen; die gehoert nicht an jede
+ Karte, die irgendwer aufmacht. Wer nicht zur Leitung gehoert,
+ bekommt das Feld gar nicht erst -- nicht leer, sondern
+ ueberhaupt nicht: Ein leeres Feld laedt dazu ein, es spaeter
+ „versehentlich" zu fuellen. */
+ const stand = new Map();
+ if (istLeitung(req.person)) {
+ for (const z of db().prepare(
+ `SELECT ep.eintrag_id, ep.person_id, p.name, COUNT(*) AS n
+ FROM event_punkte ep
+ LEFT JOIN personen p ON p.id = ep.person_id
+ WHERE ep.eintrag_id IN (${p5})
+ GROUP BY ep.eintrag_id, ep.person_id
+ ORDER BY n DESC, p.name`)
+ .all(...events.map((e) => e.id))) {
+ if (!stand.has(z.eintrag_id)) stand.set(z.eintrag_id, []);
+ stand.get(z.eintrag_id).push({ id: z.person_id, name: z.name, geschafft: z.n });
+ }
+ }
+
+ for (const e of events) {
+ const haken = meine.get(e.id) || new Set();
+ e.event_punkte = punkteAusText(e.event_aufgaben)
+ .map((p) => ({ ...p, meiner: haken.has(p.schluessel) }));
+ e.event_regelpunkte = punkteAusText(e.event_regeln);
+ /* DIE ZAHL KOMMT MIT, statt im Browser gezaehlt zu werden --
+ sie steht sonst an zwei Stellen, und „2 von 3" ist genau
+ die Sorte Angabe, die man nicht zweimal haben will. */
+ e.event_geschafft = e.event_punkte.filter((p) => p.meiner).length;
+ if (stand.size) e.event_stand = stand.get(e.id) || [];
+ }
+ }
}
/* DASS DIES EIN BRETT DES TREFFS IST, SAGT DER SERVER (11.09.2026).
@@ -2555,6 +2705,41 @@ bereicheRouter.post("/workspace/api/bereich/:bereich", gleicheHerkunft, (req, re
/* ---------- Ändern und Löschen ------------------------------------------- */
+/** Darf diese Person diesen Eintrag sehen?
+ *
+ * ENTSTANDEN AM 03.10.2026, weil das Banner eines Agentur-Events fuer
+ * Creator mit 404 beantwortet wurde -- gemessen, nicht vermutet:
+ *
+ * workspace.dogfather-universe.com admin Bild=200
+ * workspace.dogfather-universe.com creator Bild=404
+ *
+ * Der Bildweg (workspace-dateien.js) kannte bisher zwei Regeln: die
+ * Treff-Regel ("wer das Brett sieht, sieht das Bild" -- aber nur fuer
+ * die sieben Community-Bretter) und sonst die ABLAGE-Regel, die an
+ * Creator-Zuordnungen haengt. Ein Agentur-Event ist weder das eine
+ * noch das andere: Es gehoert niemandem, und `agentur` ist kein
+ * Treff-Brett. Also fiel es durch beide.
+ *
+ * Vor dem Umbau fiel das kaum auf -- das Bild war ein Anhang unter
+ * dem Text. Seit es die Buehne hinter dem Titel ist, saehe eine
+ * Creatorin eine leere Flaeche an genau der Karte, die fuer sie
+ * gemacht ist.
+ *
+ * WARUM EINE FUNKTION UND KEINE ZWEITE LISTE: Der naheliegende Weg
+ * waere gewesen, in workspace-dateien.js `|| e.bereich === "agentur"`
+ * zu ergaenzen. Das waere eine zweite Stelle, an der steht, wer was
+ * sehen darf -- und die laeuft beim naechsten Brett aus. Hier wird
+ * stattdessen dieselbe Regel gefragt, die auch das Brett selbst
+ * benutzt (sichtbarEintrag). Was dort verborgen ist, bleibt es auch
+ * hier; es gibt keine Rechteausweitung, nur keine Luecke mehr. */
+export function darfEintragSehen(person, eintragId) {
+ if (!person || !Number.isInteger(Number(eintragId))) return false;
+ const regel = sichtbarEintrag(person);
+ if (!regel) return false;
+ return !!db().prepare(`SELECT e.id ${VERBUND} WHERE ${regel.wo} AND e.id = ?`)
+ .get(...regel.werte, Number(eintragId));
+}
+
function holen(req, id) {
const regel = sichtbarEintrag(req.person);
if (!regel) return null;
@@ -2674,6 +2859,114 @@ function darfBildAnhaengen(req, res, next) {
next();
}
+
+/* =====================================================================
+ EINEN PUNKT ABHAKEN (03.10.2026)
+
+ Filipe: „ich will das viel geiler viel perfekter. und auch wenn man
+ die eintraegt und so soll viel mehr perfektioniert und
+ uebersichtlicher sein."
+
+ Ein Agentur-Event stellt Bedingungen -- „jeden Tag live",
+ „22 Live-Stunden", „33k Diamanten". Wer mitmacht, will sehen, wo er
+ steht, ohne nachzufragen. Genau das ist der Unterschied zwischen
+ einem Aushang und einer Aktion, an der jemand teilnimmt.
+
+ JEDER HAKT NUR FUER SICH SELBST. Es gibt bewusst keinen Weg, einen
+ Haken bei einer ANDEREN Person zu setzen -- auch nicht fuer die
+ Leitung. „Ich habe meine 22 Stunden" ist eine Aussage ueber sich,
+ und wer sie fuer andere treffen koennte, macht aus einer Selbstaus-
+ kunft eine Bewertung. Die Person steht deshalb nirgends in der
+ Adresse; sie kommt aus der Sitzung.
+
+ KEIN KOERPER BEIM DELETE (gemessen 11.09.2026, steht so schon in
+ bereich.js): Nodes HTTP-Parser weist ein DELETE mit Koerper mit
+ einem leeren 400 ab, bevor Express die Anfrage sieht.
+ ===================================================================== */
+function punktZugang(req, res, next) {
+ const id = Number(req.params.id);
+ const schluessel = String(req.params.punkt || "");
+ /* Der Schluessel ist 16 Hexzeichen -- was anders aussieht, kommt
+ nicht aus unserer Liste und hat in der Tabelle nichts zu suchen.
+ Ohne diese Pruefung koennte jemand beliebigen Text ablegen und die
+ Tabelle als Notizbuch missbrauchen. */
+ if (!Number.isInteger(id) || !/^[0-9a-f]{16}$/.test(schluessel)) {
+ return res.status(400).json({ fehler: "ungueltig" });
+ }
+ const eintrag = holen(req, id);
+ if (!eintrag) return res.status(404).json({ fehler: "nicht_gefunden" });
+
+ /* GEHOERT DER PUNKT UEBERHAUPT ZU DIESEM EVENT?
+
+ Das ist der eigentliche Grund fuer diesen Waechter. Ohne ihn
+ koennte man einen beliebigen gueltig aussehenden Schluessel
+ setzen; die Zahl „3 von 3" waere danach erreichbar, ohne dass eine
+ der drei Aufgaben je dastand. Eine Zaehlung, die man sich selbst
+ ausdenken kann, zaehlt nichts.
+
+ Gefragt wird die LISTE, nicht der Text: punkteAusText ist die eine
+ Stelle, die entscheidet, was ein Punkt ist. */
+ const punkte = punkteAusText(eintrag.event_aufgaben);
+ if (!punkte.some((p) => p.schluessel === schluessel)) {
+ return res.status(404).json({ fehler: "punkt_unbekannt" });
+ }
+ req.eventEintrag = eintrag;
+ req.eventPunkt = schluessel;
+ next();
+}
+
+bereicheRouter.put("/workspace/api/eintrag/:id(\\d+)/punkt/:punkt",
+ angemeldet, gleicheHerkunft, punktZugang, (req, res) => {
+ try {
+ /* ON CONFLICT DO NOTHING und kein Fehler beim zweiten Mal: Zwei
+ Klicks kurz hintereinander (oder ein zweites Geraet) sind kein
+ Versehen, das man melden muesste -- das Ergebnis ist in beiden
+ Faellen „der Haken ist gesetzt". */
+ db().prepare(
+ "INSERT INTO event_punkte (eintrag_id, person_id, punkt, gesetzt)"
+ + " VALUES (?,?,?,?) ON CONFLICT DO NOTHING")
+ .run(req.eventEintrag.id, req.person.id, req.eventPunkt, jetzt());
+ res.json(punktStand(req.eventEintrag, req.person.id));
+ } catch (fehler) {
+ console.error("[bereich] Punkt setzen:", fehler?.message);
+ res.status(503).json({ fehler: "nicht_verfuegbar" });
+ }
+ });
+
+bereicheRouter.delete("/workspace/api/eintrag/:id(\\d+)/punkt/:punkt",
+ angemeldet, gleicheHerkunft, punktZugang, (req, res) => {
+ try {
+ db().prepare(
+ "DELETE FROM event_punkte WHERE eintrag_id = ? AND person_id = ? AND punkt = ?")
+ .run(req.eventEintrag.id, req.person.id, req.eventPunkt);
+ res.json(punktStand(req.eventEintrag, req.person.id));
+ } catch (fehler) {
+ console.error("[bereich] Punkt loeschen:", fehler?.message);
+ res.status(503).json({ fehler: "nicht_verfuegbar" });
+ }
+ });
+
+/** Was nach einem Haken zurueckgeht: der gueltige Stand, vom Server.
+ *
+ * NICHT „ok". Der Browser rechnet sonst selbst weiter („war 2, jetzt
+ * 3") -- und liegt daneben, sobald zwei Fenster offen sind oder eine
+ * Aufgabe inzwischen umgeschrieben wurde. Die Zahl, die auf der Karte
+ * steht, kommt immer von hier. */
+function punktStand(eintrag, personId) {
+ const punkte = punkteAusText(eintrag.event_aufgaben);
+ const meine = new Set(db().prepare(
+ "SELECT punkt FROM event_punkte WHERE eintrag_id = ? AND person_id = ?")
+ .all(eintrag.id, personId).map((z) => z.punkt));
+ return {
+ gesamt: punkte.length,
+ /* Nur zaehlen, was AUCH in der Liste steht: Wird eine Aufgabe
+ umgeschrieben, bleibt ihr alter Haken als Zeile liegen (das ist
+ richtig, siehe Tabelle) -- mitzaehlen darf er nicht. */
+ geschafft: punkte.filter((p) => meine.has(p.schluessel)).length,
+ punkte: punkte.map((p) => ({ ...p, meiner: meine.has(p.schluessel) })),
+ };
+}
+
bereicheRouter.post("/workspace/api/bereich/:bereich/:id(\\d+)/bild",
gleicheHerkunft,
darfBildAnhaengen,
diff --git a/server/workspace-dateien.js b/server/workspace-dateien.js
index ebe293a1..17ffee89 100644
--- a/server/workspace-dateien.js
+++ b/server/workspace-dateien.js
@@ -36,6 +36,9 @@ import {
ohneTeamDogi, ohneAgentur, siehtModis,
TREFF_BRETTER, TREFF_ROLLEN,
} from "./workspace.js";
+/* Die eine Stelle, die beantwortet, wer welchen Eintrag sehen darf.
+ Kein Zirkel: workspace-bereiche.js kennt diese Datei nicht. */
+import { darfEintragSehen } from "./workspace-bereiche.js";
export const dateienRouter = express.Router();
@@ -658,6 +661,29 @@ dateienRouter.get("/workspace/api/dateien/:id/bild", (req, res) => {
const e = db().prepare("SELECT bereich FROM eintraege WHERE id = ?").get(datei.eintrag_id);
erlaubt = !!e && TREFF_BRETTER.includes(e.bereich)
&& TREFF_ROLLEN.has((req.sicht || req.person)?.rolle);
+ /* ==== UND SONST: WER DEN EINTRAG SIEHT, SIEHT SEIN BILD =======
+ (03.10.2026, gemessen am Banner eines Agentur-Events.)
+
+ Die Zeile darueber deckt die sieben Community-Bretter ab. Ein
+ Agentur-Event ist keines davon -- und es gehoert auch
+ niemandem, also griff danach auch die Ablage-Regel nicht, die
+ an Creator-Zuordnungen haengt. Ergebnis, nachgemessen:
+
+ admin -> Bild 200
+ creator -> Bild 404
+
+ Also genau an der Karte, die fuer Creator gemacht ist, eine
+ leere Buehne. Vor dem Umbau war das Bild ein Anhang unter dem
+ Text und fiel kaum auf.
+
+ GEFRAGT WIRD DIESELBE REGEL WIE AUF DEM BRETT
+ (darfEintragSehen -> sichtbarEintrag). Keine zweite Liste von
+ Brettnamen hier: Was dort verborgen ist, bleibt auch hier
+ verborgen, und beim naechsten neuen Brett muss niemand daran
+ denken. Die Gegenprobe dazu steht in pruef-eventkarte.mjs --
+ ein Eintrag, den die Person NICHT sehen darf, liefert
+ weiterhin 404. */
+ if (!erlaubt) erlaubt = darfEintragSehen(req.sicht || req.person, datei.eintrag_id);
}
if (!erlaubt) {
const { wo, werte } = sichtbar(req.person);
diff --git a/server/workspace.js b/server/workspace.js
index 36390d4a..f2c86640 100644
--- a/server/workspace.js
+++ b/server/workspace.js
@@ -5940,6 +5940,57 @@ export function db() {
geaendert TEXT
);
CREATE INDEX IF NOT EXISTS idx_eintraege_bereich ON eintraege (bereich, creator_id);
+
+ /* =================================================================
+ WER HAT WELCHEN PUNKT EINES EVENTS GESCHAFFT? (03.10.2026)
+
+ Filipe: „ich will das viel geiler viel perfekter."
+
+ Ein Agentur-Event ist ein Wettbewerb mit Bedingungen -- „jeden
+ Tag live", „22 Live-Stunden", „33k Diamanten". Bis heute stand
+ das als Fliesstext auf der Karte, und jede Creatorin musste
+ sich selbst merken, wo sie steht. Genau daran stirbt eine
+ Aktion: nicht am Aufruf, sondern daran, dass niemand seinen
+ eigenen Stand sieht.
+
+ EINE ZEILE IST EIN HAKEN -- eine Person, ein Punkt. Kein Feld
+ am Eintrag mit einer Liste darin: „jeder fuer sich" heisst,
+ dass zwanzig Personen am SELBEN Event verschiedene Haken
+ haben. Dieselbe Ueberlegung steht schon bei termin_wecker.
+
+ WARUM EIN SCHLUESSEL AUS DEM TEXT UND KEINE ZEILENNUMMER:
+ Die Aufgaben stehen als Text in der Spalte
+ event_aufgaben;
+ DogFather kann sie jederzeit umschreiben. Haengt der Haken an
+ „Zeile 2", wandert er beim Einfuegen einer Zeile darueber
+ stillschweigend auf eine andere Aufgabe -- und jemand hat
+ plotzlich etwas abgehakt, was er nie gemacht hat. Das ist der
+ schlimmere Ausgang, weil es niemandem auffaellt.
+
+ Haengt er dagegen am TEXT der Zeile, gilt: Zeilen umsortieren
+ oder daruebersetzen aendert nichts; wird die Aufgabe selbst
+ umgeschrieben, faellt ihr Haken weg. Letzteres ist richtig --
+ eine geaenderte Bedingung ist eine andere Bedingung, und ein
+ Haken daran waere eine Falschauskunft.
+
+ Der Schluessel wird in workspace-bereiche.js gebildet
+ (punktSchluessel); hier steht er nur als Text. Eine zweite
+ Rechenvorschrift in der Datenbank waere die, die auslaeuft.
+
+ ON DELETE CASCADE an beiden Seiten: Ein Haken ohne Event ist
+ nichts, und einer ohne Person auch nicht. */
+ CREATE TABLE IF NOT EXISTS event_punkte (
+ eintrag_id INTEGER NOT NULL REFERENCES eintraege(id) ON DELETE CASCADE,
+ person_id INTEGER NOT NULL REFERENCES personen(id) ON DELETE CASCADE,
+ punkt TEXT NOT NULL,
+ gesetzt TEXT NOT NULL,
+ PRIMARY KEY (eintrag_id, person_id, punkt)
+ );
+ /* Fuer „wie weit ist mein Team?" -- die Abfrage geht ueber das
+ Event, nicht ueber die Person. */
+ CREATE INDEX IF NOT EXISTS idx_event_punkte_eintrag
+ ON event_punkte (eintrag_id);
+
/* Die Tabellen des Rudels folgen weiter unten, gleich nachdem
dieser Bauplan durch ist -- sie haengen an eintraege und
personen und koennen deshalb nicht davor stehen. Ihr SQL steht
diff --git a/workspace/anruf-probe.html b/workspace/anruf-probe.html
index 9523d704..e667a25e 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`. -->
-
-
+
+
-
+