diff --git a/server/crew-adresse.js b/server/crew-adresse.js index 54dfa8e5..99e620a3 100644 --- a/server/crew-adresse.js +++ b/server/crew-adresse.js @@ -387,6 +387,11 @@ export const GEHOERT_ZU_ADRESSE = [ die Daten sind die Sache. */ { datei: "/workspace/notizen.html", zuhause: istCrewAdresse }, { datei: "/workspace/reaktion.html", zuhause: istCrewAdresse }, + /* Das Foyer davor (08.10.2026) -- dieselbe Tuer, dieselbe + Trennung. Ohne diesen Eintrag waere es auf der Agenturadresse + abrufbar, weil express.static jede Datei ausliefert, die man + beim Namen nennt. */ + { datei: "/workspace/reaktion-foyer.html", zuhause: istCrewAdresse }, /* DIE ZWEI BUEHNENQUELLEN (28.09.2026). Sie kommen ohne Anmeldung aus -- geprueft wird ein Schluessel in der Adresse -- und gehoeren trotzdem in genau ein Haus. */ diff --git a/server/mess-foyer.mjs b/server/mess-foyer.mjs new file mode 100644 index 00000000..624cef15 --- /dev/null +++ b/server/mess-foyer.mjs @@ -0,0 +1,681 @@ +/* ===================================================================== + WIE DAS FOYER AUSSIEHT (08.10.2026) + + `pruef-reaktion` misst, was die Schnittstelle antwortet. Das sagt + nichts darueber, ob die drei Reihen auf einem Bildschirm + nebeneinander passen, ob der Knopf in der Mitte ins Auge fiele und + ob am Telefon die Sendung oben steht. + + WARUM EIGENES WERKZEUG UND KEIN ABSCHNITT IN mess-reaktion: + Dort laufen Kameras, Video und Chat -- jede geoeffnete Seite haelt + zwei stehende Verbindungen, und der Browser gibt je Adresse nur + wenige her. Das Foyer braucht keine davon. Es misst in Sekunden, + was dort zwischen zwei Kameraaufbauten untergehen wuerde. + + GEMESSEN WIRD, WAS MAN NICHT ERRATEN KANN: + + * Steht der Plan bei einem Gast wirklich NICHT da -- und steht + auch kein Stueck daraus irgendwo im Dokument? + * Ist die Mitte breiter als die Seiten, und rutschen die zwei + uebrigen Reihen auf, wenn der Plan fehlt? + * Steht am Telefon die Sendung in der ERSTEN Zeile? + * Ist der Knopf bei "zu" weg und bei "gleich"/"live" da? + * Sind alle Beruehrziele 44 Pixel hoch und alle Schriften + mindestens 11,5 Pixel gross? + * Und geht der ganze Weg durch: Gast schickt ein Video, + DogFather sieht es MIT Namen, nimmt es -- und es steht links + im Plan. + + ZU JEDER MESSUNG, DIE "in Ordnung" SAGEN KANN, GEHOERT EINE + GEGENPROBE. Am Ende wird jede der tragenden Messungen absichtlich + kaputtgemacht und muss anschlagen. Eine Messung, die immer + bestaetigt, bestaetigt nichts. + + NIE DIE ECHTE DATENBANK. Eigener Ordner, eigener Port, eigenes + Zertifikat -- und am Ende ist alles weg. + + DREI AUSGAENGE: + 0 in Ordnung + 1 ein Befund + 3 konnte nicht nachsehen (Playwright fehlt, kein Zertifikat, + die Buehne nimmt nichts an, oder der Stand hat sich waehrend + des Laufs geaendert) + + Aufrufen mit: node server/mess-foyer.mjs + ===================================================================== */ +import { mkdtempSync, rmSync, readFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join, dirname } from "node:path"; +import { fileURLToPath } from "node:url"; +import { request } from "node:http"; +import { createServer as httpsServer } from "node:https"; +import { zertifikatBauen } from "./helfer-openssl.mjs"; +import { notbremse } from "./helfer-notbremse.mjs"; +import { eigenerPort } from "./helfer-port.mjs"; + +const HIER = dirname(fileURLToPath(import.meta.url)); +const ordner = mkdtempSync(join(tmpdir(), "mess-foyer-")); +process.env.WORKSPACE_DB = join(ordner, "workspace.db"); + +const PORT = await eigenerPort(import.meta); +const HTTPS_PORT = await eigenerPort(import.meta, "mess-foyer (HTTPS)", 1); +process.env.PORT = `${PORT}`; +process.env.SITE_ACCESS_SECRET = "lokaler-test"; +process.env.SITE_PUBLIC_LAUNCH_AT = "2020-01-01T00:00:00+01:00"; + +const express = (await import("express")).default; +/* Der Sitzungskeks ist `secure`. Der Vorbau unten spricht https, der + Server dahinter nicht -- ohne diesen Griff setzt er den Keks und + der Browser verwirft ihn. */ +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, "mess-foyer"); +await new Promise((r) => setTimeout(r, 1000)); + +const CREW = "crew.dogfather-universe.com"; + +/* ---------- Leute ------------------------------------------------------- */ +const { DatabaseSync } = await import("node:sqlite"); +const { scryptSync, randomBytes, createHmac } = await import("node:crypto"); +const d = new DatabaseSync(process.env.WORKSPACE_DB); +const kenn = randomBytes(32).toString("hex"); +d.prepare("INSERT INTO einstellungen (schluessel, wert, geaendert, von)" + + " VALUES (?,?,?,NULL) ON CONFLICT(schluessel) DO NOTHING") + .run("code_kennung_schluessel", kenn, new Date().toISOString()); +const ALT = new Date(Date.now() - 200 * 86400000).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," + + " code_kennung, aktiv, erstellt) VALUES (?,?,?,?,?,?,1,?)") + .run(name, rolle, hash, salt, 32768, + createHmac("sha256", kenn).update(code).digest("hex"), ALT); +} +const CODES = { + admin: "CODE-M00-000001", hand: "CODE-M01-000001", + gast: "CODE-M02-000001", gast2: "CODE-M03-000001", +}; +/* VANVAN IST DIE RECHTE HAND und wird mitgemessen, nicht nur + DogFather: Filipe hat "nur ich und vanvan" gesagt, und ob die + zweite Haelfte davon stimmt, sieht man nur, wenn sie dabei ist. */ +anlegen("DogFather", "admin", CODES.admin); +anlegen("VanVan", "hand", CODES.hand); +anlegen("Nachtfalter", "gast", CODES.gast); +anlegen("Lenny", "gast", CODES.gast2); +d.close(); + +function roh(pfad, { method = "GET", keks = "", rumpf = null } = {}) { + return new Promise((fertig, schief) => { + const daten = rumpf === null ? null : Buffer.from(JSON.stringify(rumpf)); + const a = request({ + host: "127.0.0.1", port: PORT, path: pfad, method, + headers: { + Host: CREW, Origin: `https://${CREW}`, + ...(keks ? { Cookie: keks } : {}), + ...(daten ? { "Content-Type": "application/json", "Content-Length": daten.length } : {}), + }, + }, (antwort) => { + const teile = []; + antwort.on("data", (s) => teile.push(s)); + antwort.on("end", () => { + const text = Buffer.concat(teile).toString("utf8"); + let j = null; try { j = JSON.parse(text); } catch { /* kein JSON */ } + fertig({ code: antwort.statusCode, text, j, kopf: antwort.headers }); + }); + }); + a.on("error", schief); + if (daten) a.write(daten); + a.end(); + }); +} +async function anmelden(wer) { + const a = await roh("/workspace/api/anmelden", { + method: "POST", + rumpf: { rolle: wer === "gast2" ? "gast" : wer, code: CODES[wer], alter_ok: true }, + }); + return [].concat(a.kopf["set-cookie"] || []).map((z) => z.split(";")[0]).join("; "); +} +const k = { + admin: await anmelden("admin"), + hand: await anmelden("hand"), + gast: await anmelden("gast"), + gast2: await anmelden("gast2"), +}; + +/* ---------- Zaehlwerk --------------------------------------------------- */ +let fehler = 0; +let geprueft = 0; +const ok = (b, t) => { + geprueft++; + console.log((b ? " ok " : " FEHL ") + t); + if (!b) fehler++; +}; +function nichtNachsehbar(grund) { + console.log("\n -- NICHT NACHSEHBAR"); + console.log(` ${grund}`); + console.log(" Das ist KEIN Befund am Code -- es heisst nur, dass"); + console.log(" gerade niemand nachsehen konnte.\n"); + try { rmSync(ordner, { recursive: true, force: true }); } catch { /* egal */ } + process.exit(3); +} + +/* ---------- Die Buehne fuellen ------------------------------------------ */ +/* ZWEI STUECKE IN DER WARTESCHLANGE, EINES MIT TERMIN UND EINES OHNE. + Genau das ist die Aussage der linken Reihe ("was keinen hat, steht + unten"), und mit nur einem Eintrag liesse sie sich nicht messen. */ +for (const adresse of ["https://youtu.be/9bZkp7q19f0", + "https://www.youtube.com/watch?v=kJQP7kiw5Fk"]) { + const a = await roh("/workspace/api/reaktion/liste", { + method: "POST", keks: k.admin, rumpf: { video: adresse }, + }); + if (a.code >= 400) nichtNachsehbar(`Die Warteschlange nimmt nichts an (HTTP ${a.code}).`); +} +const liste0 = (await roh("/workspace/api/reaktion", { keks: k.admin })).j?.liste || []; +if (liste0.length !== 2) { + nichtNachsehbar(`In der Warteschlange stehen ${liste0.length} Stuecke statt 2.`); +} + +/* Der Termin liegt in der ZUKUNFT und wird relativ gerechnet. Ein + festes Datum waere eine Zeitbombe: Es ist richtig, bis der Kalender + es einholt -- und dann schlaegt "ueberfaellig" an, ohne dass etwas + kaputt ist. */ +{ + const a = await roh(`/workspace/api/reaktion/liste/${liste0[0].id}/wann`, { + method: "PUT", keks: k.admin, + rumpf: { wann: new Date(Date.now() + 3 * 86400000).toISOString() }, + }); + if (a.code >= 400) nichtNachsehbar(`Der Termin laesst sich nicht setzen (HTTP ${a.code}).`); +} +await roh("/workspace/api/reaktion", { + method: "PUT", keks: k.admin, + rumpf: { + titel: "Wir schauen den neuen Trailer", + video: "https://www.youtube.com/watch?v=dQw4w9WgXcQ", + beginnt_am: new Date(Date.now() + 7 * 60_000).toISOString(), + }, +}); + +/* ---------- Der Vorbau -------------------------------------------------- */ +const schluesselDatei = join(ordner, "p.key"); +const zertDatei = join(ordner, "p.crt"); +try { + zertifikatBauen(schluesselDatei, zertDatei, CREW); +} catch (f) { + nichtNachsehbar(`Kein Zertifikat zu bauen: ${f?.message}\n` + + " openssl liegt bei Git in mingw64/bin -- im System-PATH\n" + + " steht oft nur Git/cmd."); +} +const vorbau = httpsServer({ + key: readFileSync(schluesselDatei), cert: readFileSync(zertDatei), +}, (von, nach) => { + const weiter = request({ + 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); + }); + weiter.on("error", () => { try { nach.writeHead(502); nach.end(); } catch { /* egal */ } }); + von.pipe(weiter); +}); +await new Promise((r) => vorbau.listen(HTTPS_PORT, "127.0.0.1", r)); +const BASIS = `https://${CREW}:${HTTPS_PORT}`; + +let pw = null; +try { + pw = await import( + "file:///C:/Users/qciga/Documents/Obelix/Analyse/node_modules/playwright/index.mjs"); +} catch (f) { + nichtNachsehbar(`Playwright ist nicht da: ${f?.message}`); +} +const browser = await pw.chromium.launch({ + args: [`--host-resolver-rules=MAP ${CREW} 127.0.0.1`, "--ignore-certificate-errors"], +}); + +async function alsWer(wer, breite, hoehe) { + const kontext = await browser.newContext({ + viewport: { width: breite, height: hoehe }, + /* MIT FINGER, WENN ES EIN HANDY IST. Ohne `hasTouch` meldet der + Browser `pointer: fine` -- und damit greift KEINE Regel aus + `@media (pointer: coarse)`. Dort stehen im ganzen Haus die + 44-Pixel-Beruehrziele. Eine Handy-Messung mit Mauszeiger misst + eine Seite, die auf keinem Handy so aussieht. */ + hasTouch: breite <= 860, + ignoreHTTPSErrors: true, + }); + await kontext.addCookies((k[wer] || "").split("; ").filter(Boolean).map((z) => { + const [n, ...r] = z.split("="); + return { name: n, value: r.join("="), domain: CREW, path: "/" }; + })); + const seite = await kontext.newPage(); + const konsole = []; + seite.on("pageerror", (f) => konsole.push(String(f?.message || f))); + seite.on("console", (m) => { if (m.type() === "error") konsole.push(m.text()); }); + seite.on("response", (a) => { + /* Vorschaubilder kommen von YouTube und sind hier nicht + erreichbar -- das ist die Umgebung und kein Befund. */ + if (a.status() >= 400 && !/ytimg\.com/.test(a.url())) { + konsole.push(`${a.status()} ${a.url().replace(/^https?:\/\/[^/]+/, "")}`); + } + }); + await seite.goto(`${BASIS}/workspace/reaktion-foyer.html`, { waitUntil: "networkidle" }); + /* NICHT AUF EINE ZEIT WARTEN, SONDERN AUF DIE AUSKUNFT. Solange im + Kopf "Wird geladen" steht, ist der Stand noch nicht da -- eine + Messung davor misst den leeren Rahmen. */ + await seite.waitForFunction( + () => !/Wird geladen/.test(document.getElementById("kopf-satz")?.textContent || ""), + null, { timeout: 15000 }); + await seite.waitForTimeout(250); + return { kontext, seite, konsole }; +} + +async function bild(seite, name) { + await seite.screenshot({ path: join(HIER, `mess-foyer-${name}.png`), fullPage: true }); + console.log(` Bild: server/mess-foyer-${name}.png`); +} + +/* ===================================================================== + DIE MESSUNGEN, ALLE IM BROWSER + + Sie stehen als Funktionen da und nicht als Code im Ablauf: Nur so + kann die Gegenprobe am Ende DIESELBE Messung auf eine absichtlich + kaputte Seite anwenden. Zwei Abschriften derselben Frage waeren + zwei Antworten, und eine davon waere irgendwann aelter. + ===================================================================== */ + +/** Welche Reihen stehen da, wo, wie breit -- und wie viele Spalten? */ +const reihenLesen = (seite) => seite.evaluate(() => { + const da = (id) => { + const e = document.getElementById(id); + if (!e) return null; + /* `hidden` allein genuegt nicht: Eine Reihe kann auch durch + `display:none` am Stilblatt verschwinden. Gefragt ist, ob ein + Mensch sie sieht. */ + if (!e.offsetParent) return null; + const r = e.getBoundingClientRect(); + return { oben: Math.round(r.top), breit: Math.round(r.width), hoch: Math.round(r.height) }; + }; + return { + plan: da("reihe-plan"), + saal: da("reihe-saal"), + raten: da("reihe-raten"), + spalten: getComputedStyle(document.getElementById("reihen")) + .gridTemplateColumns.split(/\s+/).filter(Boolean).length, + }; +}); + +/** Steht eines dieser Woerter irgendwo im Dokument? */ +const leckLesen = (seite, woerter) => seite.evaluate((w) => { + const text = document.body.innerText + " " + document.body.innerHTML; + return w.filter((z) => z && text.includes(z)); +}, woerter); + +/** Das kleinste Beruehrziel, die kleinste Schrift, der Ueberstand. */ +const massLesen = (seite) => seite.evaluate(() => { + const wurzel = document.getElementById("foyer"); + const sichtbar = (e) => { + const r = e.getBoundingClientRect(); + return r.width > 0 && r.height > 0 && e.offsetParent !== null; + }; + let ziel = null; + for (const e of wurzel.querySelectorAll("a, button, input, select, textarea")) { + if (!sichtbar(e)) continue; + const r = e.getBoundingClientRect(); + if (!ziel || Math.round(r.height) < ziel.hoch) { + ziel = { + hoch: Math.round(r.height), breit: Math.round(r.width), + was: e.id || e.className || e.tagName, + }; + } + } + let schrift = null; + for (const e of wurzel.querySelectorAll("*")) { + if (!sichtbar(e)) continue; + /* NUR ELEMENTE, DIE SELBST TEXT TRAGEN. Sonst waere jede + Umhuellung mitgezaehlt, und die Messung sagte etwas ueber den + Kasten statt ueber die Schrift. */ + if (![...e.childNodes].some((n) => n.nodeType === 3 && n.textContent.trim())) continue; + const gr = parseFloat(getComputedStyle(e).fontSize); + if (!schrift || gr < schrift.gr) schrift = { gr, was: e.id || e.className || e.tagName }; + } + return { + ziel, schrift, + ueber: Math.max(0, document.documentElement.scrollWidth + - document.documentElement.clientWidth), + }; +}); + +/** Was steht in der Mitte? */ +const mitteLesen = (seite) => seite.evaluate(() => { + const t = (id) => document.getElementById(id)?.textContent.trim() || ""; + const knopf = document.getElementById("saal-knopf"); + return { + lampe: t("lampe-wort"), + stand: document.getElementById("lampe")?.dataset.stand || "?", + titel: t("saal-titel"), + satz: t("saal-satz"), + knopfDa: !!(knopf && knopf.offsetParent), + knopfWort: t("saal-knopf-wort"), + knopfZiel: knopf?.getAttribute("href") || "", + }; +}); + +/** Die Zeilen der linken Reihe. */ +const planLesen = (seite) => seite.evaluate(() => [ + ...document.querySelectorAll("#plan-liste .plan-zeile")].map((z) => ({ + titel: z.querySelector(".plan-zeile__titel")?.textContent.trim() || "", + wann: z.querySelector(".plan-zeile__wann")?.textContent.trim() || "", + termin: z.dataset.termin || "", +}))); + +/** Die Zeilen der rechten Reihe. */ +const ratenLesen = (seite) => seite.evaluate(() => [ + ...document.querySelectorAll("#raten-liste .raten-zeile")].map((z) => ({ + titel: z.querySelector(".raten-zeile__titel")?.textContent.trim() || "", + unter: z.querySelector(".raten-zeile__unter")?.textContent.trim() || "", + stand: z.dataset.stand || "", + knoepfe: [...z.querySelectorAll(".raten-knopf")].map((b) => b.textContent.trim()), +}))); + +/* ===================================================================== + 1. GESCHLOSSEN, AM RECHNER, ALS COMMUNITY + ===================================================================== */ +console.log("\n=== 1. Geschlossen, am Rechner, als Community ==="); +{ + const gast = await alsWer("gast", 1440, 900); + const r = await reihenLesen(gast.seite); + ok(r.plan === null, "Gast: die linke Reihe steht gar nicht da"); + ok(r.saal !== null && r.raten !== null, "Gast: Mitte und Anraten stehen da"); + ok(r.spalten === 2, `Gast: zwei Spalten, nicht drei (${r.spalten})`); + ok(!!(r.saal && r.raten) && r.saal.breit > r.raten.breit, + `Gast: die Mitte ist die breitere (${r.saal?.breit} zu ${r.raten?.breit} px)`); + + const leck = await leckLesen(gast.seite, + [...liste0.map((z) => z.titel), ...liste0.map((z) => z.video)]); + ok(leck.length === 0, "Gast: kein Stueck aus der Warteschlange im Dokument" + + (leck.length ? " -> " + leck.join(", ") : "")); + + const m = await mitteLesen(gast.seite); + ok(m.lampe === "Geschlossen", `Gast: die Lampe sagt Geschlossen (${m.lampe})`); + ok(m.knopfDa === false, "Gast: bei zu gibt es keinen Knopf"); + ok(m.satz.length > 20, `Gast: ein Satz erklaert den Zustand (${m.satz.length} Zeichen)`); + + const mass = await massLesen(gast.seite); + ok(mass.ueber === 0, `Gast: nichts ragt seitlich hinaus (${mass.ueber} px)`); + ok(!!mass.schrift && mass.schrift.gr >= 11.5, + `Gast: kleinste Schrift ${mass.schrift?.gr} px (noetig 11,5) bei ${mass.schrift?.was}`); + ok(gast.konsole.length === 0, "Gast: nichts in der Konsole" + + (gast.konsole.length ? " -> " + gast.konsole.slice(0, 3).join(" | ") : "")); + await bild(gast.seite, "1-zu-gast"); + await gast.kontext.close(); +} + +/* ===================================================================== + 2. GESCHLOSSEN, DOGFATHER UND VANVAN + ===================================================================== */ +console.log("\n=== 2. Geschlossen, DogFather und VanVan ==="); +for (const wer of ["admin", "hand"]) { + const chef = await alsWer(wer, 1440, 900); + const r = await reihenLesen(chef.seite); + ok(r.plan !== null, `${wer}: die linke Reihe steht da`); + ok(r.spalten === 3, `${wer}: drei Spalten (${r.spalten})`); + ok(!!(r.plan && r.saal && r.raten) + && r.saal.breit > r.plan.breit && r.saal.breit > r.raten.breit, + `${wer}: die Mitte ist die breiteste ` + + `(${r.plan?.breit} / ${r.saal?.breit} / ${r.raten?.breit} px)`); + + const p = await planLesen(chef.seite); + ok(p.length === 2, `${wer}: beide Stuecke stehen im Plan (${p.length})`); + ok(p[0]?.termin === "ja", `${wer}: das mit Termin steht oben (${p[0]?.termin || "ohne"})`); + ok(/\d{1,2}:\d{2}/.test(p[0]?.wann || ""), + `${wer}: beim ersten steht eine Uhrzeit (${p[0]?.wann})`); + ok(p[1]?.wann === "noch ohne Termin", + `${wer}: das ohne Termin steht unten und sagt das (${p[1]?.wann})`); + + const mass = await massLesen(chef.seite); + ok(mass.ueber === 0, `${wer}: nichts ragt seitlich hinaus (${mass.ueber} px)`); + ok(chef.konsole.length === 0, `${wer}: nichts in der Konsole` + + (chef.konsole.length ? " -> " + chef.konsole.slice(0, 3).join(" | ") : "")); + if (wer === "admin") await bild(chef.seite, "2-zu-dogfather"); + await chef.kontext.close(); +} + +/* ===================================================================== + 3. GLEICH -- DER WARTEBEREICH IST OFFEN + ===================================================================== */ +console.log("\n=== 3. Gleich (Wartebereich) ==="); +await roh("/workspace/api/reaktion/stand", + { method: "POST", keks: k.admin, rumpf: { stand: "vorbereitung" } }); +{ + const gast = await alsWer("gast", 1440, 900); + const m = await mitteLesen(gast.seite); + ok(m.lampe === "Gleich", `die Lampe sagt Gleich (${m.lampe})`); + ok(m.knopfDa, "der Knopf steht da -- auch bei der Vorschau schon"); + ok(m.knopfWort === "In den Wartebereich", `er heisst In den Wartebereich (${m.knopfWort})`); + ok(m.knopfZiel === "reaktion.html", `er fuehrt in den Saal (${m.knopfZiel})`); + ok(/Start um/.test(m.satz), `der Satz nennt die Startzeit (${m.satz})`); + ok(m.titel.includes("Trailer"), `der Titel der Sendung steht da (${m.titel})`); + await bild(gast.seite, "3-gleich-gast"); + await gast.kontext.close(); +} + +/* ===================================================================== + 4. AUF SENDUNG + ===================================================================== */ +console.log("\n=== 4. Auf Sendung ==="); +await roh("/workspace/api/reaktion/stand", + { method: "POST", keks: k.admin, rumpf: { stand: "live" } }); +{ + const gast = await alsWer("gast", 1440, 900); + const m = await mitteLesen(gast.seite); + ok(m.lampe === "Auf Sendung", `die Lampe sagt Auf Sendung (${m.lampe})`); + ok(m.knopfWort === "Jetzt beitreten", `der Knopf heisst Jetzt beitreten (${m.knopfWort})`); + ok(/dieselbe Stelle/.test(m.satz), `der Satz verspricht den Gleichlauf (${m.satz})`); + await bild(gast.seite, "4-live-gast"); + await gast.kontext.close(); +} + +/* ===================================================================== + 5. AM TELEFON -- DIE SENDUNG STEHT OBEN + ===================================================================== */ +console.log("\n=== 5. Am Telefon (390 px) ==="); +{ + const gast = await alsWer("gast", 390, 844); + const r = await reihenLesen(gast.seite); + ok(r.plan === null, "Gast am Telefon: keine linke Reihe"); + ok(!!(r.saal && r.raten) && r.saal.oben < r.raten.oben, + `Gast am Telefon: die Sendung steht ueber dem Anraten ` + + `(${r.saal?.oben} vor ${r.raten?.oben} px)`); + const mass = await massLesen(gast.seite); + ok(mass.ueber === 0, `Gast am Telefon: nichts ragt seitlich hinaus (${mass.ueber} px)`); + ok(!!mass.ziel && mass.ziel.hoch >= 44, + `Gast am Telefon: kleinstes Beruehrziel ${mass.ziel?.hoch} px ` + + `(noetig 44) bei ${mass.ziel?.was}`); + ok(!!mass.schrift && mass.schrift.gr >= 11.5, + `Gast am Telefon: kleinste Schrift ${mass.schrift?.gr} px bei ${mass.schrift?.was}`); + await bild(gast.seite, "5-live-handy-gast"); + await gast.kontext.close(); +} +{ + const chef = await alsWer("admin", 390, 844); + const r = await reihenLesen(chef.seite); + ok(!!(r.plan && r.saal && r.raten) + && r.saal.oben < r.raten.oben && r.raten.oben < r.plan.oben, + "DogFather am Telefon: Sendung, Anraten, Plan -- in dieser Folge " + + `(${r.saal?.oben} / ${r.raten?.oben} / ${r.plan?.oben} px)`); + ok(r.spalten === 1, `DogFather am Telefon: eine Spalte (${r.spalten})`); + const mass = await massLesen(chef.seite); + ok(mass.ueber === 0, `DogFather am Telefon: nichts ragt hinaus (${mass.ueber} px)`); + ok(!!mass.ziel && mass.ziel.hoch >= 44, + `DogFather am Telefon: kleinstes Beruehrziel ${mass.ziel?.hoch} px bei ${mass.ziel?.was}`); + await bild(chef.seite, "6-live-handy-dogfather"); + await chef.kontext.close(); +} + +/* ===================================================================== + 6. DER GANZE WEG: ANRATEN, SEHEN, NEHMEN + + Hier wird nicht die Schnittstelle gefragt, sondern GEKLICKT. Der + Unterschied ist nicht theoretisch: Ein Knopf, an dem nichts haengt, + antwortet in der Pruefung nebenan tadellos. + ===================================================================== */ +console.log("\n=== 6. Anraten, sehen, nehmen ==="); +{ + const gast = await alsWer("gast", 1440, 900); + await gast.seite.fill("#raten-video", "https://www.youtube.com/watch?v=aqz-KE-bpKQ"); + await gast.seite.fill("#raten-notiz", "Das muessen wir zusammen sehen."); + await gast.seite.click("#raten-los"); + await gast.seite.waitForFunction( + () => document.querySelectorAll("#raten-liste .raten-zeile").length === 1, + null, { timeout: 10000 }); + const meine = await ratenLesen(gast.seite); + ok(meine.length === 1, `der Gast sieht seinen eigenen Vorschlag (${meine.length})`); + ok(meine[0]?.stand === "offen", `er steht auf offen (${meine[0]?.stand})`); + ok(meine[0]?.unter === "wartet", + `und sagt wartet -- ohne Namen, auch nicht den eigenen (${meine[0]?.unter})`); + ok(!meine[0]?.knoepfe.includes("Nehmen"), "der Gast hat keinen Nehmen-Knopf"); + ok(meine[0]?.knoepfe.includes("\u00d7"), "aber er darf zurueckziehen, was noch offen ist"); + const fuss = await gast.seite.textContent("#raten-fuss"); + ok(/noch 4 Videos/.test(fuss || ""), `er erfaehrt, wie viele noch gehen (${fuss})`); + await bild(gast.seite, "7-angeraten-gast"); + await gast.kontext.close(); +} +{ + const chef = await alsWer("admin", 1440, 900); + const alle = await ratenLesen(chef.seite); + ok(alle.length === 1, `DogFather sieht den Vorschlag (${alle.length})`); + ok(/Nachtfalter/.test(alle[0]?.unter || ""), + `und bei ihm steht, wer es war (${alle[0]?.unter})`); + ok(alle[0]?.knoepfe.includes("Nehmen") && alle[0]?.knoepfe.includes("Diesmal nicht"), + `er hat beide Knoepfe (${alle[0]?.knoepfe.join(" / ")})`); + const zahl = await chef.seite.textContent("#raten-zahl"); + ok(/1 offen/.test(zahl || ""), `das Schild zaehlt die offenen (${zahl})`); + await bild(chef.seite, "8-angeraten-dogfather"); + + /* NEHMEN -- und links muss es ankommen. */ + await chef.seite.click(".raten-knopf--ja"); + await chef.seite.waitForFunction( + () => document.querySelectorAll("#plan-liste .plan-zeile").length === 3, + null, { timeout: 10000 }); + const p = await planLesen(chef.seite); + ok(p.length === 3, `nach Nehmen stehen drei Stuecke im Plan (${p.length})`); + const danach = await ratenLesen(chef.seite); + ok(danach[0]?.stand === "uebernommen", + `der Vorschlag steht auf uebernommen (${danach[0]?.stand})`); + ok(/kommt dran/.test(danach[0]?.unter || ""), + `und sagt kommt dran (${danach[0]?.unter})`); + await bild(chef.seite, "9-genommen-dogfather"); + await chef.kontext.close(); +} +{ + /* UND DER EINSENDER ERFAEHRT ES. Ohne diese Messung waere der Stand + nur im Pult richtig und beim Gast von gestern. */ + const gast = await alsWer("gast", 1440, 900); + const meine = await ratenLesen(gast.seite); + ok(meine[0]?.stand === "uebernommen", + `der Gast sieht den neuen Stand (${meine[0]?.stand})`); + ok(!meine[0]?.knoepfe.includes("\u00d7"), + "und kann nichts mehr zurueckziehen, was entschieden ist"); + await gast.kontext.close(); +} +{ + /* LENNY SIEHT NICHTS DAVON -- "nur ich und vanvan sehen sie", + gemessen und nicht nur gelesen. */ + const lenny = await alsWer("gast2", 1440, 900); + const seine = await ratenLesen(lenny.seite); + ok(seine.length === 0, `Lenny sieht fremde Vorschlaege nicht (${seine.length})`); + const leck = await leckLesen(lenny.seite, ["Nachtfalter", "aqz-KE-bpKQ"]); + ok(leck.length === 0, "und auch nicht im Dokument" + + (leck.length ? " -> " + leck.join(", ") : "")); + await lenny.kontext.close(); +} + +/* ===================================================================== + 7. DIE GEGENPROBEN + + Fuenf Messungen oben koennen "in Ordnung" sagen. Hier wird jede + absichtlich kaputtgemacht -- schlaegt sie nicht an, ist sie + wertlos, und alle gruenen Haken davor beweisen nichts. + ===================================================================== */ +console.log("\n=== 7. Gegenproben ==="); +{ + const chef = await alsWer("admin", 390, 844); + + /* (a) Die Reihenfolge am Telefon. */ + await chef.seite.addStyleTag({ content: ".foyer-reihe--saal { order: 9 !important; }" }); + await chef.seite.waitForTimeout(120); + const verdreht = await reihenLesen(chef.seite); + ok(!(verdreht.saal.oben < verdreht.raten.oben), + `Gegenprobe: eine verdrehte Reihenfolge wird erkannt ` + + `(${verdreht.saal.oben} / ${verdreht.raten.oben} px)`); + + /* (b) Ein zu kleines Beruehrziel. */ + await chef.seite.addStyleTag({ + content: "#raten-los { min-height: 0 !important; height: 20px !important;" + + " padding: 0 !important; }", + }); + await chef.seite.waitForTimeout(120); + const klein = await massLesen(chef.seite); + ok(!!klein.ziel && klein.ziel.hoch < 44, + `Gegenprobe: ein 20-px-Knopf wird gefunden (${klein.ziel?.hoch} px)`); + + /* (c) Eine zu kleine Schrift. */ + await chef.seite.addStyleTag({ content: "#saal-satz { font-size: 9px !important; }" }); + await chef.seite.waitForTimeout(120); + const winzig = await massLesen(chef.seite); + ok(!!winzig.schrift && winzig.schrift.gr < 11.5, + `Gegenprobe: 9-px-Schrift wird gefunden (${winzig.schrift?.gr} px)`); + + /* (d) Und ein Ueberstand. */ + await chef.seite.addStyleTag({ content: "#saal-titel { width: 900px !important; }" }); + await chef.seite.waitForTimeout(120); + const breit = await massLesen(chef.seite); + ok(breit.ueber > 0, `Gegenprobe: ein Ueberstand wird gemessen (${breit.ueber} px)`); + await chef.kontext.close(); +} +{ + const gast = await alsWer("gast", 1440, 900); + + /* (e) Das Leck. Ein Wort wird von Hand ins Dokument geschrieben -- + findet die Messung es nicht, hat sie oben nichts geprueft. */ + await gast.seite.evaluate(() => { + document.getElementById("saal-satz").textContent += " ZZTESTWORTZZ"; + }); + const leck = await leckLesen(gast.seite, ["ZZTESTWORTZZ"]); + ok(leck.length === 1, "Gegenprobe: ein Wort im Dokument wird gefunden"); + + /* (f) Die Reihen-Messung muss eine AUFGEDECKTE Reihe auch sichtbar + nennen -- sonst waere "der Plan steht nicht da" oben nur die + Folge davon, dass sie nie etwas findet. */ + await gast.seite.evaluate(() => { + document.getElementById("reihe-plan").hidden = false; + }); + await gast.seite.waitForTimeout(120); + const sichtbar = await reihenLesen(gast.seite); + ok(sichtbar.plan !== null, "Gegenprobe: eine aufgedeckte Reihe gilt als sichtbar"); + await gast.kontext.close(); +} + +/* ===================================================================== + SCHLUSS + ===================================================================== */ +await browser.close(); +await new Promise((r) => vorbau.close(r)); +try { rmSync(ordner, { recursive: true, force: true }); } catch { /* egal */ } + +/* DIE ANZAHL GEHOERT IN DIE BEDINGUNG. Faende dieses Werkzeug keine + einzige Messung, waere "0 Fehler" das beste Ergebnis ueberhaupt -- + und voellig wertlos. */ +console.log(`\n${geprueft} Messungen, ${fehler} Fehler`); +if (geprueft < 40) { + console.log(`NICHT NACHSEHBAR: nur ${geprueft} Messungen gelaufen, erwartet mindestens 40.`); + process.exit(3); +} +if (fehler) { + console.log("NICHT IN ORDNUNG"); + process.exit(1); +} +console.log("ALLES IN ORDNUNG"); +process.exit(0); diff --git a/server/pruef-community-sicht.mjs b/server/pruef-community-sicht.mjs index 1c82a375..f784d788 100644 --- a/server/pruef-community-sicht.mjs +++ b/server/pruef-community-sicht.mjs @@ -123,6 +123,12 @@ const COMMUNITY_SEITEN = [ dieser Eintrag hier; die Pruefung sagt beim Anschlagen selbst, welche Seite fehlt oder zu viel ist. */ "reaktion.html", // zusammen schauen (seit 28.09.2026) + /* DAS FOYER davor (08.10.2026): die Seite, auf die die Kachel + seither zeigt. Sie ist IMMER offen -- dort steht, was ansteht, + und dort kann jeder ein Video anraten. Was darin nur die zwei + sehen duerfen, entscheidet der Server bei den Daten und nicht + diese Liste. */ + "reaktion-foyer.html", // das Foyer davor (seit 08.10.2026) "start.html", // die Wand "steckbrief.html", // der eigene Steckbrief "support.html", // etwas ist kaputt (seit 24.09. fuer alle) diff --git a/server/pruef-css-klassen.mjs b/server/pruef-css-klassen.mjs index 95c75faa..fa66a2a9 100644 --- a/server/pruef-css-klassen.mjs +++ b/server/pruef-css-klassen.mjs @@ -620,20 +620,35 @@ console.log("\n=== Rollenlisten: eine Quelle, nicht neun"); } /* ======================================================================= - DIE SIEBEN KOPIEN DER MODULLISTE MÜSSEN GLEICH SEIN + DIE ACHT KOPIEN DER MODULLISTE MÜSSEN GLEICH SEIN module.css beschreibt die Form aller Module -- Silhouette, - Kantenlicht, Raster, Handy, Kontrastmodus. Dieselbe Klassenliste - steht dafür siebenmal in der Datei (im Kontrastmodus dreimal); CSS - kennt keine Variable für Selektoren. + Kantenlicht, Raster, Umrandung beim Tabben, Handy, Kontrastmodus. + Dieselbe Klassenliste steht dafür achtmal in der Datei (im + Kontrastmodus dreimal); CSS kennt keine Variable für Selektoren. + + WARUM DIE ZAHL VON SIEBEN AUF ACHT GESTIEGEN IST (08.10.2026): + Nicht, weil eine Kopie dazugekommen wäre. Die achte stand seit dem + 07.09.2026 da -- die Umrandung beim Tabben (`:focus-visible`) --, + nur ohne Marke. Sie wurde deshalb als Einzige nie verglichen, und + das ausgerechnet bei der Regel, die niemand sieht, solange er die + Maus benutzt. Aufgefallen ist es beim Eintragen von + `.foyer-reihe`: acht Vorkommen der Liste, sieben Marken. Eine + steigende Zahl ist hier also kein Mehraufwand, sondern eine + geschlossene Lücke. + + EINE ZAHL, DIE SICH ÄNDERT, WIRD ERKLÄRT -- auch wenn sie steigt. + Die Hausregel kennt nur den Fall der FALLENDEN Prüfzahl; der Grund + dahinter gilt aber in beide Richtungen: Wer eine Zahl anpasst, + ohne zu sagen warum, hat die Prüfung an das Ergebnis angepasst. Das ist die verwundbare Stelle: Wer eine Karte hinzufügt und nur - sechs der sieben Stellen pflegt, bekommt eine Karte MIT Schräge und + sieben der acht Stellen pflegt, bekommt eine Karte MIT Schräge und OHNE Kantenlicht -- und niemandem fällt es auf, weil nichts kaputt geht. Genau diese Sorte stiller Abweichung fängt hier eine Prüfung ab, statt sich auf Sorgfalt zu verlassen. ======================================================================= */ -console.log("\n=== module.css: die Modulliste steht siebenmal — und überall gleich"); +console.log("\n=== module.css: die Modulliste steht achtmal — und überall gleich"); { const pfad = join(WURZEL, "workspace/assets/css/module.css"); const text = readFileSync(pfad, "utf8"); @@ -649,7 +664,7 @@ console.log("\n=== module.css: die Modulliste steht siebenmal — und überall g /* Die Anzahl gehört in die BEDINGUNG, nicht nur in den Meldetext: Fände die Prüfung keine einzige Liste, wäre "alle gleich" sonst trivial erfüllt -- grün, ohne etwas angesehen zu haben. */ - ok(listen.length === 7, `sieben markierte Kopien gefunden (${listen.length})`); + ok(listen.length === 8, `acht markierte Kopien gefunden (${listen.length})`); const erste = listen[0] || []; ok(erste.length >= 30, `die Liste umfasst ${erste.length} Klassen`); @@ -657,7 +672,7 @@ console.log("\n=== module.css: die Modulliste steht siebenmal — und überall g const abweichung = listen.findIndex( (l) => l.length !== erste.length || l.some((k, i) => k !== erste[i])); ok(abweichung === -1, abweichung === -1 - ? "alle sieben Kopien sind Zeichen für Zeichen gleich" + ? "alle acht Kopien sind Zeichen für Zeichen gleich" : `Kopie ${abweichung + 1} weicht ab (${listen[abweichung].length} statt ${erste.length} Klassen)`); /* GEGENPROBE: Der Vergleich muss auch NEIN sagen können. Ohne sie diff --git a/server/pruef-reaktion.mjs b/server/pruef-reaktion.mjs index 7015631f..ff55179c 100644 --- a/server/pruef-reaktion.mjs +++ b/server/pruef-reaktion.mjs @@ -784,7 +784,10 @@ melde("=== 10. Was um die Seite herum stimmen muss ==="); haben sich in diesem Haus schon zweimal geaendert. */ const w = await import("./workspace.js"); const alle = w.bereicheFuer({ id: IDS.gast, rolle: "gast", name: "Nachtfalter" }, CREW); - const kachel = (alle || []).find((b) => b.ziel === "reaktion.html"); + /* SEIT DEM 08.10.2026 ZEIGT SIE AUFS FOYER. Filipe: „wenn man + drauf drückt geht zuerst eine seite auf mit reihen/kategorien." + In den Saal geht es von dort aus. */ + const kachel = (alle || []).find((b) => b.ziel === "reaktion-foyer.html"); ok(!!kachel, "die Community hat die Reaction-Kachel"); ok(kachel?.live === true, "sie traegt das Merkmal `live` statt einer Ton-Nummer (der Farbraum ist bei 46 voll)"); @@ -3200,6 +3203,311 @@ melde("=== 19. Vorraum, Geraete, zweites Video ==="); "und wer mit der Tastatur bedient, wird gesehen"); } +/* ======================================================================= + 20. DAS FOYER (08.10.2026) + + Filipe: „diese kachel soll nicht mehr geschlossen sein, ABER wenn + man drauf drückt geht zuerst eine seite auf mit reihen/kategorien. + links eine reihe wo die nächsten geplanten videos schon bereit + stehen. wan und umd wie viel uhr … und das soll geschlossen sein + außer für vanvan und dogfather. in der mitte sollen die leute dan + wenn eine sendung läuft drauf drücken können und der sendung + beitreten … und dan rechts … da können die leute videos + reinschicken und nur ich und vanvan sehen sie." + ======================================================================= */ +melde(""); +melde("=== 20. Das Foyer: Plan, Eintritt, Vorschlaege ==="); +{ + /* ---- Die Seite selbst ---- */ + for (const rolle of ["admin", "hand", "linke", "modi", "gast"]) { + const s = await roh(CREW, "/workspace/reaktion-foyer.html", { keks: k[rolle] }); + ok(s.code === 200, `${rolle}: kommt ins Foyer (${s.code})`); + } + { + const kAgentur = await anmelden("admin", AGENTUR); + const a = await roh(AGENTUR, "/workspace/reaktion-foyer.html", { keks: kAgentur }); + ok(!!kAgentur && a.code === 404, `und die Agentur kennt es nicht (${a.code})`); + } + /* DIE KACHEL ZEIGT AUFS FOYER UND NICHT MEHR IN DEN SAAL. Zeigte + sie weiter auf `reaktion.html`, gaebe es das Foyer zwar -- nur + fuehrte kein Weg hin. */ + { + const w = await import("./workspace.js"); + const alle = w.bereicheFuer({ id: IDS.gast, rolle: "gast", name: "Nachtfalter" }, CREW); + const kachel = (alle || []).find((b) => b.name === "Reaction"); + ok(kachel?.ziel === "reaktion-foyer.html", + `die Kachel fuehrt ins Foyer (${kachel?.ziel})`); + ok(kachel?.live === true, + "und traegt weiterhin ihren Zustand (grau / bernstein / rot)"); + /* ==== UND DER SAAL BLEIBT TROTZDEM ERREICHBAR ============== + Er hat seit heute KEINE Kachel mehr, und die Adressregel vom + 15.09. verlangt eine. Ohne den Eintrag in + OHNE_KACHEL_UEBERALL liefe der Knopf „Jetzt beitreten" auf + die Startseite -- wortlos. Genau so ist diese Pruefung beim + Umhaengen rot geworden. */ + ok(w.OHNE_KACHEL_UEBERALL.has("/workspace/reaktion.html"), + "und der Saal ist ohne Kachel trotzdem erreichbar"); + } + + /* ---- Der Termin an der Warteschlange ---- */ + await roh(CREW, "/workspace/api/reaktion/zuruecksetzen", { method: "POST", keks: k.admin }); + const angehaengt = await roh(CREW, "/workspace/api/reaktion/liste", { + method: "POST", keks: k.admin, rumpf: { video: "dQw4w9WgXcQ" }, + }); + const zeile = (angehaengt.j?.liste || [])[0]; + ok(!!zeile, `ein Video liegt bereit (${angehaengt.code})`); + ok(zeile?.wann === "", `und es hat zuerst KEINEN Termin ("${zeile?.wann}")`); + + const termin = new Date(Date.now() + 86400000).toISOString(); + const gesetzt = await roh(CREW, `/workspace/api/reaktion/liste/${zeile.id}/wann`, { + method: "PUT", keks: k.admin, rumpf: { wann: termin }, + }); + ok(gesetzt.code === 200 + && (gesetzt.j?.liste || []).find((z) => z.id === zeile.id)?.wann === termin, + `der Termin laesst sich setzen (${gesetzt.code})`); + + const leer = await roh(CREW, `/workspace/api/reaktion/liste/${zeile.id}/wann`, { + method: "PUT", keks: k.admin, rumpf: { wann: "" }, + }); + ok(leer.code === 200 + && (leer.j?.liste || []).find((z) => z.id === zeile.id)?.wann === "", + "und wieder wegnehmen -- leer ist ein gueltiger Wert und der Normalfall"); + await roh(CREW, `/workspace/api/reaktion/liste/${zeile.id}/wann`, { + method: "PUT", keks: k.admin, rumpf: { wann: termin }, + }); + + const quatsch = await roh(CREW, `/workspace/api/reaktion/liste/${zeile.id}/wann`, { + method: "PUT", keks: k.admin, rumpf: { wann: "irgendwann mal" }, + }); + ok(quatsch.code === 400 && quatsch.j?.fehler === "termin_unklar", + `ein freier Text wird abgelehnt (${quatsch.code}/${quatsch.j?.fehler})`); + + for (const rolle of ["linke", "modi", "gast"]) { + const a = await roh(CREW, `/workspace/api/reaktion/liste/${zeile.id}/wann`, { + method: "PUT", keks: k[rolle], rumpf: { wann: termin }, + }); + ok(a.code === 403, `${rolle}: setzt keinen Termin (${a.code})`); + } + + /* ==== UND DIE GANZE REIHE SIEHT NUR DIE LEITUNG ============== + Filipe: „und das soll geschlossen sein außer für vanvan und + dogfather." Die Reihe im Foyer entsteht aus `liste` -- wer die + nicht bekommt, bekommt die Reihe nicht. */ + { + const beiDenZweien = await roh(CREW, "/workspace/api/reaktion", { keks: k.hand }); + ok(Array.isArray(beiDenZweien.j?.liste) && beiDenZweien.j.liste.length > 0, + "die rechte Hand bekommt den Plan"); + for (const rolle of ["linke", "modi", "gast"]) { + const a = await roh(CREW, "/workspace/api/reaktion", { keks: k[rolle] }); + ok(a.j?.liste === null, `${rolle}: bekommt ihn NICHT (${a.j?.liste})`); + } + } + + /* ---- Die Vorschlaege ---- */ + const v1 = await roh(CREW, "/workspace/api/reaktion/vorschlag", { + method: "POST", keks: k.gast, + rumpf: { video: "https://www.youtube.com/watch?v=9bZkp7q19f0", notiz: "Das waere was" }, + }); + ok(v1.code === 201, `ein Zuschauer darf anraten (${v1.code})`); + ok(v1.j?.vorschlag?.video === "9bZkp7q19f0", + `aus der Adresse wird die Kennung (${v1.j?.vorschlag?.video})`); + + /* EINER JE PERSON UND MINUTE -- gegen den Doppelklick. */ + const vSchnell = await roh(CREW, "/workspace/api/reaktion/vorschlag", { + method: "POST", keks: k.gast, rumpf: { video: "bm0sK2-KkW8" }, + }); + ok(vSchnell.code === 429, `zweimal gleich hintereinander nicht (${vSchnell.code})`); + + const vDoppelt = await roh(CREW, "/workspace/api/reaktion/vorschlag", { + method: "POST", keks: k.gast2, rumpf: { video: "9bZkp7q19f0" }, + }); + ok(vDoppelt.code === 409 && vDoppelt.j?.fehler === "vorschlag_doppelt", + `dasselbe Video nicht zweimal offen (${vDoppelt.code}/${vDoppelt.j?.fehler})`); + + const vBereit = await roh(CREW, "/workspace/api/reaktion/vorschlag", { + method: "POST", keks: k.modi, rumpf: { video: "dQw4w9WgXcQ" }, + }); + ok(vBereit.code === 409 && vBereit.j?.fehler === "steht_schon_bereit", + `und nichts, was schon bereitliegt (${vBereit.code}/${vBereit.j?.fehler})`); + + /* ==== WER SIEHT WAS -- DIE EIGENTLICHE FRAGE ================= + Filipe: „nur ich und vanvan sehen sie." */ + { + const beiDogi = await roh(CREW, "/workspace/api/reaktion/vorschlaege", { keks: k.admin }); + ok(beiDogi.j?.darf_entscheiden === true && (beiDogi.j?.vorschlaege || []).length === 1, + `DogFather sieht alle (${beiDogi.j?.vorschlaege?.length})`); + ok(beiDogi.j?.vorschlaege?.[0]?.name === "Nachtfalter", + `mit dem Namen dessen, der ihn geschickt hat (${beiDogi.j?.vorschlaege?.[0]?.name})`); + ok(beiDogi.j?.offen === 1, `und wie viele offen sind (${beiDogi.j?.offen})`); + + const beiVanVan = await roh(CREW, "/workspace/api/reaktion/vorschlaege", { keks: k.hand }); + ok((beiVanVan.j?.vorschlaege || []).length === 1, "die rechte Hand auch"); + + /* DER EINSENDER SIEHT SEINEN EIGENEN -- und zwar OHNE Namen: + Dort staende er selbst, und eine Zeile, die einem den eigenen + Namen vorliest, ist Platz ohne Auskunft. */ + const beiIhr = await roh(CREW, "/workspace/api/reaktion/vorschlaege", { keks: k.gast }); + ok(beiIhr.j?.darf_entscheiden === false && (beiIhr.j?.vorschlaege || []).length === 1, + "der Einsender sieht seinen eigenen"); + ok(beiIhr.j?.vorschlaege?.[0]?.name === undefined, + "aber keinen Namen"); + ok(beiIhr.j?.offen === null, "und nicht, wie viele insgesamt offen sind"); + + /* UND EIN DRITTER SIEHT GAR NICHTS. Das ist der Satz, um den es + geht. */ + const fremd = await roh(CREW, "/workspace/api/reaktion/vorschlaege", { keks: k.gast2 }); + ok((fremd.j?.vorschlaege || []).length === 0, + `wer nichts geschickt hat, sieht nichts (${fremd.j?.vorschlaege?.length})`); + const modi = await roh(CREW, "/workspace/api/reaktion/vorschlaege", { keks: k.modi }); + ok((modi.j?.vorschlaege || []).length === 0, + "auch ein Modi nicht -- Moderation ist etwas anderes als Leitung"); + } + + /* ---- Entscheiden ---- */ + const offene = await roh(CREW, "/workspace/api/reaktion/vorschlaege", { keks: k.admin }); + const vid = offene.j.vorschlaege[0].id; + for (const rolle of ["linke", "modi", "gast"]) { + const a = await roh(CREW, `/workspace/api/reaktion/vorschlag/${vid}/uebernehmen`, { + method: "POST", keks: k[rolle], + }); + ok(a.code === 403, `${rolle}: entscheidet nicht (${a.code})`); + } + + const vorListe = (await roh(CREW, "/workspace/api/reaktion", { keks: k.admin })) + .j?.liste?.length || 0; + const genommen = await roh(CREW, `/workspace/api/reaktion/vorschlag/${vid}/uebernehmen`, { + method: "POST", keks: k.admin, + }); + ok(genommen.code === 200 && (genommen.j?.liste || []).length === vorListe + 1, + `uebernehmen legt es in die Warteschlange (${vorListe} -> ${genommen.j?.liste?.length})`); + ok((genommen.j?.liste || []).some((z) => z.video === "9bZkp7q19f0"), + "und zwar genau dieses Video"); + + const nochmal = await roh(CREW, `/workspace/api/reaktion/vorschlag/${vid}/uebernehmen`, { + method: "POST", keks: k.admin, + }); + ok(nochmal.code === 409 && nochmal.j?.fehler === "schon_entschieden", + `zweimal uebernehmen geht nicht (${nochmal.code}/${nochmal.j?.fehler})`); + + /* DER EINSENDER ERFAEHRT, WAS DARAUS WURDE. Ohne das waere das + Feld ein Loch, in das man etwas wirft. */ + { + const beiIhr = await roh(CREW, "/workspace/api/reaktion/vorschlaege", { keks: k.gast }); + ok(beiIhr.j?.vorschlaege?.[0]?.stand === "uebernommen", + `und sie sieht, dass er drankommt (${beiIhr.j?.vorschlaege?.[0]?.stand})`); + } + + /* ---- Ablehnen und Zurueckziehen ---- */ + { + const neu = await roh(CREW, "/workspace/api/reaktion/vorschlag", { + method: "POST", keks: k.gast2, rumpf: { video: "bm0sK2-KkW8" }, + }); + ok(neu.code === 201, `noch ein Vorschlag (${neu.code})`); + const id2 = neu.j.vorschlag.id; + + /* Ein Fremder kann ihn nicht wegnehmen -- auch nicht den + offenen. */ + const fremdWeg = await roh(CREW, `/workspace/api/reaktion/vorschlag/${id2}`, { + method: "DELETE", keks: k.gast, + }); + ok(fremdWeg.code === 403 && fremdWeg.j?.fehler === "nicht_deiner", + `ein Fremder nimmt ihn nicht weg (${fremdWeg.code}/${fremdWeg.j?.fehler})`); + + const abgelehnt = await roh(CREW, `/workspace/api/reaktion/vorschlag/${id2}/ablehnen`, { + method: "POST", keks: k.admin, + }); + ok(abgelehnt.code === 200, `ablehnen geht (${abgelehnt.code})`); + const danach = await roh(CREW, "/workspace/api/reaktion/vorschlaege", { keks: k.gast2 }); + ok(danach.j?.vorschlaege?.[0]?.stand === "abgelehnt", + "und er sieht es -- die Zeile bleibt stehen"); + + /* ==== WAS ENTSCHIEDEN IST, ZIEHT ER NICHT ZURUECK ========== + Sonst verschwaende die Spur, dass ueberhaupt entschieden + wurde -- und derselbe Vorschlag saehe beim naechsten Mal aus + wie der erste. */ + const zurueck = await roh(CREW, `/workspace/api/reaktion/vorschlag/${id2}`, { + method: "DELETE", keks: k.gast2, + }); + ok(zurueck.code === 403, + `einen abgelehnten zieht der Einsender nicht zurueck (${zurueck.code})`); + /* GEGENPROBE: einen OFFENEN schon. Ohne sie bewiese das Obige + nur, dass Loeschen ueberhaupt nicht geht. */ + const frisch = await roh(CREW, "/workspace/api/reaktion/vorschlag", { + method: "POST", keks: k.linke, rumpf: { video: "kJQP7kiw5Fk" }, + }); + const weg = await roh(CREW, `/workspace/api/reaktion/vorschlag/${frisch.j.vorschlag.id}`, { + method: "DELETE", keks: k.linke, + }); + ok(frisch.code === 201 && weg.code === 200, + `Gegenprobe: seinen eigenen OFFENEN schon (${weg.code})`); + } + + /* ---- Die Grenze je Person ---- */ + { + const { db } = await import("./workspace.js"); + const { VORSCHLAEGE_JE_PERSON } = await import("./reaktion-tabellen.js"); + /* Zurueckdatieren statt warten: Eine Pruefung, die 45 Sekunden + je Vorschlag wartet, wird beim naechsten Mal uebersprungen. */ + const alt = () => db().prepare("UPDATE reaktion_vorschlaege SET erstellt = ?" + + " WHERE person_id = ?").run(new Date(Date.now() - 600_000).toISOString(), IDS.modi); + + const kennungen = ["aqz-KE-bpKQ", "ZZ5LpwO-An4", "e-ORhEE9VVg", "YQHsXMglC9A", "fJ9rUzIMcZQ"]; + let letzte = null; + for (const v of kennungen) { + alt(); + letzte = await roh(CREW, "/workspace/api/reaktion/vorschlag", { + method: "POST", keks: k.modi, rumpf: { video: v }, + }); + if (letzte.code !== 201) break; + } + alt(); + const einerZuViel = await roh(CREW, "/workspace/api/reaktion/vorschlag", { + method: "POST", keks: k.modi, rumpf: { video: "L_jWHffIx5E" }, + }); + ok(einerZuViel.code === 409 && einerZuViel.j?.fehler === "zu_viele_vorschlaege", + `hoechstens ${VORSCHLAEGE_JE_PERSON} offene je Person ` + + `(${einerZuViel.code}/${einerZuViel.j?.fehler})`); + } + + /* ---- Und was auf der Seite stehen muss ---- */ + { + const html = readFileSync(join(HIER, "..", "workspace", "reaktion-foyer.html"), "utf8"); + const js = readFileSync( + join(HIER, "..", "workspace", "assets", "js", "reaktion-foyer.js"), "utf8"); + const css = readFileSync( + join(HIER, "..", "workspace", "assets", "css", "foyer.css"), "utf8"); + ok(/id="reihe-plan"/.test(html) && /id="reihe-saal"/.test(html) + && /id="reihe-raten"/.test(html), + "die drei Reihen stehen in der Seite"); + ok(/id="saal-knopf"/.test(html) && /href="reaktion\.html"/.test(html), + "und die Mitte fuehrt in den Saal"); + ok(/ z.name)); + if (!listeSpalten.has("wann")) { + d.exec("ALTER TABLE reaktion_liste ADD COLUMN wann TEXT NOT NULL DEFAULT ''"); + console.log("[reaktion] Spalte wann an der Warteschlange angelegt."); + } + const gaeste = new Set( d.prepare("PRAGMA table_info(reaktion_gaeste)").all().map((z) => z.name)); if (!gaeste.has("stand")) { diff --git a/server/rechte.js b/server/rechte.js index 2482e6b2..ea463dd6 100644 --- a/server/rechte.js +++ b/server/rechte.js @@ -580,6 +580,13 @@ export const SEITEN = { ausdruecklich mit drin; wer nicht Host ist, sieht dort die Zuschauerseite. */ "/workspace/reaktion.html": ["admin", "hand", "linke", "modi", "gast"], + /* DAS FOYER (08.10.2026) steht VOR dem Saal und ist fuer + dieselben da. Was darin nur die zwei sehen duerfen -- den + Plan und die Vorschlaege --, entscheidet nicht diese Tafel, + sondern der Server bei den Daten. Eine Seite zu sperren, auf + der drei Reihen stehen und nur eine davon heikel ist, waere + die grobe Antwort auf eine feine Frage. */ + "/workspace/reaktion-foyer.html": ["admin", "hand", "linke", "modi", "gast"], "/workspace/rechte.html": ["admin", "hand"], }; diff --git a/server/workspace-reaktion.js b/server/workspace-reaktion.js index 107dbc02..cebd9ac6 100644 --- a/server/workspace-reaktion.js +++ b/server/workspace-reaktion.js @@ -53,6 +53,7 @@ import { einstellung } from "./workspace.js"; import { mitZugangsdaten, wegeAusfaechern } from "./workspace-turn.js"; import { SICHT_PLAETZE, GAESTE_MAX, EINLADUNG_SEKUNDEN, TEXT_MAX, SCHREIB_BREMSE, + VORSCHLAEGE_JE_PERSON, VORSCHLAG_NOTIZ_MAX, VORSCHLAG_BREMSE_SEKUNDEN, STAENDE, LAYOUTS, TEMPI, KAMERA_GROESSEN, KAMERA_ECKEN, MELDUNG_TAGE, @@ -287,10 +288,33 @@ async function videoTitel(kennung) { /** Die Warteschlange, in ihrer Reihenfolge. */ function listeHolen() { - return db().prepare("SELECT id, video, titel, platz FROM reaktion_liste" + return db().prepare("SELECT id, video, titel, platz, wann FROM reaktion_liste" + " ORDER BY platz, id").all(); } +/* ====================================================================== + DER PLAN FUERS FOYER (08.10.2026) + + Filipe: „links eine reihe wo die nächsten geplanten videos schon + bereit stehen. wan und umd wie viel uhr." + + ES IST DIESELBE LISTE -- nur anders sortiert: nach TERMIN statt + nach Platz. Im Pult ist die Reihenfolge die Aussage („was kommt + als Naechstes im Live"), im Foyer der Termin („Donnerstag um + acht"). + + SORTIERT WIRD IM BROWSER UND NICHT HIER. Ein zweites Feld im + Stand waere eine zweite Antwort auf dieselbe Frage -- dieselben + Zeilen, zweimal uebertragen, und beim naechsten Umbau pflegt + jemand nur eine der beiden Stellen. Was hinausgeht, ist `liste` + mit `wann`; wie man sie anschaut, entscheidet die Seite. + + VERGANGENES FAELLT NICHT WEG. Ein Termin von gestern, den niemand + weggeraeumt hat, steht weiter da und ist damit sichtbar + ueberfaellig. Ihn stillschweigend auszublenden hiesse, dass eine + Vorbereitung verschwindet, ohne dass jemand etwas getan hat. + ====================================================================== */ + /** Das naechste Video aus der Liste ins Bild holen. * * EINE STELLE FUER ZWEI KNOEPFE. „Naechstes" und „Jetzt spielen" @@ -1459,6 +1483,262 @@ reaktionRouter.delete("/workspace/api/reaktion/liste/:id", nurHost, (req, res) = } }); +/* ---------- Wann ein vorbereitetes Video laufen soll --------------------- + Filipe: „links eine reihe wo die nächsten geplanten videos schon + bereit stehen. wan und umd wie viel uhr." + + EIN EIGENER WEG UND KEIN FELD AM ANHAENGEN. Der Termin entsteht + fast nie beim Einfuegen -- man raeumt erst ein und legt danach + fest, wann es laufen soll. Ein Pflichtfeld beim Anhaengen haette + aus dem schnellsten Handgriff des Pults („Adresse einfuegen, + Enter") ein Formular gemacht. + + DER WERT WIRD GEPRUEFT UND NICHT NUR GESPEICHERT. Was hier + hereinkommt, steht spaeter als Datum im Foyer; ein freier Text + ergaebe dort „Invalid Date" -- und das sieht aus, als sei die + Seite kaputt, nicht die Eingabe. */ +reaktionRouter.put("/workspace/api/reaktion/liste/:id/wann", nurHost, + express.json({ limit: "2kb" }), (req, res) => { + try { + const id = Number(req.params.id) || 0; + const roh = String(req.body?.wann ?? "").trim(); + let wann = ""; + if (roh) { + const d = new Date(roh); + if (Number.isNaN(d.getTime())) return res.status(400).json({ fehler: "termin_unklar" }); + wann = d.toISOString(); + } + const a = db().prepare("UPDATE reaktion_liste SET wann = ? WHERE id = ?").run(wann, id); + if (!a.changes) return res.status(404).json({ fehler: "nicht_in_der_liste" }); + + const l = lage(req.person); + melden("reaktion", { was: "liste", lage: oeffentlich(l) }); + res.json(l); + } catch (fehler) { + console.error("[reaktion] Termin:", fehler?.message); + res.status(503).json({ fehler: "nicht_verfuegbar" }); + } + }); + +/* ======================================================================= + DIE VORSCHLAEGE (08.10.2026) + + Filipe: „da sollen die leute alle mir videos anraten können. also + da können die leute videos reinschicken und nur ich und vanvan + sehen sie." + + ----------------------------------------------------------------------- + WER WAS SIEHT -- UND WARUM DER EINSENDER SEINEN EIGENEN SIEHT + + „Nur ich und vanvan sehen sie" heisst: Niemand sieht die + Vorschlaege ANDERER. Dass jemand seinen eigenen zurueckbekommt, + ist kein Widerspruch, sondern die Voraussetzung dafuer, dass er + ueberhaupt einen schickt: Ein Formular, das nach dem Absenden + nichts mehr zeigt, sieht aus wie ein Loch. Er sieht seine eigene + Zeile mit dem Stand -- offen, uebernommen, abgelehnt -- und sonst + nichts. + + DER TITEL WIRD BEI YOUTUBE GEHOLT, nicht vom Einsender getippt. + Sonst stuende im Pult ein Name, den jemand frei gewaehlt hat, + neben einer Kennung, die etwas anderes ist -- und die Entscheidung + fiele ueber den falschen Film. + ======================================================================= */ + +/** Eine Zeile, wie sie hinausgeht. */ +function alsVorschlag(z, fuerHost) { + return { + id: z.id, + video: z.video, + titel: z.titel || "", + notiz: z.notiz || "", + stand: z.stand, + erstellt: z.erstellt, + /* WER ES WAR, SIEHT NUR DIE LEITUNG. Fuer den Einsender steht + dort ohnehin er selbst; den Namen mitzuschicken waere eine + Auskunft ueber andere, sobald sich die Liste einmal aendert. */ + name: fuerHost ? (z.name || "") : undefined, + mein: undefined, + }; +} + +reaktionRouter.get("/workspace/api/reaktion/vorschlaege", (req, res) => { + try { + const istHost = HOST_ROLLEN.has(req.person.rolle); + const reihen = istHost + ? db().prepare(`SELECT * FROM reaktion_vorschlaege + ORDER BY (stand = 'offen') DESC, id DESC LIMIT 200`).all() + : db().prepare(`SELECT * FROM reaktion_vorschlaege WHERE person_id = ? + ORDER BY id DESC LIMIT 50`).all(req.person.id); + res.json({ + vorschlaege: reihen.map((z) => alsVorschlag(z, istHost)), + /* WIE VIELE NOCH OFFEN SIND -- die eine Zahl, die ins Register + und an die Kachel gehoert. Sie wird hier gerechnet und nicht + im Browser aus der Liste gezaehlt: Wer nur seine eigenen + bekommt, zaehlte sonst seine eigenen. */ + offen: istHost + ? (db().prepare("SELECT COUNT(*) AS n FROM reaktion_vorschlaege" + + " WHERE stand = 'offen'").get()?.n || 0) + : null, + /* Was der Einsender wissen muss, um nicht gegen eine Wand zu + laufen: wie viele er noch offen haben darf. */ + je_person_max: VORSCHLAEGE_JE_PERSON, + notiz_max: VORSCHLAG_NOTIZ_MAX, + darf_entscheiden: istHost, + }); + } catch (fehler) { + console.error("[reaktion] Vorschlaege:", fehler?.message); + res.status(503).json({ fehler: "nicht_verfuegbar" }); + } +}); + +reaktionRouter.post("/workspace/api/reaktion/vorschlag", express.json({ limit: "4kb" }), + async (req, res) => { + try { + const video = videoKennung(String(req.body?.video ?? "")); + if (!video) return res.status(400).json({ fehler: "video_unklar" }); + + /* ==== DREI SPERREN, UND JEDE BEANTWORTET ETWAS ANDERES ==== + (1) Zu schnell -- gegen den Doppelklick. + (2) Zu viele offen -- gegen die Liste, die niemand mehr + durchsieht. + (3) Schon da -- gegen dreimal dieselbe Entscheidung. */ + const kurz = new Date(Date.now() - VORSCHLAG_BREMSE_SEKUNDEN * 1000).toISOString(); + const eben = db().prepare(`SELECT COUNT(*) AS n FROM reaktion_vorschlaege + WHERE person_id = ? AND erstellt > ?`).get(req.person.id, kurz); + if ((eben?.n || 0) >= 1) return res.status(429).json({ fehler: "zu_schnell" }); + + const meine = db().prepare(`SELECT COUNT(*) AS n FROM reaktion_vorschlaege + WHERE person_id = ? AND stand = 'offen'`).get(req.person.id); + if ((meine?.n || 0) >= VORSCHLAEGE_JE_PERSON) { + return res.status(409).json({ fehler: "zu_viele_vorschlaege" }); + } + + const schonOffen = db().prepare(`SELECT id FROM reaktion_vorschlaege + WHERE video = ? AND stand = 'offen'`).get(video); + if (schonOffen) return res.status(409).json({ fehler: "vorschlag_doppelt" }); + + /* Und was schon in der Warteschlange liegt, braucht niemand + mehr anzuraten -- das waere eine Bitte um etwas, das bereits + entschieden ist. */ + const inDerListe = db().prepare("SELECT id FROM reaktion_liste WHERE video = ?") + .get(video); + if (inDerListe) return res.status(409).json({ fehler: "steht_schon_bereit" }); + + const notiz = String(req.body?.notiz ?? "").trim().slice(0, VORSCHLAG_NOTIZ_MAX); + const titel = await videoTitel(video); + const { lastInsertRowid } = db().prepare(`INSERT INTO reaktion_vorschlaege + (person_id, name, video, titel, notiz, stand, erstellt) + VALUES (?,?,?,?,?,'offen',?)`) + .run(req.person.id, req.person.name, video, titel, notiz, jetzt()); + + const z = db().prepare("SELECT * FROM reaktion_vorschlaege WHERE id = ?") + .get(Number(lastInsertRowid)); + + /* NUR DIE ZWEI ERFAHREN ES. Ein Rundruf an den Saal haette + genau das verraten, was niemand sehen soll. */ + stromAn( + db().prepare("SELECT id FROM personen WHERE rolle IN ('admin','hand') AND aktiv = 1") + .all().map((p) => p.id), + "reaktion", { was: "vorschlag" }, + ); + res.status(201).json({ ok: true, vorschlag: alsVorschlag(z, false) }); + } catch (fehler) { + console.error("[reaktion] Vorschlag:", fehler?.message); + res.status(503).json({ fehler: "nicht_verfuegbar" }); + } + }); + +/** Einen Vorschlag uebernehmen -- er wandert in die Warteschlange. */ +reaktionRouter.post("/workspace/api/reaktion/vorschlag/:id(\\d+)/uebernehmen", nurHost, + (req, res) => { + try { + const z = db().prepare("SELECT * FROM reaktion_vorschlaege WHERE id = ?") + .get(Number(req.params.id)); + if (!z) return res.status(404).json({ fehler: "nicht_gefunden" }); + if (z.stand !== "offen") return res.status(409).json({ fehler: "schon_entschieden" }); + + const wie_viele = db().prepare("SELECT COUNT(*) AS n FROM reaktion_liste").get()?.n || 0; + if (wie_viele >= LISTE_MAX) return res.status(409).json({ fehler: "liste_voll" }); + const schon = db().prepare("SELECT id FROM reaktion_liste WHERE video = ?").get(z.video); + if (schon) return res.status(409).json({ fehler: "liste_doppelt" }); + + /* EIN SCHREIBVORGANG. Bricht es zwischen den beiden + Anweisungen ab, stuende der Vorschlag als „uebernommen" da, + ohne dass etwas in der Schlange liegt -- und niemand wuerde + es je bemerken. */ + const letzter = db().prepare("SELECT MAX(platz) AS p FROM reaktion_liste").get()?.p || 0; + db().exec("BEGIN IMMEDIATE"); + try { + db().prepare("INSERT INTO reaktion_liste (video, titel, platz, von_id, gesetzt_am)" + + " VALUES (?,?,?,?,?)") + .run(z.video, z.titel, letzter + 1, req.person.id, jetzt()); + db().prepare(`UPDATE reaktion_vorschlaege SET stand = 'uebernommen', + erledigt_am = ?, erledigt_von = ? WHERE id = ?`) + .run(jetzt(), req.person.id, z.id); + db().exec("COMMIT"); + } catch (f) { db().exec("ROLLBACK"); throw f; } + + protokolliere("reaktion_vorschlag", { + personId: req.person.id, + detail: `uebernommen: ${z.titel || z.video} (von ${z.name || "?"})`, + ip: echteIp(req), + }); + /* DER EINSENDER ERFAEHRT ES. Ohne diese Zeile stuende sein + Vorschlag bei ihm bis zum naechsten Laden auf „offen" -- + und die schoenste Nachricht des Abends kaeme nie an. */ + if (z.person_id) stromAn([z.person_id], "reaktion", { was: "vorschlag" }); + + const l = lage(req.person); + melden("reaktion", { was: "liste", lage: oeffentlich(l) }); + res.json(l); + } catch (fehler) { + console.error("[reaktion] Vorschlag uebernehmen:", fehler?.message); + res.status(503).json({ fehler: "nicht_verfuegbar" }); + } + }); + +reaktionRouter.post("/workspace/api/reaktion/vorschlag/:id(\\d+)/ablehnen", nurHost, + (req, res) => { + try { + const a = db().prepare(`UPDATE reaktion_vorschlaege SET stand = 'abgelehnt', + erledigt_am = ?, erledigt_von = ? WHERE id = ? AND stand = 'offen'`) + .run(jetzt(), req.person.id, Number(req.params.id)); + if (!a.changes) return res.status(409).json({ fehler: "schon_entschieden" }); + const z = db().prepare("SELECT person_id FROM reaktion_vorschlaege WHERE id = ?") + .get(Number(req.params.id)); + if (z?.person_id) stromAn([z.person_id], "reaktion", { was: "vorschlag" }); + res.json({ ok: true }); + } catch (fehler) { + console.error("[reaktion] Vorschlag ablehnen:", fehler?.message); + res.status(503).json({ fehler: "nicht_verfuegbar" }); + } + }); + +/** Wegnehmen: der Einsender seinen eigenen OFFENEN, die Leitung jeden. + * + * ==== WARUM DER EINSENDER NUR DEN OFFENEN ZURUECKZIEHEN DARF + * + * Was entschieden ist, ist Geschichte -- und zwar auch fuer ihn. + * Koennte er einen abgelehnten loeschen, verschwaende damit die + * Spur, dass ueberhaupt etwas entschieden wurde; der naechste + * Vorschlag desselben Videos saehe dann aus wie der erste. */ +reaktionRouter.delete("/workspace/api/reaktion/vorschlag/:id(\\d+)", (req, res) => { + try { + const id = Number(req.params.id); + const z = db().prepare("SELECT * FROM reaktion_vorschlaege WHERE id = ?").get(id); + if (!z) return res.status(404).json({ fehler: "nicht_gefunden" }); + const istHost = HOST_ROLLEN.has(req.person.rolle); + const meiner = z.person_id === req.person.id && z.stand === "offen"; + if (!istHost && !meiner) return res.status(403).json({ fehler: "nicht_deiner" }); + + db().prepare("DELETE FROM reaktion_vorschlaege WHERE id = ?").run(id); + res.json({ ok: true }); + } catch (fehler) { + console.error("[reaktion] Vorschlag weg:", fehler?.message); + res.status(503).json({ fehler: "nicht_verfuegbar" }); + } +}); + /** Eins hoch oder runter. * * GETAUSCHT WIRD MIT DEM NACHBARN, nicht neu durchnummeriert. Beim diff --git a/server/workspace.js b/server/workspace.js index 57c457b3..e8a69f9b 100644 --- a/server/workspace.js +++ b/server/workspace.js @@ -1625,7 +1625,14 @@ const TREFF_BEREICHE = [ ihre Flaeche selbst mitbringt, genau wie `papier` beim Notizblock und `silber` bei den Aufgaben. */ { ...TREFF_GRUPPE, name: "Reaction", unter: "Zusammen schauen \u2013 live mit Kamera und Chat", - zeichen: "reaktion", live: true, ziel: "reaktion.html", szene: "arena" }, + /* ZIEL IST SEIT DEM 08.10.2026 DAS FOYER und nicht mehr der + Saal. Filipe: „wenn man drauf drückt geht zuerst eine seite + auf mit reihen/kategorien." Wer die Kachel antippt, landet + also erst dort, wo steht, was ansteht -- und geht von da aus + hinein. Die Benachrichtigung beim Einschalten zeigt weiter + direkt in den Saal: Wenn es laeuft, will niemand noch eine + Zwischenseite. */ + zeichen: "reaktion", live: true, ziel: "reaktion-foyer.html", szene: "arena" }, /* DIE FARBE, DIE "REGELN & HILFE" HATTE (22.09.2026). Filipes screen5: "die farbe dieser kachel soll die farbe von der kachel: regeln & hilfe werden". Regeln & Hilfe bekommt dafuer @@ -7302,6 +7309,23 @@ export const OHNE_KACHEL_UEBERALL = new Set([ wurde auf crew deshalb NIE angezeigt. Ausgerechnet der, der acht von elf Leuten betrifft. Eine Zeile hier heilt beides. */ "/workspace/anruf-probe.html", + /* DER SAAL DER REACTION (08.10.2026). + + Seit heute zeigt die Kachel auf das FOYER (reaktion-foyer.html); + in den Saal geht es von dort aus. Damit hat `reaktion.html` keine + Kachel mehr -- und die Adressregel vom 15.09. verlangt eine. + + GEMESSEN UND NICHT VERMUTET: `pruef-reaktion` wurde in derselben + Minute rot, in der die Kachel umgehaengt war -- „die Seite steht + der Community offen (302)". Die Seite war unveraendert; sie war + nur nicht mehr erreichbar. Ohne diese Zeile waere der Saal fuer + das ganze Haus auf die Startseite gelaufen, wortlos, und zwar + ausgerechnet ueber den Knopf „Jetzt beitreten". + + Auch die Benachrichtigung beim Einschalten zeigt direkt hierher + (workspace-reaktion.js) -- wenn es laeuft, will niemand noch eine + Zwischenseite. */ + "/workspace/reaktion.html", ]); /** Gibt es diese Seite auf der Adresse, auf der die Person gerade steht? */ diff --git a/workspace/anleitung.html b/workspace/anleitung.html index d262bc7a..72f2ada6 100644 --- a/workspace/anleitung.html +++ b/workspace/anleitung.html @@ -7,17 +7,17 @@ - - - - - + + + + + - - + + @@ -289,16 +289,16 @@ Und anleitung.js braucht es doppelt: Symbol, Farbe, Name und Adresse jeder Karte kommen von dort, nicht vom Server. --> - + - - - - - - + + + + + + diff --git a/workspace/anruf-probe.html b/workspace/anruf-probe.html index a1982ef9..86fd1a4c 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`. --> - - + + - +