Files
dogfather-universe/server/workspace-reaktion.js
T
DogFatherGitandClaude Opus 5 807d7d4a1c Das Foyer: eine Seite vor dem Saal, mit Plan, Eintritt und Vorschlägen
Filipe: „diese kachel soll nicht mehr geschlossen sein, ABER wenn man
drauf drückt geht zuerst eine seite auf mit reihen/kategorien. links
eine reihe wo die nächsten geplanten videos schon bereit stehen. wan
und umd wie viel uhr … und das soll geschlossen sein außer für vanvan
und dogfather. in der mitte sollen die leute dan wenn eine sendung
läuft drauf drücken können und der sendung beitreten. auch bei der
vorschau schon und dan rechts will ich die letzte reihe/kategorie, da
sollen die leute alle mir videos anraten können … und nur ich und
vanvan sehen sie."

Haus: Team Dogi (crew.dogfather-universe.com). Die Agentur ist nicht
berührt -- pruef-haus-trennung 107/0, pruef-workspace-seiten 37/0.

WARUM EINE EIGENE SEITE UND KEIN VIERTER ZUSTAND DES SAALS

Der Saal meldet jeden an, der ihn öffnet („dabei"), baut Verbindungen
auf und hält den Ereignisstrom offen. Das ist richtig für jemanden,
der zusieht -- und falsch für jemanden, der nur nachsehen will, ob
heute Abend etwas läuft. Wäre das Foyer der vierte Zustand derselben
Seite, zählte jeder Blick als Zuschauer, und die Zahl im Regiepult
sähe nach Publikum aus, wo niemand zusieht.

Im Foyer wird nur gelesen: Stand holen, zeichnen, alle 20 Sekunden
nachfragen -- und nicht, solange der Reiter im Hintergrund liegt. Kein
Ereignisstrom: Der hielte eine Verbindung offen, und manche Browser
geben je Adresse nur sechs her; im Saal wird jede davon gebraucht.

DREI REIHEN

  LINKS   die Warteschlange, nach TERMIN sortiert statt nach Platz.
          Was keinen hat, steht unten; was vorbei ist, bekommt
          „· überfällig". Nur admin und rechte Hand -- und für alle
          anderen steht sie gar nicht erst da, statt leer oder
          gesperrt zu sein. Ein Kasten mit Schloss erzählt, dass es
          etwas zu sehen gäbe. Entschieden wird das am Server; die
          Seite fragt nicht nach der Rolle, sondern danach, ob die
          Auskunft gekommen ist.
  MITTE   Lampe (Geschlossen / Gleich / Auf Sendung), Vorschaubild,
          Titel, Satz -- und der Knopf, der NUR dasteht, wenn er auch
          hineinführt. Er ist ein Link und kein Button: Wer ihn mit
          der mittleren Maustaste antippt, bekommt einen neuen Reiter.
  RECHTS  Video anraten. Jeder darf schicken, niemand sieht fremde,
          jeder sieht seine eigenen mit Stand. Vier Sperren, und jede
          beantwortet etwas anderes: zu schnell (45 s), zu viele offen
          (5 je Person), schon angeraten, steht schon bereit.

Am Telefon steht die MITTE oben. Dort ist „links" nur noch „vor allem
anderen", und das wäre die falsche Reihenfolge.

WAS SONST NOCH DAZUGEHÖRT

* Jede Zeile der Warteschlange im Saal hat jetzt ein Terminfeld.
* Die Kachel sagt nicht mehr „geschlossen" -- das Foyer ist immer
  offen. „gleich" und „● live" bleiben: Sie sagen etwas, das man ohne
  sie nicht weiß.
* reaktion.html steht in OHNE_KACHEL_UEBERALL. Die erlaubten Seiten
  werden aus den Kachelzielen abgeleitet; als die Kachel aufs Foyer
  zeigte, war der Saal plötzlich von keiner mehr genannt und
  antwortete mit einer Umleitung.

DREI DOPPELTE ANTWORTEN BESEITIGT

* „Wie viele Vorschläge darf einer offen haben" stand VIERMAL:
  dreimal als `je_person_max || 5` und einmal als Wort „fünf" im Satz
  (plus ein fünftes Mal in meldung.js). Kommt jetzt vom Server.
* `maxlength="200"` im Formular neben VORSCHLAG_NOTIZ_MAX im Server.
  Das Feld lernt die Grenze von der Auskunft.
* Die drei Foyer-Tafeln hatten eine eigene Fläche (Weiß mit 4,5 %) und
  eine eigene Form. Beides gibt es im Haus längst: Material in
  start.css (45 Klassen, gemessen), Form in module.css. Die eigene
  Fläche war dazu fast durchsichtig -- über einem hellen
  Hintergrundbild stand der Text ohne deckenden Grund. Gefunden hat
  das nicht eine Prüfung, sondern das Bildschirmfoto.

EINE LÜCKE IN DER PRÜFUNG GESCHLOSSEN

In module.css steht dieselbe 48er-Klassenliste ACHTMAL. Sieben trugen
die Marke MODULLISTE und wurden verglichen; die achte -- die Umrandung
beim Tabben (:focus-visible) -- nicht. Sie war damit die Einzige, die
still hätte abweichen können, ausgerechnet bei der Regel, die nur
sieht, wer mit der Tastatur bedient. pruef-css-klassen erwartet jetzt
acht. Eine steigende Prüfzahl, und sie ist begründet.

UND EINER, DEN KEIN WERKZEUG FINDEN KANN

Der Satz oben lautete „Links steht, was ansteht; rechts kannst du
etwas anraten." Für die Community gibt es „links" gar nicht, und am
Telefon stehen die Reihen untereinander. Eine Richtung im Text ist
eine Aussage über das Aussehen -- und das ändert sich mit dem
Bildschirm und mit der Rolle. Jetzt steht dort, WAS geht, nicht WO.

GEMESSEN

  mess-foyer (neu)        70 Messungen, 0 Fehler, 9 Bildschirmfotos
  pruef-reaktion          538, 0 Fehler (vorher 478)
  pruef-handy-teamdogi    263 Seitenaufrufe, 0 Befunde (vorher 244)
  pruef-sackgassen        14, 0 Fehler
  pruef-crew-adresse      173, 0 Fehler
  pruef-haus-trennung     107, 0 Fehler
  pruef-struktur          102, 0 Fehler
  pruef-neue-seiten       109, 0 Fehler
  pruef-portnummern        41, 0 Fehler
  pruef-haus-seiten        38, 0 Fehler
  pruef-workspace-seiten   37, 0 Fehler
  pruef-breiten           45 Seiten auf fünf Breiten, 0 beanstandet
  pruef-community-sicht    10, 0 Fehler
  pruef-deutsche-texte     12, 0 Fehler
  pruef-css-klassen       grün

mess-foyer klickt den ganzen Weg durch: Ein Gast schickt ein Video,
DogFather sieht es mit Namen, nimmt es, und es muss links im Plan
stehen. Zum Schluss macht es sechs seiner Messungen absichtlich kaputt
und prüft, dass sie das merken.

NICHT BEHOBEN, WEIL NICHT MEINS: pruef-meldungen ist rot (28
Kennungen ohne Satz, 3 Rohanzeigen) -- gemessen im worktree auf dem
letzten Commit schon vorher, Zeichen für Zeichen dieselbe Liste. Die
Dateien gehören überwiegend der Agentur; das gehört in einen eigenen
Durchgang.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-10-08 11:02:54 +02:00

2671 lines
117 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";
/* Der Spender-Glanz im Chat. EINE RICHTUNG: Die Reaction fragt die
Spenden, nie umgekehrt -- `workspace-spenden.js` kennt diese Datei
nicht und soll sie nicht kennen, sonst stünden sich beim Laden
zwei Dateien gegenseitig im Weg. */
import { spenderGlanz } from "./workspace-spenden.js";
import { einstellung } from "./workspace.js";
import { mitZugangsdaten, wegeAusfaechern } from "./workspace-turn.js";
import {
SICHT_PLAETZE, GAESTE_MAX, EINLADUNG_SEKUNDEN, TEXT_MAX, SCHREIB_BREMSE,
VORSCHLAEGE_JE_PERSON, VORSCHLAG_NOTIZ_MAX, VORSCHLAG_BREMSE_SEKUNDEN,
STAENDE, LAYOUTS, TEMPI,
KAMERA_GROESSEN, KAMERA_ECKEN,
MELDUNG_TAGE,
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";
/* Wer zum Crew-Haus gehoert, steht in `crew-adresse.js` -- die
Einladung liest dieselbe Menge wie die Hausweiche, statt eine
zweite danebenzustellen. */
import { AGENTUR_ROLLEN } from "./crew-adresse.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();
/* ======================================================================
WAS ZU EINER EINZELNEN SENDUNG GEHOERT (30.09.2026)
Filipe: „wenn eine sendung beendet wurde soll alles automatisch
verschwinden. also von daten die da stehen von dem video."
Diese Aufzaehlung stand bis dahin EINMAL mitten in der
Zuruecksetzen-Route. Jetzt brauchen sie zwei Wege -- das Beenden
und der Knopf --, und zwei Abschriften waeren die, die beim
naechsten Spaltenzuwachs auseinanderlaufen. Genau so sind am
11.09. drei Spalten mit Inhalt verlorengegangen.
SIE STEHT EINZELN DA UND NICHT ALS „alles ausser". Eine Liste von
Ausnahmen waechst still mit jeder neuen Spalte mit -- und dann
loescht das Aufraeumen etwas, das es nie loeschen sollte. Was hier
fehlt, bleibt stehen, und das ist die sichere Richtung.
====================================================================== */
const SENDUNGS_FELDER = {
titel: "", video: "", video_titel: "", vorschau: "",
beginnt_am: null, laeuft: 0, sekunde: 0, sekunde_am: 0, gestartet_am: null,
zweit_video: "", zweit_titel: "", zweit_sekunde: 0,
};
/* UND WAS EINSTELLUNG IST -- nur beim ausdruecklichen Zuruecksetzen.
Bildaufteilung, Kameragroesse und Chatmodus gelten fuer alle
Sendungen. Wer sie nach jedem Beenden neu setzen muesste, haette
nach der dritten Sendung genug davon -- und schaltet dann lieber
gar nicht mehr ab. Das ist der Unterschied zwischen Aufraeumen und
Abraeumen. */
const PULT_EINSTELLUNGEN = {
tempo: 1, layout: "kino", kamera_gross: 1, kamera_ecke: "ur",
chat_modus: "offen",
};
/** Wie alt ist dieser Spielstand -- in Millisekunden?
*
* DAS IST DIE EINE ZAHL, DIE HINAUSGEHT (30.09.2026), und
* ausdruecklich keine Uhrzeit. Ein Alter ist die Differenz zweier
* Serverzeiten; der Empfaenger braucht dafuer keine eigene Uhr.
*
* Vorher ging `gesendet: Date.now()` hinaus, und der Browser rechnete
* `Date.now() - gesendet`. Das verglich die Serveruhr mit der Uhr des
* Telefons. Gemessen am 30.09.2026: Ein Geraet, dessen Uhr 30 s
* vorgeht, spielte das Video 29,97 s weiter als alle anderen. Kein
* Fehler im Netz, kein langsames Handy -- nur zwei Uhren, die nie
* jemand verglichen hat.
*
* GEDECKELT. Ein `sekunde_am` von 0 (alte Zeile, frisch angelegte
* Sendung) hiesse sonst „dieser Stand ist 56 Jahre alt". Wer nichts
* weiss, gibt `null` zurueck -- und `null` heisst beim Empfaenger
* „nicht hochrechnen", nicht „null Millisekunden". */
const MAX_ALTER_MS = 60_000;
function standAlter(s) {
const am = Number(s?.sekunde_am) || 0;
if (!am) return null;
const alt = Date.now() - am;
if (alt < 0 || alt > MAX_ALTER_MS) return null;
return Math.round(alt);
}
/** Der Rundruf „so steht das Video jetzt" -- an EINER Stelle.
*
* ER WURDE AN DREI STELLEN VON HAND ZUSAMMENGESETZT: beim Starten
* der Sendung, beim Takt des Hosts und beim Tauschen der beiden
* Videos. Beim Einbau des Alters (30.09.2026) habe ich zuerst nur
* eine davon nachgezogen -- das Tauschen haette danach weiter die
* alte, kaputte Rechnung verschickt, und zwar still.
*
* Dieselbe Sorte Doppelung wie am 11.09.2026, als drei Spalten mit
* Inhalt verlorengegangen sind. Eine Liste, die niemand pflegt, kann
* nicht veralten.
*
* GELESEN WIRD AUS DER ZEILE, nicht aus dem, was der Aufrufer
* gerade zu wissen glaubt. */
function videoRundruf(s) {
melden("reaktion", {
was: "video",
video: s.video,
laeuft: !!s.laeuft,
sekunde: s.sekunde,
tempo: Number(s.tempo) || 1,
/* DAS ALTER, NICHT DIE UHRZEIT. Warum: siehe standAlter(). */
alter: standAlter(s),
/* `gesendet` bleibt vorerst mit drin. Ein Browser, der die alte
Fassung noch im Zwischenspeicher hat, wuerde sonst gar nicht
mehr nachfuehren -- und „gar nicht" ist schlechter als „etwas
ungenau". Darf weg, sobald der Stempel ueberall durch ist. */
gesendet: Date.now(),
});
}
/* ---------- 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, wann FROM reaktion_liste"
+ " ORDER BY platz, id").all();
}
/* ======================================================================
DER PLAN FUERS FOYER (08.10.2026)
Filipe: „links eine reihe wo die nächsten geplanten videos schon
bereit stehen. wan und umd wie viel uhr."
ES IST DIESELBE LISTE -- nur anders sortiert: nach TERMIN statt
nach Platz. Im Pult ist die Reihenfolge die Aussage („was kommt
als Naechstes im Live"), im Foyer der Termin („Donnerstag um
acht").
SORTIERT WIRD IM BROWSER UND NICHT HIER. Ein zweites Feld im
Stand waere eine zweite Antwort auf dieselbe Frage -- dieselben
Zeilen, zweimal uebertragen, und beim naechsten Umbau pflegt
jemand nur eine der beiden Stellen. Was hinausgeht, ist `liste`
mit `wann`; wie man sie anschaut, entscheidet die Seite.
VERGANGENES FAELLT NICHT WEG. Ein Termin von gestern, den niemand
weggeraeumt hat, steht weiter da und ist damit sichtbar
ueberfaellig. Ihn stillschweigend auszublenden hiesse, dass eine
Vorbereitung verschwindet, ohne dass jemand etwas getan hat.
====================================================================== */
/** Das naechste Video aus der Liste ins Bild holen.
*
* EINE STELLE FUER ZWEI KNOEPFE. „Naechstes" und „Jetzt spielen"
* 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, sekunde_am = unixepoch('subsec') * 1000, 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. */
/* HIER GILT DIE SEKUNDE JETZT -- das Video faengt von vorn an.
Ohne `sekunde_am` waere der Stand „unbekannt alt" und niemand
duerfte hochrechnen; ausgerechnet beim Start, wo alle
gleichzeitig einsteigen. */
videoRundruf(s);
return s;
}
/** Wer wirklich auf der Buehne steht.
*
* NUR `dabei`. Seit dem 08.10.2026 gibt es Zeilen, die noch keine
* Gaeste sind (gefragt) und welche, die keine mehr werden
* (abgelehnt, keine Antwort). Haetten die hier gestanden, saehe der
* ganze Saal ein Kamerafenster fuer jemanden, der die Frage noch
* gar nicht gelesen hat -- und die Anruflogik haette versucht, ihn
* anzurufen. */
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
WHERE g.stand = 'dabei'
ORDER BY g.platz`).all();
}
/** Die offenen und beantworteten Einladungen -- nur fuer den Host.
*
* ==== DIE FRIST WIRD BEIM LESEN GEZOGEN UND NICHT VON EINER UHR
*
* Ein eigener Zeitgeber waere ein zweiter Ort, an dem dieselbe
* Entscheidung faellt -- und er liefe auch dann, wenn niemand
* hinsieht. Hier gilt: Wer die Liste liest, bekommt den Stand, der
* jetzt gilt. Das ist dieselbe Form wie bei `dabeiListe()`, die
* ihre Karteileichen auch beim Lesen wegraeumt.
*
* UND SIE WIRD NICHT GELOESCHT, SONDERN UMGESCHRIEBEN. Eine
* verfallene Einladung, die verschwindet, sieht fuer den Host aus
* wie „ich habe mich verklickt". */
function einladungenListe() {
const grenze = new Date(Date.now() - EINLADUNG_SEKUNDEN * 1000).toISOString();
db().prepare(`UPDATE reaktion_gaeste SET stand = 'keine_antwort'
WHERE stand = 'gefragt' AND gefragt_am < ?`).run(grenze);
return db().prepare(`
SELECT g.person_id AS id, g.stand, g.gefragt_am, p.name
FROM reaktion_gaeste g JOIN personen p ON p.id = g.person_id
WHERE g.stand <> 'dabei'
ORDER BY g.gefragt_am`).all()
.map((z) => ({
id: z.id, name: z.name, stand: z.stand,
/* ==== ALS REST UND NICHT ALS UHRZEIT ====================
Der Empfaenger bekommt „noch so viele Sekunden" statt eines
Zeitpunkts. Eine Uhrzeit muesste gegen die Uhr seines
Geraets gerechnet werden, und die kann falsch gehen --
dieselbe Lehre wie beim Gleichlauf des Videos am
30.09.2026. Ein Handy, das zwei Minuten vorgeht, saehe
sonst jede Einladung als abgelaufen. */
noch_sek: z.stand === "gefragt"
? Math.max(0, Math.round(
(Date.parse(z.gefragt_am) + EINLADUNG_SEKUNDEN * 1000 - Date.now()) / 1000))
: 0,
}));
}
/** Wie viele Plaetze gerade belegt oder vorgemerkt sind. */
function gaesteBelegt() {
return db().prepare(`SELECT COUNT(*) AS n FROM reaktion_gaeste
WHERE stand IN ('dabei','gefragt')`).get()?.n || 0;
}
/** 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,
/* WIE ALT DIESE SEKUNDE IST. Ohne sie setzte sich jeder, der
mitten in der Sendung dazukam, auf den Stand des letzten
Host-Taktes -- gemessen 6,91 s hinter allen anderen. Jetzt
rechnet er die verstrichene Zeit dazu und steigt an der
richtigen Stelle ein. */
alter: standAlter(s),
/* 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 OFFENEN EINLADUNGEN (08.10.2026) ==================
Nur fuer den Host. Wer gefragt wurde und noch nicht geantwortet
hat, ist noch kein Gast -- und dass jemand gefragt WURDE, geht
den Saal nichts an. Wird in `oeffentlich()` ausdruecklich
entfernt. */
einladungen: istHost ? einladungenListe() : undefined,
einladung_sekunden: EINLADUNG_SEKUNDEN,
/* ==== 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,
/* ==== BIN ICH GERADE GEFRAGT? (08.10.2026) ==============
Die eine Auskunft, aus der die Seite den Vorhang baut:
„DogFather will dich auf die Bühne". Sie steht an `ich` und
nicht in einer Liste -- jeder bekommt nur seine eigene, und
dass jemand anderes gefragt wurde, geht ihn nichts an.
`noch_sek` reist mit, damit die Seite den Countdown ohne
eine eigene Uhr zeichnen kann. */
einladung: (() => {
const e = db().prepare(`SELECT gefragt_am FROM reaktion_gaeste
WHERE person_id = ? AND stand = 'gefragt'`).get(person.id);
if (!e) return null;
const rest = Math.round(
(Date.parse(e.gefragt_am) + EINLADUNG_SEKUNDEN * 1000 - Date.now()) / 1000);
/* Abgelaufen heißt „keine Einladung mehr". Die Zeile wird
beim nächsten Lesen der Hostliste umgeschrieben; hier
reicht es, sie nicht mehr zu zeigen -- sonst stünde vor
jemandem ein Vorhang mit „noch -40 Sekunden". */
if (rest <= 0) return null;
/* WER EINLÄDT, STEHT SCHON IM STAND (`lage.host.name`). Den
Namen hier ein zweites Mal mitzuschicken wäre eine zweite
Antwort auf dieselbe Frage -- und die beiden könnten
auseinanderlaufen, sobald jemand mitten in der Sendung die
Führung übernimmt. */
return { noch_sek: rest };
})(),
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;
}
/** Rundruf an alle, die von einer Aenderung erfahren sollen.
*
* `wer` ist ausdruecklich ueberschreibbar (30.09.2026): Eine
* Aenderung, die die Zuschauerliste LEERT, muss ihre Empfaenger
* vorher bestimmt haben. Sonst sucht der Rundruf sie in einer
* Liste, die es nicht mehr gibt -- und ausgerechnet die Nachricht
* „der Saal ist zu" erreicht niemanden, der darin sitzt. */
function melden(art, daten, wer) {
stromAn(wer || 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 {
/* BEIDE LISTEN, und beide stehen oben bei den Tabellen -- der
Knopf raeumt mehr weg als das blosse Beenden: auch die
Einstellungen und die Warteschlange. */
const felder = {
...SENDUNGS_FELDER, ...PULT_EINSTELLUNGEN,
stand: "zu", stand_seit: jetzt(), geaendert: jetzt(),
};
const spalten = Object.keys(felder).map((k) => `${k} = ?`).join(", ");
db().prepare(`UPDATE reaktion SET ${spalten} WHERE id = 1`)
.run(...Object.values(felder));
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();
/* WER DAVON ERFAHREN SOLL, STEHT JETZT FEST -- vor jeder
Aenderung. „zu" loescht `reaktion_dabei`; wuerde der Rundruf
seine Empfaenger erst danach suchen, ginge ausgerechnet die
Nachricht „der Saal ist zu" an niemanden, der darin sitzt.
Ihre Seite blieb dann auf „live" stehen, bis sie neu luden. */
const erfahren = empfaenger();
/* 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");
/* UND DER SCHREIBTISCH WIRD LEER (30.09.2026).
Filipe: „wenn eine sendung beendet wurde soll alles
automatisch verschwinden. also von daten die da stehen von
dem video."
Vorher blieben Titel, Video, Vorschau, Beginn und das
zweite Video stehen. Beim naechsten Aufmachen stand dort
der Titel der letzten Sendung und ein Beginn, der in der
Vergangenheit liegt -- man musste jedes Feld einzeln
leeren, bevor man anfangen konnte.
ERST DER VERLAUF, DANN DAS LEEREN. Die Zeile darueber
schreibt Titel und Video in `reaktion_verlauf`; umgekehrt
stuende dort nichts. Verloren geht also nichts -- es wandert
nur dorthin, wo Vergangenes hingehoert.
Die Einstellungen (Bildaufteilung, Kameragroesse,
Chatmodus) und die Warteschlange bleiben. Wer sie auch weg
haben will, hat dafuer den Knopf „Alles zuruecksetzen". */
Object.assign(felder, SENDUNGS_FELDER);
}
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) }, erfahren);
/* ERST JETZT EINLADEN, nach dem Schreiben und nach `melden`.
Wer die Meldung antippt, landet auf einer Seite, die den
neuen Stand schon kennt -- sonst sieht der Schnellste einen
Saal, der noch zu ist. */
if (neu === "live" && s.stand !== "live") einladen(sendung(), req.person.id);
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" });
/* Verfallene Einladungen erst wegräumen — sonst hält eine
Frage von vor einer Stunde noch einen Platz besetzt. */
einladungenListe();
if (gaesteBelegt() >= GAESTE_MAX) {
return res.status(409).json({ fehler: "gaeste_voll" });
}
/* ==== GEFRAGT, NICHT GEHOLT (08.10.2026) =================
Filipe: „wenn gäste eingeladen werden, sollen die
akzeptieren oder ablehnen können. bevor die auch angezeigt
werden sollen die auswählen können ob die kamera an ist oder
aus ist. also iohre eigene."
Vorher stand hier ein Platz und eine Zeile — und die Kamera
des Gastes sprang im selben Augenblick an. Jetzt entsteht
erst eine FRAGE. Der Platz wird beim Zusagen vergeben, nicht
jetzt: Wer ablehnt, soll Platz 1 nicht für zwei Minuten
blockiert haben.
`INSERT OR REPLACE` räumt dabei eine alte Antwort derselben
Person weg — wer eben abgelehnt hat und jetzt noch einmal
gefragt wird, steht nicht zweimal in der Liste. */
db().prepare(`INSERT OR REPLACE INTO reaktion_gaeste
(person_id, seit, stumm, platz, stand, gefragt_am)
VALUES (?,?,0,0,'gefragt',?)`).run(id, jetzt(), jetzt());
protokolliere("reaktion_gast_gefragt", {
personId: req.person.id,
detail: p.name,
ip: echteIp(req),
});
const l = lage(req.person);
/* KEIN RUNDRUF AN DEN SAAL. Es hat sich für ihn nichts
geändert — auf der Bühne steht niemand Neues. Ein Rundruf
hier hieße, dass alle erfahren, wer gefragt wurde. */
stromAn([id], "reaktion", { was: "du_bist_eingeladen" });
res.status(201).json(l);
} catch (fehler) {
console.error("[reaktion] Gast:", fehler?.message);
res.status(503).json({ fehler: "nicht_verfuegbar" });
}
});
/* ---------- Die Antwort des Gastes -------------------------------------
Filipe: „sollen die akzeptieren oder ablehnen können. bevor die
auch angezeigt werden sollen die auswählen können ob die kamera an
ist oder aus ist."
ZWEI DINGE IN EINEM AUFRUF, und das ist Absicht: Die Zusage UND
die Vorwahl von Kamera und Mikro kommen zusammen an. Zwei Aufrufe
hintereinander hätten ein Fenster dazwischen, in dem jemand schon
auf der Bühne steht, dessen Kamera noch auf „an" steht — und genau
diese Sekunde ist das, was abgestellt werden soll.
DAS IST KEIN HOST-WEG. Hier antwortet der Gefragte selbst, für
sich. `nurHost` wäre hier falsch herum.
----------------------------------------------------------------------- */
reaktionRouter.post("/workspace/api/reaktion/gast/antwort",
express.json({ limit: "2kb" }), (req, res) => {
try {
const e = db().prepare(`SELECT * FROM reaktion_gaeste
WHERE person_id = ? AND stand = 'gefragt'`).get(req.person.id);
if (!e) return res.status(404).json({ fehler: "keine_einladung" });
/* Die Frist gilt auch hier und nicht nur beim Anzeigen. Sonst
könnte eine Seite, die seit einer Stunde offen liegt, eine
längst verfallene Einladung annehmen — und stünde plötzlich
im Bild, ohne dass jemand damit rechnet. */
if (Date.parse(e.gefragt_am) + EINLADUNG_SEKUNDEN * 1000 <= Date.now()) {
db().prepare("UPDATE reaktion_gaeste SET stand = 'keine_antwort' WHERE person_id = ?")
.run(req.person.id);
return res.status(409).json({ fehler: "einladung_abgelaufen" });
}
const ja = req.body?.ja === true;
if (!ja) {
db().prepare("UPDATE reaktion_gaeste SET stand = 'abgelehnt' WHERE person_id = ?")
.run(req.person.id);
protokolliere("reaktion_gast_abgelehnt", {
personId: req.person.id, detail: req.person.name, ip: echteIp(req),
});
/* Nur der Host erfährt es — und er erfährt es SOFORT, sonst
wartet er zwei Minuten auf eine Antwort, die schon da
ist. */
const s = sendung();
if (s?.host_id) {
stromAn([s.host_id], "reaktion",
{ was: "einladung_beantwortet", name: req.person.name, ja: false });
}
return res.json(lage(req.person));
}
/* Zwischen Frage und Zusage kann ein zweiter Gast zugesagt
haben. Ohne diese Prüfung stünden drei auf einer Bühne für
zwei. */
const dabeiJetzt = db().prepare(`SELECT COUNT(*) AS n FROM reaktion_gaeste
WHERE stand = 'dabei'`).get()?.n || 0;
if (dabeiJetzt >= 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++;
/* ==== DIE VORWAHL WIRD GESETZT, BEVOR DIE BÜHNE AUFGEHT ===
Sie steht in `reaktion_dabei` — dort, wo auch der
Senderknopf sie später hinschreibt. Eine zweite Spalte an
der Gastzeile wäre eine zweite Antwort auf dieselbe Frage,
und spätestens beim ersten Klick auf „Kamera" liefen beide
auseinander.
FEHLT DIE ANGABE, IST SIE AN. Das ist der Zustand, den jeder
erwartet, der gerade „ja" gesagt hat — und die Seite
schickt sie immer mit. */
const kameraAus = req.body?.kamera === false ? 1 : 0;
const mikroAus = req.body?.mikro === false ? 1 : 0;
db().exec("BEGIN IMMEDIATE");
try {
db().prepare(`UPDATE reaktion_gaeste SET stand = 'dabei', platz = ?, seit = ?
WHERE person_id = ?`).run(platz, jetzt(), req.person.id);
/* ==== EINFÜGEN ODER ÄNDERN, ABER NIE ÜBERSCHREIBEN ====
Wer antwortet, sitzt im Saal und steht damit längst in
`reaktion_dabei` — der Normalfall ist also ein ÄNDERN.
Ein reines `UPDATE` hätte aber einen stillen Ausgang: Ist
die Zeile zwischendurch weggeräumt worden (zwei Minuten
ohne Lebenszeichen genügen), ändert es null Zeilen, meldet
nichts, und der Gast stünde mit laufender Kamera im Bild,
obwohl er sie abgewählt hat. Genau die Sorte Fehler, die
man erst im Stream sieht.
`ON CONFLICT` setzt deshalb nur die beiden Felder und
lässt `seit`, `gesehen` und `sicht` in Ruhe. */
db().prepare(`INSERT INTO reaktion_dabei
(person_id, seit, gesehen, sicht, kamera_aus, mikro_aus)
VALUES (?,?,?,0,?,?)
ON CONFLICT(person_id) DO UPDATE SET
kamera_aus = excluded.kamera_aus, mikro_aus = excluded.mikro_aus`)
.run(req.person.id, jetzt(), jetzt(), kameraAus, mikroAus);
db().exec("COMMIT");
} catch (f) {
db().exec("ROLLBACK");
throw f;
}
protokolliere("reaktion_gast_dazu", {
personId: req.person.id,
detail: `${req.person.name} (Platz ${platz}, Kamera ${kameraAus ? "aus" : "an"})`,
ip: echteIp(req),
});
const l = lage(req.person);
melden("reaktion", { was: "gaeste", lage: oeffentlich(l) });
const s = sendung();
if (s?.host_id) {
stromAn([s.host_id], "reaktion",
{ was: "einladung_beantwortet", name: req.person.name, ja: true });
}
res.json(l);
} catch (fehler) {
console.error("[reaktion] Gastantwort:", 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);
/* WIE ALT DIE ABLESUNG BEIM ABSENDEN WAR.
Der Host misst das oertlich (Differenz zweier eigener
Messpunkte) -- das stimmt auch auf einem Geraet mit falscher
Uhr, waehrend eine mitgeschickte Uhrzeit dort falsch waere.
Gedeckelt, weil es vom Browser kommt: Was hereinkommt, wird
geprueft, nicht geglaubt. */
const alter = Math.min(5000, Math.max(0, Number(req.body?.alter) || 0));
const sekundeAm = Date.now() - alter;
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 = ?,
sekunde_am = ?, geaendert = ? WHERE id = 1`)
.run(video, laeuft, sekunde, sekundeAm, 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 = ?, sekunde_am = ? WHERE id = 1")
.run(laeuft, sekunde, sekundeAm);
}
const s = sendung();
videoRundruf(s);
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 = ?,
sekunde_am = unixepoch('subsec') * 1000,
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();
videoRundruf(n);
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" });
}
});
/* ---------- Wann ein vorbereitetes Video laufen soll ---------------------
Filipe: „links eine reihe wo die nächsten geplanten videos schon
bereit stehen. wan und umd wie viel uhr."
EIN EIGENER WEG UND KEIN FELD AM ANHAENGEN. Der Termin entsteht
fast nie beim Einfuegen -- man raeumt erst ein und legt danach
fest, wann es laufen soll. Ein Pflichtfeld beim Anhaengen haette
aus dem schnellsten Handgriff des Pults („Adresse einfuegen,
Enter") ein Formular gemacht.
DER WERT WIRD GEPRUEFT UND NICHT NUR GESPEICHERT. Was hier
hereinkommt, steht spaeter als Datum im Foyer; ein freier Text
ergaebe dort „Invalid Date" -- und das sieht aus, als sei die
Seite kaputt, nicht die Eingabe. */
reaktionRouter.put("/workspace/api/reaktion/liste/:id/wann", nurHost,
express.json({ limit: "2kb" }), (req, res) => {
try {
const id = Number(req.params.id) || 0;
const roh = String(req.body?.wann ?? "").trim();
let wann = "";
if (roh) {
const d = new Date(roh);
if (Number.isNaN(d.getTime())) return res.status(400).json({ fehler: "termin_unklar" });
wann = d.toISOString();
}
const a = db().prepare("UPDATE reaktion_liste SET wann = ? WHERE id = ?").run(wann, id);
if (!a.changes) return res.status(404).json({ fehler: "nicht_in_der_liste" });
const l = lage(req.person);
melden("reaktion", { was: "liste", lage: oeffentlich(l) });
res.json(l);
} catch (fehler) {
console.error("[reaktion] Termin:", fehler?.message);
res.status(503).json({ fehler: "nicht_verfuegbar" });
}
});
/* =======================================================================
DIE VORSCHLAEGE (08.10.2026)
Filipe: „da sollen die leute alle mir videos anraten können. also
da können die leute videos reinschicken und nur ich und vanvan
sehen sie."
-----------------------------------------------------------------------
WER WAS SIEHT -- UND WARUM DER EINSENDER SEINEN EIGENEN SIEHT
„Nur ich und vanvan sehen sie" heisst: Niemand sieht die
Vorschlaege ANDERER. Dass jemand seinen eigenen zurueckbekommt,
ist kein Widerspruch, sondern die Voraussetzung dafuer, dass er
ueberhaupt einen schickt: Ein Formular, das nach dem Absenden
nichts mehr zeigt, sieht aus wie ein Loch. Er sieht seine eigene
Zeile mit dem Stand -- offen, uebernommen, abgelehnt -- und sonst
nichts.
DER TITEL WIRD BEI YOUTUBE GEHOLT, nicht vom Einsender getippt.
Sonst stuende im Pult ein Name, den jemand frei gewaehlt hat,
neben einer Kennung, die etwas anderes ist -- und die Entscheidung
fiele ueber den falschen Film.
======================================================================= */
/** Eine Zeile, wie sie hinausgeht. */
function alsVorschlag(z, fuerHost) {
return {
id: z.id,
video: z.video,
titel: z.titel || "",
notiz: z.notiz || "",
stand: z.stand,
erstellt: z.erstellt,
/* WER ES WAR, SIEHT NUR DIE LEITUNG. Fuer den Einsender steht
dort ohnehin er selbst; den Namen mitzuschicken waere eine
Auskunft ueber andere, sobald sich die Liste einmal aendert. */
name: fuerHost ? (z.name || "") : undefined,
mein: undefined,
};
}
reaktionRouter.get("/workspace/api/reaktion/vorschlaege", (req, res) => {
try {
const istHost = HOST_ROLLEN.has(req.person.rolle);
const reihen = istHost
? db().prepare(`SELECT * FROM reaktion_vorschlaege
ORDER BY (stand = 'offen') DESC, id DESC LIMIT 200`).all()
: db().prepare(`SELECT * FROM reaktion_vorschlaege WHERE person_id = ?
ORDER BY id DESC LIMIT 50`).all(req.person.id);
res.json({
vorschlaege: reihen.map((z) => alsVorschlag(z, istHost)),
/* WIE VIELE NOCH OFFEN SIND -- die eine Zahl, die ins Register
und an die Kachel gehoert. Sie wird hier gerechnet und nicht
im Browser aus der Liste gezaehlt: Wer nur seine eigenen
bekommt, zaehlte sonst seine eigenen. */
offen: istHost
? (db().prepare("SELECT COUNT(*) AS n FROM reaktion_vorschlaege"
+ " WHERE stand = 'offen'").get()?.n || 0)
: null,
/* Was der Einsender wissen muss, um nicht gegen eine Wand zu
laufen: wie viele er noch offen haben darf. */
je_person_max: VORSCHLAEGE_JE_PERSON,
notiz_max: VORSCHLAG_NOTIZ_MAX,
darf_entscheiden: istHost,
});
} catch (fehler) {
console.error("[reaktion] Vorschlaege:", fehler?.message);
res.status(503).json({ fehler: "nicht_verfuegbar" });
}
});
reaktionRouter.post("/workspace/api/reaktion/vorschlag", express.json({ limit: "4kb" }),
async (req, res) => {
try {
const video = videoKennung(String(req.body?.video ?? ""));
if (!video) return res.status(400).json({ fehler: "video_unklar" });
/* ==== DREI SPERREN, UND JEDE BEANTWORTET ETWAS ANDERES ====
(1) Zu schnell -- gegen den Doppelklick.
(2) Zu viele offen -- gegen die Liste, die niemand mehr
durchsieht.
(3) Schon da -- gegen dreimal dieselbe Entscheidung. */
const kurz = new Date(Date.now() - VORSCHLAG_BREMSE_SEKUNDEN * 1000).toISOString();
const eben = db().prepare(`SELECT COUNT(*) AS n FROM reaktion_vorschlaege
WHERE person_id = ? AND erstellt > ?`).get(req.person.id, kurz);
if ((eben?.n || 0) >= 1) return res.status(429).json({ fehler: "zu_schnell" });
const meine = db().prepare(`SELECT COUNT(*) AS n FROM reaktion_vorschlaege
WHERE person_id = ? AND stand = 'offen'`).get(req.person.id);
if ((meine?.n || 0) >= VORSCHLAEGE_JE_PERSON) {
return res.status(409).json({ fehler: "zu_viele_vorschlaege" });
}
const schonOffen = db().prepare(`SELECT id FROM reaktion_vorschlaege
WHERE video = ? AND stand = 'offen'`).get(video);
if (schonOffen) return res.status(409).json({ fehler: "vorschlag_doppelt" });
/* Und was schon in der Warteschlange liegt, braucht niemand
mehr anzuraten -- das waere eine Bitte um etwas, das bereits
entschieden ist. */
const inDerListe = db().prepare("SELECT id FROM reaktion_liste WHERE video = ?")
.get(video);
if (inDerListe) return res.status(409).json({ fehler: "steht_schon_bereit" });
const notiz = String(req.body?.notiz ?? "").trim().slice(0, VORSCHLAG_NOTIZ_MAX);
const titel = await videoTitel(video);
const { lastInsertRowid } = db().prepare(`INSERT INTO reaktion_vorschlaege
(person_id, name, video, titel, notiz, stand, erstellt)
VALUES (?,?,?,?,?,'offen',?)`)
.run(req.person.id, req.person.name, video, titel, notiz, jetzt());
const z = db().prepare("SELECT * FROM reaktion_vorschlaege WHERE id = ?")
.get(Number(lastInsertRowid));
/* NUR DIE ZWEI ERFAHREN ES. Ein Rundruf an den Saal haette
genau das verraten, was niemand sehen soll. */
stromAn(
db().prepare("SELECT id FROM personen WHERE rolle IN ('admin','hand') AND aktiv = 1")
.all().map((p) => p.id),
"reaktion", { was: "vorschlag" },
);
res.status(201).json({ ok: true, vorschlag: alsVorschlag(z, false) });
} catch (fehler) {
console.error("[reaktion] Vorschlag:", fehler?.message);
res.status(503).json({ fehler: "nicht_verfuegbar" });
}
});
/** Einen Vorschlag uebernehmen -- er wandert in die Warteschlange. */
reaktionRouter.post("/workspace/api/reaktion/vorschlag/:id(\\d+)/uebernehmen", nurHost,
(req, res) => {
try {
const z = db().prepare("SELECT * FROM reaktion_vorschlaege WHERE id = ?")
.get(Number(req.params.id));
if (!z) return res.status(404).json({ fehler: "nicht_gefunden" });
if (z.stand !== "offen") return res.status(409).json({ fehler: "schon_entschieden" });
const wie_viele = db().prepare("SELECT COUNT(*) AS n FROM reaktion_liste").get()?.n || 0;
if (wie_viele >= LISTE_MAX) return res.status(409).json({ fehler: "liste_voll" });
const schon = db().prepare("SELECT id FROM reaktion_liste WHERE video = ?").get(z.video);
if (schon) return res.status(409).json({ fehler: "liste_doppelt" });
/* EIN SCHREIBVORGANG. Bricht es zwischen den beiden
Anweisungen ab, stuende der Vorschlag als „uebernommen" da,
ohne dass etwas in der Schlange liegt -- und niemand wuerde
es je bemerken. */
const letzter = db().prepare("SELECT MAX(platz) AS p FROM reaktion_liste").get()?.p || 0;
db().exec("BEGIN IMMEDIATE");
try {
db().prepare("INSERT INTO reaktion_liste (video, titel, platz, von_id, gesetzt_am)"
+ " VALUES (?,?,?,?,?)")
.run(z.video, z.titel, letzter + 1, req.person.id, jetzt());
db().prepare(`UPDATE reaktion_vorschlaege SET stand = 'uebernommen',
erledigt_am = ?, erledigt_von = ? WHERE id = ?`)
.run(jetzt(), req.person.id, z.id);
db().exec("COMMIT");
} catch (f) { db().exec("ROLLBACK"); throw f; }
protokolliere("reaktion_vorschlag", {
personId: req.person.id,
detail: `uebernommen: ${z.titel || z.video} (von ${z.name || "?"})`,
ip: echteIp(req),
});
/* DER EINSENDER ERFAEHRT ES. Ohne diese Zeile stuende sein
Vorschlag bei ihm bis zum naechsten Laden auf „offen" --
und die schoenste Nachricht des Abends kaeme nie an. */
if (z.person_id) stromAn([z.person_id], "reaktion", { was: "vorschlag" });
const l = lage(req.person);
melden("reaktion", { was: "liste", lage: oeffentlich(l) });
res.json(l);
} catch (fehler) {
console.error("[reaktion] Vorschlag uebernehmen:", fehler?.message);
res.status(503).json({ fehler: "nicht_verfuegbar" });
}
});
reaktionRouter.post("/workspace/api/reaktion/vorschlag/:id(\\d+)/ablehnen", nurHost,
(req, res) => {
try {
const a = db().prepare(`UPDATE reaktion_vorschlaege SET stand = 'abgelehnt',
erledigt_am = ?, erledigt_von = ? WHERE id = ? AND stand = 'offen'`)
.run(jetzt(), req.person.id, Number(req.params.id));
if (!a.changes) return res.status(409).json({ fehler: "schon_entschieden" });
const z = db().prepare("SELECT person_id FROM reaktion_vorschlaege WHERE id = ?")
.get(Number(req.params.id));
if (z?.person_id) stromAn([z.person_id], "reaktion", { was: "vorschlag" });
res.json({ ok: true });
} catch (fehler) {
console.error("[reaktion] Vorschlag ablehnen:", fehler?.message);
res.status(503).json({ fehler: "nicht_verfuegbar" });
}
});
/** Wegnehmen: der Einsender seinen eigenen OFFENEN, die Leitung jeden.
*
* ==== WARUM DER EINSENDER NUR DEN OFFENEN ZURUECKZIEHEN DARF
*
* Was entschieden ist, ist Geschichte -- und zwar auch fuer ihn.
* Koennte er einen abgelehnten loeschen, verschwaende damit die
* Spur, dass ueberhaupt etwas entschieden wurde; der naechste
* Vorschlag desselben Videos saehe dann aus wie der erste. */
reaktionRouter.delete("/workspace/api/reaktion/vorschlag/:id(\\d+)", (req, res) => {
try {
const id = Number(req.params.id);
const z = db().prepare("SELECT * FROM reaktion_vorschlaege WHERE id = ?").get(id);
if (!z) return res.status(404).json({ fehler: "nicht_gefunden" });
const istHost = HOST_ROLLEN.has(req.person.rolle);
const meiner = z.person_id === req.person.id && z.stand === "offen";
if (!istHost && !meiner) return res.status(403).json({ fehler: "nicht_deiner" });
db().prepare("DELETE FROM reaktion_vorschlaege WHERE id = ?").run(id);
res.json({ ok: true });
} catch (fehler) {
console.error("[reaktion] Vorschlag weg:", fehler?.message);
res.status(503).json({ fehler: "nicht_verfuegbar" });
}
});
/** Eins hoch oder runter.
*
* GETAUSCHT WIRD MIT DEM NACHBARN, nicht neu durchnummeriert. Beim
* 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);
/* `stand = 'dabei'` seit dem 08.10.2026: Wer erst GEFRAGT ist,
sendet noch nicht und darf hier auch nichts schalten. */
const binGast = !!db().prepare("SELECT 1 FROM reaktion_gaeste"
+ " WHERE person_id = ? AND stand = 'dabei'").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" });
}
});
/* =====================================================================
DIE EINLADUNG -- wenn die Sendung aufgeht (29.09.2026)
Filipe: „ich will dass du die benarichtigungen perfektionierst.
dogfather und alle anderen sollen die benachrichtigungen perfekt
von dieser seite hier bekommen."
NACHGEMESSEN WAR DIESE DATEI STUMM: kein einziges `benachrichtige`.
Geht die Sendung auf Sendung, ruft sie `melden("reaktion", ...)` --
und das laeuft ueber den Ereignisstrom, also NUR zu Leuten, die die
Seite ohnehin schon offen haben. Wer nicht hinschaut, erfaehrt
nichts. Das Kino macht auf, und die Einladung verlaesst das Haus
nie.
Genau dasselbe Muster wie bei den Bewerbungen am 23.09.: beide Wege
gebaut, beide stumm.
===================================================================== */
/** Wer wird eingeladen?
*
* Alle mit einem angemeldeten Geraet, deren Rolle NICHT der Agentur
* gehoert -- das ist woertlich die Hausteilung aus `crew-adresse.js`
* und keine neue Liste. Ein Scout bekaeme sonst eine Einladung in
* ein Kino, dessen Tuer fuer ihn nicht existiert: `zielFuer` schickt
* ihn auf die Startseite, und er stuende vor einer Meldung, die
* nirgendwohin fuehrt.
*
* OHNE DEN, DER EINGESCHALTET HAT. Er hat gerade den Knopf
* gedrueckt. */
function eingeladene(ausser) {
return db().prepare(`SELECT DISTINCT a.person_id AS id, p.rolle
FROM push_anmeldungen a JOIN personen p ON p.id = a.person_id`).all()
.filter((z) => z.id !== ausser && !AGENTUR_ROLLEN.has(z.rolle))
.map((z) => z.id);
}
/** Lief eben schon eine Einladung raus?
*
* DIE BREMSE GEGEN DEN NEUSTART. Das Merkmal allein reicht nicht:
* Es haengt an `gestartet_am`, und das wird bei jedem Wechsel nach
* „live" neu gesetzt. Wer aus Versehen auf „Vorbereitung" und gleich
* wieder auf „Auf Sendung" drueckt -- oder wem der Rechner
* abstuerzt --, laedt sonst zweimal ein. Acht Menschen bekommen dann
* zwei Meldungen fuer dieselbe Sendung, und das ist der schnellste
* Weg, dass jemand die Art abschaltet.
*
* ZWEI STUNDEN, weil eine Reaction etwa so lange dauert. Wer danach
* eine zweite, wirklich neue Sendung aufmacht, laedt zu Recht neu
* ein.
*
* Gefragt wird `push_verschickt` -- also das, was WIRKLICH rausging.
* Wurde die erste Einladung verschluckt (Ruhezeit, kein Geraet),
* steht dort nichts, und der zweite Versuch darf. */
function ebenSchonEingeladen() {
const z = db().prepare(
`SELECT MAX(zeit) AS zuletzt FROM push_verschickt
WHERE merkmal LIKE 'reaktion_live:%'`).get();
if (!z?.zuletzt) return false;
return (Date.now() - Date.parse(z.zuletzt)) < 2 * 60 * 60 * 1000;
}
/** Die Einladung verschicken -- ohne die Antwort aufzuhalten.
*
* Der Knopf „Auf Sendung" muss sofort reagieren. Acht
* Verschluesselungen und acht Netzwege dauern laenger als die
* Antwort dauern darf, und wenn ein Push-Dienst gerade klemmt,
* haenge die ganze Sendung daran. Deshalb losgeschickt und nicht
* abgewartet -- ein Fehler dabei steht im Protokoll, nicht im Weg. */
function einladen(sendungJetzt, hostId) {
(async () => {
try {
if (ebenSchonEingeladen()) {
console.log("[reaktion] Einladung uebersprungen -- vor weniger als "
+ "zwei Stunden lief schon eine.");
return;
}
const { benachrichtige } = await import("./workspace-push.js");
const wer = eingeladene(hostId);
const titel = String(sendungJetzt.video_titel || sendungJetzt.titel || "").trim();
let raus = 0;
for (const id of wer) {
const e = await benachrichtige(id, "reaktion_live", {
titel: "Die Reaction läuft",
/* WAS GESCHAUT WIRD, STEHT DRIN -- das ist der Unterschied
zwischen „komm rein" und „komm rein, wir schauen X". Ist
kein Titel da, wird keiner erfunden. */
text: titel ? `Wir schauen: ${titel}` : "DogFather hat die Sendung aufgemacht.",
ziel: "/workspace/reaktion.html",
merkmal: `reaktion_live:${sendungJetzt.gestartet_am || jetzt()}`,
});
if (e?.verschickt) raus++;
}
console.log(`[reaktion] Einladung an ${raus} von ${wer.length} verschickt.`);
} catch (f) {
console.error("[reaktion] Einladung:", f?.message);
}
})();
}
/* 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 ----------------------------------------------- */
/* =====================================================================
DAS ABZEICHEN STATT DER ROLLE (07.10.2026)
=====================================================================
GEFUNDEN BEIM NACHGEHEN EINES ROTEN LAUFS, nicht gesucht:
`pruef-modi-wortleck` meldete drei Fundstellen des Rollennamens in
ausgelieferten Dateien. Beim Nachsehen, wie der Name dorthin kam,
stellte sich heraus, dass er gar nicht erst dort stehen muss -- und
dass er ausserdem an einer Stelle steht, die jene Pruefung gar nicht
sehen KANN: in den DATEN.
Hier stand `SELECT id, person_id, name, rolle, ...` und darunter ein
schlichtes `res.json`. Eine Chatnachricht eines Modis trug damit
`"rolle":"modi"` an JEDEN im Saal -- auch an Creator und Scouts, vor
denen der Zugang verborgen sein soll. Ein Blick in die Netzwerkspur
genuegte.
DIE OBERFLAECHE BRAUCHTE DIE ROLLE NIE. Sie tat damit genau zwei
Dinge: ein Abzeichen malen und "gehoert zum Team" setzen. Beides
wird jetzt HIER entschieden und fertig verschickt.
WARUM DAS WORT „Modi" TROTZDEM MITGEHT: Es steht ohnehin sichtbar
auf dem Abzeichen -- Filipe wollte das ausdruecklich („die modis,
linke hand und rechte hand soll im chat extra aussehen"), und das
Modi-Team ist oeffentlich. Verborgen ist nicht das TEAM, sondern
dass es einen eigenen ZUGANG gibt; und genau der Schluessel `modi`
verlaesst das Haus jetzt nicht mehr. Die Farbkennung heisst
`moderation` und benennt die Aufgabe, nicht den Zugang. */
const CHAT_ABZEICHEN = {
admin: { wort: "Dogi", ton: "dogi" },
hand: { wort: "Team", ton: "team" },
linke: { wort: "Team", ton: "team" },
modi: { wort: "Modi", ton: "moderation" },
};
/** Ein Beitrag, wie ihn der Saal sehen darf -- ohne Rollenschluessel.
*
* AN EINER STELLE, weil es zwei Ausgaenge gibt: die Liste beim Laden
* und die Meldung beim Schreiben. Zwei Fassungen waeren zwei
* Gelegenheiten, eine davon zu vergessen -- und vergessen wuerde man
* die, die seltener angefasst wird. */
function fuerDenSaal(b, glanz) {
if (!b) return b;
const { rolle, ...rest } = b;
/* ==== DER SPENDER-GLANZ (08.10.2026) ==========================
Filipe: „die leute die spenden gemacht haben sollen auch für
eine kurze zeit speziell aussehen je nach höhe von spende."
DIE KARTE WIRD EINMAL JE ANFRAGE GEBAUT und hereingereicht --
der Chat lädt 120 Beiträge, und eine Abfrage je Beitrag wären
120 Abfragen für eine Auskunft, die sich dazwischen gar nicht
ändert. Fehlt sie (einzelner neuer Beitrag), holt diese
Funktion sie selbst; dann ist es genau eine.
WARUM DER GLANZ AM BEITRAG HÄNGT UND NICHT AN DER PERSON: Ein
Beitrag von vor einer Stunde soll nicht nachträglich leuchten,
wenn jemand eben gespendet hat. Er bekommt den Stand, der beim
Laden gilt -- und beim nächsten Laden den dann gültigen. */
const k = (glanz || spenderGlanz()).get(b.person_id);
return {
...rest,
abzeichen: CHAT_ABZEICHEN[rolle] || null,
/* „Gehoert zum Team" war vorher `b.rolle !== 'gast'` in der
Oberflaeche. Dieselbe Frage, nur ohne den Namen. */
team: !!rolle && rolle !== "gast",
spende: k
? {
stufe: k.stufe, farbe: k.farbe, dogen_mille: k.dogen_mille,
muenze: k.muenze, noch_ms: k.noch_ms,
}
: null,
};
}
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();
const glanz = spenderGlanz();
res.json({ beitraege: reihen.reverse().map((b) => fuerDenSaal(b, glanz)) });
} 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 = fuerDenSaal(db().prepare(
"SELECT id, person_id, name, rolle, text, erstellt FROM reaktion_chat WHERE id = ?")
.get(Number(lastInsertRowid)));
/* BEIDE WEGE, nicht nur einer: `melden` schiebt den Beitrag ueber
den Ereignisstrom an alle im Saal, `res.json` an den Schreiber
selbst. Wer nur den einen saeubert, hat die Rolle weiterhin bei
allen anderen -- also genau dort, wo sie nicht hingehoert. */
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. */
/* `einladungen` KAM AM 08.10.2026 DAZU. Dass jemand gefragt wurde
und noch nicht geantwortet hat, geht den Saal nichts an -- und
erst recht nicht, dass jemand ABGELEHNT hat. Der Rundruf wird
aus dem Stand dessen gebaut, der gerade etwas getan hat; war das
der Host, ginge die ganze Liste an jeden im Saal. Genau dieselbe
Falle wie bei `meldungen` am 28.09. */
const { zuschauer, ich, liste, meldungen, massnahmen, zweit, einladungen,
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);
}
}