From a02cf5c0265287f3020636cc53cf50d0dffa7836 Mon Sep 17 00:00:00 2001 From: Dogfather Date: Mon, 28 Sep 2026 09:32:10 +0200 Subject: [PATCH] Reaction: Rollen sichtbar, Moderation im Live-Chat MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Filipes Wunsch: „die modis, linke hand und rechte hand soll im chat extra aussehen und auch sachen wie stummschaltungen, sperrungen, meldunge und alles mögliche im chat machen können falls sich jemand nicht benimmt." WER SPRICHT Jeder Beitrag aus dem Team trägt jetzt ein Abzeichen: „Dogi", „Team" (beide Hände) und „Modi". Ein WORT und nicht nur eine Farbe — rund acht Prozent der Männer unterscheiden Rot und Grün schlecht, und hier hängt an der Unterscheidung etwas. Gedeckte Töne auf schwacher Fläche, 11,52 px (die Hausgrenze ist 11,5), weil daneben ein Video läuft. Die Wörter sagen den RANG nicht: Filipe steht nie über seinem Team. Eine Prüfung schlägt an, wenn dort „Chef", „Boss" oder „Leitung" stünde. WAS DIE MODERATION KANN Am Namen öffnet sich ein Menü mit vier Wegen: Beitrag wegnehmen, 10 Minuten stumm, aus der Sendung — und der Verweis in den Treff, wo längere Maßnahmen hingehören (mit Begründungspflicht und Frist in Tagen). Die Reaction baut kein zweites Sperrsystem daneben: Wer im Treff eine Pause hat, schreibt hier auch nicht. Neu ist nur, was der Treff nicht hat — MINUTEN. Höchstens eine Stunde; wer länger etwas braucht, nimmt den Treff. Nicht gegen das Team: Wer einen Modi stummschalten könnte, hätte einen Weg, die Moderation selbst auszuschalten, mitten in der Sendung. MELDEN DARF JEDER Die Moderation sieht nicht alles, wer mitliest schon. Zweimal melden zählt einmal (sonst füllt einer allein die Liste). Bei der Moderation erscheint im Kopf der Schiene „1 Meldung" — nur wenn etwas offen ist; eine Zahl, die immer dasteht und meistens null ist, wird nach drei Tagen nicht mehr gelesen. VIER FEHLER, DIE DABEI AUFGEFALLEN SIND 1. `oeffentlich()` nahm `meldungen` und `massnahmen` NICHT aus dem Rundruf. Der Rundruf wird aus dem Stand dessen gebaut, der gerade etwas getan hat — bei einer Moderationshandlung wären der gemeldete Text, der Name des Gemeldeten und der Name des MELDERS an jeden im Saal gegangen. Wer meldet, muss sich darauf verlassen können, dass das niemand sieht. 2. `meldung.js` hatte zwei Schlüssel doppelt: `geschlossen` und `nur_leitung`. Der spätere gewinnt stillschweigend — in der Hilfe stand dadurch „Der Saal ist zu". Ein Wort, ein Satz; eine Prüfung hält das jetzt fest. 3. Wer stummgeschaltet war, bekam bei OFFENEM Chat „Der Chat ist gerade zu" — der Aufrufer reimte sich den Grund aus der Chat-Stufe zusammen. Jetzt gibt `schreibGrund()` den echten Grund zurück. 4. Wer rausgenommen wurde, merkte nichts: Das Video lief weiter, nur die Anwesenheitsmeldung schlug still fehl. Jetzt hält alles an und es steht ein Satz da, der sagt, dass es nur für heute gilt. UND EINER, DEN NUR DAS BILDSCHIRMFOTO GEFUNDEN HAT Das Menü lag messbar komplett im Fenster (x=1099..1315 von 1440, y=652..840 von 900) und war trotzdem abgeschnitten: Die Chatliste rollt, und ein rollender Vorfahre beschneidet sein Kind. „Im Fenster" und „sichtbar" sind zwei verschiedene Fragen, und ich hatte die falsche gemessen. Das Menü hängt jetzt fest am Fenster und klappt nach oben oder links, wo kein Platz ist. Die Messung fasst seitdem mit `elementFromPoint` an jeden Knopf an, statt Rechtecke zu vergleichen. GEPRUEFT pruef-reaktion: 216 Prüfungen (vorher 156), 0 Fehler — zwei neue Abschnitte. mess-reaktion misst die Moderation jetzt im echten Browser: Abzeichen, Überlauf der Schiene, Menü, Stummschaltung beim Betroffenen, Meldung bis zur Moderation, Rauswurf. Dazu grün: pruef-meldungen, pruef-struktur, pruef-css-klassen, pruef-tippziele, pruef-deutsche-texte, pruef-hilfe. Co-Authored-By: Claude Opus 5 --- server/mess-reaktion.mjs | 266 ++++++++++++++++++++++- server/pruef-reaktion.mjs | 264 ++++++++++++++++++++++- server/reaktion-tabellen.js | 56 +++++ server/workspace-reaktion.js | 263 +++++++++++++++++++++-- workspace/anruf-probe.html | 10 +- workspace/app.webmanifest | 8 +- workspace/assets/css/reaktion.css | 202 ++++++++++++++++++ workspace/assets/js/meldung.js | 35 +++- workspace/assets/js/reaktion.js | 338 +++++++++++++++++++++++++++++- workspace/aufgaben.html | 38 ++-- workspace/automation.html | 32 +-- workspace/befinden.html | 34 +-- workspace/bereich.html | 44 ++-- workspace/bewerben.html | 38 ++-- workspace/bewerbungen.html | 36 ++-- workspace/buehne.html | 2 +- workspace/calls.html | 34 +-- workspace/chat.html | 40 ++-- workspace/content.html | 34 +-- workspace/crew-index.html | 12 +- workspace/crew.webmanifest | 8 +- workspace/dateien.html | 34 +-- workspace/entwicklung.html | 36 ++-- workspace/hilfe.html | 30 +-- workspace/index.html | 10 +- workspace/kalender.html | 34 +-- workspace/leistung.html | 32 +-- workspace/material.html | 32 +-- workspace/notizen.html | 28 +-- workspace/personen.html | 32 +-- workspace/profil.html | 36 ++-- workspace/reaktion.html | 40 ++-- workspace/rechte.html | 34 +-- workspace/report.html | 36 ++-- workspace/scouting.html | 32 +-- workspace/start.html | 32 +-- workspace/startcheck.html | 34 +-- workspace/steckbrief.html | 36 ++-- workspace/support.html | 28 +-- workspace/tafel.html | 6 +- workspace/talente.html | 36 ++-- workspace/team.html | 32 +-- workspace/teamlage.html | 34 +-- workspace/teilen.html | 30 +-- workspace/treff-moderation.html | 32 +-- workspace/treff-regeln.html | 32 +-- workspace/uebersicht.html | 32 +-- workspace/unsere-seiten.html | 30 +-- workspace/unterstuetzen.html | 32 +-- workspace/werdegang.html | 32 +-- workspace/willkommen.html | 30 +-- workspace/wissen.html | 34 +-- 52 files changed, 2071 insertions(+), 691 deletions(-) diff --git a/server/mess-reaktion.mjs b/server/mess-reaktion.mjs index 515d9056..e697bba0 100644 --- a/server/mess-reaktion.mjs +++ b/server/mess-reaktion.mjs @@ -68,11 +68,19 @@ function anlegen(name, rolle, code) { createHmac("sha256", kenn).update(code).digest("hex"), ALT); return d.prepare("SELECT last_insert_rowid() AS id").get().id; } -const CODES = { admin: "CODE-M00-000001", gast: "CODE-M01-000001", gast2: "CODE-M02-000001" }; +const CODES = { + admin: "CODE-M00-000001", gast: "CODE-M01-000001", gast2: "CODE-M02-000001", + modi: "CODE-M03-000001", linke: "CODE-M04-000001", +}; const IDS = { admin: anlegen("DogFather", "admin", CODES.admin), gast: anlegen("Nachtfalter", "gast", CODES.gast), gast2: anlegen("Lenny", "gast", CODES.gast2), + /* Fuer die Moderation. Sie ist der Teil, bei dem ein Fehler am + meisten kostet -- also wird sie im Browser gemessen und nicht + nur ueber die Schnittstelle. */ + modi: anlegen("Miss", "modi", CODES.modi), + linke: anlegen("Funny", "linke", CODES.linke), }; d.close(); @@ -111,6 +119,8 @@ const k = { admin: await anmelden("admin"), gast: await anmelden("gast"), gast2: await anmelden("gast2"), + modi: await anmelden("modi"), + linke: await anmelden("linke"), }; /* ---------- Die Buehne fuellen ------------------------------------------ */ @@ -139,6 +149,12 @@ const REDEN = [ ["gast2", "Ich auch! Hab extra Popcorn geholt"], ["gast", "Wie lange noch?"], ["admin", "Zwei Minuten, ich richte noch die Kamera"], + /* DAS TEAM SCHREIBT MIT. Ohne einen Beitrag von einer linken Hand + und einem Modi liesse sich gar nicht messen, ob die Abzeichen + ankommen -- und eine Messung, die ihren Gegenstand nicht + herstellt, misst nichts. */ + ["linke", "Ich pass auf den Chat auf, schreibt ruhig"], + ["modi", "Und ich auch 🙂"], ["gast2", "Kein Stress, wir sitzen bequem"], ["gast", "Letztes Mal war so gut, ich hab geheult"], ]; @@ -658,6 +674,254 @@ await vermessen(zuschauer.seite, "Zuschauerin am Rechner"); await bild(zuschauer.seite, "4-live-zuschauer"); if (zuschauer.konsole.length) console.log(" KONSOLE:", zuschauer.konsole.slice(0, 4).join(" | ")); +/* ---- Rollen und Moderation im Chat -------------------------------- + + Filipe: „die modis, linke hand und rechte hand soll im chat extra + aussehen und auch sachen wie stummschaltungen, sperrungen, + meldunge ... machen koennen." + + GEMESSEN UND NICHT HERGELEITET. Die Schiene ist die schmalste + Flaeche der ganzen Seite, und in jede Beitragszeile ist gerade ein + Abzeichen UND ein zweiter Knopf dazugekommen. Genau so ist am + 28.09. die Regie 197 px uebergelaufen: Die Rechnung ging auf, die + Wirklichkeit nicht. */ +console.log(""); +console.log(" --- Rollen und Moderation ---"); +const modi = await alsWer("modi", 1440, 900); +await modi.seite.goto(`${BASIS}/workspace/reaktion.html`, { waitUntil: "domcontentloaded" }); +await modi.seite.waitForTimeout(2500); + +const abz = await modi.seite.evaluate(() => { + const liste = document.getElementById("chat-liste"); + const marken = [...liste.querySelectorAll(".beitrag__rolle")].map((m) => ({ + wort: m.textContent.trim(), + rolle: m.dataset.rolle, + schrift: Math.round(parseFloat(getComputedStyle(m).fontSize) * 100) / 100, + breit: Math.round(m.getBoundingClientRect().width), + })); + /* LAEUFT EINE ZEILE UEBER? Nicht die Zahl aus dem Stilblatt, die + tatsaechliche Breite gegen die tatsaechliche Liste. */ + const innen = liste.getBoundingClientRect(); + const ueber = [...liste.querySelectorAll(".beitrag")] + .map((b) => Math.round(b.scrollWidth - b.clientWidth)) + .filter((x) => x > 0); + /* Und die Kopfzeile selbst -- dort stehen Name, Abzeichen und + Knopf nebeneinander. */ + const koepfe = [...liste.querySelectorAll(".beitrag__wer")] + .map((h) => Math.round(h.scrollWidth - h.clientWidth)) + .filter((x) => x > 0); + return { + marken, ueber, koepfe, + listenbreite: Math.round(innen.width), + namensknoepfe: liste.querySelectorAll(".beitrag__name").length, + beitraege: liste.children.length, + }; +}); +console.log(` Schiene ${abz.listenbreite} px breit, ${abz.beitraege} Beitraege, ` + + `${abz.marken.length} Abzeichen, ${abz.namensknoepfe} Namen zum Anfassen.`); +for (const m of abz.marken) { + console.log(` „${m.wort}" (${m.rolle}) ${m.schrift} px, ${m.breit} px breit`); +} +if (abz.marken.length < 3) { + console.log(" ACHTUNG: Die Abzeichen kommen im Chat nicht an."); + ausgang = 1; +} +/* 11,5 px ist die Untergrenze des Hauses (pruef-css-klassen). */ +if (abz.marken.some((m) => m.schrift < 11.5)) { + console.log(" ACHTUNG: Ein Abzeichen ist kleiner als 11,5 px."); + ausgang = 1; +} +if (abz.ueber.length || abz.koepfe.length) { + console.log(` ACHTUNG: ${abz.ueber.length} Beitraege und ${abz.koepfe.length} Kopfzeilen ` + + `laufen ueber (${[...abz.ueber, ...abz.koepfe].join(", ")} px).`); + ausgang = 1; +} +/* EIN MODI MELDET NICHT -- er handelt. Der Melden-Knopf gehoert + denen, die nichts anderes tun koennen. */ +const modiSicht = await modi.seite.evaluate(() => + document.querySelectorAll("#chat-liste .beitrag__melden").length); +console.log(` Der Modi sieht ${modiSicht} Melden-Knoepfe (soll: 0) ` + + `und ${abz.namensknoepfe} Namen zum Anfassen.`); +if (modiSicht !== 0 || abz.namensknoepfe < 3) { + console.log(" ACHTUNG: Melden und Moderieren sind vertauscht."); + ausgang = 1; +} + +/* DAS MENUE MUSS INS BILD PASSEN. Ein Menue, das unten oder rechts + heraussteht, ist auf dem Handy die Haelfte der Zeit nicht + bedienbar -- und man sieht es erst, wenn man es aufmacht. */ +await modi.seite.click("#chat-liste .beitrag:last-child .beitrag__name").catch(() => {}); +await modi.seite.waitForTimeout(400); +const menue = await modi.seite.evaluate(() => { + const m = document.querySelector(".modmenue"); + if (!m) return null; + const r = m.getBoundingClientRect(); + const knoepfe = [...m.querySelectorAll(".modmenue__knopf")].map((k2) => ({ + wort: k2.textContent.trim(), + hoch: Math.round(k2.getBoundingClientRect().height), + })); + /* IST ES WIRKLICH ZU SEHEN? „Im Fenster" reicht nicht als Frage. + Beim ersten Bau lag das Menue nachweislich komplett im Fenster + (x=1099..1315 von 1440, y=652..840 von 900) und war trotzdem + abgeschnitten -- die Chatliste rollt, und ein rollender + Vorfahre beschneidet sein Kind. Gefunden hat das nicht die + Messung, sondern das Bildschirmfoto. + Deshalb jetzt der einzige Test, der die Frage beantwortet: An + die Stelle jedes Knopfes fassen und nachsehen, was dort + tatsaechlich liegt. */ + const greifbar = [...m.querySelectorAll(".modmenue__knopf")].filter((k2) => { + const q = k2.getBoundingClientRect(); + const oben = document.elementFromPoint(q.left + q.width / 2, q.top + q.height / 2); + return !!oben && (k2 === oben || k2.contains(oben)); + }).length; + return { + links: Math.round(r.left), rechts: Math.round(r.right), + oben: Math.round(r.top), unten: Math.round(r.bottom), + breit: Math.round(r.width), knoepfe, greifbar, + fensterBreit: window.innerWidth, fensterHoch: window.innerHeight, + }; +}); +if (!menue) { + console.log(" ACHTUNG: Das Moderationsmenue geht nicht auf."); + ausgang = 1; +} else { + console.log(` Menue ${menue.breit} px breit bei x=${menue.links}..${menue.rechts} ` + + `(Fenster ${menue.fensterBreit}), y=${menue.oben}..${menue.unten}`); + for (const b of menue.knoepfe) console.log(` „${b.wort}" ${b.hoch} px hoch`); + console.log(` ${menue.greifbar} von ${menue.knoepfe.length} Knoepfen sind anfassbar`); + const raus = menue.rechts > menue.fensterBreit || menue.links < 0 + || menue.unten > menue.fensterHoch; + if (raus) { + console.log(" ACHTUNG: Das Menue steht aus dem Bild heraus."); + ausgang = 1; + } + if (menue.greifbar !== menue.knoepfe.length) { + console.log(" ACHTUNG: Ein Knopf des Menues ist verdeckt oder abgeschnitten."); + ausgang = 1; + } + if (menue.knoepfe.length < 4 || menue.knoepfe.some((b) => b.hoch < 44)) { + console.log(" ACHTUNG: Ein Knopf im Menue ist kleiner als 44 px."); + ausgang = 1; + } +} +await bild(modi.seite, "12-moderationsmenue"); + +/* STUMM SCHALTEN -- und beim Betroffenen nachsehen. + Der Chat steht dabei auf „offen": genau der Fall, in dem die + Auskunft vorher falsch war („Der Chat ist gerade zu"). */ +await modi.seite.click('.modmenue__knopf:text("10 Minuten stumm")').catch(async () => { + await modi.seite.evaluate(() => { + const k2 = [...document.querySelectorAll(".modmenue__knopf")] + .find((x) => x.textContent.includes("stumm")); + if (k2) k2.click(); + }); +}); +await modi.seite.waitForTimeout(1500); + +const beimGast = await zuschauer.seite.evaluate(() => { + const f = document.getElementById("chat-feld"); + return { gesperrt: !!f?.disabled, grund: f?.placeholder || "" }; +}); +console.log(` Die Stummgeschaltete sieht: gesperrt=${beimGast.gesperrt}, ` + + `Grund „${beimGast.grund}"`); +const serverSagt = await roh("/workspace/api/reaktion/chat", { + method: "POST", keks: k.gast, rumpf: { text: "und jetzt?" }, +}); +console.log(` ... und der Server: ${serverSagt.code}/${serverSagt.j?.fehler}`); +if (!beimGast.gesperrt || !beimGast.grund.includes("stumm") + || serverSagt.j?.fehler !== "stumm_geschaltet") { + console.log(" ACHTUNG: Die Stummschaltung kommt nicht oder mit falschem Grund an."); + ausgang = 1; +} +await roh(`/workspace/api/reaktion/massnahme/${IDS.gast}`, { method: "DELETE", keks: k.admin }); +await modi.seite.waitForTimeout(900); + +/* MELDEN -- der Weg fuer die, die selbst nichts tun koennen. */ +const letzter = await roh("/workspace/api/reaktion/chat", { keks: k.admin }); +const zuMelden = (letzter.j?.beitraege || []).filter((b) => b.person_id === IDS.gast2).pop(); +if (zuMelden) { + await roh(`/workspace/api/reaktion/chat/${zuMelden.id}/melden`, { + method: "POST", keks: k.gast, rumpf: { grund: "unfreundlich" }, + }); +} +await modi.seite.waitForTimeout(1800); +const chip = await modi.seite.evaluate(() => { + const k2 = document.getElementById("chat-meldungen"); + if (!k2) return null; + const r = k2.getBoundingClientRect(); + return { versteckt: !!k2.hidden, wort: k2.textContent.trim(), hoch: Math.round(r.height) }; +}); +console.log(` Meldungs-Zeichen: „${chip?.wort}" (versteckt=${chip?.versteckt}, ` + + `${chip?.hoch} px hoch)`); +if (!chip || chip.versteckt || !/Meldung/.test(chip.wort)) { + console.log(" ACHTUNG: Die Meldung erreicht die Moderation nicht."); + ausgang = 1; +} +await modi.seite.click("#chat-meldungen").catch(() => {}); +await modi.seite.waitForTimeout(400); +const liste = await modi.seite.evaluate(() => { + const m = document.querySelector(".modmenue--liste"); + if (!m) return null; + const r = m.getBoundingClientRect(); + const knoepfe = [...m.querySelectorAll(".modmenue__knopf")]; + const greifbar = knoepfe.filter((k2) => { + const q = k2.getBoundingClientRect(); + const oben = document.elementFromPoint(q.left + q.width / 2, q.top + q.height / 2); + return !!oben && (k2 === oben || k2.contains(oben)); + }).length; + return { + zeilen: m.querySelectorAll(".modmeldung").length, + text: m.querySelector(".modmeldung__text")?.textContent || "", + knoepfe: knoepfe.length, greifbar, + rechts: Math.round(r.right), unten: Math.round(r.bottom), + fensterBreit: window.innerWidth, fensterHoch: window.innerHeight, + }; +}); +if (!liste || liste.zeilen < 1) { + console.log(" ACHTUNG: Die Meldungsliste geht nicht auf."); + ausgang = 1; +} else { + console.log(` ${liste.zeilen} Meldung(en), erste: „${liste.text}" -- ` + + `${liste.greifbar}/${liste.knoepfe} Knoepfe anfassbar`); + if (liste.rechts > liste.fensterBreit || liste.unten > liste.fensterHoch) { + console.log(" ACHTUNG: Die Meldungsliste steht aus dem Bild heraus."); + ausgang = 1; + } + if (liste.greifbar !== liste.knoepfe) { + console.log(" ACHTUNG: Ein Knopf der Meldungsliste ist verdeckt."); + ausgang = 1; + } +} +await bild(modi.seite, "13-meldungen"); +if (modi.konsole.length) console.log(" KONSOLE:", modi.konsole.slice(0, 4).join(" | ")); + +/* AUS DER SENDUNG -- der haerteste Fall. Wer rausgenommen wird, muss + es ERFAHREN; vorher lief bei ihm das Video weiter, und nur die + Anwesenheitsmeldung schlug still fehl. */ +await roh("/workspace/api/reaktion/massnahme", { + method: "POST", keks: k.admin, rumpf: { person_id: IDS.gast, art: "raus" }, +}); +await zuschauer.seite.waitForTimeout(2500); +const rausgesetzt = await zuschauer.seite.evaluate(() => { + const k2 = document.querySelector(".raus"); + return { + da: !!k2, markiert: document.body.dataset.raus === "ja", + titel: k2?.querySelector(".raus__titel")?.textContent || "", + deckt: k2 ? Math.round(k2.getBoundingClientRect().height) : 0, + fensterHoch: window.innerHeight, + }; +}); +console.log(` Die Rausgenommene sieht: „${rausgesetzt.titel}" ` + + `(${rausgesetzt.deckt} von ${rausgesetzt.fensterHoch} px)`); +if (!rausgesetzt.da || !rausgesetzt.markiert + || rausgesetzt.deckt < rausgesetzt.fensterHoch - 2) { + console.log(" ACHTUNG: Wer rausgenommen wird, merkt es nicht."); + ausgang = 1; +} +await bild(zuschauer.seite, "14-aus-der-sendung"); +await roh(`/workspace/api/reaktion/massnahme/${IDS.gast}`, { method: "DELETE", keks: k.admin }); + +await modi.kontext.close(); await host.kontext.close(); await gast2.kontext.close(); await zuschauer.kontext.close(); diff --git a/server/pruef-reaktion.mjs b/server/pruef-reaktion.mjs index 7dc09ea3..b9846715 100644 --- a/server/pruef-reaktion.mjs +++ b/server/pruef-reaktion.mjs @@ -580,7 +580,7 @@ melde("=== 9. Beenden ==="); const c = await roh(CREW, "/workspace/api/reaktion/chat", { method: "POST", keks: k.gast, rumpf: { text: "Hallo?" }, }); - ok(c.code === 409 && c.j?.fehler === "geschlossen", + ok(c.code === 409 && c.j?.fehler === "saal_zu", `und schreiben geht nicht mehr (${c.code}/${c.j?.fehler})`); } @@ -1095,6 +1095,268 @@ melde("=== 14. Keine doppelten Wege ==="); ok(reaktionswege >= 17, `die Reaction bringt ${reaktionswege} eigene Wege mit`); } + +/* ======================================================================= + 15. MODERATION IM LIVE-CHAT + + Der heikelste Teil der ganzen Reaction: Hier kann jemand einem + anderen das Wort nehmen. Geprueft wird deshalb nicht nur, dass es + GEHT, sondern vor allem, wer es NICHT darf und was dabei nicht + herauskommen darf. + ======================================================================= */ +melde(""); +melde("=== 15. Moderation im Live-Chat ==="); +{ + /* Sauberer Anfang: Der Chat steht auf „offen". */ + await roh(CREW, "/workspace/api/reaktion/chatmodus", { + method: "POST", keks: k.admin, rumpf: { modus: "offen" }, + }); + + /* ---- Wer eine Massnahme verhaengen darf ---- */ + const zuschauer = await roh(CREW, "/workspace/api/reaktion/massnahme", { + method: "POST", keks: k.gast, rumpf: { person_id: IDS.gast2, art: "stumm", minuten: 10 }, + }); + ok(zuschauer.code === 403 && zuschauer.j?.fehler === "nur_moderation", + `ein Zuschauer schaltet niemanden stumm (${zuschauer.code}/${zuschauer.j?.fehler})`); + + /* ---- Nicht gegen das Team ---- + Wer einen Modi stummschalten koennte, haette einen Weg, die + Moderation selbst auszuschalten -- mitten in der Sendung. */ + const gegenTeam = await roh(CREW, "/workspace/api/reaktion/massnahme", { + method: "POST", keks: k.modi, rumpf: { person_id: IDS.linke, art: "stumm", minuten: 10 }, + }); + ok(gegenTeam.code === 403 && gegenTeam.j?.fehler === "nicht_gegen_team", + `ein Modi schaltet die linke Hand nicht stumm (${gegenTeam.code}/${gegenTeam.j?.fehler})`); + const gegenHost = await roh(CREW, "/workspace/api/reaktion/massnahme", { + method: "POST", keks: k.modi, rumpf: { person_id: IDS.admin, art: "raus" }, + }); + ok(gegenHost.code === 403, `und DogFather erst recht nicht (${gegenHost.code})`); + + /* ---- Die Dauer hat eine Obergrenze ---- */ + const { STUMM_MAX_MINUTEN, MASSNAHMEN } = await import("./reaktion-tabellen.js"); + for (const [min, wort] of [[0, "null"], [-5, "minus"], [STUMM_MAX_MINUTEN + 1, "zu lang"]]) { + const a = await roh(CREW, "/workspace/api/reaktion/massnahme", { + method: "POST", keks: k.modi, rumpf: { person_id: IDS.gast2, art: "stumm", minuten: min }, + }); + ok(a.code === 400 && a.j?.fehler === "dauer_unklar", + `${min} Minuten (${wort}) wird abgelehnt (${a.code})`); + } + const quatsch = await roh(CREW, "/workspace/api/reaktion/massnahme", { + method: "POST", keks: k.modi, rumpf: { person_id: IDS.gast2, art: "verbannen" }, + }); + ok(quatsch.code === 400 && quatsch.j?.fehler === "massnahme_unbekannt", + `eine erfundene Massnahme wird abgelehnt (${quatsch.code})`); + ok(MASSNAHMEN.length === 2 && MASSNAHMEN.includes("stumm") && MASSNAHMEN.includes("raus"), + `es gibt genau zwei kurze Massnahmen: ${MASSNAHMEN.join(", ")}`); + + /* ---- Stumm wirkt -- und sagt den richtigen Grund ---- + Der Chat steht dabei auf „offen". Genau das ist der Fall, der + vorher die falsche Auskunft gab („Der Chat ist gerade zu"). */ + const vorher = await roh(CREW, "/workspace/api/reaktion/chat", { + method: "POST", keks: k.gast2, rumpf: { text: "vorher geht es" }, + }); + ok(vorher.code === 201, `vor der Massnahme schreibt gast2 (${vorher.code})`); + + const stumm = await roh(CREW, "/workspace/api/reaktion/massnahme", { + method: "POST", keks: k.modi, rumpf: { person_id: IDS.gast2, art: "stumm", minuten: 10 }, + }); + ok(stumm.code === 200, `ein Modi schaltet einen Zuschauer stumm (${stumm.code})`); + + const nachher = await roh(CREW, "/workspace/api/reaktion/chat", { + method: "POST", keks: k.gast2, rumpf: { text: "und jetzt?" }, + }); + ok(nachher.code === 403 && nachher.j?.fehler === "stumm_geschaltet", + `danach nicht mehr -- und der Grund stimmt (${nachher.code}/${nachher.j?.fehler})`); + + const sichtGast2 = await roh(CREW, "/workspace/api/reaktion", { keks: k.gast2 }); + ok(sichtGast2.j?.ich?.darf_schreiben === false + && sichtGast2.j?.ich?.schreib_grund === "stumm_geschaltet", + "und er steht auch in seinem eigenen Stand"); + ok(sichtGast2.j?.ich?.massnahme?.art === "stumm" && !!sichtGast2.j.ich.massnahme.bis, + "mit Art und Ablaufzeitpunkt"); + + /* ZUSEHEN GEHT WEITER. „stumm" ist nicht „raus" -- wer das + verwechselt, wirft jemanden fuer einen Satz aus der Sendung. */ + const nochDabei = await roh(CREW, "/workspace/api/reaktion/dabei", { + method: "POST", keks: k.gast2, + }); + ok(nochDabei.code === 200, `zusehen geht weiter (${nochDabei.code})`); + + /* ---- Die Massnahmen sind keine oeffentliche Liste ---- + In `meldungen` steht der Name dessen, der gemeldet hat. Wer + meldet, muss sich darauf verlassen koennen, dass das niemand + sieht -- sonst meldet beim naechsten Mal keiner mehr. */ + ok(Array.isArray(stumm.j?.massnahmen) && stumm.j.massnahmen.length >= 1, + "die Moderation sieht die laufenden Massnahmen"); + ok(sichtGast2.j?.massnahmen === null && sichtGast2.j?.meldungen === null, + "ein Zuschauer sieht weder Massnahmen noch Meldungen"); + const sichtModi = await roh(CREW, "/workspace/api/reaktion", { keks: k.modi }); + ok(Array.isArray(sichtModi.j?.meldungen), "ein Modi schon"); + + /* GEGENPROBE AM RUNDRUF: `oeffentlich()` muss beide Felder + wegnehmen. Es aus dem Quelltext zu lesen ist hier genauer als + jede Messung -- der Rundruf geht an alle, und ein Fehler darin + waere erst aufgefallen, wenn jemand mitgelesen haette. */ + { + const quelle = readFileSync(join(HIER, "workspace-reaktion.js"), "utf8"); + const zeile = quelle.match(/const \{([^}]*)\} = l;/); + const weg = (zeile ? zeile[1] : "").split(",").map((x) => x.trim().replace(/\.\.\..*/, "")); + for (const feld of ["zuschauer", "ich", "liste", "meldungen", "massnahmen"]) { + ok(weg.includes(feld), `oeffentlich() nimmt „${feld}" aus dem Rundruf`); + } + } + + /* ---- Wieder aufheben ---- */ + const auf = await roh(CREW, `/workspace/api/reaktion/massnahme/${IDS.gast2}`, { + method: "DELETE", keks: k.gast, + }); + ok(auf.code === 403, `ein Zuschauer hebt sie nicht auf (${auf.code})`); + const auf2 = await roh(CREW, `/workspace/api/reaktion/massnahme/${IDS.gast2}`, { + method: "DELETE", keks: k.modi, + }); + ok(auf2.code === 200, `ein Modi schon (${auf2.code})`); + const wieder = await roh(CREW, "/workspace/api/reaktion/chat", { + method: "POST", keks: k.gast2, rumpf: { text: "wieder da" }, + }); + ok(wieder.code === 201, `Gegenprobe: danach schreibt er wieder (${wieder.code})`); + + /* ---- Melden ---- */ + const zuMelden = wieder.j.id; + const selbst = await roh(CREW, `/workspace/api/reaktion/chat/${zuMelden}/melden`, { + method: "POST", keks: k.gast2, rumpf: {}, + }); + ok(selbst.code === 400 && selbst.j?.fehler === "eigener_beitrag", + `den eigenen Beitrag meldet man nicht (${selbst.code})`); + + const gemeldet = await roh(CREW, `/workspace/api/reaktion/chat/${zuMelden}/melden`, { + method: "POST", keks: k.gast, rumpf: { grund: "unfreundlich" }, + }); + ok(gemeldet.code === 201, `ein Zuschauer darf melden (${gemeldet.code})`); + + /* ZWEIMAL MELDEN IST EINMAL. Ohne die Sperre koennte einer allein + die Liste fuellen, und die Moderation sieht die echte nicht. */ + const nochmal = await roh(CREW, `/workspace/api/reaktion/chat/${zuMelden}/melden`, { + method: "POST", keks: k.gast, rumpf: {}, + }); + ok(nochmal.code === 200 && nochmal.j?.schon === true, + `zweimal melden zaehlt einmal -- ohne Fehlermeldung (${nochmal.code})`); + + const beiModi = await roh(CREW, "/workspace/api/reaktion", { keks: k.modi }); + const m = (beiModi.j?.meldungen || []).find((x) => x.beitrag_id === zuMelden); + ok(!!m, "die Meldung steht bei der Moderation"); + ok(m?.text === "wieder da" && m?.wer_id === IDS.gast2, + "mit dem Text und der Person, um die es geht"); + + /* ---- Erledigen ---- */ + const nein = await roh(CREW, `/workspace/api/reaktion/meldung/${m.id}/erledigt`, { + method: "POST", keks: k.gast, + }); + ok(nein.code === 403, `ein Zuschauer erledigt keine Meldung (${nein.code})`); + const ja = await roh(CREW, `/workspace/api/reaktion/meldung/${m.id}/erledigt`, { + method: "POST", keks: k.modi, + }); + ok(ja.code === 200, `ein Modi schon (${ja.code})`); + ok(!(ja.j?.meldungen || []).some((x) => x.id === m.id), + "und sie ist aus der offenen Liste"); + const zweimal = await roh(CREW, `/workspace/api/reaktion/meldung/${m.id}/erledigt`, { + method: "POST", keks: k.modi, + }); + ok(zweimal.code === 409 && zweimal.j?.fehler === "nicht_offen", + `zweimal erledigen geht nicht (${zweimal.code})`); + + /* ---- „raus" heisst raus ---- */ + const raus = await roh(CREW, "/workspace/api/reaktion/massnahme", { + method: "POST", keks: k.hand, rumpf: { person_id: IDS.gast2, art: "raus" }, + }); + ok(raus.code === 200, `die rechte Hand nimmt jemanden aus der Sendung (${raus.code})`); + const rein = await roh(CREW, "/workspace/api/reaktion/dabei", { + method: "POST", keks: k.gast2, + }); + ok(rein.code === 403 && rein.j?.fehler === "aus_der_sendung", + `er kommt nicht mehr herein -- und erfaehrt warum (${rein.code}/${rein.j?.fehler})`); + + const weg2 = await roh(CREW, "/workspace/api/reaktion/chat", { + method: "POST", keks: k.gast2, rumpf: { text: "hallo?" }, + }); + ok(weg2.code === 403 && weg2.j?.fehler === "aus_der_sendung", + `und schreiben auch nicht (${weg2.code}/${weg2.j?.fehler})`); + + await roh(CREW, `/workspace/api/reaktion/massnahme/${IDS.gast2}`, { + method: "DELETE", keks: k.admin, + }); + const zurueck = await roh(CREW, "/workspace/api/reaktion/dabei", { + method: "POST", keks: k.gast2, + }); + ok(zurueck.code === 200, `Gegenprobe: aufgehoben, und er ist wieder dabei (${zurueck.code})`); +} + +/* ======================================================================= + 16. WAS DIE SEITE DARAUS MACHT + ======================================================================= */ +melde(""); +melde("=== 16. Rollen und Moderation auf der Seite ==="); +{ + const js = readFileSync(join(HIER, "..", "workspace", "assets", "js", "reaktion.js"), "utf8"); + const css = readFileSync(join(HIER, "..", "workspace", "assets", "css", "reaktion.css"), "utf8"); + const html = readFileSync(join(HIER, "..", "workspace", "reaktion.html"), "utf8"); + const saetze = readFileSync(join(HIER, "..", "workspace", "assets", "js", "meldung.js"), "utf8"); + + /* JEDE ROLLE MIT EIGENEM AUSSEHEN BEKOMMT AUCH EINE REGEL. + Ein Abzeichen ohne Stil ist ein Wort ohne Kasten -- es faellt + nicht auf, und damit ist der ganze Zweck weg. */ + const abzeichen = [...js.matchAll(/^ {4}(admin|hand|linke|modi): \['([^']+)', '([^']+)'\],/gm)]; + ok(abzeichen.length === 4, `vier Rollen tragen ein Abzeichen (${abzeichen.length})`); + for (const [, rolle, wort, marke] of abzeichen) { + ok(wort.length >= 3 && wort.length <= 6, `„${wort}" fuer ${rolle} ist kurz genug`); + ok(css.includes(`.beitrag__rolle[data-rolle="${marke}"]`), + `und „${marke}" hat eine eigene Farbe`); + } + /* GEGENPROBE: Kann diese Pruefung „nein" sagen? */ + ok(!css.includes('.beitrag__rolle[data-rolle="gibtesnicht"]'), + "Gegenprobe: eine erfundene Marke hat keine Regel"); + + /* DAS WORT IST NICHT DER RANG. Filipe steht nie ueber seinem Team -- + Woerter wie „Chef" oder „Leitung" haetten hier nichts zu suchen. */ + const rangwoerter = ["Chef", "Boss", "Leiter", "Leitung", "Kopf"]; + for (const [, , wort] of abzeichen) { + ok(!rangwoerter.includes(wort), `„${wort}" ist kein Rang`); + } + + ok(html.includes('id="chat-meldungen"'), "der Meldungs-Knopf steht in der Seite"); + ok(css.includes(".schiene__meldungen"), "und hat eine Regel"); + ok(js.includes("meldungenZeichnen()") && js.includes("meldungenZeigen"), + "die Seite zeichnet und oeffnet ihn"); + + /* DIE TIPPZIELE IM MODERATIONSMENUE. Hier wird jemand stumm + geschaltet; ein Fehlgriff trifft den Falschen. */ + const knopf = css.match(/\.modmenue__knopf \{[^}]*\}/); + ok(!!knopf && /min-height:\s*44px/.test(knopf[0]), + "jeder Knopf im Moderationsmenue ist 44 px hoch"); + + /* JEDES FEHLERWORT DES SERVERS HAT EINEN DEUTSCHEN SATZ. + Sonst steht im Chat ein Maschinenwort wie „nicht_gegen_team". */ + const server = readFileSync(join(HIER, "workspace-reaktion.js"), "utf8"); + const woerter = [...new Set([...server.matchAll(/fehler:\s*"([a-z_0-9]+)"/g)] + .map((x) => x[1]))].filter((w) => w !== "nicht_verfuegbar"); + ok(woerter.length > 20, `${woerter.length} Fehlerwoerter im Server gefunden`); + const ohneSatz = woerter.filter((w) => !new RegExp(`^\\s+${w}:`, "m").test(saetze)); + ok(ohneSatz.length === 0, + ohneSatz.length ? `OHNE SATZ: ${ohneSatz.join(", ")}` : "alle haben einen deutschen Satz"); + /* Die Gegenprobe braucht ein Wort, das es WIRKLICH nicht gibt -- + „gibt_es_nicht" steht seit Langem in der Tafel und liess die + Gegenprobe genau das melden, wovor sie warnen sollte. */ + ok(!/^\s+zwetschgenknoedel:/m.test(saetze), + "Gegenprobe: ein erfundenes Wort hat keinen Satz"); + + /* KEIN SCHLUESSEL STEHT ZWEIMAL IN DER SATZTAFEL. + Ein zweiter Eintrag mit demselben Namen gewinnt stillschweigend -- + genau so stand am 28.09. in der Hilfe „Der Saal ist zu". */ + const schluessel = [...saetze.matchAll(/^ {4}([a-z_0-9]+):/gm)].map((x) => x[1]); + const doppelt = [...new Set(schluessel.filter((x, i) => schluessel.indexOf(x) !== i))]; + ok(doppelt.length === 0, + doppelt.length ? `DOPPELT: ${doppelt.join(", ")}` : `${schluessel.length} Saetze, keiner doppelt`); +} + melde(""); melde(`${geprueft} Pruefungen, ${fehler} Fehler`); melde(fehler === 0 ? "ALLES IN ORDNUNG" : "NICHT IN ORDNUNG"); diff --git a/server/reaktion-tabellen.js b/server/reaktion-tabellen.js index cd21a4b5..39ce88d9 100644 --- a/server/reaktion-tabellen.js +++ b/server/reaktion-tabellen.js @@ -83,6 +83,17 @@ export const STAENDE = ["zu", "vorbereitung", "live"]; /** Die Anordnungen des Bildes. */ export const LAYOUTS = ["kino", "gleich", "kamera"]; +/** Wie lange eine Stummschaltung hoechstens gilt (Minuten). + * + * Eine Stunde. Wer laenger etwas braucht, nimmt die Massnahmen des + * Treffs -- die haben eine Begruendungspflicht, eine Frist in Tagen + * und stehen in der Geschichte. Eine Stummschaltung ueber Stunden + * waere eine Sperre ohne all das. */ +export const STUMM_MAX_MINUTEN = 60; + +/** Die zwei kurzen Massnahmen der Sendung. */ +export const MASSNAHMEN = ["stumm", "raus"]; + /** Wer im Live-Chat schreiben darf. * * Filipes Notiz nennt „Chat" als eine der acht Host-Steuerungen. @@ -165,6 +176,51 @@ export function reaktionTabellen(d) { weg_von INTEGER REFERENCES personen(id) ON DELETE SET NULL ); + /* ==== DIE KURZEN MASSNAHMEN EINER SENDUNG ==================== + + „stumm" darf zusehen, aber nicht schreiben. „raus" ist aus + dieser Sendung heraus. + + BEIDES ENDET MIT DER SENDUNG -- wie die Gaeste und der + Live-Chat auch. Was laenger gelten soll, gehoert in die + Massnahmen des Treffs: Die haben eine Begruendungspflicht, + eine Frist in Tagen und stehen in der Geschichte. Ein + Fuenf-Minuten-Aerger gehoert dort nicht hinein. + + EINE ZEILE JE PERSON. Zwei Massnahmen gegen dieselbe Person + waeren zwei Antworten auf dieselbe Frage -- und dann + entscheidet die Reihenfolge, welche gilt. */ + CREATE TABLE IF NOT EXISTS reaktion_massnahmen ( + person_id INTEGER PRIMARY KEY REFERENCES personen(id) ON DELETE CASCADE, + art TEXT NOT NULL CHECK (art IN ('stumm','raus')), + /* Leer heisst: bis zum Ende der Sendung. */ + bis TEXT, + grund TEXT NOT NULL DEFAULT '', + von INTEGER REFERENCES personen(id) ON DELETE SET NULL, + erstellt TEXT NOT NULL + ); + + /* Gemeldete Beitraege. Eigene Tabelle und nicht die des Treffs: + Die zeigt auf „eintraege“, hier geht es um „reaktion_chat“ -- + ein anderer Gegenstand, und ein Fremdschluessel kann nicht auf + zweierlei zeigen. */ + CREATE TABLE IF NOT EXISTS reaktion_meldungen ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + beitrag_id INTEGER NOT NULL REFERENCES reaktion_chat(id) ON DELETE CASCADE, + melder_id INTEGER REFERENCES personen(id) ON DELETE SET NULL, + grund TEXT NOT NULL DEFAULT '', + erstellt TEXT NOT NULL, + erledigt_am TEXT, + erledigt_von INTEGER REFERENCES personen(id) ON DELETE SET NULL + ); + /* DIESELBE PERSON MELDET DENSELBEN BEITRAG EINMAL. Ohne diese + Sperre koennte einer allein eine Liste fuellen, und die + Moderation sieht vor lauter Meldungen die echte nicht. */ + CREATE UNIQUE INDEX IF NOT EXISTS idx_reaktion_meldung_einmal + ON reaktion_meldungen (beitrag_id, melder_id); + CREATE INDEX IF NOT EXISTS idx_reaktion_meldung_offen + ON reaktion_meldungen (erledigt_am, id); + /* Was gelaufen ist. Eine Sendung je Zeile, erst beim Beenden. */ CREATE TABLE IF NOT EXISTS reaktion_verlauf ( id INTEGER PRIMARY KEY AUTOINCREMENT, diff --git a/server/workspace-reaktion.js b/server/workspace-reaktion.js index 9c5c4063..9f8da8b6 100644 --- a/server/workspace-reaktion.js +++ b/server/workspace-reaktion.js @@ -48,8 +48,13 @@ import { einstellung } from "./workspace.js"; import { mitZugangsdaten, wegeAusfaechern } from "./workspace-turn.js"; import { SICHT_PLAETZE, GAESTE_MAX, TEXT_MAX, SCHREIB_BREMSE, STAENDE, LAYOUTS, - VERLAUF_TAGE, LISTE_MAX, CHAT_MODI, + VERLAUF_TAGE, LISTE_MAX, CHAT_MODI, STUMM_MAX_MINUTEN, MASSNAHMEN, } from "./reaktion-tabellen.js"; +/* DIE SPERREN DES TREFFS GELTEN AUCH HIER. Ein zweites System + daneben hiesse, dass jemand im Treff gesperrt ist und in der + Sendung weiterschreibt -- genau die Luecke, die man erst bemerkt, + wenn sie benutzt wird. */ +import { treffSperreLesen } from "./treff-tabellen.js"; export const reaktionRouter = express.Router(); @@ -266,6 +271,22 @@ function lage(person) { })), chat_modus: s.chat_modus || "offen", chat_modi: CHAT_MODI, + /* WAS DIE MODERATION SEHEN MUSS -- und niemand sonst. Eine Liste + von Massnahmen gegen andere ist fuer einen Zuschauer eine + Liste darueber, wer sich danebenbenommen hat. */ + meldungen: MODERIERT.has(person.rolle) + ? db().prepare(`SELECT m.id, m.beitrag_id, m.grund, m.erstellt, + p.name AS melder, c.text, c.name AS wer, c.person_id AS wer_id, c.weg_am + FROM reaktion_meldungen m + JOIN reaktion_chat c ON c.id = m.beitrag_id + LEFT JOIN personen p ON p.id = m.melder_id + WHERE m.erledigt_am IS NULL ORDER BY m.id`).all() + : null, + massnahmen: MODERIERT.has(person.rolle) + ? db().prepare(`SELECT m.person_id, m.art, m.bis, m.grund, p.name + FROM reaktion_massnahmen m JOIN personen p ON p.id = m.person_id + ORDER BY m.erstellt DESC`).all() + : null, dabei: dabei.length, /* ==== WER DIE LISTE BEKOMMT -- UND WARUM GENAU DIESE ========== @@ -301,6 +322,15 @@ function lage(person) { kamera_aus: !!(dabei.find((d) => d.id === person.id)?.kamera_aus), mikro_aus: !!(dabei.find((d) => d.id === person.id)?.mikro_aus), darf_schreiben: darfSchreiben(person, s), + /* Der Grund steht daneben -- die Seite muss ihn nicht raten. */ + schreib_grund: schreibGrund(person, s), + /* WER STUMM IST, MUSS ES ERFAHREN. Ein Feld, das sich nicht + beschreiben laesst und schweigt, sieht kaputt aus -- und dann + sucht jemand den Fehler bei sich statt bei seinem Verhalten. */ + massnahme: (() => { + const m = massnahmeFuer(person.id); + return m ? { art: m.art, bis: m.bis, grund: m.grund } : null; + })(), }, /* DIE WARTESCHLANGE SEHEN NUR DIE ZWEI. Sie ist Teil der Vorbereitung, und Filipe hat dazu gesagt: „nur die zwei sollen @@ -329,17 +359,55 @@ function lage(person) { * erst anzubieten. Zwei Rechnungen waeren zwei Gelegenheiten, dass * ein Feld da ist und mit 403 antwortet -- und das ist eine Falle, * keine Bedienung. */ -function darfSchreiben(person, stand) { - if (stand.stand === "zu") return false; +/** Die kurze Massnahme dieser Sendung -- oder nichts. + * + * ABGELAUFENES ZAEHLT NICHT. Eine Stummschaltung ueber zehn Minuten + * endet nach zehn Minuten, ohne dass jemand etwas tun muss. Eine, + * die von Hand aufgehoben werden muss, bleibt sonst die ganze + * Sendung stehen, weil im Live niemand daran denkt. */ +function massnahmeFuer(personId) { + const m = db().prepare("SELECT * FROM reaktion_massnahmen WHERE person_id = ?") + .get(personId); + if (!m) return null; + if (!m.bis) return m; + const ende = Date.parse(m.bis); + if (Number.isFinite(ende) && ende > Date.now()) return m; + db().prepare("DELETE FROM reaktion_massnahmen WHERE person_id = ?").run(personId); + return null; +} + +/** WARUM jemand nicht schreiben darf -- oder `null`, wenn er darf. + * + * Der Grund und nicht nur das Ja/Nein, und zwar aus einem Fehler + * heraus: Vorher gab es nur „darf/darf nicht", und der Aufrufer hat + * sich den Grund aus der Chat-Stufe zusammengereimt. Fuer jemanden, + * der stummgeschaltet war, waehrend der Chat offen stand, kam damit + * „Der Chat ist gerade zu" heraus -- eine Auskunft, die nachweisbar + * falsch war und ihn beim naechsten Aufmachen wieder vergeblich + * tippen liess. Wer den Grund kennt, gibt ihn weiter. */ +function schreibGrund(person, stand) { + if (stand.stand === "zu") return "saal_zu"; + /* ERST DIE SPERREN DES TREFFS. Wer dort eine Pause hat oder + ausgeschlossen ist, schreibt hier auch nicht -- es sind dieselben + Menschen und dieselbe Gemeinschaft. */ + try { + if (treffSperreLesen(db(), person.id)) return "treff_massnahme"; + } catch { /* gibt es die Tabelle nicht, gilt sie auch nicht */ } + const m = massnahmeFuer(person.id); + if (m) return m.art === "raus" ? "aus_der_sendung" : "stumm_geschaltet"; const modus = stand.chat_modus || "offen"; - if (modus === "offen") return true; + if (modus === "offen") return null; /* „team": wer moderiert, darf. Das ist dieselbe Menge, die auch Beitraege wegnehmen darf -- wer aufraeumen soll, muss dabei reden koennen. */ - if (modus === "team") return MODERIERT.has(person.rolle); + if (modus === "team") return MODERIERT.has(person.rolle) ? null : "chat_nur_team"; /* „zu": nur die zwei, die die Sendung fuehren. Sonst saesse der Saal vor einem Fenster, in dem gar nichts mehr passiert. */ - return HOST_ROLLEN.has(person.rolle); + return HOST_ROLLEN.has(person.rolle) ? null : "chat_zu"; +} + +function darfSchreiben(person, stand) { + return schreibGrund(person, stand) === null; } function melden(art, daten) { @@ -481,7 +549,20 @@ reaktionRouter.post("/workspace/api/reaktion/dabei", express.json({ limit: "2kb" (req, res) => { try { const s = sendung(); - if (s.stand === "zu") return res.status(409).json({ fehler: "geschlossen" }); + if (s.stand === "zu") return res.status(409).json({ fehler: "saal_zu" }); + + /* WER DRAUSSEN IST, KOMMT NICHT WIEDER HEREIN. Sonst laedt die + Seite neu und ist wieder dabei -- und die Massnahme waere + eine Empfehlung. */ + const raus = massnahmeFuer(req.person.id); + if (raus && raus.art === "raus") { + return res.status(403).json({ fehler: "aus_der_sendung" }); + } + try { + if (treffSperreLesen(db(), req.person.id)) { + return res.status(403).json({ fehler: "treff_massnahme" }); + } + } catch { /* gibt es die Tabelle nicht, gilt sie auch nicht */ } const schon = db().prepare("SELECT * FROM reaktion_dabei WHERE person_id = ?") .get(req.person.id); @@ -814,11 +895,154 @@ reaktionRouter.post("/workspace/api/reaktion/weiter", nurHost, express.json({ li JEDER SENDER DARF DAS FUER SICH -- Host wie Gast. Ein Gast, der sein eigenes Bild nicht abschalten kann, muesste den Host darum bitten. */ +/* ---------- Moderation im Live-Chat ------------------------------------- + Filipe: „sachen wie stummschaltungen, sperrungen, meldunge und + alles moegliche im chat machen koennen falls sich jemand nicht + benimmt." + + WAS HIER PASSIERT, GILT NUR FUER DIESE SENDUNG. Was laenger gelten + soll, gehoert in die Massnahmen des Treffs -- die haben eine + Begruendungspflicht, eine Frist in Tagen und stehen in der + Geschichte. Ein Fuenf-Minuten-Aerger gehoert dort nicht hinein. */ + +reaktionRouter.post("/workspace/api/reaktion/massnahme", express.json({ limit: "4kb" }), + (req, res) => { + try { + if (!MODERIERT.has(req.person.rolle)) { + return res.status(403).json({ fehler: "nur_moderation" }); + } + const art = String(req.body?.art ?? ""); + if (!MASSNAHMEN.includes(art)) return res.status(400).json({ fehler: "massnahme_unbekannt" }); + + const zielId = Number(req.body?.person_id) || 0; + const ziel = db().prepare("SELECT id, name, rolle FROM personen WHERE id = ?").get(zielId); + if (!ziel) return res.status(404).json({ fehler: "nicht_gefunden" }); + + /* NICHT GEGEN DAS TEAM. Dieselbe Regel wie im Treff: Wer einen + anderen Modi stummschalten koennte, haette einen Weg, die + Moderation selbst auszuschalten -- mitten in der Sendung und + an „Personen & Zugaenge" vorbei, wo diese Entscheidung + hingehoert. */ + if (MODERIERT.has(ziel.rolle) || HOST_ROLLEN.has(ziel.rolle)) { + return res.status(403).json({ fehler: "nicht_gegen_team" }); + } + + let bis = null; + if (art === "stumm") { + const min = Number(req.body?.minuten); + if (!Number.isFinite(min) || min <= 0 || min > STUMM_MAX_MINUTEN) { + return res.status(400).json({ fehler: "dauer_unklar" }); + } + bis = new Date(Date.now() + min * 60_000).toISOString(); + } + const grund = String(req.body?.grund ?? "").trim().slice(0, 200); + + db().prepare(`INSERT INTO reaktion_massnahmen (person_id, art, bis, grund, von, erstellt) + VALUES (?,?,?,?,?,?) + ON CONFLICT(person_id) DO UPDATE SET + art = excluded.art, bis = excluded.bis, grund = excluded.grund, + von = excluded.von, erstellt = excluded.erstellt`) + .run(ziel.id, art, bis, grund, req.person.id, jetzt()); + + /* „raus" heisst raus: aus dem Saal, aus der Gaesteliste. Sonst + bliebe die Kamera im Bild, waehrend die Person nicht mehr + dabei sein soll. */ + if (art === "raus") { + db().prepare("DELETE FROM reaktion_dabei WHERE person_id = ?").run(ziel.id); + db().prepare("DELETE FROM reaktion_gaeste WHERE person_id = ?").run(ziel.id); + } + + protokolliere(req.person.id, "reaktion_massnahme", + `${art} gegen ${ziel.name}${bis ? ` bis ${bis.slice(11, 16)}` : ""}`, echteIp(req)); + + /* DIE PERSON ERFAEHRT ES SELBST -- und zwar sofort. Wer nicht + mehr schreiben kann und nicht weiss warum, sucht den Fehler + bei der Technik. */ + stromAn([ziel.id], "reaktion", { was: "massnahme", lage: null }); + melden("reaktion", { was: "moderation", lage: oeffentlich(lage(req.person)) }); + res.json(lage(req.person)); + } catch (fehler) { + console.error("[reaktion] Massnahme:", fehler?.message); + res.status(503).json({ fehler: "nicht_verfuegbar" }); + } + }); + +reaktionRouter.delete("/workspace/api/reaktion/massnahme/:id(\\d+)", (req, res) => { + try { + if (!MODERIERT.has(req.person.rolle)) { + return res.status(403).json({ fehler: "nur_moderation" }); + } + const n = db().prepare("DELETE FROM reaktion_massnahmen WHERE person_id = ?") + .run(Number(req.params.id)); + if (!n.changes) return res.status(404).json({ fehler: "nicht_gefunden" }); + stromAn([Number(req.params.id)], "reaktion", { was: "massnahme", lage: null }); + melden("reaktion", { was: "moderation", lage: oeffentlich(lage(req.person)) }); + res.json(lage(req.person)); + } catch (fehler) { + console.error("[reaktion] Massnahme weg:", fehler?.message); + res.status(503).json({ fehler: "nicht_verfuegbar" }); + } +}); + +/** Einen Beitrag melden. Darf jeder -- auch die Community. + * + * DAS IST DER PUNKT DARAN. Die Moderation sieht nicht alles; wer + * mitliest, sieht mehr. Ohne einen Weg fuer die Zusehenden bliebe + * Moderation auf das beschraenkt, was zufaellig jemand vom Team + * gerade gelesen hat. */ +reaktionRouter.post("/workspace/api/reaktion/chat/:id(\\d+)/melden", + express.json({ limit: "4kb" }), (req, res) => { + try { + const id = Number(req.params.id); + const b = db().prepare("SELECT * FROM reaktion_chat WHERE id = ?").get(id); + if (!b) return res.status(404).json({ fehler: "nicht_gefunden" }); + if (b.person_id === req.person.id) { + return res.status(400).json({ fehler: "eigener_beitrag" }); + } + const grund = String(req.body?.grund ?? "").trim().slice(0, 200); + try { + db().prepare(`INSERT INTO reaktion_meldungen (beitrag_id, melder_id, grund, erstellt) + VALUES (?,?,?,?)`).run(id, req.person.id, grund, jetzt()); + } catch { + /* Schon gemeldet. Kein Fehler -- wer zweimal tippt, meint + dasselbe, und eine Fehlermeldung dafuer waere eine + Bestrafung fuers Mithelfen. */ + return res.json({ ok: true, schon: true }); + } + /* Die Moderation erfaehrt es sofort. */ + stromAn( + db().prepare(`SELECT id FROM personen WHERE rolle IN ('admin','hand','linke','modi') + AND aktiv = 1`).all().map((p) => p.id), + "reaktion", { was: "meldung" }, + ); + res.status(201).json({ ok: true }); + } catch (fehler) { + console.error("[reaktion] Melden:", fehler?.message); + res.status(503).json({ fehler: "nicht_verfuegbar" }); + } + }); + +reaktionRouter.post("/workspace/api/reaktion/meldung/:id(\\d+)/erledigt", (req, res) => { + try { + if (!MODERIERT.has(req.person.rolle)) { + return res.status(403).json({ fehler: "nur_moderation" }); + } + const n = db().prepare(`UPDATE reaktion_meldungen SET erledigt_am = ?, erledigt_von = ? + WHERE id = ? AND erledigt_am IS NULL`) + .run(jetzt(), req.person.id, Number(req.params.id)); + if (!n.changes) return res.status(409).json({ fehler: "nicht_offen" }); + res.json(lage(req.person)); + } catch (fehler) { + console.error("[reaktion] Meldung erledigt:", fehler?.message); + res.status(503).json({ fehler: "nicht_verfuegbar" }); + } +}); + reaktionRouter.post("/workspace/api/reaktion/senden", express.json({ limit: "2kb" }), (req, res) => { try { const s = sendung(); - if (s.stand === "zu") return res.status(409).json({ fehler: "geschlossen" }); + if (s.stand === "zu") return res.status(409).json({ fehler: "saal_zu" }); const istHost = HOST_ROLLEN.has(req.person.rolle); const binGast = !!db().prepare("SELECT 1 FROM reaktion_gaeste WHERE person_id = ?") @@ -910,15 +1134,12 @@ reaktionRouter.post("/workspace/api/reaktion/chat", express.json({ limit: "8kb" (req, res) => { try { const s = sendung(); - if (s.stand === "zu") return res.status(409).json({ fehler: "geschlossen" }); + if (s.stand === "zu") return res.status(409).json({ fehler: "saal_zu" }); /* DIE STUFE GILT HIER, NICHT NUR IN DER OBERFLAECHE. Ein Feld, das ausgegraut ist, haelt niemanden auf, der die Schnittstelle kennt. */ - if (!darfSchreiben(req.person, s)) { - return res.status(403).json({ - fehler: s.chat_modus === "team" ? "chat_nur_team" : "chat_zu", - }); - } + const grund = schreibGrund(req.person, s); + if (grund) return res.status(403).json({ fehler: grund }); const text = String(req.body?.text ?? "").trim().slice(0, TEXT_MAX); if (!text) return res.status(400).json({ fehler: "leer" }); if (zuSchnell(req.person.id)) return res.status(429).json({ fehler: "zu_schnell" }); @@ -1032,7 +1253,19 @@ export function videoKennung(roh) { * ohne dass es jemandem auffaellt, weil die Oberflaeche sie ja * nicht anzeigt. */ function oeffentlich(l) { - const { zuschauer, ich, liste, ...rest } = l; + /* WAS IM RUNDRUF NICHTS ZU SUCHEN HAT. + `zuschauer`, `ich` und `liste` gehoeren nur dem Empfaenger. + + `meldungen` und `massnahmen` KAMEN AM 28.09. DAZU -- sie sind + die heikelsten Felder des ganzen Standes: In `meldungen` stehen + der gemeldete Text, der Name des Gemeldeten UND der Name dessen, + der gemeldet hat; in `massnahmen` steht, wer stumm ist und + warum. Der Rundruf wird aus dem Stand DESSEN gebaut, der gerade + etwas getan hat -- und wenn das die Moderation war, gingen beide + Listen an jeden im Saal. Wer meldet, muss sich darauf verlassen + koennen, dass das niemand sieht; sonst meldet beim naechsten Mal + keiner mehr. */ + const { zuschauer, ich, liste, meldungen, massnahmen, ...rest } = l; return rest; } diff --git a/workspace/anruf-probe.html b/workspace/anruf-probe.html index 779b1cde..9cf8b1ac 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`. --> - - + + - +