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`. --> - - + + - +