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