Filipe: „ich will dass du auch perfektionierst dass ich einen banner durchlaufen kann, oder erscheinen lassen kann mit werbung drauf ... und ich will auch das dogfather logo oben rechts links unten rechts links genau wie oben unten mittig, so dass ich es wegmachen und hinzufuegen kann." DAS BAND Zwei Arten: „Laufband" zieht die Botschaften von rechts nach links, „Wechsel" blendet sie nacheinander ein. Ein Rabattcode gehoert in den Wechsel -- wer „DOGI10" lesen will, waehrend es wandert, hat es gesehen und nicht behalten. Die Dauer ist einstellbar (8-40 s). Das Band ist Glas, kein Balken: ein Verlauf, unten dicht genug, dass jede Schrift traegt, oben offen. Man sieht das Video weiter. Das Laufband traegt die Folge ZWEIMAL. Wandert es um 50 % seiner Breite, steht die zweite dort, wo die erste war, und der Sprung zurueck ist unsichtbar. Ohne sie entstuende am Ende jedes Durchlaufs eine leere Flaeche -- das sieht aus, als sei die Einblendung abgestuerzt. Wer weniger Bewegung eingestellt hat, bekommt den Wechsel statt des Laufbands -- nicht „aus". Die Werbung soll auch dann wirken. DER RABATTCODE Ein Wort in Grossbuchstaben mit einer Ziffer darin bekommt eine eigene Flaeche in Bernstein: „DOGI10" ja, „VANVAN" nicht. Er ist der einzige Teil der Botschaft, den jemand ABSCHREIBEN soll. Der Text wird NIE als HTML eingesetzt -- die Kennzeichnung baut echte Elemente. Sonst waere ein Eingabefeld im Regiepult ein Weg, fremdes Markup in den Stream zu bringen. DIE ZWEI BILDER Der Husky und der Haekelhase aus der Zusammenarbeit mit VanVan, jeweils an einem von sechs Plaetzen: oben und unten, je links, mittig, rechts -- oder aus. Links und rechts mittig gibt es mit Absicht nicht: dort liegen bei jedem Videodienst die Bedienelemente. Zwei Bilder auf denselben Platz werden abgelehnt (400 ecke_belegt) -- sie laegen uebereinander, und man saehe von beiden nichts. Wer unten steht, weicht dem Band aus, und die Spendentafel rueckt hoch. Beides im Stilblatt ueber `:has`, nicht im Programm: Das Band kennt die Tafel nicht und die Tafel kennt das Band nicht. Der Husky ist schwarz und laege auf dunklem Videobild als Silhouette in der Nacht. Zwei weiche helle Schatten legen eine Kontur darum -- dieselbe Loesung, die jeder Sender fuer sein Wasserzeichen nimmt. IM STREAM, NICHT NUR IM SAAL Beides laeuft in der Buehnenquelle mit, die OBS abfilmt, mit groesserer Schrift (24 statt 18 px) -- ein Stream wird auf einem Handy gesehen, oft in einem Viertel des Bildes. Gemessen: die OFFENE Quelle zieht eine Umschaltung nach, ohne dass die Szene neu geladen werden muss. EINE QUELLE, NICHT DREI `werbungStand()` steht in `reaktion-tabellen.js` und wird von Saal, Regie und Buehne aufgerufen. Erst standen dort drei Abschriften derselben Abfrage; beim Entfernen der Datenbank-Kennung habe ich eine davon geaendert, und die anderen lieferten sie weiter. WAS DIE PRUEFUNGEN DABEI GEFUNDEN HABEN - pruef-buehne: Die Botschaften trugen ihre Datenbank-Kennung in die OBS-Auskunft, die nur mit einem Schluessel geschuetzt ist. Die Feldliste geht jetzt zwei Ebenen tief -- eine abschliessende Liste, die nur die oberste kennt, laesst sich umgehen, indem man ein Feld in ein vorhandenes Objekt legt. - pruef-reaktion: Acht Fehlerwoerter ohne deutschen Satz. Nachgetragen im Hausstil: was nicht geht UND was stattdessen. - pruef-struktur: Die Suche nach totem CSS las `buehne.html` und `tafel.html` nicht -- eine Ausnahme, die fuer eine ganz andere Frage gemacht war (Startbildschirm-Symbol). Sie haette verlangt, `.obs-buehne` zu loeschen, also die Regel, die den Stream traegt. - pruef-tippziele: Erkannte als Anhebung nur `min-height`/`min-width`. Ein `height: 44px` im Fingerblock hebt genauso -- Fehlalarm, und ein Fehlalarm an einer Pruefung, die nach jeder Aenderung laeuft, wird weggeklickt. Zwei Gegenproben dazu. - mess-reaktion: Die Regieleiste am Handy wurde gegen die feste Zahl SIEBEN geprueft und meldete „zeigt nur 8 von 7". Sie zaehlt jetzt selbst. - mess-buehne: Die Konsolenmeldung zeigte dauerhaft vier Fehlschlaege, alle von den Pruefungen selbst bestellt. Jetzt eng gefiltert, benannt, mit Gegenprobe -- und ein UNERWARTETER Fehlschlag in der Quelle, die in den Stream geht, faellt ab sofort durch. Gemessen: mess-reaktion 0, mess-buehne 0, pruef-reaktion 383/0, pruef-buehne 38/0, pruef-tippziele 13/0, pruef-struktur 44/0, pruef-css-klassen 0, pruef-haus-trennung 100/0, pruef-haus-seiten 38/0. Co-Authored-By: Claude Opus 5 <[email protected]>
1830 lines
78 KiB
JavaScript
1830 lines
78 KiB
JavaScript
/* =====================================================================
|
|
DIE REACTION — die Wege (28.09.2026)
|
|
|
|
Der Ablauf aus Filipes Notiz, von links nach rechts:
|
|
|
|
Kachel sichtbar -> geschlossen -> Vorbereitung ->
|
|
Wartebereich/Chat -> Countdown -> LIVE ->
|
|
Reaction + Gaeste + Chat + PayPal -> Ende
|
|
|
|
Das sind drei Staende und nicht sieben. „Wartebereich" und
|
|
„Countdown" sind keine eigenen Zustaende, sondern das, was
|
|
„vorbereitung" auf dem Bildschirm TUT: Der Chat ist offen, der
|
|
Countdown laeuft, das Vorschaubild steht. Drei Staende, die sich
|
|
gegenseitig ausschliessen, sind pruefbar; sieben, von denen sich
|
|
vier ueberlappen, sind es nicht.
|
|
|
|
---------------------------------------------------------------------
|
|
WER WAS DARF
|
|
|
|
Host = DogFather. Er stellt ein, startet, beendet, holt und
|
|
entfernt Gaeste, schaltet stumm, bestimmt die Anordnung.
|
|
Moderation= DogFather, rechte und linke Hand, Modis. Sie koennen
|
|
Beitraege aus dem Live-Chat nehmen.
|
|
Gast = wen der Host holt. Jede Rolle des Hauses.
|
|
Zuschauer = jeder im Team-Dogi-Haus, auch die Community.
|
|
|
|
Die Reaction gibt es NUR im Team-Dogi-Haus. Auf der Agenturadresse
|
|
antwortet jeder Weg mit 404 -- nicht mit 403, denn ein „verboten"
|
|
verraet, dass es hier etwas gibt.
|
|
|
|
---------------------------------------------------------------------
|
|
DAS VIDEO LAEUFT NICHT UEBER DIESEN SERVER
|
|
|
|
Uebertragen wird nur der Spielstand: Kennung, laeuft/pausiert,
|
|
Sekunde. Jeder Browser laedt das Video selbst von YouTube. Die
|
|
Begruendung steht in reaktion-tabellen.js.
|
|
|
|
Aufrufen laesst sich alles ohne Sonderfall: Wer zusieht, meldet
|
|
sich alle 30 Sekunden mit `/dabei`. Wer sich zwei Minuten nicht
|
|
meldet, faellt heraus -- ein sauberes „Tschuess" gibt es im Netz
|
|
nicht, ein Handy geht einfach aus.
|
|
===================================================================== */
|
|
import express from "express";
|
|
import { db, sitzungLesen, protokolliere, echteIp } from "./workspace.js";
|
|
import { stromAn } from "./workspace-chat.js";
|
|
import { buehneMelden } from "./workspace-buehne.js";
|
|
import { einstellung } from "./workspace.js";
|
|
import { mitZugangsdaten, wegeAusfaechern } from "./workspace-turn.js";
|
|
import {
|
|
SICHT_PLAETZE, GAESTE_MAX, TEXT_MAX, SCHREIB_BREMSE, STAENDE, LAYOUTS, TEMPI,
|
|
KAMERA_GROESSEN, KAMERA_ECKEN,
|
|
MELDUNG_TAGE,
|
|
VERLAUF_TAGE, LISTE_MAX, CHAT_MODI, STUMM_MAX_MINUTEN, MASSNAHMEN,
|
|
BANNER_ARTEN,
|
|
BANNER_TEMPI,
|
|
BANNER_MAX,
|
|
BANNER_TEXT_MAX,
|
|
BANNER_UNTER_MAX,
|
|
BANNER_BILDER,
|
|
ECKEN_PLAETZE,
|
|
ECKEN_GROESSEN,
|
|
werbungStand,
|
|
} 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();
|
|
|
|
/** Wer die Sendung fuehrt: DogFather und die rechte Hand.
|
|
*
|
|
* Filipe am 28.09.2026: „die rolle dogfather und vanvan sollen alles
|
|
* sehen und bereit machen auch vorher. nur die zwei sollen alles
|
|
* sehen, vorbereiten und einschakten koennen."
|
|
*
|
|
* Das ist eine AUFGABE und kein Rang -- wer die Sendung fuehrt,
|
|
* bedient die Technik. Beide duerfen dasselbe; es gibt hier keinen
|
|
* Ersten und keinen Zweiten. */
|
|
const HOST_ROLLEN = new Set(["admin", "hand"]);
|
|
|
|
/** Wer Beitraege aus dem Chat nehmen darf.
|
|
*
|
|
* BLEIBT GROESSER ALS HOST_ROLLEN, und das ist Absicht: Einen
|
|
* Beitrag wegzunehmen ist weder „alles sehen" noch „vorbereiten"
|
|
* noch „einschalten" -- es ist dasselbe, was die Modis im Treff
|
|
* ohnehin tun. Wer moderiert, bekommt deshalb aber AUCH NICHT die
|
|
* Liste derer, die zusehen; die beiden Dinge hingen vorher
|
|
* zusammen, ohne dass es einen Grund dafuer gab. */
|
|
const MODERIERT = new Set(["admin", "hand", "linke", "modi"]);
|
|
|
|
export const REAKTION_HOST_ROLLEN = HOST_ROLLEN;
|
|
|
|
const jetzt = () => new Date().toISOString();
|
|
|
|
/* ---------- Schranken --------------------------------------------------- */
|
|
|
|
function angemeldet(req, res, next) {
|
|
const person = sitzungLesen(req);
|
|
if (!person) return res.status(401).json({ fehler: "nicht_angemeldet" });
|
|
req.person = person;
|
|
next();
|
|
}
|
|
|
|
/** Nicht das andere Haus.
|
|
*
|
|
* Gefragt wird „ist es die Agentur?" und nicht „ist es crew?" --
|
|
* auf einer Pruefadresse hat niemand ein Haus, und eine Schranke,
|
|
* die das mit dem fremden Haus verwechselt, macht die Seite dort
|
|
* still tot. Genau dieser Fehler ist am 27.09. beim Notizblock
|
|
* passiert und von pruef-handy gefunden worden. */
|
|
function nurTeamDogi(req, res, next) {
|
|
if (req.person?.haus === "agentur") {
|
|
return res.status(404).json({ fehler: "nicht_gefunden" });
|
|
}
|
|
next();
|
|
}
|
|
|
|
function nurHost(req, res, next) {
|
|
if (!HOST_ROLLEN.has(req.person?.rolle)) {
|
|
return res.status(403).json({ fehler: "nur_host" });
|
|
}
|
|
next();
|
|
}
|
|
|
|
/* Schutz gegen Anfragen von fremden Seiten -- wortgleich zu den
|
|
uebrigen Modulen. */
|
|
function gleicheHerkunft(req, res, next) {
|
|
const herkunft = req.get("origin");
|
|
if (!herkunft) return next();
|
|
let erlaubt;
|
|
try { erlaubt = new URL(herkunft).host === req.get("host"); } catch { erlaubt = false; }
|
|
if (!erlaubt) return res.status(403).json({ fehler: "fremde_herkunft" });
|
|
next();
|
|
}
|
|
|
|
/* ---------- Die Schreibbremse -------------------------------------------
|
|
Ein Live-Chat ist der eine Ort im Haus, an dem jemand in zehn
|
|
Sekunden dreissig Zeilen schreiben kann -- aus Begeisterung oder
|
|
aus Aerger. Die Bremse zaehlt je Person und Minute. */
|
|
const bremse = new Map();
|
|
function zuSchnell(personId) {
|
|
const minute = Math.floor(Date.now() / 60_000);
|
|
const s = bremse.get(personId);
|
|
if (!s || s.minute !== minute) { bremse.set(personId, { minute, n: 1 }); return false; }
|
|
s.n += 1;
|
|
return s.n > SCHREIB_BREMSE;
|
|
}
|
|
|
|
/* ---------- Hilfen ------------------------------------------------------ */
|
|
|
|
const sendung = () => db().prepare("SELECT * FROM reaktion WHERE id = 1").get();
|
|
|
|
/** Wie heisst das Video? — einmal bei YouTube nachfragen.
|
|
*
|
|
* UEBER oEmbed, NICHT UEBER DIE DATEN-API. oEmbed braucht keinen
|
|
* Schluessel, kein Konto und kein Kontingent; es ist genau dafuer
|
|
* da, dass eine fremde Seite einen Titel anzeigen darf.
|
|
*
|
|
* DREI AUSGAENGE, NICHT ZWEI. Geht der Abruf schief -- kein Netz,
|
|
* YouTube langsam, Video nicht oeffentlich --, kommt ein LEERER
|
|
* Titel zurueck, und die Oberflaeche zeigt dann die Kennung. Sie
|
|
* erfindet keinen Namen und behauptet auch nicht, es gaebe keinen.
|
|
* „Konnte nicht nachsehen" ist eine eigene Antwort.
|
|
*
|
|
* VIER SEKUNDEN UND DANN SCHLUSS. Das Anhaengen eines Videos darf
|
|
* nicht daran haengen, ob ein fremder Dienst gerade Zeit hat. Ohne
|
|
* Frist stuende Filipe vor einem Knopf, der sich dreht, und wuesste
|
|
* nicht, ob er noch einmal druecken soll. */
|
|
async function videoTitel(kennung) {
|
|
if (!kennung) return "";
|
|
try {
|
|
const adresse = "https://www.youtube.com/oembed?format=json&url="
|
|
+ encodeURIComponent("https://www.youtube.com/watch?v=" + kennung);
|
|
const a = await fetch(adresse, { signal: AbortSignal.timeout(4000) });
|
|
if (!a.ok) return "";
|
|
const j = await a.json();
|
|
return String(j?.title || "").trim().slice(0, 160);
|
|
} catch (fehler) {
|
|
console.warn("[reaktion] Titel von YouTube:", fehler?.message);
|
|
return "";
|
|
}
|
|
}
|
|
|
|
/** Die Warteschlange, in ihrer Reihenfolge. */
|
|
function listeHolen() {
|
|
return db().prepare("SELECT id, video, titel, platz FROM reaktion_liste"
|
|
+ " ORDER BY platz, id").all();
|
|
}
|
|
|
|
/** Das naechste Video aus der Liste ins Bild holen.
|
|
*
|
|
* EINE STELLE FUER ZWEI KNOEPFE. „Naechstes" und „Jetzt spielen"
|
|
* tun dasselbe, nur mit unterschiedlicher Zeile -- zwei Kopien
|
|
* desselben Ablaufs waeren zwei Stellen, an denen man spaeter das
|
|
* Zuruecksetzen der Sekunde vergessen kann. */
|
|
function weiterZu(zeile, personId) {
|
|
db().prepare("UPDATE reaktion SET video = ?, video_titel = ?, laeuft = 1,"
|
|
+ " sekunde = 0, geaendert = ? WHERE id = 1")
|
|
.run(zeile.video, zeile.titel, jetzt());
|
|
db().prepare("DELETE FROM reaktion_liste WHERE id = ?").run(zeile.id);
|
|
protokolliere("reaktion_weiter", { personId, detail: zeile.video });
|
|
const s = sendung();
|
|
/* ZWEI MELDUNGEN, UND BEIDE WERDEN GEBRAUCHT.
|
|
|
|
„video" bringt jeden Player auf das neue Stueck -- das ist die
|
|
eilige. „reaktion" bringt die Liste und den Titel nach; sie
|
|
betrifft nur die Anzeige und darf einen Wimpernschlag spaeter
|
|
kommen. Nur die erste zu schicken hiesse: Das Video wechselt,
|
|
und im Pult steht weiter der alte Name. */
|
|
melden("reaktion", { was: "video", video: s.video, laeuft: true, sekunde: 0, gesendet: Date.now() });
|
|
return s;
|
|
}
|
|
|
|
function gaesteListe() {
|
|
return db().prepare(`
|
|
SELECT g.person_id AS id, g.stumm, g.platz, p.name, p.rolle
|
|
FROM reaktion_gaeste g JOIN personen p ON p.id = g.person_id
|
|
ORDER BY g.platz`).all();
|
|
}
|
|
|
|
/** Wer gerade dabei ist -- und ob noch ein Kameraplatz frei war.
|
|
*
|
|
* ZWEI MINUTEN OHNE LEBENSZEICHEN HEISST WEG. Ein Browser, der
|
|
* zugeklappt wird, schickt kein „ich gehe". Ohne diese Grenze
|
|
* waechst die Zuschauerzahl den ganzen Abend und stimmt nie. */
|
|
function dabeiListe() {
|
|
const grenze = new Date(Date.now() - 120_000).toISOString();
|
|
db().prepare("DELETE FROM reaktion_dabei WHERE gesehen < ?").run(grenze);
|
|
return db().prepare(`
|
|
SELECT d.person_id AS id, d.sicht, d.seit, d.kamera_aus, d.mikro_aus, d.bildschirm,
|
|
p.name, p.rolle
|
|
FROM reaktion_dabei d JOIN personen p ON p.id = d.person_id
|
|
ORDER BY d.seit`).all();
|
|
}
|
|
|
|
/** Alle, die von einer Aenderung erfahren sollen. */
|
|
function empfaenger() {
|
|
const ids = new Set(dabeiListe().map((d) => d.id));
|
|
for (const g of gaesteListe()) ids.add(g.id);
|
|
const s = sendung();
|
|
if (s?.host_id) ids.add(s.host_id);
|
|
return [...ids];
|
|
}
|
|
|
|
/** Was jeder sehen darf -- egal ob Zuschauer, Gast oder Host. */
|
|
function lage(person) {
|
|
const s = sendung();
|
|
const gaeste = gaesteListe();
|
|
const dabei = dabeiListe();
|
|
const istHost = HOST_ROLLEN.has(person.rolle);
|
|
const binGast = gaeste.some((g) => g.id === person.id);
|
|
return {
|
|
stand: s.stand,
|
|
titel: s.titel,
|
|
video: s.video,
|
|
video_titel: s.video_titel || "",
|
|
vorschau: s.vorschau,
|
|
beginnt_am: s.beginnt_am,
|
|
layout: s.layout,
|
|
laeuft: !!s.laeuft,
|
|
sekunde: s.sekunde,
|
|
/* DIE GESCHWINDIGKEIT GEHT AN ALLE. Sie ist kein Regieknopf,
|
|
sondern Teil dessen, was gerade laeuft -- wer sie nicht
|
|
bekaeme, saehe dasselbe Video in einem anderen Tempo und waere
|
|
nach fuenf Minuten zwei Minuten woanders. */
|
|
tempo: Number(s.tempo) || 1,
|
|
tempi: TEMPI,
|
|
/* GROESSE UND ECKE DER KAMERAS GEHEN AN ALLE. Sie sind Teil des
|
|
Bildes, nicht der Regie -- wer sie nicht bekaeme, saehe die
|
|
Fenster woanders und in einer anderen Groesse als der, der
|
|
darueber redet. */
|
|
kamera_gross: Number(s.kamera_gross) || 1,
|
|
kamera_ecke: s.kamera_ecke || "ur",
|
|
kamera_groessen: KAMERA_GROESSEN,
|
|
kamera_ecken: KAMERA_ECKEN,
|
|
gestartet_am: s.gestartet_am,
|
|
/* WER GERADE SEIN BILD AUS HAT -- fuer den Host und fuer jeden
|
|
Gast. Ohne diese Auskunft waere ein abgeschaltetes Bild von
|
|
einem kaputten nicht zu unterscheiden: beides ein schwarzes
|
|
Fenster. Mit ihr steht „Kamera aus" darin, und niemand fragt
|
|
nach. */
|
|
host: s.host_id
|
|
? (() => {
|
|
const p = db().prepare("SELECT id, name, rolle FROM personen WHERE id = ?").get(s.host_id);
|
|
if (!p) return null;
|
|
const z = dabei.find((d) => d.id === p.id);
|
|
return {
|
|
...p,
|
|
kamera_aus: !!z?.kamera_aus,
|
|
mikro_aus: !!z?.mikro_aus,
|
|
bildschirm: !!z?.bildschirm,
|
|
};
|
|
})()
|
|
: null,
|
|
gaeste: gaeste.map((g) => ({
|
|
id: g.id, name: g.name, rolle: g.rolle, stumm: !!g.stumm, platz: g.platz,
|
|
kamera_aus: !!(dabei.find((d) => d.id === g.id)?.kamera_aus),
|
|
mikro_aus: !!(dabei.find((d) => d.id === g.id)?.mikro_aus),
|
|
/* WER TEILT, MUSS ERKENNBAR SEIN. Ohne diese Auskunft waere
|
|
ein geteilter Bildschirm ein Kamerafenster, in dem ploetzlich
|
|
ein Schreibtisch steht -- und das Fenster wuerde ihn
|
|
beschneiden, weil eine Kamera hochkant zugeschnitten wird
|
|
und ein Bildschirm nicht. */
|
|
bildschirm: !!(dabei.find((d) => d.id === g.id)?.bildschirm),
|
|
})),
|
|
chat_modus: s.chat_modus || "offen",
|
|
chat_modi: CHAT_MODI,
|
|
/* ==== DIE WERBUNG SIEHT JEDER =============================
|
|
Sie ist das Einzige auf dieser Seite, das ausdruecklich fuer
|
|
alle gedacht ist -- eine Einblendung, die nur der Host sieht,
|
|
waere ein Zettel am eigenen Bildschirm.
|
|
|
|
ZWEI LISTEN, UND DAS MIT GRUND: `band` sind die Botschaften,
|
|
die gerade laufen -- die bekommt jeder. `banner_alle` ist die
|
|
Verwaltung samt abgeschalteter Eintraege; die geht nur an den,
|
|
der sie bearbeiten darf. Wer einen Entwurf abschaltet, will
|
|
ihn nicht trotzdem im Stream haben. */
|
|
werbung: werbungStand(db()),
|
|
banner_alle: HOST_ROLLEN.has(person.rolle)
|
|
? db().prepare(`SELECT id, text, unterzeile, bild, aktiv, reihung
|
|
FROM reaktion_banner ORDER BY reihung, id`).all()
|
|
.map((z) => ({ ...z, aktiv: !!z.aktiv }))
|
|
: undefined,
|
|
banner_arten: BANNER_ARTEN,
|
|
banner_tempi: BANNER_TEMPI,
|
|
banner_bilder: BANNER_BILDER,
|
|
ecken_plaetze: ECKEN_PLAETZE,
|
|
ecken_groessen: ECKEN_GROESSEN,
|
|
/* 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 ==========
|
|
|
|
Namen der Zusehenden bekommt, wer auf der BUEHNE steht (Host,
|
|
Gaeste) oder moderiert. Ein Zuschauer bekommt sie nicht: Er
|
|
soll nicht ablesen koennen, wer sonst noch zusieht -- im Live
|
|
sieht man einander am Chat, und das reicht.
|
|
|
|
DIE GAESTE STEHEN HIER NICHT AUS HOEFLICHKEIT. Wer sendet,
|
|
muss wissen, wen er anrufen soll: Zuschauer rufen grundsaetzlich
|
|
nie an (sonst rufen zwei gleichzeitig an, und beide
|
|
scheitern -- „glare"). Ohne die Liste kaeme das Bild eines
|
|
Gastes deshalb bei keinem Zuschauer an. Zwei fuer sich richtige
|
|
Regeln, die zusammen ein Loch ergeben.
|
|
|
|
DIE MODERATION STAND HIER AUCH -- SEIT DEM 28.09. NICHT MEHR.
|
|
Filipe: „nur die zwei sollen alles sehen." Wer einen Beitrag
|
|
wegnehmen darf, muss deshalb nicht wissen, wer im Saal sitzt;
|
|
das war eine Vermischung zweier Dinge, die nichts miteinander
|
|
zu tun haben. Moderiert wird weiter -- im Chat sieht man
|
|
ohnehin, wer da ist. */
|
|
zuschauer: (istHost || binGast)
|
|
? dabei.map((d) => ({ id: d.id, name: d.name, rolle: d.rolle, sicht: !!d.sicht }))
|
|
: null,
|
|
ich: {
|
|
id: person.id,
|
|
name: person.name,
|
|
rolle: person.rolle,
|
|
host: istHost,
|
|
gast: binGast,
|
|
moderiert: MODERIERT.has(person.rolle),
|
|
sicht: !!(dabei.find((d) => d.id === person.id)?.sicht),
|
|
kamera_aus: !!(dabei.find((d) => d.id === person.id)?.kamera_aus),
|
|
bildschirm: !!(dabei.find((d) => d.id === person.id)?.bildschirm),
|
|
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;
|
|
})(),
|
|
},
|
|
/* ==== DAS ZWEITE VIDEO SIEHT NUR, WER SENDET ==================
|
|
|
|
Was als Naechstes kommt, darf jeder wissen (`naechstes`) --
|
|
das macht Lust. Was DANEBEN vorbereitet ist, nicht: Es ist die
|
|
Rueckhand fuer den Fall, dass etwas nicht laeuft, und die
|
|
verraet man nicht vorher. Dieselbe Linie wie bei der
|
|
Warteschlange. */
|
|
zweit: istHost
|
|
? (s.zweit_video
|
|
? { video: s.zweit_video, titel: s.zweit_titel || "", sekunde: s.zweit_sekunde || 0 }
|
|
: null)
|
|
: null,
|
|
/* DIE WARTESCHLANGE SEHEN NUR DIE ZWEI. Sie ist Teil der
|
|
Vorbereitung, und Filipe hat dazu gesagt: „nur die zwei sollen
|
|
alles sehen, vorbereiten und einschakten koennen." */
|
|
liste: istHost ? listeHolen() : null,
|
|
/* WAS ALS NAECHSTES KOMMT, DARF JEDER WISSEN -- und nur das.
|
|
Auf der Wartetafel steht damit etwas, das Lust macht, ohne den
|
|
ganzen Abend zu verraten. */
|
|
naechstes: (() => {
|
|
const n = db().prepare("SELECT titel, video FROM reaktion_liste"
|
|
+ " ORDER BY platz, id LIMIT 1").get();
|
|
return n ? (n.titel || n.video) : null;
|
|
})(),
|
|
liste_zahl: db().prepare("SELECT COUNT(*) AS n FROM reaktion_liste").get()?.n || 0,
|
|
liste_max: LISTE_MAX,
|
|
plaetze: SICHT_PLAETZE,
|
|
gaeste_max: GAESTE_MAX,
|
|
text_max: TEXT_MAX,
|
|
};
|
|
}
|
|
|
|
/** Darf diese Person gerade in den Live-Chat schreiben?
|
|
*
|
|
* EINE STELLE FUER ZWEI FRAGEN. Der Server muss es wissen, um
|
|
* abzulehnen; die Oberflaeche muss es wissen, um das Feld gar nicht
|
|
* erst anzubieten. Zwei Rechnungen waeren zwei Gelegenheiten, dass
|
|
* ein Feld da ist und mit 403 antwortet -- und das ist eine Falle,
|
|
* keine Bedienung. */
|
|
/** 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 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) ? 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) ? null : "chat_zu";
|
|
}
|
|
|
|
function darfSchreiben(person, stand) {
|
|
return schreibGrund(person, stand) === null;
|
|
}
|
|
|
|
function melden(art, daten) {
|
|
stromAn(empfaenger(), art, daten);
|
|
/* UND AN DIE BUEHNENQUELLEN. Sie haengen an keiner Person -- OBS
|
|
meldet sich mit einem Schluessel, nicht mit einem Zugang. Beide
|
|
Aufrufe stehen hier, an EINER Stelle: zwei Stellen, die je einen
|
|
machen, waeren die Gelegenheit, eine zu vergessen. */
|
|
if (art === "reaktion" || art === "video") buehneMelden(art, daten);
|
|
}
|
|
|
|
/* ======================================================================
|
|
DIE WEGE
|
|
====================================================================== */
|
|
reaktionRouter.use("/workspace/api/reaktion", angemeldet, nurTeamDogi, gleicheHerkunft);
|
|
|
|
/** Wie steht es? — der eine Aufruf, den jede Seite beim Laden macht. */
|
|
reaktionRouter.get("/workspace/api/reaktion", (req, res) => {
|
|
try {
|
|
res.json(lage(req.person));
|
|
} catch (fehler) {
|
|
console.error("[reaktion] Lage:", fehler?.message);
|
|
res.status(503).json({ fehler: "nicht_verfuegbar" });
|
|
}
|
|
});
|
|
|
|
/* ======================================================================
|
|
ALLES ZURUECKSETZEN
|
|
|
|
Filipe: „brauch ich auch noch einen button wo ich alles easy
|
|
zuruecksetzen kann."
|
|
|
|
---------------------------------------------------------------------
|
|
DER SCHREIBTISCH, NICHT DAS GEDAECHTNIS
|
|
|
|
Geleert wird, was zur naechsten Sendung gehoert: Titel, Video,
|
|
zweites Video, Vorschaubild, Beginn, Warteschlange, Gaesteliste --
|
|
und das Bild geht auf seine Vorgaben zurueck.
|
|
|
|
NICHT ANGETASTET wird, was Menschen getan haben: Spenden, die
|
|
Dogen der Leute, der Chatverlauf, die Massnahmen der Moderation.
|
|
Eine bezahlte Spende aus den Buechern zu nehmen waere eine
|
|
Faelschung; eine stillschweigend aufgehobene Sperre waere eine
|
|
Entscheidung, die niemand getroffen hat. „Alles" heisst hier
|
|
„alles, was ich selbst eingetragen habe".
|
|
|
|
NICHT WAEHREND EINER SENDUNG. Mitten im Live zurueckzusetzen
|
|
zieht allen das Video unter den Fuessen weg -- und zwar ohne dass
|
|
es rueckgaengig zu machen waere.
|
|
====================================================================== */
|
|
reaktionRouter.post("/workspace/api/reaktion/zuruecksetzen", nurHost, (req, res) => {
|
|
try {
|
|
const s = sendung();
|
|
if (s.stand === "live") return res.status(409).json({ fehler: "laeuft_noch" });
|
|
|
|
db().exec("BEGIN IMMEDIATE");
|
|
try {
|
|
/* DIE SPALTEN STEHEN EINZELN DA UND NICHT ALS „alles ausser".
|
|
Eine Liste von Ausnahmen waechst still mit jeder neuen
|
|
Spalte mit -- und dann loescht das Zuruecksetzen etwas, das
|
|
es nie loeschen sollte. Was hier fehlt, bleibt stehen, und
|
|
das ist die sichere Richtung. */
|
|
db().prepare(`UPDATE reaktion SET
|
|
stand = 'zu', titel = '', video = '', video_titel = '', vorschau = '',
|
|
beginnt_am = NULL, laeuft = 0, sekunde = 0, tempo = 1,
|
|
layout = 'kino', kamera_gross = 1, kamera_ecke = 'ur',
|
|
chat_modus = 'offen',
|
|
zweit_video = '', zweit_titel = '', zweit_sekunde = 0,
|
|
gestartet_am = NULL, stand_seit = ?, geaendert = ?
|
|
WHERE id = 1`).run(jetzt(), jetzt());
|
|
db().prepare("DELETE FROM reaktion_liste").run();
|
|
db().prepare("DELETE FROM reaktion_gaeste").run();
|
|
db().exec("COMMIT");
|
|
} catch (f) {
|
|
db().exec("ROLLBACK");
|
|
throw f;
|
|
}
|
|
|
|
protokolliere("reaktion_zurueckgesetzt", {
|
|
personId: req.person.id,
|
|
detail: "Pult geleert (Spenden, Dogen, Chat und Massnahmen unberuehrt)",
|
|
ip: echteIp(req),
|
|
});
|
|
const l = lage(req.person);
|
|
melden("reaktion", { was: "stand", lage: oeffentlich(l) });
|
|
res.json(l);
|
|
} catch (fehler) {
|
|
console.error("[reaktion] Zuruecksetzen:", fehler?.message);
|
|
res.status(503).json({ fehler: "nicht_verfuegbar" });
|
|
}
|
|
});
|
|
|
|
/** Der Host stellt ein: Titel, Video, Vorschaubild, Beginn. */
|
|
reaktionRouter.put("/workspace/api/reaktion", nurHost, express.json({ limit: "32kb" }),
|
|
async (req, res) => {
|
|
try {
|
|
const titel = String(req.body?.titel ?? "").trim().slice(0, 120);
|
|
const video = videoKennung(String(req.body?.video ?? ""));
|
|
const vorschau = String(req.body?.vorschau ?? "").trim().slice(0, 400);
|
|
const beginn = req.body?.beginnt_am ? String(req.body.beginnt_am).slice(0, 40) : null;
|
|
|
|
if (req.body?.video && !video) {
|
|
return res.status(400).json({ fehler: "video_unklar" });
|
|
}
|
|
/* EIN BEGINN IN DER VERGANGENHEIT IST KEIN BEGINN. Der Countdown
|
|
zaehlte sonst rueckwaerts ins Minus, und niemand koennte
|
|
sagen, ob das Absicht war. */
|
|
if (beginn && Number.isFinite(Date.parse(beginn)) && Date.parse(beginn) < Date.now() - 60_000) {
|
|
return res.status(400).json({ fehler: "beginn_vorbei" });
|
|
}
|
|
|
|
/* HOST_ID WIRD BEIM EINSTELLEN NUR GESETZT, WENN NOCH NIEMAND
|
|
DASTEHT (28.09.2026).
|
|
|
|
Vorher stand hier, wer zuletzt etwas eingestellt hat. Mit
|
|
zwei Leuten, die vorbereiten duerfen, ist das eine Falle:
|
|
VanVan richtet am Nachmittag alles ein, Filipe drueckt
|
|
abends auf Sendung -- und im Bild stuende VanVans Name,
|
|
waehrend Filipe redet.
|
|
|
|
Wer die Sendung FUEHRT, entscheidet sich beim Einschalten
|
|
(siehe /stand). Hier wird nur der leere Platz gefuellt, damit
|
|
die Ankuendigung „Als Naechstes" jemanden nennen kann. */
|
|
const bisher = sendung();
|
|
/* DEN NAMEN DES VIDEOS NUR HOLEN, WENN ES EIN ANDERES IST.
|
|
Sonst faende bei jedem „Uebernehmen" -- auch wenn nur der
|
|
Beginn verschoben wurde -- eine Abfrage bei YouTube statt,
|
|
und das Speichern haengt an einem fremden Dienst. */
|
|
const videoTitelNeu = (video && video !== bisher.video)
|
|
? await videoTitel(video)
|
|
: (video ? (bisher.video_titel || "") : "");
|
|
db().prepare(`UPDATE reaktion SET titel = ?, video = ?, video_titel = ?,
|
|
vorschau = ?, beginnt_am = ?, host_id = ?, geaendert = ? WHERE id = 1`)
|
|
.run(titel, video, videoTitelNeu, vorschau, beginn,
|
|
bisher.host_id || req.person.id, jetzt());
|
|
|
|
const l = lage(req.person);
|
|
melden("reaktion", { was: "eingestellt", lage: oeffentlich(l) });
|
|
res.json(l);
|
|
} catch (fehler) {
|
|
console.error("[reaktion] Einstellen:", fehler?.message);
|
|
res.status(503).json({ fehler: "nicht_verfuegbar" });
|
|
}
|
|
});
|
|
|
|
/** Der Stand wechselt: zu / vorbereitung / live. */
|
|
reaktionRouter.post("/workspace/api/reaktion/stand", nurHost, express.json({ limit: "4kb" }),
|
|
(req, res) => {
|
|
try {
|
|
const neu = String(req.body?.stand ?? "");
|
|
if (!STAENDE.includes(neu)) return res.status(400).json({ fehler: "stand_unbekannt" });
|
|
const s = sendung();
|
|
|
|
/* OHNE VIDEO KEIN LIVE. Der Knopf waere sonst da, das Ergebnis
|
|
ein schwarzes Rechteck -- und die Leute sitzen davor. */
|
|
if (neu === "live" && !s.video) {
|
|
return res.status(400).json({ fehler: "kein_video" });
|
|
}
|
|
|
|
const felder = { stand: neu, stand_seit: jetzt(), geaendert: jetzt() };
|
|
if (neu === "live" && s.stand !== "live") {
|
|
felder.gestartet_am = jetzt();
|
|
felder.laeuft = 1;
|
|
felder.sekunde = 0;
|
|
/* WER EINSCHALTET, IST IM BILD. Seine Kamera bekommt den
|
|
Platz des Hosts -- und sein Name steht daran. Der andere
|
|
kann jederzeit als Gast dazukommen. */
|
|
felder.host_id = req.person.id;
|
|
}
|
|
if (neu === "zu" && s.stand === "live") {
|
|
/* WAS VORBEI IST, WANDERT IN DEN VERLAUF -- und der Live-Chat
|
|
dieser Sendung ist damit Geschichte. Wuerde er stehen
|
|
bleiben, faenge die naechste Sendung mit den Rufen der
|
|
letzten an. */
|
|
const zahl = db().prepare("SELECT COUNT(*) AS n FROM reaktion_chat WHERE weg_am IS NULL").get();
|
|
db().prepare(`INSERT INTO reaktion_verlauf
|
|
(titel, video, host_id, begonnen, beendet, zuschauer, beitraege)
|
|
VALUES (?,?,?,?,?,?,?)`)
|
|
.run(s.titel, s.video, s.host_id, s.gestartet_am, jetzt(),
|
|
dabeiListe().length, zahl?.n || 0);
|
|
db().exec("DELETE FROM reaktion_chat");
|
|
db().exec("DELETE FROM reaktion_gaeste");
|
|
felder.laeuft = 0;
|
|
felder.sekunde = 0;
|
|
felder.gestartet_am = null;
|
|
}
|
|
if (neu === "zu") db().exec("DELETE FROM reaktion_dabei");
|
|
|
|
const spalten = Object.keys(felder).map((k) => `${k} = ?`).join(", ");
|
|
db().prepare(`UPDATE reaktion SET ${spalten} WHERE id = 1`)
|
|
.run(...Object.values(felder));
|
|
|
|
protokolliere("reaktion_stand", {
|
|
personId: req.person.id,
|
|
detail: `${s.stand} -> ${neu}`,
|
|
ip: echteIp(req),
|
|
});
|
|
const l = lage(req.person);
|
|
melden("reaktion", { was: "stand", lage: oeffentlich(l) });
|
|
res.json(l);
|
|
} catch (fehler) {
|
|
console.error("[reaktion] Stand:", fehler?.message);
|
|
res.status(503).json({ fehler: "nicht_verfuegbar" });
|
|
}
|
|
});
|
|
|
|
/** Ich bin da / ich bin noch da. Alle 30 Sekunden. */
|
|
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: "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);
|
|
if (schon) {
|
|
db().prepare("UPDATE reaktion_dabei SET gesehen = ? WHERE person_id = ?")
|
|
.run(jetzt(), req.person.id);
|
|
} else {
|
|
/* DER KAMERAPLATZ WIRD BEIM EINTRETEN VERGEBEN, nicht beim
|
|
Zeichnen. Sonst haetten zwei gleichzeitig eintretende
|
|
Zuschauer denselben letzten Platz gelesen und beide
|
|
gerechnet, sie haetten ihn. */
|
|
const mitSicht = db().prepare(
|
|
"SELECT COUNT(*) AS n FROM reaktion_dabei WHERE sicht = 1").get()?.n || 0;
|
|
db().prepare("INSERT INTO reaktion_dabei (person_id, seit, gesehen, sicht) VALUES (?,?,?,?)")
|
|
.run(req.person.id, jetzt(), jetzt(), mitSicht < SICHT_PLAETZE ? 1 : 0);
|
|
melden("reaktion", { was: "dabei", lage: oeffentlich(lage(req.person)) });
|
|
}
|
|
res.json(lage(req.person));
|
|
} catch (fehler) {
|
|
console.error("[reaktion] Dabei:", fehler?.message);
|
|
res.status(503).json({ fehler: "nicht_verfuegbar" });
|
|
}
|
|
});
|
|
|
|
function abmelden(req, res) {
|
|
try {
|
|
db().prepare("DELETE FROM reaktion_dabei WHERE person_id = ?").run(req.person.id);
|
|
melden("reaktion", { was: "weg", lage: oeffentlich(lage(req.person)) });
|
|
res.json({ ok: true });
|
|
} catch (fehler) {
|
|
console.error("[reaktion] Weg:", fehler?.message);
|
|
res.status(503).json({ fehler: "nicht_verfuegbar" });
|
|
}
|
|
}
|
|
|
|
reaktionRouter.delete("/workspace/api/reaktion/dabei", abmelden);
|
|
|
|
/* DERSELBE WEG NOCH EINMAL ALS POST.
|
|
|
|
Beim Schliessen des Fensters wird ein gewoehnlicher Abruf
|
|
abgebrochen -- dafuer gibt es `navigator.sendBeacon`. Der schickt
|
|
aber IMMER POST; ein DELETE waere nie angekommen, und jeder haette
|
|
danach zwei Minuten lang als anwesend gegolten. Die Zuschauerzahl
|
|
waere den ganzen Abend gestiegen und nie gefallen.
|
|
|
|
Kein zweiter Code, nur ein zweiter Eingang: Beide zeigen auf
|
|
dieselbe Funktion. */
|
|
reaktionRouter.post("/workspace/api/reaktion/dabei-weg", abmelden);
|
|
|
|
/* ---------- Die Verbindungsadressen -------------------------------------
|
|
STUN sagt einem Browser, wie er von aussen aussieht; TURN leitet
|
|
weiter, wenn eine direkte Verbindung nicht zustande kommt.
|
|
|
|
WARUM DIE REACTION EINE EIGENE TUER HAT: Vor den Anruf-Wegen steht
|
|
`darfTelefonieren`, und die Community ist dort ausdruecklich nicht
|
|
dabei -- ein Zuschauer soll kein Teammitglied anklingeln koennen.
|
|
Das ist richtig und bleibt. Hier sitzt derselbe Mensch aber im
|
|
Saal, und ohne Adressen bliebe sein Bild schwarz.
|
|
|
|
Dieselbe Einstellung, dieselben Helfer, andere Tuer. Und die Tuer
|
|
ist nur offen, solange etwas laeuft: Bei „zu" gibt es nichts zu
|
|
holen -- sonst waere das ein dauerhaft offener Weg zu den
|
|
TURN-Zugangsdaten fuer jeden im Haus. */
|
|
reaktionRouter.get("/workspace/api/reaktion/adressen", (req, res) => {
|
|
try {
|
|
if (sendung().stand === "zu") return res.json({ adressen: [] });
|
|
let liste = [];
|
|
try { liste = JSON.parse(einstellung("anruf_adressen") || "[]"); } catch { liste = []; }
|
|
/* Als Kennung die Personennummer und nicht der Name -- sie landet
|
|
im Protokoll des TURN-Servers, und das ist kein Ort fuer
|
|
Klarnamen. Wortgleich zum Anrufteil. */
|
|
const { adressen } = mitZugangsdaten(wegeAusfaechern(liste), `r${req.person.id}`);
|
|
res.json({ adressen });
|
|
} catch (fehler) {
|
|
console.error("[reaktion] Adressen:", fehler?.message);
|
|
/* KEIN 503. Ohne eigene Adressen nimmt der Browser seine Vorgabe
|
|
und die Sendung laeuft weiter -- eine leere Liste ist hier die
|
|
ehrlichere Antwort als ein Fehler, der nach Ausfall aussieht. */
|
|
res.json({ adressen: [] });
|
|
}
|
|
});
|
|
|
|
/* ---------- Gaeste ------------------------------------------------------ */
|
|
|
|
reaktionRouter.post("/workspace/api/reaktion/gast", nurHost, express.json({ limit: "4kb" }),
|
|
(req, res) => {
|
|
try {
|
|
const id = Number(req.body?.person_id);
|
|
if (!Number.isInteger(id) || id < 1) return res.status(400).json({ fehler: "person_unklar" });
|
|
const p = db().prepare("SELECT id, name, rolle FROM personen WHERE id = ? AND aktiv = 1").get(id);
|
|
if (!p) return res.status(404).json({ fehler: "nicht_gefunden" });
|
|
if (id === req.person.id) return res.status(400).json({ fehler: "host_ist_kein_gast" });
|
|
|
|
const da = db().prepare("SELECT COUNT(*) AS n FROM reaktion_gaeste").get()?.n || 0;
|
|
if (da >= GAESTE_MAX) return res.status(409).json({ fehler: "gaeste_voll" });
|
|
|
|
/* Der Platz ist die kleinste freie Zahl -- nicht „Anzahl + 1".
|
|
Wer Gast 1 entfernt und dann jemanden holt, bekaeme sonst
|
|
wieder Platz 2, und Platz 1 bliebe fuer immer leer. */
|
|
const belegt = new Set(gaesteListe().map((g) => g.platz));
|
|
let platz = 1;
|
|
while (belegt.has(platz)) platz++;
|
|
|
|
db().prepare("INSERT OR REPLACE INTO reaktion_gaeste (person_id, seit, stumm, platz) VALUES (?,?,0,?)")
|
|
.run(id, jetzt(), platz);
|
|
protokolliere("reaktion_gast_dazu", {
|
|
personId: req.person.id,
|
|
detail: `${p.name} (Platz ${platz})`,
|
|
ip: echteIp(req),
|
|
});
|
|
const l = lage(req.person);
|
|
melden("reaktion", { was: "gaeste", lage: oeffentlich(l) });
|
|
stromAn([id], "reaktion", { was: "du_bist_gast", lage: oeffentlich(l) });
|
|
res.status(201).json(l);
|
|
} catch (fehler) {
|
|
console.error("[reaktion] Gast:", fehler?.message);
|
|
res.status(503).json({ fehler: "nicht_verfuegbar" });
|
|
}
|
|
});
|
|
|
|
reaktionRouter.delete("/workspace/api/reaktion/gast/:id", nurHost, (req, res) => {
|
|
try {
|
|
const id = Number(req.params.id);
|
|
const weg = db().prepare("DELETE FROM reaktion_gaeste WHERE person_id = ?").run(id);
|
|
if (!weg.changes) return res.status(404).json({ fehler: "nicht_gefunden" });
|
|
const l = lage(req.person);
|
|
melden("reaktion", { was: "gaeste", lage: oeffentlich(l) });
|
|
stromAn([id], "reaktion", { was: "du_bist_kein_gast", lage: oeffentlich(l) });
|
|
res.json(l);
|
|
} catch (fehler) {
|
|
console.error("[reaktion] Gast weg:", fehler?.message);
|
|
res.status(503).json({ fehler: "nicht_verfuegbar" });
|
|
}
|
|
});
|
|
|
|
reaktionRouter.put("/workspace/api/reaktion/gast/:id/stumm", nurHost,
|
|
express.json({ limit: "2kb" }), (req, res) => {
|
|
try {
|
|
const id = Number(req.params.id);
|
|
const stumm = req.body?.stumm ? 1 : 0;
|
|
const a = db().prepare("UPDATE reaktion_gaeste SET stumm = ? WHERE person_id = ?")
|
|
.run(stumm, id);
|
|
if (!a.changes) return res.status(404).json({ fehler: "nicht_gefunden" });
|
|
const l = lage(req.person);
|
|
melden("reaktion", { was: "gaeste", lage: oeffentlich(l) });
|
|
res.json(l);
|
|
} catch (fehler) {
|
|
console.error("[reaktion] Stumm:", fehler?.message);
|
|
res.status(503).json({ fehler: "nicht_verfuegbar" });
|
|
}
|
|
});
|
|
|
|
/* ---------- Das Video --------------------------------------------------
|
|
Der Host sagt, wo das Video steht. Alle anderen folgen.
|
|
|
|
GESCHICKT WIRD DER SPIELSTAND, NICHT DAS BILD -- die Begruendung
|
|
steht in reaktion-tabellen.js. Hier steht nur die Folge davon:
|
|
Diese Route ist die einzige, die waehrend der Sendung oft laeuft,
|
|
und sie schreibt deshalb so wenig wie moeglich. */
|
|
reaktionRouter.post("/workspace/api/reaktion/video", nurHost, express.json({ limit: "2kb" }),
|
|
(req, res) => {
|
|
try {
|
|
const laeuft = req.body?.laeuft ? 1 : 0;
|
|
const sekunde = Math.max(0, Number(req.body?.sekunde) || 0);
|
|
const video = req.body?.video === undefined ? null : videoKennung(String(req.body.video));
|
|
if (req.body?.video !== undefined && !video) {
|
|
return res.status(400).json({ fehler: "video_unklar" });
|
|
}
|
|
/* DIE GESCHWINDIGKEIT WIRD NUR GESETZT, WENN SIE MITKOMMT.
|
|
Diese Route laeuft alle fuenf Sekunden -- schriebe sie das
|
|
Tempo jedes Mal mit, wuerde ein Takt des Hosts, der noch die
|
|
alte Zahl im Kopf hat, die Einstellung eines anderen
|
|
zurueckdrehen. Ein Wert, der nicht gemeint ist, wird nicht
|
|
geschrieben. */
|
|
let tempo = null;
|
|
if (req.body?.tempo !== undefined) {
|
|
tempo = Number(req.body.tempo);
|
|
if (!TEMPI.includes(tempo)) return res.status(400).json({ fehler: "tempo_unklar" });
|
|
}
|
|
const s0 = sendung();
|
|
if (tempo !== null) {
|
|
db().prepare("UPDATE reaktion SET tempo = ? WHERE id = 1").run(tempo);
|
|
}
|
|
if (video) {
|
|
db().prepare("UPDATE reaktion SET video = ?, laeuft = ?, sekunde = ?, geaendert = ? WHERE id = 1")
|
|
.run(video, laeuft, sekunde, jetzt());
|
|
/* Der Name wird NACHGEREICHT und nicht abgewartet: Diese
|
|
Route laeuft waehrend der Sendung im Sekundentakt. Sie
|
|
darf unter keinen Umstaenden auf YouTube warten. */
|
|
if (video !== s0.video) {
|
|
videoTitel(video).then((t) => {
|
|
try {
|
|
db().prepare("UPDATE reaktion SET video_titel = ? WHERE id = 1 AND video = ?")
|
|
.run(t, video);
|
|
} catch { /* dann steht dort die Kennung, und das ist ehrlich */ }
|
|
});
|
|
}
|
|
} else {
|
|
db().prepare("UPDATE reaktion SET laeuft = ?, sekunde = ? WHERE id = 1")
|
|
.run(laeuft, sekunde);
|
|
}
|
|
const s = sendung();
|
|
melden("reaktion", {
|
|
was: "video",
|
|
video: s.video, laeuft: !!s.laeuft, sekunde: s.sekunde,
|
|
tempo: Number(s.tempo) || 1,
|
|
/* Die Uhrzeit des Servers geht mit: Der Empfaenger rechnet
|
|
die Laufzeit seit dem Absenden dazu. Ohne das waere jeder
|
|
genau so weit hinten, wie sein Netz langsam ist. */
|
|
gesendet: Date.now(),
|
|
});
|
|
res.json({ ok: true, tempo: Number(s.tempo) || 1 });
|
|
} catch (fehler) {
|
|
console.error("[reaktion] Video:", fehler?.message);
|
|
res.status(503).json({ fehler: "nicht_verfuegbar" });
|
|
}
|
|
});
|
|
|
|
/* ---------- Das zweite Video -------------------------------------------
|
|
Filipe: „auch gerne so dass ich vielleicht noch ein zweites
|
|
nebenbei vorbereiten kann so dass ich hin und her switchen kann wie
|
|
ich es brauche und wil."
|
|
|
|
WARUM DAS NICHT DIE WARTESCHLANGE IST. Die Schlange ist eine
|
|
Reihenfolge -- eins nach dem anderen, und das Gespielte ist
|
|
danach heraus. Hier geht es um zwei Videos NEBENEINANDER, und
|
|
jedes merkt sich seine Stelle. Mit der Schlange nachgebaut waere
|
|
es eine Schlange, aus der man nie wieder herauskommt: Jeder
|
|
Wechsel haenge das eine hinten an und nehme das andere heraus, die
|
|
gemerkte Stelle ginge dabei jedes Mal verloren. */
|
|
reaktionRouter.post("/workspace/api/reaktion/zweitvideo", nurHost,
|
|
express.json({ limit: "2kb" }), (req, res) => {
|
|
try {
|
|
const roh = String(req.body?.video ?? "").trim();
|
|
/* Ein leeres Feld raeumt den Platz. Das ist kein Sonderfall,
|
|
sondern der Normalfall nach einer Sendung. */
|
|
if (!roh) {
|
|
db().prepare(`UPDATE reaktion SET zweit_video = '', zweit_titel = '',
|
|
zweit_sekunde = 0 WHERE id = 1`).run();
|
|
return res.json(lage(req.person));
|
|
}
|
|
const video = videoKennung(roh);
|
|
if (!video) return res.status(400).json({ fehler: "video_unklar" });
|
|
/* DASSELBE VIDEO AUF BEIDEN SEITEN WAERE EIN UMSCHALTER OHNE
|
|
WIRKUNG -- und man sieht es dem Knopf nicht an. */
|
|
const s = sendung();
|
|
if (video === s.video) return res.status(409).json({ fehler: "schon_im_bild" });
|
|
|
|
db().prepare(`UPDATE reaktion SET zweit_video = ?, zweit_titel = '',
|
|
zweit_sekunde = 0 WHERE id = 1`).run(video);
|
|
/* Der Name wird nachgereicht -- wie beim ersten Video auch.
|
|
Auf YouTube zu warten hiesse, den Knopf haengen zu lassen. */
|
|
videoTitel(video).then((t) => {
|
|
try {
|
|
db().prepare("UPDATE reaktion SET zweit_titel = ? WHERE id = 1 AND zweit_video = ?")
|
|
.run(t, video);
|
|
} catch { /* dann steht dort die Kennung, und das ist ehrlich */ }
|
|
});
|
|
res.json(lage(req.person));
|
|
} catch (fehler) {
|
|
console.error("[reaktion] Zweitvideo:", fehler?.message);
|
|
res.status(503).json({ fehler: "nicht_verfuegbar" });
|
|
}
|
|
});
|
|
|
|
/** Hin und her. Beide Seiten behalten ihre Stelle. */
|
|
reaktionRouter.post("/workspace/api/reaktion/tauschen", nurHost,
|
|
express.json({ limit: "2kb" }), (req, res) => {
|
|
try {
|
|
const s = sendung();
|
|
if (!s.zweit_video) return res.status(409).json({ fehler: "kein_zweites" });
|
|
|
|
/* DIE STELLE KOMMT VOM HOST UND NICHT AUS DER DATENBANK.
|
|
In `reaktion.sekunde` steht der Stand vom letzten Takt -- bis
|
|
zu fuenf Sekunden alt. Beim Zurueckschalten waeren das jedes
|
|
Mal fuenf Sekunden, die man zweimal sieht. Wer den Knopf
|
|
drueckt, hat den Spieler vor sich und weiss es genauer. */
|
|
const jetztSek = req.body?.sekunde === undefined
|
|
? (s.sekunde || 0)
|
|
: Math.max(0, Number(req.body.sekunde) || 0);
|
|
|
|
const neu = s.zweit_video;
|
|
const neuTitel = s.zweit_titel || "";
|
|
const neuSek = s.zweit_sekunde || 0;
|
|
|
|
/* EIN SCHREIBVORGANG UND NICHT ZWEI. Ein Tausch, der zwischen
|
|
zwei Anweisungen abbricht, haette dasselbe Video auf beiden
|
|
Seiten stehen -- und die gemerkte Stelle des anderen waere
|
|
weg. `node:sqlite` kennt kein `.transaction()`, also von
|
|
Hand. */
|
|
db().exec("BEGIN IMMEDIATE");
|
|
try {
|
|
db().prepare(`UPDATE reaktion SET
|
|
video = ?, video_titel = ?, sekunde = ?,
|
|
zweit_video = ?, zweit_titel = ?, zweit_sekunde = ?,
|
|
geaendert = ?
|
|
WHERE id = 1`)
|
|
.run(neu, neuTitel, neuSek, s.video, s.video_titel || "", jetztSek, jetzt());
|
|
db().exec("COMMIT");
|
|
} catch (f) {
|
|
db().exec("ROLLBACK");
|
|
throw f;
|
|
}
|
|
|
|
/* Fehlt der Name noch (das Nachreichen von YouTube war nicht
|
|
fertig), wird er jetzt geholt -- er steht ab sofort im Bild. */
|
|
if (!neuTitel) {
|
|
videoTitel(neu).then((t) => {
|
|
try {
|
|
db().prepare("UPDATE reaktion SET video_titel = ? WHERE id = 1 AND video = ?")
|
|
.run(t, neu);
|
|
} catch { /* dann die Kennung */ }
|
|
});
|
|
}
|
|
|
|
const n = sendung();
|
|
melden("reaktion", {
|
|
was: "video",
|
|
video: n.video, laeuft: !!n.laeuft, sekunde: n.sekunde,
|
|
tempo: Number(n.tempo) || 1, gesendet: Date.now(),
|
|
});
|
|
melden("reaktion", { was: "getauscht", lage: oeffentlich(lage(req.person)) });
|
|
res.json(lage(req.person));
|
|
} catch (fehler) {
|
|
console.error("[reaktion] Tauschen:", fehler?.message);
|
|
res.status(503).json({ fehler: "nicht_verfuegbar" });
|
|
}
|
|
});
|
|
|
|
/* ---------- Die Warteschlange ------------------------------------------
|
|
Vorher einraeumen, waehrend der Sendung nur weiterschalten.
|
|
|
|
WARUM DAS EINE EIGENE SACHE IST UND NICHT „NOCH EIN TEXTFELD":
|
|
Bisher gab es genau ein Video. Wer zwei Sachen hintereinander
|
|
schauen wollte, musste mitten in der Sendung eine YouTube-Adresse
|
|
in ein Feld tippen -- vor Publikum, mit laufender Kamera, mit dem
|
|
Risiko eines Tippfehlers und damit eines schwarzen Rechtecks.
|
|
Jetzt ist das eine Vorbereitung und kein Handgriff im Live. */
|
|
|
|
reaktionRouter.post("/workspace/api/reaktion/liste", nurHost, express.json({ limit: "8kb" }),
|
|
async (req, res) => {
|
|
try {
|
|
const video = videoKennung(String(req.body?.video ?? ""));
|
|
if (!video) return res.status(400).json({ fehler: "video_unklar" });
|
|
|
|
const wie_viele = db().prepare("SELECT COUNT(*) AS n FROM reaktion_liste").get()?.n || 0;
|
|
if (wie_viele >= LISTE_MAX) return res.status(409).json({ fehler: "liste_voll" });
|
|
|
|
/* DASSELBE VIDEO NICHT ZWEIMAL. Nicht aus Ordnungsliebe: Wer
|
|
es zweimal anhaengt, hat es schlicht vergessen -- und merkt
|
|
es erst, wenn es zum zweiten Mal laeuft und alle es schon
|
|
kennen. */
|
|
const schon = db().prepare("SELECT id FROM reaktion_liste WHERE video = ?").get(video);
|
|
if (schon) return res.status(409).json({ fehler: "liste_doppelt" });
|
|
|
|
const titel = await videoTitel(video);
|
|
const letzter = db().prepare("SELECT MAX(platz) AS p FROM reaktion_liste").get()?.p || 0;
|
|
db().prepare("INSERT INTO reaktion_liste (video, titel, platz, von_id, gesetzt_am)"
|
|
+ " VALUES (?,?,?,?,?)")
|
|
.run(video, titel, letzter + 1, req.person.id, jetzt());
|
|
|
|
const l = lage(req.person);
|
|
melden("reaktion", { was: "liste", lage: oeffentlich(l) });
|
|
res.json(l);
|
|
} catch (fehler) {
|
|
console.error("[reaktion] Liste anhaengen:", fehler?.message);
|
|
res.status(503).json({ fehler: "nicht_verfuegbar" });
|
|
}
|
|
});
|
|
|
|
reaktionRouter.delete("/workspace/api/reaktion/liste/:id", nurHost, (req, res) => {
|
|
try {
|
|
db().prepare("DELETE FROM reaktion_liste WHERE id = ?").run(Number(req.params.id) || 0);
|
|
const l = lage(req.person);
|
|
melden("reaktion", { was: "liste", lage: oeffentlich(l) });
|
|
res.json(l);
|
|
} catch (fehler) {
|
|
console.error("[reaktion] Liste weg:", fehler?.message);
|
|
res.status(503).json({ fehler: "nicht_verfuegbar" });
|
|
}
|
|
});
|
|
|
|
/** Eins hoch oder runter.
|
|
*
|
|
* GETAUSCHT WIRD MIT DEM NACHBARN, nicht neu durchnummeriert. Beim
|
|
* Durchnummerieren schreibt ein Klick jede Zeile der Liste neu;
|
|
* zwei Klicks gleichzeitig -- Filipe am Rechner, VanVan am Handy --
|
|
* ergeben dann eine Reihenfolge, die keiner von beiden wollte. Ein
|
|
* Tausch beruehrt zwei Zeilen und ist in sich abgeschlossen. */
|
|
reaktionRouter.post("/workspace/api/reaktion/liste/:id/schieben", nurHost,
|
|
express.json({ limit: "2kb" }), (req, res) => {
|
|
try {
|
|
const id = Number(req.params.id) || 0;
|
|
const hoch = String(req.body?.richtung ?? "hoch") === "hoch";
|
|
const alle = listeHolen();
|
|
const wo = alle.findIndex((z) => z.id === id);
|
|
if (wo < 0) return res.status(404).json({ fehler: "nicht_in_der_liste" });
|
|
const nachbar = alle[hoch ? wo - 1 : wo + 1];
|
|
if (!nachbar) return res.json(lage(req.person)); // schon ganz oben/unten
|
|
|
|
/* `node:sqlite` kennt kein `.transaction()` -- das gibt es nur
|
|
in better-sqlite3. Hier wird von Hand geklammert, so wie im
|
|
ganzen Haus. Ohne Klammer koennte zwischen den beiden
|
|
Schreibvorgaengen gelesen werden, und dann haetten zwei
|
|
Zeilen kurz denselben Platz. */
|
|
db().exec("BEGIN IMMEDIATE");
|
|
try {
|
|
db().prepare("UPDATE reaktion_liste SET platz = ? WHERE id = ?")
|
|
.run(nachbar.platz, alle[wo].id);
|
|
db().prepare("UPDATE reaktion_liste SET platz = ? WHERE id = ?")
|
|
.run(alle[wo].platz, nachbar.id);
|
|
db().exec("COMMIT");
|
|
} catch (schief) {
|
|
try { db().exec("ROLLBACK"); } catch { /* war schon zurueck */ }
|
|
throw schief;
|
|
}
|
|
|
|
const l = lage(req.person);
|
|
melden("reaktion", { was: "liste", lage: oeffentlich(l) });
|
|
res.json(l);
|
|
} catch (fehler) {
|
|
console.error("[reaktion] Liste schieben:", fehler?.message);
|
|
res.status(503).json({ fehler: "nicht_verfuegbar" });
|
|
}
|
|
});
|
|
|
|
/** Dieses hier jetzt -- oder schlicht das naechste. */
|
|
reaktionRouter.post("/workspace/api/reaktion/weiter", nurHost, express.json({ limit: "2kb" }),
|
|
(req, res) => {
|
|
try {
|
|
const id = Number(req.body?.id) || 0;
|
|
const zeile = id
|
|
? db().prepare("SELECT * FROM reaktion_liste WHERE id = ?").get(id)
|
|
: db().prepare("SELECT * FROM reaktion_liste ORDER BY platz, id LIMIT 1").get();
|
|
if (!zeile) return res.status(409).json({ fehler: "liste_leer" });
|
|
|
|
weiterZu(zeile, req.person.id);
|
|
const l = lage(req.person);
|
|
melden("reaktion", { was: "liste", lage: oeffentlich(l) });
|
|
res.json(l);
|
|
} catch (fehler) {
|
|
console.error("[reaktion] Weiter:", fehler?.message);
|
|
res.status(503).json({ fehler: "nicht_verfuegbar" });
|
|
}
|
|
});
|
|
|
|
/* ---------- Kamera aus, Kamera an ---------------------------------------
|
|
Filipes Notiz nennt „Kamera" als erste der acht Host-Steuerungen.
|
|
Sie fehlte: Wer kurz aufstehen, trinken oder etwas holen wollte,
|
|
musste die Sendung verlassen oder sich dabei filmen lassen.
|
|
|
|
DAS BILD WIRD AM GERAET ABGESCHALTET, NICHT AM SERVER. Die Spur
|
|
wird stillgelegt (`track.enabled = false`); die Verbindung bleibt
|
|
stehen, der Ton laeuft weiter, und beim Wiedereinschalten ist das
|
|
Bild sofort da statt nach einem neuen Verbindungsaufbau. Dieser
|
|
Weg hier sagt nur allen ANDEREN Bescheid, damit im Fenster
|
|
„Kamera aus" steht und nicht ein schwarzes Rechteck, das nach
|
|
einem Fehler aussieht.
|
|
|
|
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("reaktion_massnahme", {
|
|
personId: req.person.id,
|
|
detail: `${art} gegen ${ziel.name}${bis ? ` bis ${bis.slice(11, 16)}` : ""}`,
|
|
ip: 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: "saal_zu" });
|
|
|
|
const istHost = HOST_ROLLEN.has(req.person.rolle);
|
|
const binGast = !!db().prepare("SELECT 1 FROM reaktion_gaeste WHERE person_id = ?")
|
|
.get(req.person.id);
|
|
if (!istHost && !binGast) return res.status(403).json({ fehler: "sendest_nicht" });
|
|
|
|
/* TEILWEISE AENDERN IST ERLAUBT. Wer nur die Kamera schaltet,
|
|
schickt nur die Kamera -- sonst muesste jede Seite immer
|
|
beide Werte mitschicken und koennte dabei den anderen
|
|
versehentlich zuruecksetzen. */
|
|
const felder = [];
|
|
const werte = [];
|
|
if (req.body?.kamera_aus !== undefined) {
|
|
felder.push("kamera_aus = ?"); werte.push(req.body.kamera_aus ? 1 : 0);
|
|
}
|
|
if (req.body?.mikro_aus !== undefined) {
|
|
felder.push("mikro_aus = ?"); werte.push(req.body.mikro_aus ? 1 : 0);
|
|
}
|
|
if (req.body?.bildschirm !== undefined) {
|
|
felder.push("bildschirm = ?"); werte.push(req.body.bildschirm ? 1 : 0);
|
|
}
|
|
if (!felder.length) return res.status(400).json({ fehler: "nichts_zu_tun" });
|
|
|
|
const geschrieben = db().prepare(
|
|
`UPDATE reaktion_dabei SET ${felder.join(", ")} WHERE person_id = ?`)
|
|
.run(...werte, req.person.id);
|
|
/* WER NICHT IN DER LISTE STEHT, SITZT NICHT IM SAAL. Das ist
|
|
kein Sonderfall, sondern ein Hinweis: Die Seite hat sich
|
|
zwei Minuten nicht gemeldet. Stillschweigend nichts zu tun
|
|
waere ein Knopf, der nicht wirkt. */
|
|
if (!geschrieben.changes) return res.status(409).json({ fehler: "nicht_dabei" });
|
|
|
|
const l = lage(req.person);
|
|
melden("reaktion", { was: "senden", lage: oeffentlich(l) });
|
|
res.json(l);
|
|
} catch (fehler) {
|
|
console.error("[reaktion] Kamera:", fehler?.message);
|
|
res.status(503).json({ fehler: "nicht_verfuegbar" });
|
|
}
|
|
});
|
|
|
|
/* ---------- Der Chat als Ganzes ----------------------------------------
|
|
„Chat" aus Filipes Liste der acht Steuerungen. Gemeint ist nicht
|
|
die Moderation -- die gab es schon -- sondern der Chat selbst.
|
|
Einen Beitrag wegzunehmen, nachdem er stand, ist etwas anderes,
|
|
als ihn gar nicht erst zuzulassen. */
|
|
reaktionRouter.post("/workspace/api/reaktion/chatmodus", nurHost, express.json({ limit: "2kb" }),
|
|
(req, res) => {
|
|
try {
|
|
const modus = String(req.body?.modus ?? "");
|
|
if (!CHAT_MODI.includes(modus)) return res.status(400).json({ fehler: "chatmodus_unbekannt" });
|
|
db().prepare("UPDATE reaktion SET chat_modus = ? WHERE id = 1").run(modus);
|
|
protokolliere("reaktion_chatmodus", {
|
|
personId: req.person.id,
|
|
detail: modus,
|
|
ip: echteIp(req),
|
|
});
|
|
const l = lage(req.person);
|
|
melden("reaktion", { was: "chatmodus", lage: oeffentlich(l) });
|
|
res.json(l);
|
|
} catch (fehler) {
|
|
console.error("[reaktion] Chatmodus:", fehler?.message);
|
|
res.status(503).json({ fehler: "nicht_verfuegbar" });
|
|
}
|
|
});
|
|
|
|
reaktionRouter.post("/workspace/api/reaktion/layout", nurHost, express.json({ limit: "2kb" }),
|
|
(req, res) => {
|
|
try {
|
|
/* ==== DREI EINSTELLUNGEN, EIN WEG =========================
|
|
|
|
Anordnung, Groesse und Ecke beantworten zusammen eine
|
|
einzige Frage: „Wie sieht das Bild aus?" Drei Wege dafuer
|
|
waeren drei Rundrufe, und zwischen ihnen gaebe es
|
|
Augenblicke, in denen die Zusehenden eine Mischung sehen --
|
|
neue Anordnung, alte Ecke.
|
|
|
|
JEDES FELD IST FREIWILLIG, aber eines muss dabei sein. Ein
|
|
Aufruf, der nichts aendert, ist ein Rundruf ohne Anlass. */
|
|
const aendern = [];
|
|
const werte = [];
|
|
|
|
if (req.body?.layout !== undefined) {
|
|
const l = String(req.body.layout);
|
|
if (!LAYOUTS.includes(l)) return res.status(400).json({ fehler: "layout_unbekannt" });
|
|
aendern.push("layout = ?"); werte.push(l);
|
|
}
|
|
if (req.body?.kamera_gross !== undefined) {
|
|
const g = Number(req.body.kamera_gross);
|
|
if (!KAMERA_GROESSEN.includes(g)) {
|
|
return res.status(400).json({ fehler: "kameragroesse_unklar" });
|
|
}
|
|
aendern.push("kamera_gross = ?"); werte.push(g);
|
|
}
|
|
if (req.body?.kamera_ecke !== undefined) {
|
|
const e = String(req.body.kamera_ecke);
|
|
if (!KAMERA_ECKEN.includes(e)) {
|
|
return res.status(400).json({ fehler: "ecke_unklar" });
|
|
}
|
|
aendern.push("kamera_ecke = ?"); werte.push(e);
|
|
}
|
|
if (!aendern.length) return res.status(400).json({ fehler: "nichts_zu_tun" });
|
|
|
|
db().prepare(`UPDATE reaktion SET ${aendern.join(", ")} WHERE id = 1`).run(...werte);
|
|
const n = sendung();
|
|
const bild = {
|
|
layout: n.layout,
|
|
kamera_gross: Number(n.kamera_gross) || 1,
|
|
kamera_ecke: n.kamera_ecke || "ur",
|
|
};
|
|
melden("reaktion", { was: "layout", ...bild });
|
|
res.json({ ok: true, ...bild });
|
|
} catch (fehler) {
|
|
console.error("[reaktion] Layout:", fehler?.message);
|
|
res.status(503).json({ fehler: "nicht_verfuegbar" });
|
|
}
|
|
});
|
|
|
|
/* ---------- Die Werbeeinblendung ---------------------------------------- */
|
|
|
|
/* ==== EIN WEG FUER DAS GANZE AUSSEHEN ==============================
|
|
|
|
Art, Tempo und die beiden Ecken beantworten zusammen eine Frage:
|
|
„Was steht gerade ueber dem Bild?" Vier Wege dafuer waeren vier
|
|
Rundrufe -- und zwischen ihnen saehen die Zusehenden eine
|
|
Mischung aus neuem Band und alter Ecke.
|
|
|
|
Dasselbe Muster wie beim Bild (Anordnung, Groesse, Ecke). */
|
|
reaktionRouter.post("/workspace/api/reaktion/werbung", nurHost,
|
|
express.json({ limit: "2kb" }), (req, res) => {
|
|
try {
|
|
const aendern = [];
|
|
const werte = [];
|
|
|
|
if (req.body?.art !== undefined) {
|
|
const a = String(req.body.art);
|
|
if (!BANNER_ARTEN.includes(a)) return res.status(400).json({ fehler: "bannerart_unklar" });
|
|
aendern.push("banner_art = ?"); werte.push(a);
|
|
}
|
|
if (req.body?.tempo !== undefined) {
|
|
const t = Number(req.body.tempo);
|
|
if (!BANNER_TEMPI.includes(t)) return res.status(400).json({ fehler: "bannertempo_unklar" });
|
|
aendern.push("banner_tempo = ?"); werte.push(t);
|
|
}
|
|
for (const [feld, spalte] of [["ecke_dogi", "ecke_dogi"], ["ecke_hase", "ecke_hase"]]) {
|
|
if (req.body?.[feld] === undefined) continue;
|
|
const e = String(req.body[feld]);
|
|
if (!ECKEN_PLAETZE.includes(e)) return res.status(400).json({ fehler: "eckenplatz_unklar" });
|
|
aendern.push(`${spalte} = ?`); werte.push(e);
|
|
}
|
|
if (req.body?.ecke_gross !== undefined) {
|
|
const g = Number(req.body.ecke_gross);
|
|
if (!ECKEN_GROESSEN.includes(g)) return res.status(400).json({ fehler: "eckengroesse_unklar" });
|
|
aendern.push("ecke_gross = ?"); werte.push(g);
|
|
}
|
|
if (!aendern.length) return res.status(400).json({ fehler: "nichts_zu_tun" });
|
|
|
|
/* ZWEI BILDER AUF DEMSELBEN PLATZ waeren ein Bild auf dem
|
|
anderen. Gemeldet wird es, statt es stillschweigend zu
|
|
verschieben: Wer es nicht wollte, korrigiert; wer es
|
|
wollte, findet seine Absicht nicht heimlich geaendert. */
|
|
const probe = { ...sendung() };
|
|
for (let i = 0; i < aendern.length; i++) {
|
|
const feld = aendern[i].split(" ")[0];
|
|
probe[feld] = werte[i];
|
|
}
|
|
if (probe.ecke_dogi !== "aus" && probe.ecke_dogi === probe.ecke_hase) {
|
|
return res.status(400).json({ fehler: "ecke_belegt" });
|
|
}
|
|
|
|
db().prepare(`UPDATE reaktion SET ${aendern.join(", ")} WHERE id = 1`).run(...werte);
|
|
melden("reaktion", { was: "werbung", ...werbungJetzt() });
|
|
res.json({ ok: true, ...werbungJetzt() });
|
|
} catch (fehler) {
|
|
console.error("[reaktion] Werbung:", fehler?.message);
|
|
res.status(503).json({ fehler: "nicht_verfuegbar" });
|
|
}
|
|
});
|
|
|
|
/* Der Stand der Einblendung -- an EINER Stelle gerechnet. Drei
|
|
Aufrufer (setzen, anlegen, aendern) brauchen dasselbe Ergebnis;
|
|
dreimal abgeschrieben waere die Gelegenheit, dass einer davon
|
|
das Band vergisst. */
|
|
function werbungJetzt() {
|
|
return werbungStand(db());
|
|
}
|
|
|
|
/* Alle Botschaften samt den abgeschalteten -- nur fuer die Regie. */
|
|
function bannerAlle() {
|
|
return db().prepare(`SELECT id, text, unterzeile, bild, aktiv, reihung
|
|
FROM reaktion_banner ORDER BY reihung, id`).all()
|
|
.map((z) => ({ ...z, aktiv: !!z.aktiv }));
|
|
}
|
|
|
|
/* Text pruefen -- einmal fuer Anlegen und Aendern.
|
|
|
|
LEERZEICHEN WERDEN GESTUTZT, Zeilenumbrueche fallen weg: Ein
|
|
Band ist eine Zeile. Ein hineinkopierter Absatz wuerde es
|
|
auseinanderreissen, und man saehe es erst im Stream. */
|
|
function bandText(roh, max) {
|
|
return String(roh ?? "").replace(/\s+/g, " ").trim().slice(0, max);
|
|
}
|
|
|
|
reaktionRouter.post("/workspace/api/reaktion/banner", nurHost,
|
|
express.json({ limit: "4kb" }), (req, res) => {
|
|
try {
|
|
const text = bandText(req.body?.text, BANNER_TEXT_MAX);
|
|
if (!text) return res.status(400).json({ fehler: "text_fehlt" });
|
|
const unter = bandText(req.body?.unterzeile, BANNER_UNTER_MAX);
|
|
const bild = BANNER_BILDER.includes(String(req.body?.bild))
|
|
? String(req.body.bild) : "keins";
|
|
|
|
const wieviele = db().prepare("SELECT COUNT(*) AS n FROM reaktion_banner").get().n;
|
|
if (wieviele >= BANNER_MAX) return res.status(400).json({ fehler: "banner_voll" });
|
|
|
|
const naechste = db().prepare(
|
|
"SELECT COALESCE(MAX(reihung), -1) + 1 AS r FROM reaktion_banner").get().r;
|
|
db().prepare(`INSERT INTO reaktion_banner
|
|
(text, unterzeile, bild, aktiv, reihung, erstellt) VALUES (?,?,?,1,?,?)`)
|
|
.run(text, unter, bild, naechste, new Date().toISOString());
|
|
|
|
melden("reaktion", { was: "werbung", ...werbungJetzt() });
|
|
res.json({ ok: true, banner: bannerAlle() });
|
|
} catch (fehler) {
|
|
console.error("[reaktion] Banner anlegen:", fehler?.message);
|
|
res.status(503).json({ fehler: "nicht_verfuegbar" });
|
|
}
|
|
});
|
|
|
|
reaktionRouter.post("/workspace/api/reaktion/banner/:id(\\d+)", nurHost,
|
|
express.json({ limit: "4kb" }), (req, res) => {
|
|
try {
|
|
const id = Number(req.params.id);
|
|
const da = db().prepare("SELECT id FROM reaktion_banner WHERE id = ?").get(id);
|
|
if (!da) return res.status(404).json({ fehler: "nicht_gefunden" });
|
|
|
|
const aendern = [];
|
|
const werte = [];
|
|
if (req.body?.text !== undefined) {
|
|
const t = bandText(req.body.text, BANNER_TEXT_MAX);
|
|
if (!t) return res.status(400).json({ fehler: "text_fehlt" });
|
|
aendern.push("text = ?"); werte.push(t);
|
|
}
|
|
if (req.body?.unterzeile !== undefined) {
|
|
aendern.push("unterzeile = ?");
|
|
werte.push(bandText(req.body.unterzeile, BANNER_UNTER_MAX));
|
|
}
|
|
if (req.body?.bild !== undefined) {
|
|
const b = String(req.body.bild);
|
|
if (!BANNER_BILDER.includes(b)) return res.status(400).json({ fehler: "bild_unklar" });
|
|
aendern.push("bild = ?"); werte.push(b);
|
|
}
|
|
if (req.body?.aktiv !== undefined) {
|
|
aendern.push("aktiv = ?"); werte.push(req.body.aktiv ? 1 : 0);
|
|
}
|
|
if (!aendern.length) return res.status(400).json({ fehler: "nichts_zu_tun" });
|
|
|
|
db().prepare(`UPDATE reaktion_banner SET ${aendern.join(", ")} WHERE id = ?`)
|
|
.run(...werte, id);
|
|
melden("reaktion", { was: "werbung", ...werbungJetzt() });
|
|
res.json({ ok: true, banner: bannerAlle() });
|
|
} catch (fehler) {
|
|
console.error("[reaktion] Banner aendern:", fehler?.message);
|
|
res.status(503).json({ fehler: "nicht_verfuegbar" });
|
|
}
|
|
});
|
|
|
|
reaktionRouter.delete("/workspace/api/reaktion/banner/:id(\\d+)", nurHost, (req, res) => {
|
|
try {
|
|
const weg = db().prepare("DELETE FROM reaktion_banner WHERE id = ?")
|
|
.run(Number(req.params.id));
|
|
if (!weg.changes) return res.status(404).json({ fehler: "nicht_gefunden" });
|
|
melden("reaktion", { was: "werbung", ...werbungJetzt() });
|
|
res.json({ ok: true, banner: bannerAlle() });
|
|
} catch (fehler) {
|
|
console.error("[reaktion] Banner loeschen:", fehler?.message);
|
|
res.status(503).json({ fehler: "nicht_verfuegbar" });
|
|
}
|
|
});
|
|
|
|
/* Die Reihenfolge. Getauscht wird mit dem Nachbarn -- so wie in der
|
|
Warteschlange der Videos, damit man nicht zwei Bedienungen fuer
|
|
dasselbe lernen muss. */
|
|
reaktionRouter.post("/workspace/api/reaktion/banner/:id(\\d+)/schieben", nurHost,
|
|
express.json({ limit: "1kb" }), (req, res) => {
|
|
try {
|
|
const id = Number(req.params.id);
|
|
const wohin = String(req.body?.wohin || "");
|
|
if (!["hoch", "runter"].includes(wohin)) {
|
|
return res.status(400).json({ fehler: "richtung_unklar" });
|
|
}
|
|
const alle = db().prepare(
|
|
"SELECT id, reihung FROM reaktion_banner ORDER BY reihung, id").all();
|
|
const i = alle.findIndex((z) => z.id === id);
|
|
if (i < 0) return res.status(404).json({ fehler: "nicht_gefunden" });
|
|
const j = wohin === "hoch" ? i - 1 : i + 1;
|
|
if (j < 0 || j >= alle.length) return res.status(400).json({ fehler: "nichts_zu_tun" });
|
|
|
|
/* IN EINEM ZUG. Zwei einzelne Schreibvorgaenge liessen
|
|
dazwischen zwei Botschaften auf derselben Zahl stehen --
|
|
und die Reihenfolge waere dann Zufall. */
|
|
const setz = db().prepare("UPDATE reaktion_banner SET reihung = ? WHERE id = ?");
|
|
db().exec("BEGIN IMMEDIATE");
|
|
try {
|
|
alle.forEach((z, k) => setz.run(k, z.id));
|
|
setz.run(j, alle[i].id);
|
|
setz.run(i, alle[j].id);
|
|
db().exec("COMMIT");
|
|
} catch (f) { db().exec("ROLLBACK"); throw f; }
|
|
|
|
melden("reaktion", { was: "werbung", ...werbungJetzt() });
|
|
res.json({ ok: true, banner: bannerAlle() });
|
|
} catch (fehler) {
|
|
console.error("[reaktion] Banner schieben:", fehler?.message);
|
|
res.status(503).json({ fehler: "nicht_verfuegbar" });
|
|
}
|
|
});
|
|
|
|
/* ---------- Der Live-Chat ----------------------------------------------- */
|
|
|
|
reaktionRouter.get("/workspace/api/reaktion/chat", (req, res) => {
|
|
try {
|
|
const reihen = db().prepare(`
|
|
SELECT id, person_id, name, rolle, text, erstellt
|
|
FROM reaktion_chat WHERE weg_am IS NULL ORDER BY id DESC LIMIT 120`).all();
|
|
res.json({ beitraege: reihen.reverse() });
|
|
} catch (fehler) {
|
|
console.error("[reaktion] Chat:", fehler?.message);
|
|
res.status(503).json({ fehler: "nicht_verfuegbar" });
|
|
}
|
|
});
|
|
|
|
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: "saal_zu" });
|
|
/* DIE STUFE GILT HIER, NICHT NUR IN DER OBERFLAECHE. Ein Feld,
|
|
das ausgegraut ist, haelt niemanden auf, der die Schnittstelle
|
|
kennt. */
|
|
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" });
|
|
|
|
const { lastInsertRowid } = db().prepare(`INSERT INTO reaktion_chat
|
|
(person_id, name, rolle, text, erstellt) VALUES (?,?,?,?,?)`)
|
|
.run(req.person.id, req.person.name, req.person.rolle, text, jetzt());
|
|
const beitrag = db().prepare(
|
|
"SELECT id, person_id, name, rolle, text, erstellt FROM reaktion_chat WHERE id = ?")
|
|
.get(Number(lastInsertRowid));
|
|
melden("reaktion_chat", { was: "neu", beitrag });
|
|
res.status(201).json(beitrag);
|
|
} catch (fehler) {
|
|
console.error("[reaktion] Schreiben:", fehler?.message);
|
|
res.status(503).json({ fehler: "nicht_verfuegbar" });
|
|
}
|
|
});
|
|
|
|
/** Moderation: einen Beitrag wegnehmen.
|
|
*
|
|
* WEGGENOMMEN UND NICHT GELOESCHT. Wer moderiert, soll spaeter
|
|
* sagen koennen, was er weggenommen hat und warum -- eine geloeschte
|
|
* Zeile kann das nicht. */
|
|
reaktionRouter.delete("/workspace/api/reaktion/chat/:id", (req, res) => {
|
|
try {
|
|
const id = Number(req.params.id);
|
|
const b = db().prepare("SELECT * FROM reaktion_chat WHERE id = ?").get(id);
|
|
if (!b || b.weg_am) return res.status(404).json({ fehler: "nicht_gefunden" });
|
|
|
|
const eigen = b.person_id === req.person.id;
|
|
if (!eigen && !MODERIERT.has(req.person.rolle)) {
|
|
return res.status(403).json({ fehler: "nicht_erlaubt" });
|
|
}
|
|
db().prepare("UPDATE reaktion_chat SET weg_am = ?, weg_von = ? WHERE id = ?")
|
|
.run(jetzt(), req.person.id, id);
|
|
if (!eigen) {
|
|
protokolliere("reaktion_chat_weg", {
|
|
personId: req.person.id,
|
|
detail: `Beitrag ${id} von ${b.name}`,
|
|
ip: echteIp(req),
|
|
});
|
|
}
|
|
melden("reaktion_chat", { was: "weg", id });
|
|
res.json({ ok: true });
|
|
} catch (fehler) {
|
|
console.error("[reaktion] Wegnehmen:", fehler?.message);
|
|
res.status(503).json({ fehler: "nicht_verfuegbar" });
|
|
}
|
|
});
|
|
|
|
/* ---------- Die Kameras ------------------------------------------------
|
|
Nur der Hinweg. Was in `signal` steht, liest dieser Server nicht --
|
|
es geht unveraendert an den Empfaenger. Genau so macht es der
|
|
Anrufteil seit dem 18.09.
|
|
|
|
GESCHICKT WIRD NUR AN LEUTE, DIE GERADE DABEI SIND. Ohne das
|
|
koennte jemand aus dem Haus Verbindungsdaten an jeden Beliebigen
|
|
schicken, und die Reaction waere ein Weg, jemanden anzuklingeln. */
|
|
reaktionRouter.post("/workspace/api/reaktion/signal", express.json({ limit: "256kb" }),
|
|
(req, res) => {
|
|
try {
|
|
const an = Number(req.body?.an);
|
|
const daten = req.body?.daten;
|
|
if (!Number.isInteger(an) || daten === undefined) {
|
|
return res.status(400).json({ fehler: "unvollstaendig" });
|
|
}
|
|
const kreis = new Set(empfaenger());
|
|
if (!kreis.has(an) || !kreis.has(req.person.id)) {
|
|
return res.status(409).json({ fehler: "nicht_dabei" });
|
|
}
|
|
const wie_viele = stromAn([an], "reaktion_signal", {
|
|
von: req.person.id, name: req.person.name, daten,
|
|
});
|
|
res.json({ ok: true, zugestellt: wie_viele });
|
|
} catch (fehler) {
|
|
console.error("[reaktion] Signal:", fehler?.message);
|
|
res.status(503).json({ fehler: "nicht_verfuegbar" });
|
|
}
|
|
});
|
|
|
|
/* ======================================================================
|
|
HILFEN
|
|
====================================================================== */
|
|
|
|
/** Aus allem, was jemand einfuegen koennte, die YouTube-Kennung.
|
|
*
|
|
* Angenommen werden die vier Formen, die man wirklich in der
|
|
* Zwischenablage hat: die lange Adresse, die kurze, der
|
|
* Einbett-Pfad und die nackte Kennung. Alles andere wird
|
|
* abgelehnt -- eine falsche Kennung ist ein schwarzes Rechteck vor
|
|
* Publikum, und das faellt erst auf, wenn alle zusehen. */
|
|
export function videoKennung(roh) {
|
|
const t = String(roh || "").trim();
|
|
if (!t) return "";
|
|
if (/^[A-Za-z0-9_-]{11}$/.test(t)) return t;
|
|
const muster = [
|
|
/[?&]v=([A-Za-z0-9_-]{11})/,
|
|
/youtu\.be\/([A-Za-z0-9_-]{11})/,
|
|
/youtube\.com\/embed\/([A-Za-z0-9_-]{11})/,
|
|
/youtube\.com\/shorts\/([A-Za-z0-9_-]{11})/,
|
|
/youtube\.com\/live\/([A-Za-z0-9_-]{11})/,
|
|
];
|
|
for (const m of muster) {
|
|
const t2 = t.match(m);
|
|
if (t2) return t2[1];
|
|
}
|
|
return "";
|
|
}
|
|
|
|
/** Die Lage ohne die Namensliste -- fuer den Rundruf.
|
|
*
|
|
* `lage()` gibt einem Host die Namen aller Zusehenden. Genau diese
|
|
* Antwort geht per Strom an ALLE. Ohne dieses Sieb bekaeme jeder
|
|
* Zuschauer die Liste, die nur die Leitung sehen darf -- und zwar
|
|
* ohne dass es jemandem auffaellt, weil die Oberflaeche sie ja
|
|
* nicht anzeigt. */
|
|
function oeffentlich(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. */
|
|
/* `zweit` kam am 28.09. dazu: das zweite, vorbereitete Video. Es
|
|
ist die Rueckhand fuer den Fall, dass etwas nicht laeuft -- die
|
|
verraet man nicht vorher, und sie gehoert genau wie die
|
|
Warteschlange nur denen, die die Sendung fuehren. */
|
|
/* `banner_alle` KAM AM 29.09. DAZU. Es ist die Verwaltungsliste
|
|
der Werbung -- mit den ABGESCHALTETEN Eintraegen darin. Die
|
|
laufenden Botschaften stehen ohnehin unter `werbung.band` und
|
|
gehoeren allen; ein Entwurf, den jemand bewusst ausgeschaltet
|
|
hat, gehoert niemandem. */
|
|
const { zuschauer, ich, liste, meldungen, massnahmen, zweit,
|
|
banner_alle: _verwaltung, ...rest } = l;
|
|
return rest;
|
|
}
|
|
|
|
/** Aufraeumen: alter Verlauf und liegengebliebene Zusehende. */
|
|
export function reaktionAufraeumen() {
|
|
try {
|
|
const grenze = new Date(Date.now() - VERLAUF_TAGE * 86400_000).toISOString();
|
|
const a = db().prepare("DELETE FROM reaktion_verlauf WHERE beendet < ?").run(grenze);
|
|
const b = db().prepare("DELETE FROM reaktion_dabei WHERE gesehen < ?")
|
|
.run(new Date(Date.now() - 600_000).toISOString());
|
|
/* ERLEDIGTE MELDUNGEN. Meistens sind sie schon mit dem Chat
|
|
verschwunden (CASCADE); das hier greift, wenn eine Sendung nie
|
|
ordentlich beendet wurde. */
|
|
const c = db().prepare("DELETE FROM reaktion_meldungen WHERE erledigt_am IS NOT NULL"
|
|
+ " AND erledigt_am < ?")
|
|
.run(new Date(Date.now() - MELDUNG_TAGE * 86400_000).toISOString());
|
|
/* Und abgelaufene Massnahmen -- sie enden ohnehin mit der
|
|
Sendung, aber eine Zeile, die niemand mehr wegraeumt, bleibt
|
|
sonst fuer immer stehen. */
|
|
const e = db().prepare("DELETE FROM reaktion_massnahmen WHERE bis IS NOT NULL AND bis < ?")
|
|
.run(jetzt());
|
|
if (a.changes || b.changes || c.changes || e.changes) {
|
|
console.log(`[reaktion] Aufgeraeumt: ${a.changes} Verlauf, ${b.changes} Zusehende, `
|
|
+ `${c.changes} Meldungen, ${e.changes} Massnahmen.`);
|
|
}
|
|
} catch (fehler) {
|
|
console.error("[reaktion] Aufraeumen:", fehler?.message);
|
|
}
|
|
}
|