diff --git a/server/helfer-tag.mjs b/server/helfer-tag.mjs index 524dc113..79c1ad8e 100644 --- a/server/helfer-tag.mjs +++ b/server/helfer-tag.mjs @@ -41,8 +41,31 @@ export function heuteLokal() { return `${d.getFullYear()}-${zwei(d.getMonth() + 1)}-${zwei(d.getDate())}`; } -/** Derselbe Tag, um `versatz` Tage verschoben. Negativ heißt zurück. */ +/** Derselbe Tag, um `versatz` Tage verschoben. Negativ heißt zurück. + * + * ZWEI DINGE AM 22.09.2026 NACHGEZOGEN, beide durch einen echten Schaden: + * + * (1) `versatz = 0`. Dogi-Media rief `tagLokal()` ohne Argument auf und + * bekam `"NaN-NaN-NaN"` zurück -- eine Zeichenkette, die aussieht + * wie ein Datum und sich wie keines verhält. Im Vergleich + * `"2026-12-31" < "NaN-NaN-NaN"` gewinnt das N, also galt JEDES + * Stück mit Enddatum vom ersten Tag an als abgelaufen, und + * "Kommt noch" gab es nie. Kein Absturz, keine Meldung, keine + * Zeile im Protokoll -- nur eine Seite, die etwas anderes zeigt + * als die Wahrheit. Die drei Kopien dieser Funktion waren an + * dieser Stelle auseinandergelaufen: helfer-tag.mjs hatte die + * Vorbelegung, die beiden anderen nicht. + * + * (2) Der Wurf bei einer Zahl, die keine ist. Eine Vorbelegung deckt + * nur den leeren Aufruf ab; `tagLokal(irgendwas)` mit einer + * undefinierten Variablen läge weiter still daneben. Ein Datum, + * das sich nicht ausrechnen lässt, muss SCHEITERN und nicht + * schweigen -- sonst wandert der Unsinn in die Datenbank und in + * die Anzeige, und gefunden wird er Wochen später. */ export function tagLokal(versatz = 0) { + if (!Number.isFinite(Number(versatz))) { + throw new TypeError(`tagLokal: "${versatz}" ist keine Zahl von Tagen.`); + } const d = new Date(Date.now() + versatz * 86400_000); return `${d.getFullYear()}-${zwei(d.getMonth() + 1)}-${zwei(d.getDate())}`; } diff --git a/server/helfer-zeit.mjs b/server/helfer-zeit.mjs index e663d1e7..33f0c462 100644 --- a/server/helfer-zeit.mjs +++ b/server/helfer-zeit.mjs @@ -40,12 +40,35 @@ export function heuteLokal() { return `${d.getFullYear()}-${p(d.getMonth() + 1)}-${p(d.getDate())}`; } -/** Derselbe Tag, um n Tage verschoben (negativ = in der Vergangenheit). +/** Derselbe Tag, um `versatz` Tage verschoben. Negativ heißt zurück. + * + * ZWEI DINGE AM 22.09.2026 NACHGEZOGEN, beide durch einen echten Schaden: + * + * (1) `versatz = 0`. Dogi-Media rief `tagLokal()` ohne Argument auf und + * bekam `"NaN-NaN-NaN"` zurück -- eine Zeichenkette, die aussieht + * wie ein Datum und sich wie keines verhält. Im Vergleich + * `"2026-12-31" < "NaN-NaN-NaN"` gewinnt das N, also galt JEDES + * Stück mit Enddatum vom ersten Tag an als abgelaufen, und + * "Kommt noch" gab es nie. Kein Absturz, keine Meldung, keine + * Zeile im Protokoll -- nur eine Seite, die etwas anderes zeigt + * als die Wahrheit. Die drei Kopien dieser Funktion waren an + * dieser Stelle auseinandergelaufen: helfer-tag.mjs hatte die + * Vorbelegung, die beiden anderen nicht. + * + * (2) Der Wurf bei einer Zahl, die keine ist. Eine Vorbelegung deckt + * nur den leeren Aufruf ab; `tagLokal(irgendwas)` mit einer + * undefinierten Variablen läge weiter still daneben. Ein Datum, + * das sich nicht ausrechnen lässt, muss SCHEITERN und nicht + * schweigen -- sonst wandert der Unsinn in die Datenbank und in + * die Anzeige, und gefunden wird er Wochen später. * * Gerechnet wird über die Millisekunden und danach lokal ausgelesen. * `setDate(getDate() + n)` täte es auch, hat aber an den zwei * Umstellungstagen im Jahr seine Tücken. */ -export function tagLokal(versatz) { +export function tagLokal(versatz = 0) { + if (!Number.isFinite(Number(versatz))) { + throw new TypeError(`tagLokal: "${versatz}" ist keine Zahl von Tagen.`); + } const d = new Date(Date.now() + versatz * 86400_000); const p = (n) => String(n).padStart(2, "0"); return `${d.getFullYear()}-${p(d.getMonth() + 1)}-${p(d.getDate())}`; diff --git a/server/pruef-css-klassen.mjs b/server/pruef-css-klassen.mjs index 3688dd44..93be3a60 100644 --- a/server/pruef-css-klassen.mjs +++ b/server/pruef-css-klassen.mjs @@ -693,5 +693,154 @@ console.log("\n=== module.css: die Modulliste steht siebenmal — und überall g `die zweite Wand laedt ihr Haus zuletzt (${wandBl.length} Stilvorlagen)`); } +/* ========================================================================= + IST DAS CSS UEBERHAUPT HEIL? (22.09.2026) + ========================================================================= + + ENTSTANDEN AUS EINEM ECHTEN FEHLER, der zwei Wochen live stand. + + In start.css fehlten an einer Stelle die zwei Zeichen, die einen + Kommentar schliessen: + + ... + der Inhalt darin angeordnet ist. + ===================================================== + <- hier gehoerte `*` `/` hin + .kacheln { + + CSS-Kommentare schachteln NICHT. Der Block lief deshalb weiter bis + zum naechsten Abschluss -- und der stand mitten in `.kacheln`. + Verschluckt wurde damit die oeffnende Klammer. Die Fehlerbehebung + des Browsers hat danach auch noch die FOLGENDE Regel mitgenommen, + weil sie deren Rumpf als Block einer ungueltigen Auswahl las. + + Gemessen in Chromium, beide Fassungen nacheinander: 738 statt 740 + Regeln. Sichtbar war es als Kachelreihe mit drei Spalten zu 413 px + statt fuenf zu 243 px. + + WARUM ES NIEMANDEM AUFFIEL: Eine aeltere `.kacheln`-Regel weiter + oben sprang ein. Die Seite sah nicht kaputt aus -- nur anders als + gemeint. Das ist die gefaehrlichste Sorte: Es gibt nichts zu melden, + keinen Fehler in der Konsole, keinen roten Haken. Nur eine Seite, + die etwas anderes zeigt als das, was im Quelltext steht. + + WAS HIER GEMESSEN WIRD -- und warum gerade das: + + Auf oberster Ebene einer Stilvorlage darf nur dreierlei stehen: eine + Auswahl mit ihrem Block, eine At-Regel, und Leerraum. Steht dort + eine ANGABE (`gap: 12px;`) oder eine schliessende Klammer ohne ihre + oeffnende, dann ist etwas verschluckt worden -- fast immer ein + Kommentar ohne Abschluss. + + Das ist strenger als blosses Klammernzaehlen: Eine Klammernprobe ist + gruen, solange gleich viele auf- wie zugehen, auch wenn ein + Kommentar mittendrin einen ganzen Block gefressen hat. + + Kommentare und Zeichenketten werden dabei genauso ueberlesen, wie + der Browser sie ueberliest -- sonst schlaegt die Pruefung bei jedem + `/* ... } ... *' + '/` an, und eine Warnung, die immer kommt, ist + keine mehr. + ========================================================================= */ +console.log("\n=== Ist das CSS ueberhaupt heil? ==="); + +/** Alle Formfehler einer Stilvorlage. Leeres Feld = in Ordnung. */ +function formfehler(text) { + const funde = []; + const zeileVon = (stelle) => text.slice(0, stelle).split("\n").length; + let i = 0, tiefe = 0, puffer = "", pufferAb = 0; + + while (i < text.length) { + const c = text[i]; + + if (c === "/" && text[i + 1] === "*") { + const auf = i; + i += 2; + while (i < text.length && !(text[i] === "*" && text[i + 1] === "/")) i++; + if (i >= text.length) { + funde.push({ zeile: zeileVon(auf), was: "ein Kommentar wird nie geschlossen" }); + break; + } + i += 2; + continue; + } + if (c === '"' || c === "'") { + const q = c; i++; + while (i < text.length && text[i] !== q) { if (text[i] === "\\") i++; i++; } + i++; continue; + } + + if (tiefe === 0) { + if (c === "{") { tiefe = 1; puffer = ""; pufferAb = i + 1; i++; continue; } + if (c === ";") { + /* `@import url(...);` und Geschwister duerfen auf oberster + Ebene mit einem Strichpunkt enden. Alles andere nicht. */ + if (!puffer.trim().startsWith("@")) { + funde.push({ + zeile: zeileVon(pufferAb), + was: "eine Angabe steht ausserhalb jeder Regel", + beleg: puffer.trim().slice(0, 70).replace(/\s+/g, " "), + }); + } + puffer = ""; pufferAb = i + 1; i++; continue; + } + if (c === "}") { + funde.push({ zeile: zeileVon(i), was: "eine schliessende Klammer ohne oeffnende" }); + puffer = ""; pufferAb = i + 1; i++; continue; + } + puffer += c; i++; continue; + } + + if (c === "{") tiefe++; + else if (c === "}") tiefe--; + if (tiefe === 0) { puffer = ""; pufferAb = i + 1; } + i++; + } + + if (tiefe !== 0) { + funde.push({ zeile: "Ende", was: `${tiefe} Regel(n) werden nie geschlossen` }); + } + return funde; +} + +{ + const css = readdirSync(join(WS, "assets", "css")).filter((n) => n.endsWith(".css")); + /* DRITTER AUSGANG: Ohne Dateien misst die Pruefung nichts, und "0 + Fehler" waere dann ein gruener Haken fuer gar nichts. */ + ok(css.length > 20, `${css.length} Stilvorlagen gefunden`); + + const kaputt = []; + for (const name of css) { + const funde = formfehler(readFileSync(join(WS, "assets", "css", name), "utf8")); + for (const f of funde) { + kaputt.push(`${name}:${f.zeile} — ${f.was}` + (f.beleg ? ` ("${f.beleg}")` : "")); + } + } + ok(kaputt.length === 0 && css.length > 20, + kaputt.length + ? `${kaputt.length} Stelle(n), an denen CSS still verloren geht:\n ` + + kaputt.join("\n ") + : `keine Stilvorlage verliert still eine Regel (${css.length} geprueft)`); + + /* GEGENPROBEN: Die Pruefung muss beides koennen -- anschlagen und + schweigen. Der vorletzte Fall ist der echte von start.css, + Zeichen fuer Zeichen nachgebaut. */ + const proben = [ + ["gesundes CSS", ".a{b:c} /* } *" + "/ .d[e=';']{f:g}", false], + ["@import mit Strichpunkt", "@import url(x.css); .a{b:c}", false], + ["Kommentar ohne Abschluss", ".a{b:c} /* geht nie zu", true], + ["Angabe ausserhalb jeder Regel", "gap: 12px; .a{b:c}", true], + ["schliessende Klammer zu viel", ".a{b:c}}", true], + ["Regel ohne Abschluss", ".a{b:c", true], + ["der Fall aus start.css", "/* Titel\n ====\n\n.kacheln {\n /* innen *" + "/\n" + + " gap: 12px;\n}\n\n.kachel { color: red; }", true], + ]; + const daneben = proben.filter(([, text, soll]) => (formfehler(text).length > 0) !== soll); + ok(daneben.length === 0 && proben.length === 7, + daneben.length + ? `Gegenprobe daneben bei: ${daneben.map((p) => p[0]).join(", ")}` + : `Gegenprobe: alle ${proben.length} Faelle richtig eingeordnet ` + + `(darunter der echte aus start.css)`); +} + console.log(`\n${fehler === 0 ? "ALLES IN ORDNUNG" : `${fehler} FEHLER`}`); process.exit(fehler ? 1 : 0); diff --git a/server/pruef-material.mjs b/server/pruef-material.mjs index ae4952a8..81c8445e 100644 --- a/server/pruef-material.mjs +++ b/server/pruef-material.mjs @@ -72,7 +72,10 @@ 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(240_000, "pruef-material"); +/* 240 -> 480 s (22.09.2026): Seit Abschnitt 13 faehrt ein echter + Browser mit. Eine Notbremse, die vor dem letzten Abschnitt + zuschlaegt, macht aus einer langsamen Pruefung eine rote. */ +notbremse(480_000, "pruef-material"); await new Promise((r) => setTimeout(r, 900)); process.on("uncaughtException", (f) => { console.error("ABSTURZ:", f); process.exit(7); }); @@ -182,12 +185,17 @@ const PNG = Buffer.from( "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8BQDwAEhQGAhKmMIQAAAABJRU5ErkJggg==", "base64"); -function einstellen(keks, name, text = "") { +function einstellen(keks, name, text = "", fenster = {}) { return roh("/workspace/api/material", { methode: "POST", roh: PNG, typ: "image/png", keks, kopf: { "X-Datei-Name": encodeURIComponent(name), "X-Text": encodeURIComponent(text), + /* NUR MITSCHICKEN, WENN ES ETWAS ZU SCHICKEN GIBT. Ein leerer + Kopf ist etwas anderes als ein fehlender, und die Pruefung + soll beides treffen koennen. */ + ...(fenster.ab !== undefined ? { "X-Gilt-Ab": fenster.ab } : {}), + ...(fenster.bis !== undefined ? { "X-Gilt-Bis": fenster.bis } : {}), }, }); } @@ -421,19 +429,615 @@ melde("=== 8. Die Grenzen des Bereichs ==="); for (const r of ["gast", "modi", "hand", "linke", "admin"]) { const treffer = [...(w.bereicheFuer({ rolle: r, haus: "crew" }) || [])] .filter((k) => k.ziel === "material.html"); - ok(treffer.length === 1 && treffer[0].name === "Material", + /* HEISST SEIT DEM 22.09.2026 "Dogi-Media" (Filipe: "ersetz das wort + material durch Dogi-Media"). Der DATEINAME bleibt material.html: + Eine Adresse umzubenennen bricht jedes Lesezeichen und jeden + Verweis, und der sichtbare Name ist das, was zaehlt -- dieselbe + Entscheidung wie bei "Unsere Seiten" -> "Draussen". */ + ok(treffer.length === 1 && treffer[0].name === "Dogi-Media", `${r} hat genau eine Kachel dorthin (${treffer.length}, "${treffer[0]?.name}")`); } } +/* ===================================================================== + 9. VON WANN BIS WANN (22.09.2026) + ===================================================================== + Filipe: "mach kategorien welche sind benutzt welche nicht welche sind + noch offen, welche laufen von wan bis wann, das muss man auch machen + koennen und detailliert perfektioniert." + + GERECHNET WIRD RELATIV ZU HEUTE, nie mit einem festen Datum. Ein + Test, der "2026-10-01" als Zukunft annimmt, ist am 2. Oktober rot, + ohne dass irgendetwas kaputt waere -- und ein Fehlalarm, den niemand + nachstellen kann, schaltet den ganzen Satz ab. + ===================================================================== */ +melde(""); +melde("=== 9. Der Zeitraum: gesetzt, gespeichert, gerechnet ==="); +const { tagLokal } = await import("./helfer-tag.mjs"); +const HEUTE = tagLokal(0); +const GESTERN = tagLokal(-1); +const VORGESTERN = tagLokal(-2); +const MORGEN = tagLokal(1); +const IN10 = tagLokal(10); + +{ + /* Vier Stuecke, eines je Zustand -- und zwar wirklich vier, nicht + drei und eine Annahme. Die Zaehlung unten prueft das. */ + const faelle = [ + ["ohne-fenster.png", {}, "frei"], + ["laeuft-schon.png", { ab: VORGESTERN, bis: IN10 }, "frei"], + ["kommt-noch.png", { ab: MORGEN, bis: IN10 }, "spaeter"], + ["ist-vorbei.png", { ab: VORGESTERN, bis: GESTERN }, "abgelaufen"], + ]; + for (const [name, f, erwartet] of faelle) { + const r = await einstellen(chef.keks, name, "Probe", f); + ok(r.code === 201 && Number.isInteger(r.json?.id), + `"${name}" angelegt (${r.code})`); + } + + const l = await liste(chef.keks); + const finde = (n) => (l.json?.material || []).find((m) => m.name === n); + + for (const [name, f, erwartet] of faelle) { + const m = finde(name); + ok(!!m && m.zustand === erwartet, + `"${name}" steht als "${erwartet}" da (${m?.zustand})`); + ok(!!m && (m.gilt_ab || null) === (f.ab || null) + && (m.gilt_bis || null) === (f.bis || null), + `"${name}" gibt sein Fenster unveraendert zurueck ` + + `(${m?.gilt_ab} / ${m?.gilt_bis})`); + } + + /* DER HEUTIGE TAG KOMMT MIT UND IST EIN ECHTES DATUM. + Genau hier lag der Fehler, der diese Pruefung ausgeloest hat: + `tagLokal()` ohne Argument gab "NaN-NaN-NaN" zurueck. Beim + Zeichenkettenvergleich gewinnt das grosse N gegen jede Ziffer -- + also galt jedes Stueck mit Enddatum sofort als abgelaufen, und + "Kommt noch" gab es nie. Kein Absturz, keine Meldung. + Deshalb wird die FORM geprueft, nicht nur die Anwesenheit. */ + ok(/^\d{4}-\d{2}-\d{2}$/.test(String(l.json?.heute || "")), + `die Liste nennt den heutigen Tag als Datum ("${l.json?.heute}")`); + ok(l.json?.heute === HEUTE, + `und es ist wirklich heute (${l.json?.heute} vs ${HEUTE})`); + + /* GEGENPROBE: Wuerde "heute" nicht stimmen, muesste die Rechnung + ein anderes Ergebnis liefern. Sonst misst der Test nur, dass + irgendetwas zurueckkommt. */ + const { zustandVon } = await import("./workspace-material.js"); + ok(zustandVon({ gilt_ab: MORGEN }, HEUTE) === "spaeter" + && zustandVon({ gilt_ab: MORGEN }, "NaN-NaN-NaN") !== "spaeter", + "mit einem kaputten Tag faellt die Rechnung um -- die Probe kann also anschlagen"); + + /* ALLE VIER ZUSTAENDE KOMMEN AUCH WIRKLICH VOR. Ohne diese Zeile + waeren drei gruene Haken auch dann gruen, wenn ein Zustand gar + nicht mehr erreichbar ist. */ + const NOETIG = ["frei", "spaeter", "abgelaufen"]; + const gezaehlt = new Set((l.json?.material || []).map((m) => m.zustand)); + const fehlend = NOETIG.filter((z) => !gezaehlt.has(z)); + /* DIE ZAHL IM SATZ WIRD GEZAEHLT, NICHT GETIPPT. Der erste Entwurf + schrieb "drei Zustaende sind besetzt" und zaehlte daneben vier auf + -- ein Satz, der schon beim ersten Lesen nicht zu seiner eigenen + Klammer passte. */ + ok(fehlend.length === 0, + `alle ${NOETIG.length} Zustaende aus diesem Abschnitt sind besetzt; ` + + `gefunden: ${[...gezaehlt].sort().join(", ")}` + + (fehlend.length ? ` -- es fehlt: ${fehlend.join(", ")}` : "")); +} + +melde(""); +melde("=== 9b. Ein Fenster, das nicht geht ==="); +{ + const verkehrt = await einstellen(chef.keks, "verkehrt.png", "", { ab: IN10, bis: GESTERN }); + ok(verkehrt.code === 400 && verkehrt.json?.fehler === "fenster_verkehrt", + `Ende vor Anfang wird abgelehnt (${verkehrt.code}/${verkehrt.json?.fehler})`); + + const quatsch = await einstellen(chef.keks, "quatsch.png", "", { ab: "morgen frueh" }); + ok(quatsch.code === 400 && quatsch.json?.fehler === "datum_unbrauchbar", + `ein Datum, das keines ist, wird abgelehnt (${quatsch.code}/${quatsch.json?.fehler})`); + + /* GEGENPROBE zu beiden: Dasselbe ohne Fenster muss durchgehen -- + sonst koennte die Ablehnung auch an etwas ganz anderem liegen. */ + const sauber = await einstellen(chef.keks, "sauber.png", "", { ab: HEUTE, bis: IN10 }); + ok(sauber.code === 201, `dasselbe mit gueltigem Fenster geht (${sauber.code})`); + + /* UND NICHTS DAVON IST LIEGENGEBLIEBEN. Eine abgelehnte Anfrage darf + keine halbe Datei im Ordner hinterlassen. */ + const l = await liste(chef.keks); + const namen = (l.json?.material || []).map((m) => m.name); + ok(!namen.includes("verkehrt.png") && !namen.includes("quatsch.png"), + "die abgelehnten Stuecke stehen nirgends in der Liste"); +} + +/* ===================================================================== + 10. BEARBEITEN -- wer darf, und was bleibt unberuehrt + ===================================================================== + Filipe: "wenn wir in der kategorie material was hochladen will ich + dass die rechte und linke hand es auch bearbeiten und loeschen + koennen. dogfather auch fals es ein fehler gab." + ===================================================================== */ +melde(""); +melde("=== 10. Bearbeiten ==="); +const aendern = (id, keks, was) => + roh(`/workspace/api/material/${id}`, { methode: "PATCH", keks, body: was }); +{ + /* Das Stueck gehoert der RECHTEN HAND -- damit ist jede Aenderung + durch jemand anderen eine FREMDE Aenderung. Haette es DogFather + eingestellt, waere sein eigener Zugriff kein Nachweis. */ + const r = await einstellen(hand.keks, "fremdes-stueck.png", "Erster Text"); + const id = r.json?.id; + ok(Number.isInteger(id), `Stueck der rechten Hand liegt (${r.code})`); + + for (const [wer, s2] of [["DogFather", chef], ["rechte Hand", hand], ["linke Hand", linke]]) { + const a = await aendern(id, s2.keks, { text: `Geaendert von ${wer}` }); + ok(a.code === 200 && a.json?.text === `Geaendert von ${wer}`, + `${wer} darf fremden Text aendern (${a.code})`); + } + + for (const [wer, s2] of [["Modi", modi], ["Kessi (Community)", kessi]]) { + const a = await aendern(id, s2.keks, { text: "heimlich" }); + ok(a.code === 403 || a.code === 404, + `${wer} darf nicht (${a.code}/${a.json?.fehler})`); + } + + /* GEGENPROBE ZUR ABLEHNUNG: Der Text muss danach noch der von vorhin + sein. Eine 403, nach der die Aenderung trotzdem drinsteht, waere + der eigentliche Schaden -- und sie saehe von aussen richtig aus. */ + const l = await liste(chef.keks); + const m = (l.json?.material || []).find((x) => x.id === id); + ok(m?.text === "Geaendert von linke Hand", + `und der Text ist unveraendert geblieben ("${m?.text}")`); + + /* DAS FENSTER LAESST SICH NACHTRAEGLICH SETZEN UND WIEDER LEEREN. + Das Leeren ist der Fall, den man vergisst: Wer ein Enddatum + falsch getippt hat, muss es LOESCHEN koennen, nicht nur + ueberschreiben. */ + const setzen = await aendern(id, hand.keks, { gilt_ab: MORGEN, gilt_bis: IN10 }); + ok(setzen.code === 200 && setzen.json?.zustand === "spaeter", + `Fenster nachtraeglich gesetzt, Zustand "${setzen.json?.zustand}"`); + const leeren = await aendern(id, hand.keks, { gilt_ab: "", gilt_bis: "" }); + ok(leeren.code === 200 && !leeren.json?.gilt_ab && !leeren.json?.gilt_bis + && leeren.json?.zustand === "frei", + `und wieder geleert, Zustand "${leeren.json?.zustand}"`); + + const verkehrt = await aendern(id, hand.keks, { gilt_ab: IN10, gilt_bis: GESTERN }); + ok(verkehrt.code === 400 && verkehrt.json?.fehler === "fenster_verkehrt", + `ein verkehrtes Fenster wird auch beim Aendern abgelehnt (${verkehrt.code})`); + + /* WAS NICHT MITGESCHICKT WIRD, BLEIBT. Sonst loescht jeder, der nur + den Text aendert, nebenbei das Fenster -- und merkt es nie. */ + await aendern(id, hand.keks, { gilt_ab: MORGEN, gilt_bis: IN10 }); + const nurText = await aendern(id, hand.keks, { text: "Nur der Text" }); + ok(nurText.code === 200 && nurText.json?.gilt_ab === MORGEN + && nurText.json?.gilt_bis === IN10, + `eine Aenderung nur am Text laesst das Fenster stehen ` + + `(${nurText.json?.gilt_ab} / ${nurText.json?.gilt_bis})`); +} + +melde(""); +melde("=== 10b. Was vergeben ist, bleibt, wie es ist ==="); +{ + const r = await einstellen(chef.keks, "schon-weg.png", "Steht so da"); + const id = r.json?.id; + const n = await nehmen(id, modi.keks); + ok(n.code === 200, `ein Modi hat es genommen (${n.code})`); + + const a = await aendern(id, chef.keks, { text: "nachtraeglich umgeschrieben" }); + ok(a.code === 409 && a.json?.fehler === "schon_im_umlauf", + `selbst DogFather aendert es nicht mehr (${a.code}/${a.json?.fehler})`); + + const l = await liste(chef.keks); + const m = (l.json?.material || []).find((x) => x.id === id); + ok(m?.text === "Steht so da", `der Text von damals steht noch ("${m?.text}")`); + ok(m?.zustand === "benutzt", `und der Zustand heisst "benutzt" (${m?.zustand})`); + + /* Der vierte Zustand wird hier NEU erzeugt und nicht nur mitgezaehlt: + Weiter oben gibt es schon vergebene Stuecke aus Abschnitt 6, aber + dass AUSGERECHNET DIESES nach dem Nehmen "benutzt" heisst, ist die + Aussage -- und die gilt nur fuer das Stueck von eben. */ + ok((l.json?.material || []).filter((x) => x.zustand === "benutzt").length >= 1, + "es gibt mindestens ein Stueck im Zustand \"benutzt\""); +} + +/* ===================================================================== + 11. ENTFERNEN -- dieselbe Runde, andere Tuer + ===================================================================== */ +melde(""); +melde("=== 11. Entfernen ==="); +const wegnehmen = (id, keks) => + roh(`/workspace/api/material/${id}`, { methode: "DELETE", keks }); +{ + for (const [wer, s2] of [["DogFather", chef], ["rechte Hand", hand], ["linke Hand", linke]]) { + /* Jedes Mal ein frisches Stueck von jemand anderem -- sonst + entfernte der Zweite nur noch eine Nummer, die es nicht gibt, + und bekaeme 404 statt eines echten Nachweises. */ + const r = await einstellen(hand.keks, `weg-${wer}.png`, ""); + const id = r.json?.id; + const w = await wegnehmen(id, s2.keks); + ok(w.code === 200, `${wer} darf ein fremdes Stueck entfernen (${w.code})`); + + const l = await liste(chef.keks); + ok(!(l.json?.material || []).some((x) => x.id === id), + `und es ist danach wirklich weg`); + } + + const r = await einstellen(hand.keks, "bleibt-liegen.png", ""); + const id = r.json?.id; + for (const [wer, s2] of [["Modi", modi], ["Kessi (Community)", kessi]]) { + const w = await wegnehmen(id, s2.keks); + ok(w.code === 403 || w.code === 404, + `${wer} darf nicht entfernen (${w.code}/${w.json?.fehler})`); + } + const l = await liste(chef.keks); + ok((l.json?.material || []).some((x) => x.id === id), + "und das Stueck liegt danach immer noch da"); +} + +/* ===================================================================== + 12. WAS DIE LISTE UEBER DIE RECHTE SAGT + ===================================================================== + Die Oberflaeche rechnet NICHT nach, wer was darf -- sie liest + `darf_ich` und `darf_verwalten`. Waeren die Angaben falsch, staenden + Knoepfe da, die beim Druecken eine Absage bringen; oder sie fehlten + dort, wo jemand darf. Beides ist schlimmer als ein Serverfehler, + weil es aussieht, als sei alles in Ordnung. + ===================================================================== */ +melde(""); +melde("=== 12. Was die Liste ueber die Rechte sagt ==="); +{ + const r = await einstellen(hand.keks, "rechte-probe.png", ""); + const id = r.json?.id; + + const darf = { admin: true, hand: true, linke: true }; + for (const [wer, s2, rolle] of [["DogFather", chef, "admin"], ["rechte Hand", hand, "hand"], + ["linke Hand", linke, "linke"], ["Modi", modi, "modi"], ["Kessi", kessi, "gast"]]) { + const l = await liste(s2.keks); + const soll = darf[rolle] === true; + ok(l.json?.darf_verwalten === soll, + `${wer}: darf_verwalten = ${l.json?.darf_verwalten} (erwartet ${soll})`); + const m = (l.json?.material || []).find((x) => x.id === id); + ok(!!m && m.darf_ich === soll, + `${wer}: darf_ich am fremden Stueck = ${m?.darf_ich} (erwartet ${soll})`); + } + + /* GEGENPROBE: Am EIGENEN Stueck muss `darf_ich` auch fuer jemanden + wahr sein, der fremde nicht anfassen darf. Sonst koennte das Feld + schlicht `darf_verwalten` wiederholen und niemandem auffallen. + Ein Modi stellt nichts ein -- also nimmt die Probe die rechte + Hand, deren eigenes Stueck das hier ist. */ + const l = await liste(hand.keks); + const m = (l.json?.material || []).find((x) => x.id === id); + ok(m?.darf_ich === true, "die rechte Hand darf ihr eigenes Stueck (darf_ich = true)"); +} + +/* ===================================================================== + 13. AM ECHTEN BILDSCHIRM + ===================================================================== + Die zwoelf Abschnitte davor messen die Schnittstelle. Keiner von + ihnen haette gemerkt, wenn der Knopf "Bearbeiten" gar nicht + erscheint, das Formular hinter der Karte verschwindet oder die + Datumsfelder am Handy aus dem Bild laufen. + + Genau daran ist die Teilnehmerwahl am 05.09. gescheitert: vierzig + gruene Pruefungen, und auf dem Bildschirm klebten die Namen + aneinander. Niemand hatte gefragt, ob das Ergebnis auch AUSSIEHT wie + gedacht. + + WARUM EIN HTTPS-VORBAU: Team Dogi wohnt auf crew.dogfather-universe.com, + und welche Wand gilt, entscheidet der Host-Kopf. Chromium setzt den + selbst und hebt diese Domain ausserdem von sich aus auf https -- + also bekommt sie hier einen echten, selbst unterschriebenen + Vorbau davor. Derselbe Weg wie in pruef-willkommen. + ===================================================================== */ +melde(""); +melde("=== 13. Am echten Bildschirm ==="); +{ + const { execFileSync } = await import("node:child_process"); + const { createServer: httpsServer } = await import("node:https"); + const { readFileSync } = await import("node:fs"); + + const schluessel = join(ordner, "pruef.key"); + const zertifikat = join(ordner, "pruef.crt"); + execFileSync("openssl", ["req", "-x509", "-newkey", "rsa:2048", "-nodes", + "-keyout", schluessel, "-out", zertifikat, "-days", "2", + "-subj", `/CN=${CREW}`, "-addext", `subjectAltName=DNS:${CREW}`], + { stdio: "ignore" }); + + const HTTPS_PORT = PORT + 1; + const vorbau = httpsServer({ + key: readFileSync(schluessel), cert: readFileSync(zertifikat), + }, (von, nach) => { + const weiter = httpAnfrage({ + host: "127.0.0.1", port: PORT, path: von.url, method: von.method, + headers: { ...von.headers, host: `${CREW}:${HTTPS_PORT}` }, + }, (antwort) => { + nach.writeHead(antwort.statusCode || 502, antwort.headers); + antwort.pipe(nach); + }); + von.pipe(weiter); + }); + await new Promise((r) => vorbau.listen(HTTPS_PORT, "127.0.0.1", r)); + const SEITE = `https://${CREW}:${HTTPS_PORT}`; + + const pw = await import("file:///C:/Users/qciga/Documents/Obelix/Analyse/node_modules/playwright/index.mjs"); + const browser = await pw.chromium.launch({ + args: [`--host-resolver-rules=MAP ${CREW} 127.0.0.1`, "--ignore-certificate-errors"], + }); + + /* Ein sauberer Ausgangszustand fuer die Augen: ein Stueck mit + laufendem Fenster, eines fuer spaeter, eines abgelaufen. Alles im + Wegwerfordner -- an die echte Datenbank kommt hier nichts. */ + await einstellen(chef.keks, "schau-laeuft.png", "Laeuft gerade", { ab: VORGESTERN, bis: IN10 }); + await einstellen(chef.keks, "schau-spaeter.png", "Kommt noch", { ab: MORGEN, bis: IN10 }); + await einstellen(chef.keks, "schau-vorbei.png", "War einmal", { ab: VORGESTERN, bis: GESTERN }); + + const meckern = []; + async function seiteAls(rolle, code, breite) { + const ctx = await browser.newContext({ viewport: { width: breite, height: 950 } }); + const s2 = await ctx.newPage(); + s2.on("pageerror", (e) => meckern.push(`${rolle}: Absturz ${String(e).slice(0, 120)}`)); + s2.on("console", (m) => { + if (m.type() === "error" && !/favicon|ERR_CERT|net::ERR/i.test(m.text())) { + meckern.push(`${rolle}: Konsole ${m.text().slice(0, 120)}`); + } + }); + await s2.goto(`${SEITE}/workspace/`, { waitUntil: "networkidle" }); + await s2.click(`.rolle[data-rolle="${rolle}"]`); + await s2.fill("#code", code); + await s2.click("button[type=submit]"); + await s2.waitForTimeout(900); + await s2.goto(`${SEITE}/workspace/material.html`, { waitUntil: "networkidle" }); + await s2.waitForTimeout(1200); + return { ctx, s: s2 }; + } + + try { + /* ---- DogFather am Rechner ------------------------------------ */ + const a = await seiteAls("admin", "CODE-DOGI-0001", 1280); + try { + const bild = await a.s.evaluate(() => { + const knoepfe = [...document.querySelectorAll("#m-kat .schritt")]; + const karte = document.querySelector(".m-karte"); + const zeit = document.querySelector(".m-karte__zeit"); + return { + ueberschrift: document.querySelector("h1")?.textContent?.trim(), + kategorien: knoepfe.map((b) => ({ + wort: b.textContent.replace(/\d+$/, "").trim(), + zahl: b.querySelector(".m-kat__zahl")?.textContent, + gedrueckt: b.getAttribute("aria-pressed"), + })), + /* Sieht der Knopf nach Knopf aus? Ein `.schritt` ohne + Polsterung waere nackter Text -- genau der Fehler von der + Teilnehmerwahl. */ + knopfHoehe: knoepfe[0] ? Math.round(knoepfe[0].getBoundingClientRect().height) : 0, + verwalten: !!karte?.querySelector(".m-karte__verwalten"), + verwaltenKnoepfe: [...(karte?.querySelectorAll(".m-karte__verwalten button") || [])] + .map((b) => b.textContent.trim()), + zeitMarke: zeit?.querySelector(".m-karte__zeit-marke")?.textContent?.trim() || null, + zeitFarbe: zeit ? getComputedStyle(zeit.querySelector(".m-karte__zeit-marke")).color : null, + zeitText: zeit?.textContent?.replace(/\s+/g, " ").trim() || null, + fensterFelder: document.querySelectorAll(".m-neu__fenster input[type=date]").length, + }; + }); + + ok(bild.ueberschrift === "Dogi-Media", + `die Seite heisst "${bild.ueberschrift}"`); + ok(bild.kategorien.length === 5, + `fuenf Kategorieknoepfe stehen da (${bild.kategorien.length}: ` + + `${bild.kategorien.map((k) => `${k.wort} ${k.zahl}`).join(" | ")})`); + ok(bild.kategorien[0]?.gedrueckt === "true", + `"Alle" ist beim Aufschlagen gewaehlt (${bild.kategorien[0]?.gedrueckt})`); + ok(bild.knopfHoehe >= 26, + `und sie sehen aus wie Knoepfe, nicht wie Text (${bild.knopfHoehe} px hoch)`); + ok(bild.fensterFelder === 2, + `der Einstellkasten hat zwei Datumsfelder (${bild.fensterFelder})`); + ok(bild.verwalten && bild.verwaltenKnoepfe.join("/") === "Bearbeiten/Entfernen", + `DogFather sieht beide Verwaltungsknoepfe (${bild.verwaltenKnoepfe.join(", ")})`); + ok(!!bild.zeitMarke && !!bild.zeitText, + `der Zeitraum steht an der Karte ("${bild.zeitText}")`); + ok(bild.zeitFarbe !== null && bild.zeitFarbe !== "rgb(0, 0, 0)", + `und die Zustandsmarke hat ihre Farbe (${bild.zeitFarbe})`); + + /* ---- Der Filter filtert wirklich, und beide Listen ---- */ + const vorher = await a.s.evaluate(() => ({ + oben: document.querySelectorAll("#frei .m-karte").length, + unten: document.querySelectorAll("#weg .m-karte").length, + })); + await a.s.click('#m-kat .schritt[data-wert="spaeter"]'); + await a.s.waitForTimeout(400); + const nachher = await a.s.evaluate(() => ({ + oben: document.querySelectorAll("#frei .m-karte").length, + unten: document.querySelectorAll("#weg .m-karte").length, + gedrueckt: document.querySelector('#m-kat .schritt[data-wert="spaeter"]') + ?.getAttribute("aria-pressed"), + /* AM ZUSTAND GEMESSEN, NICHT AM DATEINAMEN. Der erste Entwurf + prueft, ob jeder Name "spaeter" enthaelt -- und wurde rot, + weil ein Stueck aus Abschnitt 10 voellig zu Recht dabei war: + Dort hat die rechte Hand ihm ein Fenster in der Zukunft + gegeben. Die Pruefung hatte recht behalten wollen, nicht + gemessen. */ + zustaende: [...document.querySelectorAll("#frei .m-karte")] + .map((k) => k.querySelector(".m-karte__zeit")?.dataset.zustand || "ohne"), + })); + ok(nachher.gedrueckt === "true" && nachher.oben < vorher.oben && nachher.oben > 0, + `"Kommt noch" filtert die obere Liste (${vorher.oben} -> ${nachher.oben})`); + ok(nachher.unten === 0 && vorher.unten > 0, + `und die untere gleich mit (${vorher.unten} -> ${nachher.unten}) ` + + `-- ein Filter, der nur die halbe Seite betrifft, sieht kaputt aus`); + ok(nachher.zustaende.length > 0 + && nachher.zustaende.every((z) => z === "spaeter"), + `uebrig bleibt nur, was spaeter gilt ` + + `(${nachher.zustaende.length} Karten, alle: ${[...new Set(nachher.zustaende)].join(", ")})`); + + /* Noch einmal auf denselben Knopf heisst "alle". */ + await a.s.click('#m-kat .schritt[data-wert="spaeter"]'); + await a.s.waitForTimeout(400); + const zurueck = await a.s.evaluate(() => + document.querySelectorAll("#frei .m-karte").length); + ok(zurueck === vorher.oben, + `ein zweiter Druck fuehrt zurueck zu allen (${zurueck} von ${vorher.oben})`); + + /* ---- BEARBEITEN: Formular IN der Karte, vorbelegt ---- */ + await a.s.click(".m-karte .m-karte__verwalten button"); + await a.s.waitForTimeout(400); + const form = await a.s.evaluate(() => { + const k = document.querySelector(".m-karte"); + const f = k?.querySelector(".m-bearb"); + if (!f) return null; + return { + inKarte: !!f && k.contains(f), + text: f.querySelector("textarea")?.value, + kartenText: k.querySelector(".m-karte__text")?.textContent, + datumsfelder: f.querySelectorAll("input[type=date]").length, + ab: f.querySelectorAll("input[type=date]")[0]?.value, + knoepfe: [...f.querySelectorAll("button")].map((b) => b.textContent.trim()), + }; + }); + ok(!!form && form.inKarte, "das Formular steht in der Karte, nicht davor"); + ok(!!form && form.text === form.kartenText, + `das Textfeld ist mit dem bisherigen Text vorbelegt ("${form?.text}")`); + ok(!!form && form.datumsfelder === 2 && !!form.ab, + `und die zwei Datumsfelder auch (${form?.ab})`); + ok(!!form && form.knoepfe.join("/") === "Speichern/Abbrechen", + `Speichern und Abbrechen stehen da (${form?.knoepfe.join(", ")})`); + + /* GEGENPROBE ZUM VORBELEGEN: Genau das konnte `frageNach` nicht, + und genau deshalb steht hier ein eigenes Formular. Ein leeres + Feld waere kein Bearbeiten, sondern ein Neuschreiben. */ + + /* ---- Ein verkehrtes Fenster wird schon hier gesagt ---- */ + const felder = ".m-karte .m-bearb input[type=date]"; + await a.s.evaluate((wahl) => { + const d2 = document.querySelectorAll(wahl); + d2[0].value = "2030-01-01"; d2[1].value = "2029-01-01"; + }, felder); + await a.s.click(".m-karte .m-bearb button[type=submit]"); + await a.s.waitForTimeout(500); + const gemeckert = await a.s.evaluate(() => + document.querySelector(".m-karte .m-bearb__fehler")?.textContent?.trim()); + ok(!!gemeckert && gemeckert.length > 10, + `ein verkehrtes Fenster wird im Formular gesagt ("${gemeckert}")`); + + /* ---- Speichern geht wirklich durch ---- */ + await a.s.evaluate(() => { + const f = document.querySelector(".m-karte .m-bearb"); + f.querySelector("textarea").value = "Am Bildschirm geaendert"; + for (const d2 of f.querySelectorAll("input[type=date]")) d2.value = ""; + }); + await a.s.click(".m-karte .m-bearb button[type=submit]"); + await a.s.waitForTimeout(1200); + const danach = await a.s.evaluate(() => ({ + formWeg: !document.querySelector(".m-karte .m-bearb"), + text: document.querySelector(".m-karte .m-karte__text")?.textContent, + })); + ok(danach.formWeg, "nach dem Speichern ist das Formular zu"); + ok(danach.text === "Am Bildschirm geaendert", + `und der neue Text steht auf der Karte ("${danach.text}")`); + } finally { await a.ctx.close(); } + + /* ---- Ein Modi am Handy ---------------------------------------- + ZWEI ANNAHMEN HIER WAREN FALSCH, und beide standen vier Zeilen + weiter oben im selben Lauf richtig da: + + "ein Modi stellt etwas bereit (201, Nummer 4)" + + Ein Modi GEHOERT zum Team Dogi. Er darf einstellen, und an + seinen EIGENEN Stuecken darf er selbstverstaendlich auch + aendern. Die erste Fassung verlangte "kein einziger + Verwaltungsknopf" und "kein Einstellkasten" -- das waere keine + Pruefung gewesen, sondern der Wunsch, recht zu behalten. + + Was wirklich zaehlt, ist die Grenze: seine eigenen ja, fremde + nein. Und die wird nicht geschaetzt, sondern gegen die Liste + gerechnet, die der Server ihm selbst schickt. */ + const b = await seiteAls("modi", "CODE-MODI-0001", 390); + try { + const seinStand = (await liste(modi.keks)).json || {}; + const darfIds = new Set((seinStand.material || []) + .filter((m) => m.darf_ich && !m.genommen).map((m) => String(m.id))); + const fremdIds = new Set((seinStand.material || []) + .filter((m) => !m.darf_ich && !m.genommen).map((m) => String(m.id))); + + const handy = await b.s.evaluate(() => ({ + karten: document.querySelectorAll(".m-karte").length, + mitVerwalten: [...document.querySelectorAll(".m-karte")] + .filter((k) => k.querySelector(".m-karte__verwalten")) + .map((k) => k.dataset.id), + einstellen: !document.getElementById("neu-block").hidden, + breite: document.documentElement.scrollWidth, + sichtbar: document.documentElement.clientWidth, + /* Jeder Kategorieknopf muss am Finger zu treffen sein. */ + kleinsteKat: Math.min(...[...document.querySelectorAll("#m-kat .schritt")] + .map((x) => Math.round(x.getBoundingClientRect().height))), + })); + + ok(handy.karten > 0, `der Modi sieht Stuecke (${handy.karten})`); + + const gezeigt = new Set(handy.mitVerwalten); + const zuViel = [...gezeigt].filter((id) => fremdIds.has(id)); + const zuWenig = [...darfIds].filter((id) => !gezeigt.has(id)); + /* DIE ANZAHL GEHOERT IN DIE BEDINGUNG: Ohne `darfIds.size > 0` + waeren beide Listen leer und die Zeile gruen, ohne dass je + ein Knopf geprueft wurde. */ + ok(darfIds.size > 0 && fremdIds.size > 0 + && zuViel.length === 0 && zuWenig.length === 0, + `Verwaltungsknoepfe genau an seinen eigenen ` + + `(${gezeigt.size} gezeigt, ${darfIds.size} erlaubt, ` + + `${fremdIds.size} fremde daneben)` + + (zuViel.length ? ` -- zu viel an: ${zuViel.join(", ")}` : "") + + (zuWenig.length ? ` -- es fehlt an: ${zuWenig.join(", ")}` : "")); + + ok(handy.einstellen === true, + "der Einstellkasten steht da -- ein Modi gehoert zum Team und stellt bereit"); + ok(handy.breite <= handy.sichtbar + 1, + `nichts laeuft seitlich aus dem Bild (${handy.breite} von ${handy.sichtbar} px)`); + ok(handy.kleinsteKat >= 26, + `die Kategorieknoepfe sind am Finger zu treffen (${handy.kleinsteKat} px)`); + } finally { await b.ctx.close(); } + + /* ---- Und die Community: gar kein Kasten ----------------------- + DAS IST DIE GEGENPROBE zur Zeile darueber. Waere der Kasten + einfach immer da, waere "er steht da" genauso gruen. */ + const c = await seiteAls("gast", "CODE-GAST-0001", 390); + try { + const gast = await c.s.evaluate(() => ({ + karten: document.querySelectorAll(".m-karte").length, + einstellen: !document.getElementById("neu-block").hidden, + verwalten: document.querySelectorAll(".m-karte__verwalten").length, + breite: document.documentElement.scrollWidth, + sichtbar: document.documentElement.clientWidth, + })); + ok(gast.karten > 0, `die Community sieht die Stuecke (${gast.karten})`); + ok(gast.einstellen === false, + "aber keinen Einstellkasten -- ein Formular, das eine Absage bringt, ist schlimmer als keins"); + ok(gast.verwalten === 0, + `und keinen einzigen Verwaltungsknopf (${gast.verwalten})`); + ok(gast.breite <= gast.sichtbar + 1, + `auch bei ihr laeuft nichts aus dem Bild (${gast.breite} von ${gast.sichtbar} px)`); + } finally { await c.ctx.close(); } + + ok(meckern.length === 0, meckern.length + ? `der Browser meldet: ${meckern.slice(0, 3).join(" | ")}` + : "kein Absturz und keine Fehlermeldung im Browser"); + } finally { + await browser.close(); + await new Promise((r) => vorbau.close(r)); + } +} + /* ===================================================================== ERGEBNIS ===================================================================== */ melde(""); /* Die Zahl gehoert in die Bedingung, nicht nur in den Text: Ein Lauf, der nichts geprueft hat, ist kein bestandener Lauf. */ -if (geprueft < 40) { - console.log(` FEHL nur ${geprueft} Pruefungen gelaufen -- erwartet waren 40+`); +/* Von 40 ueber 90 auf 145 angehoben (22.09.2026), als das Bearbeiten, + das Zeitfenster und der Blick am echten Bildschirm dazukamen. Die + Zahl ist keine Schaetzung: Sie steht knapp unter dem, was der Lauf + danach tatsaechlich gemeldet hat (155). Bei 90 waere der ganze + Browserabschnitt still ausfallen koennen, ohne dass die Zahl etwas + gesagt haette -- und genau das soll sie fangen. */ +if (geprueft < 145) { + console.log(` FEHL nur ${geprueft} Pruefungen gelaufen -- erwartet waren 145+`); fehler++; } melde(fehler === 0 diff --git a/server/workspace-material.js b/server/workspace-material.js index 094bd2ce..d7fe4bea 100644 --- a/server/workspace-material.js +++ b/server/workspace-material.js @@ -62,6 +62,11 @@ import { db, protokolliere, echteIp, sitzungLesen, DATEN_ORDNER, istDogFather, TEAM_DOGI_ROLLEN, TREFF_ROLLEN, } from "./workspace.js"; +/* DIE UHR DES HAUSES, nicht `new Date().toISOString().slice(0,10)`. + Das waere UTC -- zwischen Mitternacht und zwei Uhr laege der Tag + damit einen zurueck, und ein Stueck, das heute anfaengt, staende + noch als "spaeter" da. */ +import { tagLokal } from "./helfer-zeit.mjs"; export const materialRouter = express.Router(); @@ -103,9 +108,69 @@ function tabellen() { CREATE INDEX IF NOT EXISTS idx_material_frei ON material (genommen_von, erstellt); `); + + /* VON WANN BIS WANN (22.09.2026). + =================================================================== + Filipe: "welche sind benutzt welche nicht welche sind noch offen, + welche laufen von wan bis wann, das muss man auch machen koennen." + + Ein Bild zur Halloween-Aktion ist am 2. November kein Angebot mehr, + sondern ein Stolperstein. Ohne Fenster bleibt es bis in alle + Ewigkeit in der Liste, und irgendwann postet es jemand. + + BEIDE FELDER SIND FREIWILLIG. Der haeufigste Fall ist "gilt + einfach" -- ein Pflichtfeld erzwaenge hier ein erfundenes Datum, + und ein erfundenes Datum ist schlechter als keins. + + ERGAENZT STATT ANGENOMMEN: Die Tabelle steht auf echten Rechnern + schon mit Daten. `ALTER TABLE` waere beim zweiten Start ein + Fehler, ein neues CREATE wuerde die vorhandenen Zeilen nicht + erreichen. Also nachsehen, was da ist, und nur ergaenzen, was + fehlt -- dieselbe Bauart wie bei den Personen-Spalten. */ + const da = new Set(d.prepare("PRAGMA table_info(material)").all().map((c) => c.name)); + for (const [spalte, form] of [["gilt_ab", "TEXT"], ["gilt_bis", "TEXT"]]) { + if (da.has(spalte)) continue; + try { + d.exec(`ALTER TABLE material ADD COLUMN ${spalte} ${form}`); + console.log(`[material] Spalte '${spalte}' ergaenzt.`); + } catch (f) { console.error(`[material] Spalte '${spalte}':`, f?.message); } + } bereit = true; } +/* --------------------------------------------------------------------- + IN WELCHEM ZUSTAND IST EIN STUECK? + --------------------------------------------------------------------- + Filipe will die Liste nach Zustand sortieren koennen. Der Zustand + wird GERECHNET und nicht gespeichert: Eine Spalte "zustand" muesste + jemand taeglich nachfuehren, und am Tag, an dem das vergessen wird, + steht "laeuft" an etwas, das seit drei Wochen abgelaufen ist. + + Vier Zustaende, und die Reihenfolge der Fragen entscheidet: + benutzt -- jemand hat es genommen. Schlaegt alles andere; ein + vergebenes Stueck ist weg, auch wenn sein Fenster + noch laeuft. + spaeter -- das Fenster faengt erst an. + abgelaufen -- das Fenster ist vorbei. + frei -- alles andere. Auch alles ohne Fenster: "kein Datum" + heisst "gilt", nicht "gilt nicht". + --------------------------------------------------------------------- */ +export function zustandVon(z, heute) { + if (z.genommen_von) return "benutzt"; + if (z.gilt_ab && z.gilt_ab > heute) return "spaeter"; + if (z.gilt_bis && z.gilt_bis < heute) return "abgelaufen"; + return "frei"; +} + +/** Ein Datum im Format JJJJ-MM-TT -- oder null. Nichts dazwischen. */ +function datumOderNull(roh) { + const t = String(roh ?? "").trim(); + if (!t) return null; + if (!/^\d{4}-\d{2}-\d{2}$/.test(t)) return undefined; /* undefined = ungueltig */ + const d = new Date(`${t}T12:00:00Z`); + return Number.isNaN(d.getTime()) ? undefined : t; +} + /* --------------------------------------------------------------------- WER DARF WAS --------------------------------------------------------------------- @@ -127,6 +192,24 @@ export const darfMaterialNehmen = (person) => export const siehtWerGenommenHat = (person) => !!person && (istDogFather(person) || person.rolle === "hand"); +/* WER FREMDES BEARBEITEN UND ENTFERNEN DARF (22.09.2026). + --------------------------------------------------------------------- + Filipe: "wenn wir in der kategorie material was hochladen will ich + dass die rechte und linke hand es auch bearbeiten und loeschen + koennen. dogfather auch fals es ein fehler gab." + + Bis heute kam nur der Einsteller selbst und DogFather an ein Stueck. + Wer einen Tippfehler im Text hatte oder das falsche Bild erwischte, + musste warten, bis jemand anders Zeit hat -- und in der Zwischenzeit + steht das Falsche da, wo die Community sich bedient. + + NICHT ueber `darfMaterialEinstellen`: Dort stehen auch die Modis + drin, und die sollen ihr eigenes Stueck zuruecknehmen koennen, aber + nicht das von jemand anderem. Wer fremdes anfasst, ist die Leitung. */ +export const darfFremdesVerwalten = (person) => + !!person && (istDogFather(person) + || person.rolle === "hand" || person.rolle === "linke"); + function angemeldet(req, res, next) { const person = sitzungLesen(req); if (!person) return res.status(401).json({ fehler: "nicht_angemeldet" }); @@ -148,7 +231,7 @@ materialRouter.get("/workspace/api/material", (req, res) => { const zeigtNamen = siehtWerGenommenHat(req.person); const reihen = db().prepare(` SELECT m.id, m.dateiname, m.art, m.groesse, m.text, m.erstellt, - m.genommen_am, m.genommen_von, + m.genommen_am, m.genommen_von, m.von_id, m.gilt_ab, m.gilt_bis, v.name AS von_name, g.name AS genommen_name FROM material m @@ -157,9 +240,17 @@ materialRouter.get("/workspace/api/material", (req, res) => { ORDER BY (m.genommen_von IS NOT NULL), m.erstellt DESC `).all(); + /* HEUTE WIRD EINMAL GERECHNET, NICHT JE ZEILE. Sonst koennte eine + Liste, die ueber Mitternacht gelesen wird, zwei verschiedene + Tage benutzen -- und ein Stueck waere in derselben Antwort + gleichzeitig "laeuft" und "abgelaufen". */ + const heute = tagLokal(0); + res.json({ darf_einstellen: darfMaterialEinstellen(req.person), + darf_verwalten: darfFremdesVerwalten(req.person), sieht_wer: zeigtNamen, + heute, max_bytes: MAX_BYTES, material: reihen.map((z) => ({ id: z.id, @@ -180,6 +271,14 @@ materialRouter.get("/workspace/api/material", (req, res) => { /* Und jeder sieht, ob ER es war -- sonst wuesste er nicht mehr, was er selbst schon geholt hat. */ von_mir_genommen: z.genommen_von === req.person.id, + gilt_ab: z.gilt_ab || null, + gilt_bis: z.gilt_bis || null, + zustand: zustandVon(z, heute), + /* DARF ICH DIESES STUECK ANFASSEN? Die Antwort steht beim + Stueck und wird nicht in der Oberflaeche nachgerechnet -- + zwei Rechnungen fuer dieselbe Frage laufen auseinander, und + die im Browser ist die, der man nicht trauen darf. */ + darf_ich: z.von_id === req.person.id || darfFremdesVerwalten(req.person), })), }); } catch (f) { @@ -229,11 +328,30 @@ materialRouter.post( const datei = `${Date.now().toString(36)}-${randomBytes(8).toString("hex")}${endung}`; writeFileSync(join(ORDNER, datei), req.body, { flag: "wx" }); + /* DAS FENSTER KOMMT WIE NAME UND TEXT IN KOPFZEILEN. + Derselbe Weg wie alles andere an diesem Aufruf -- die Datei + liegt roh im Rumpf, also ist fuer Angaben nur der Kopf uebrig. + Ein zweiter Aufruf danach waere ein zweiter Weg, der beim + Abbrechen ein halbes Stueck hinterliesse. */ + const ab = datumOderNull(req.get("x-gilt-ab")); + const bis = datumOderNull(req.get("x-gilt-bis")); + if (ab === undefined || bis === undefined) { + return res.status(400).json({ fehler: "datum_unbrauchbar" }); + } + /* EIN FENSTER, DAS RUECKWAERTS LAEUFT, IST KEINS. Es waere in + jedem Zustand gleichzeitig "spaeter" und "abgelaufen" -- und + die Liste zeigte dann, was die Reihenfolge der Fragen + zufaellig ergibt, statt was jemand gemeint hat. */ + if (ab && bis && ab > bis) { + return res.status(400).json({ fehler: "fenster_verkehrt" }); + } + const { lastInsertRowid } = db().prepare(` - INSERT INTO material (datei, dateiname, typ, art, groesse, text, von_id, erstellt) - VALUES (?,?,?,?,?,?,?,?) + INSERT INTO material (datei, dateiname, typ, art, groesse, text, von_id, erstellt, + gilt_ab, gilt_bis) + VALUES (?,?,?,?,?,?,?,?,?,?) `).run(datei, name, typ, art, req.body.length, text, req.person.id, - new Date().toISOString()); + new Date().toISOString(), ab, bis); protokolliere(req.person.id, "material_eingestellt", `${art} "${name}" (${req.body.length} Bytes)`, echteIp(req)); @@ -277,7 +395,7 @@ materialRouter.post("/workspace/api/material/:id/nehmen", (req, res) => { }); } - protokolliere(req.person.id, "material_genommen", `Material ${id}`, echteIp(req)); + protokolliere(req.person.id, "material_genommen", `Dogi-Media ${id}`, echteIp(req)); res.json({ ok: true, weg: `/workspace/api/material/${id}/datei` }); } catch (f) { console.error("[material] Nehmen:", f?.message); @@ -362,17 +480,74 @@ materialRouter.get("/workspace/api/material/:id/datei", (req, res) => { /* --------------------------------------------------------------------- WEGNEHMEN --------------------------------------------------------------------- */ +/* --------------------------------------------------------------------- + BEARBEITEN (22.09.2026) + --------------------------------------------------------------------- + Filipe: "will ich dass die rechte und linke hand es auch bearbeiten + und loeschen koennen. dogfather auch fals es ein fehler gab." + + GEAENDERT WERDEN TEXT UND FENSTER -- die DATEI nicht. Wer ein + anderes Bild will, stellt ein anderes Stueck ein: Sonst zeigte die + Liste nach der Aenderung etwas anderes als das, was jemand sich + gerade angesehen hat, unter derselben Nummer. + + EIN VERGEBENES STUECK BLEIBT, WIE ES IST. Wer es genommen hat, hat + sich auf diesen Text und dieses Fenster verlassen; es hinterher zu + aendern hiesse, die Absprache nachtraeglich umzuschreiben. + --------------------------------------------------------------------- */ +materialRouter.patch("/workspace/api/material/:id", express.json({ limit: "32kb" }), + (req, res) => { + try { + const id = Number(req.params.id); + const z = db().prepare("SELECT * FROM material WHERE id = ?").get(id); + if (!z) return res.status(404).json({ fehler: "nicht_gefunden" }); + if (z.von_id !== req.person.id && !darfFremdesVerwalten(req.person)) { + return res.status(403).json({ fehler: "nicht_deins" }); + } + /* EIGENER CODE, NICHT `schon_vergeben`: Der ist im Haus schon + vergeben -- fuer Aufgaben ("die Aufgabe ist schon uebernommen"). + Ein Code, der zwei Dinge bedeutet, zeigt frueher oder spaeter + an einer der beiden Stellen den falschen Satz an. */ + if (z.genommen_von) return res.status(409).json({ fehler: "schon_im_umlauf" }); + + const text = String(req.body?.text ?? z.text ?? "").trim().slice(0, 600); + const ab = req.body?.gilt_ab === undefined + ? z.gilt_ab : datumOderNull(req.body.gilt_ab); + const bis = req.body?.gilt_bis === undefined + ? z.gilt_bis : datumOderNull(req.body.gilt_bis); + if (ab === undefined || bis === undefined) { + return res.status(400).json({ fehler: "datum_unbrauchbar" }); + } + if (ab && bis && ab > bis) { + return res.status(400).json({ fehler: "fenster_verkehrt" }); + } + + db().prepare("UPDATE material SET text = ?, gilt_ab = ?, gilt_bis = ? WHERE id = ?") + .run(text, ab, bis, id); + protokolliere(req.person.id, "material_geaendert", `Dogi-Media ${id}`, echteIp(req)); + res.json({ ok: true, id, text, gilt_ab: ab, gilt_bis: bis, + zustand: zustandVon({ ...z, gilt_ab: ab, gilt_bis: bis }, tagLokal(0)) }); + } catch (f) { + console.error("[material] Aendern:", f?.message); + res.status(503).json({ fehler: "nicht_verfuegbar" }); + } + }); + materialRouter.delete("/workspace/api/material/:id", (req, res) => { try { const id = Number(req.params.id); const z = db().prepare("SELECT * FROM material WHERE id = ?").get(id); if (!z) return res.status(404).json({ fehler: "nicht_gefunden" }); - if (z.von_id !== req.person.id && !istDogFather(req.person)) { + /* ERWEITERT AM 22.09.2026: auch rechte und linke Hand. + Vorher kam nur der Einsteller und DogFather an ein Stueck. Wer + das falsche Bild erwischt hatte, musste warten -- und bis dahin + stand das Falsche dort, wo die Community sich bedient. */ + if (z.von_id !== req.person.id && !darfFremdesVerwalten(req.person)) { return res.status(403).json({ fehler: "nicht_deins" }); } db().prepare("DELETE FROM material WHERE id = ?").run(id); try { unlinkSync(join(ORDNER, z.datei)); } catch { /* schon weg */ } - protokolliere(req.person.id, "material_entfernt", `Material ${id}`, echteIp(req)); + protokolliere(req.person.id, "material_entfernt", `Dogi-Media ${id}`, echteIp(req)); res.json({ ok: true }); } catch (f) { console.error("[material] Entfernen:", f?.message); diff --git a/server/workspace.js b/server/workspace.js index baf76f27..e48e0cfc 100644 --- a/server/workspace.js +++ b/server/workspace.js @@ -1318,7 +1318,7 @@ const TREFF_BEREICHE = [ Ton 42 ist GERECHNET, nicht gegriffen -- tools/kachel-farbe- einzeln.mjs, Abstand 0,0823 zum naechsten Nachbarn (1,3-fach besser als der erste Vorschlag), Kontrast 6,08:1. */ - { ...TREFF_GRUPPE, name: "Material", unter: "Bilder und Videos zum Posten – jedes nur einmal", + { ...TREFF_GRUPPE, name: "Dogi-Media", unter: "Bilder und Videos zum Posten – jedes nur einmal", zeichen: "galerie", ton: 42, ziel: "material.html", szene: "studio" }, /* DIE FARBE, DIE "REGELN & HILFE" HATTE (22.09.2026). Filipes screen5: "die farbe dieser kachel soll die farbe von der @@ -5849,8 +5849,31 @@ export function heuteLokal() { return `${d.getFullYear()}-${p(d.getMonth() + 1)}-${p(d.getDate())}`; } -/** Derselbe Tag, um n Tage verschoben. */ -export function tagLokal(versatz) { +/** Derselbe Tag, um `versatz` Tage verschoben. Negativ heißt zurück. + * + * ZWEI DINGE AM 22.09.2026 NACHGEZOGEN, beide durch einen echten Schaden: + * + * (1) `versatz = 0`. Dogi-Media rief `tagLokal()` ohne Argument auf und + * bekam `"NaN-NaN-NaN"` zurück -- eine Zeichenkette, die aussieht + * wie ein Datum und sich wie keines verhält. Im Vergleich + * `"2026-12-31" < "NaN-NaN-NaN"` gewinnt das N, also galt JEDES + * Stück mit Enddatum vom ersten Tag an als abgelaufen, und + * "Kommt noch" gab es nie. Kein Absturz, keine Meldung, keine + * Zeile im Protokoll -- nur eine Seite, die etwas anderes zeigt + * als die Wahrheit. Die drei Kopien dieser Funktion waren an + * dieser Stelle auseinandergelaufen: helfer-tag.mjs hatte die + * Vorbelegung, die beiden anderen nicht. + * + * (2) Der Wurf bei einer Zahl, die keine ist. Eine Vorbelegung deckt + * nur den leeren Aufruf ab; `tagLokal(irgendwas)` mit einer + * undefinierten Variablen läge weiter still daneben. Ein Datum, + * das sich nicht ausrechnen lässt, muss SCHEITERN und nicht + * schweigen -- sonst wandert der Unsinn in die Datenbank und in + * die Anzeige, und gefunden wird er Wochen später. */ +export function tagLokal(versatz = 0) { + if (!Number.isFinite(Number(versatz))) { + throw new TypeError(`tagLokal: "${versatz}" ist keine Zahl von Tagen.`); + } const d = new Date(Date.now() + versatz * 86400_000); const p = (n) => String(n).padStart(2, "0"); return `${d.getFullYear()}-${p(d.getMonth() + 1)}-${p(d.getDate())}`; diff --git a/workspace/anruf-probe.html b/workspace/anruf-probe.html index dfba0cec..f89ac0d1 100644 --- a/workspace/anruf-probe.html +++ b/workspace/anruf-probe.html @@ -16,15 +16,15 @@ verdeckt. Gefunden hat es pruef-struktur. Richtig sind `gate.css` (die Farbwerte des Hauses) und `haus.css`. --> - - + + - +