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