Filipe: „die videos muessen perfekt laufen wenn ich die reaktions
mache."
GEMESSEN, NICHT VERMUTET. mess-reaktion hat einen neuen Abschnitt:
Er liest den Spielstand am ECHTEN <video>-Element in beiden Browsern,
rechnet beide auf denselben Augenblick um und sagt, wie weit Host und
Zuschauerin auseinanderliegen. Die vorhandene Pruefung fragte nur, ob
Start und Stopp ANKOMMEN -- zwei Videos koennen beide laufen und
trotzdem zwanzig Sekunden auseinander sein.
vorher nachher
Im Lauf 0,12 s -0,04 s
Wer dazukommt 6,91 s 0,26 s
Uhr des Geraets 30 s vor -29,97 s 0,26 s
DER SCHLIMMSTE FUND: DIE UHR DES TELEFONS
Der Zuschauer rechnete `Date.now() - stand.gesendet`. `gesendet` kam
vom SERVER, `Date.now()` vom Geraet -- zwei Uhren, die nie jemand
verglichen hat. Wessen Telefon dreissig Sekunden vorgeht, schaute
29,97 s weiter als alle anderen. Kein langsames Netz, kein schwaches
Handy. Oertlich faellt das nie auf: Auf einem Rechner sind beide
Uhren dieselbe.
Jetzt geht ein ALTER hinaus statt einer Uhrzeit -- die Differenz
zweier SERVERzeiten. Der Empfaenger braucht dafuer gar keine Uhr,
nur eine Stoppuhr (`performance.now()`), und die haelt Zeitumstellung
und Aufwachen aus dem Ruhezustand aus.
WEITER
* WER DAZUKOMMT, STEIGT RICHTIG EIN. Der gespeicherte Stand war so
alt wie der letzte Takt des Hosts; neue Spalte `sekunde_am` sagt,
wann er galt.
* DAS TEMPO GEHT IN DIE HOCHRECHNUNG EIN. Bei 2x laeuft die Videouhr
doppelt so schnell -- das fehlte ganz.
* JEDER RECHNET ALLE ZWEI SEKUNDEN SELBST NACH statt nur auf Zuruf.
Wer stockte, hing bis zu fuenf Sekunden hinterher.
* DER VORLAUF NACH EINEM SPRUNG WIRD GEREGELT, nicht geschaetzt --
gemessen wird der Rest, nicht die Ladezeit (siehe unten).
* PUFFERN IST AUCH IN DER TRANSPORTLEISTE KEINE PAUSE. Der
Start-Stopp-Knopf rief beim Puffern `playVideo()` -- wer Pause
drueckte, bekam „weiter". Lampe und Knopfbild flackerten bei jedem
Stocken. Die Lektion vom 29.09. war nur an einer Stelle eingeloest.
* DAS TEMPO MELDET UEBER `hostMelden()` statt ein zweites Mal von
Hand -- es hielt Puffern fuer Stillstand und hielt damit beim
Umschalten alle an.
* EIN VIDEO-RUNDRUF STATT DREI ABSCHRIFTEN. Beim Einbau hatte ich
zuerst nur eine nachgezogen; das Tauschen der beiden Videos haette
still die alte Rechnung weiterverschickt.
DREI EIGENE FEHLER UNTERWEGS, alle im Text festgehalten
1. ZWEI FUNKTIONEN HIESSEN `springen`. Meine „spring AUF die Stelle"
wurde von der vorhandenen „spring UM so viele Sekunden"
ueberschrieben -- lautlos, rueckwirkend fuer die ganze Datei. Ein
Zuschauer bei Sekunde 100, dem „geh auf 101" gesagt wird, waere
bei 201 gelandet. DREI BROWSERLAEUFE HABEN ES NICHT GEFUNDEN:
oertlich blieb der Abstand unter der Sprungschwelle, die Zeile
lief kein einziges Mal. Gefunden hat es erst eine Frage an die
ganze Datei -- die steht jetzt als Pruefung drin und wurde
nachgestellt (rot).
2. DER VORLAUF LERNTE NUR NACH OBEN. Gemessen wurde die Ladezeit
nach einem Sprung; ein Sprung in geladenes Material dauert aber
gar nicht, meldet nichts und lehrt nichts. Ergebnis: -0,95 s
stabil. Jetzt wird das ERGEBNIS geregelt statt der Ursache.
3. DER AUSREISSER VON 39,84 s WAR MEINE EIGENE MESSUNG -- der Block
davor schickt eine erfundene Position. Jetzt wird abgeklungen und
in Messreihenfolge ausgegeben.
GEPRUEFT
pruef-reaktion 421/0 (14 neue, darunter der Riegel gegen die
Rueckkehr der Uhrenrechnung und die Wache gegen doppelte
Funktionsnamen -- beide nachgestellt), mess-reaktion ohne ein
einziges ACHTUNG, pruef-buehne 38/0, pruef-struktur, pruef-
zwischenspeicher 34/0.
Schemaaenderung: ADD COLUMN sekunde_am. Datenbank vorher gesichert
und geprueft (integrity_check ok, 20 Personen).
Co-Authored-By: Claude Opus 5 <[email protected]>
2070 lines
89 KiB
JavaScript
2070 lines
89 KiB
JavaScript
/* =====================================================================
|
|
DIE REACTION — die Wege (28.09.2026)
|
|
|
|
Der Ablauf aus Filipes Notiz, von links nach rechts:
|
|
|
|
Kachel sichtbar -> geschlossen -> Vorbereitung ->
|
|
Wartebereich/Chat -> Countdown -> LIVE ->
|
|
Reaction + Gaeste + Chat + PayPal -> Ende
|
|
|
|
Das sind drei Staende und nicht sieben. „Wartebereich" und
|
|
„Countdown" sind keine eigenen Zustaende, sondern das, was
|
|
„vorbereitung" auf dem Bildschirm TUT: Der Chat ist offen, der
|
|
Countdown laeuft, das Vorschaubild steht. Drei Staende, die sich
|
|
gegenseitig ausschliessen, sind pruefbar; sieben, von denen sich
|
|
vier ueberlappen, sind es nicht.
|
|
|
|
---------------------------------------------------------------------
|
|
WER WAS DARF
|
|
|
|
Host = DogFather. Er stellt ein, startet, beendet, holt und
|
|
entfernt Gaeste, schaltet stumm, bestimmt die Anordnung.
|
|
Moderation= DogFather, rechte und linke Hand, Modis. Sie koennen
|
|
Beitraege aus dem Live-Chat nehmen.
|
|
Gast = wen der Host holt. Jede Rolle des Hauses.
|
|
Zuschauer = jeder im Team-Dogi-Haus, auch die Community.
|
|
|
|
Die Reaction gibt es NUR im Team-Dogi-Haus. Auf der Agenturadresse
|
|
antwortet jeder Weg mit 404 -- nicht mit 403, denn ein „verboten"
|
|
verraet, dass es hier etwas gibt.
|
|
|
|
---------------------------------------------------------------------
|
|
DAS VIDEO LAEUFT NICHT UEBER DIESEN SERVER
|
|
|
|
Uebertragen wird nur der Spielstand: Kennung, laeuft/pausiert,
|
|
Sekunde. Jeder Browser laedt das Video selbst von YouTube. Die
|
|
Begruendung steht in reaktion-tabellen.js.
|
|
|
|
Aufrufen laesst sich alles ohne Sonderfall: Wer zusieht, meldet
|
|
sich alle 30 Sekunden mit `/dabei`. Wer sich zwei Minuten nicht
|
|
meldet, faellt heraus -- ein sauberes „Tschuess" gibt es im Netz
|
|
nicht, ein Handy geht einfach aus.
|
|
===================================================================== */
|
|
import express from "express";
|
|
import { db, sitzungLesen, protokolliere, echteIp } from "./workspace.js";
|
|
import { stromAn } from "./workspace-chat.js";
|
|
import { buehneMelden } from "./workspace-buehne.js";
|
|
import { einstellung } from "./workspace.js";
|
|
import { mitZugangsdaten, wegeAusfaechern } from "./workspace-turn.js";
|
|
import {
|
|
SICHT_PLAETZE, GAESTE_MAX, TEXT_MAX, SCHREIB_BREMSE, STAENDE, LAYOUTS, TEMPI,
|
|
KAMERA_GROESSEN, KAMERA_ECKEN,
|
|
MELDUNG_TAGE,
|
|
VERLAUF_TAGE, LISTE_MAX, CHAT_MODI, STUMM_MAX_MINUTEN, MASSNAHMEN,
|
|
BANNER_ARTEN,
|
|
BANNER_TEMPI,
|
|
BANNER_MAX,
|
|
BANNER_TEXT_MAX,
|
|
BANNER_UNTER_MAX,
|
|
BANNER_BILDER,
|
|
ECKEN_PLAETZE,
|
|
ECKEN_GROESSEN,
|
|
werbungStand,
|
|
} from "./reaktion-tabellen.js";
|
|
/* DIE SPERREN DES TREFFS GELTEN AUCH HIER. Ein zweites System
|
|
daneben hiesse, dass jemand im Treff gesperrt ist und in der
|
|
Sendung weiterschreibt -- genau die Luecke, die man erst bemerkt,
|
|
wenn sie benutzt wird. */
|
|
import { treffSperreLesen } from "./treff-tabellen.js";
|
|
/* 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 FROM reaktion_liste"
|
|
+ " ORDER BY platz, id").all();
|
|
}
|
|
|
|
/** Das naechste Video aus der Liste ins Bild holen.
|
|
*
|
|
* EINE STELLE FUER ZWEI KNOEPFE. „Naechstes" und „Jetzt spielen"
|
|
* tun dasselbe, nur mit unterschiedlicher Zeile -- zwei Kopien
|
|
* desselben Ablaufs waeren zwei Stellen, an denen man spaeter das
|
|
* Zuruecksetzen der Sekunde vergessen kann. */
|
|
function weiterZu(zeile, personId) {
|
|
db().prepare("UPDATE reaktion SET video = ?, video_titel = ?, laeuft = 1,"
|
|
+ " sekunde = 0, 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;
|
|
}
|
|
|
|
function gaesteListe() {
|
|
return db().prepare(`
|
|
SELECT g.person_id AS id, g.stumm, g.platz, p.name, p.rolle
|
|
FROM reaktion_gaeste g JOIN personen p ON p.id = g.person_id
|
|
ORDER BY g.platz`).all();
|
|
}
|
|
|
|
/** Wer gerade dabei ist -- und ob noch ein Kameraplatz frei war.
|
|
*
|
|
* ZWEI MINUTEN OHNE LEBENSZEICHEN HEISST WEG. Ein Browser, der
|
|
* zugeklappt wird, schickt kein „ich gehe". Ohne diese Grenze
|
|
* waechst die Zuschauerzahl den ganzen Abend und stimmt nie. */
|
|
function dabeiListe() {
|
|
const grenze = new Date(Date.now() - 120_000).toISOString();
|
|
db().prepare("DELETE FROM reaktion_dabei WHERE gesehen < ?").run(grenze);
|
|
return db().prepare(`
|
|
SELECT d.person_id AS id, d.sicht, d.seit, d.kamera_aus, d.mikro_aus, d.bildschirm,
|
|
p.name, p.rolle
|
|
FROM reaktion_dabei d JOIN personen p ON p.id = d.person_id
|
|
ORDER BY d.seit`).all();
|
|
}
|
|
|
|
/** Alle, die von einer Aenderung erfahren sollen. */
|
|
function empfaenger() {
|
|
const ids = new Set(dabeiListe().map((d) => d.id));
|
|
for (const g of gaesteListe()) ids.add(g.id);
|
|
const s = sendung();
|
|
if (s?.host_id) ids.add(s.host_id);
|
|
return [...ids];
|
|
}
|
|
|
|
/** Was jeder sehen darf -- egal ob Zuschauer, Gast oder Host. */
|
|
function lage(person) {
|
|
const s = sendung();
|
|
const gaeste = gaesteListe();
|
|
const dabei = dabeiListe();
|
|
const istHost = HOST_ROLLEN.has(person.rolle);
|
|
const binGast = gaeste.some((g) => g.id === person.id);
|
|
return {
|
|
stand: s.stand,
|
|
titel: s.titel,
|
|
video: s.video,
|
|
video_titel: s.video_titel || "",
|
|
vorschau: s.vorschau,
|
|
beginnt_am: s.beginnt_am,
|
|
layout: s.layout,
|
|
laeuft: !!s.laeuft,
|
|
sekunde: s.sekunde,
|
|
/* 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 WERBUNG SIEHT JEDER =============================
|
|
Sie ist das Einzige auf dieser Seite, das ausdruecklich fuer
|
|
alle gedacht ist -- eine Einblendung, die nur der Host sieht,
|
|
waere ein Zettel am eigenen Bildschirm.
|
|
|
|
ZWEI LISTEN, UND DAS MIT GRUND: `band` sind die Botschaften,
|
|
die gerade laufen -- die bekommt jeder. `banner_alle` ist die
|
|
Verwaltung samt abgeschalteter Eintraege; die geht nur an den,
|
|
der sie bearbeiten darf. Wer einen Entwurf abschaltet, will
|
|
ihn nicht trotzdem im Stream haben. */
|
|
werbung: werbungStand(db()),
|
|
banner_alle: HOST_ROLLEN.has(person.rolle)
|
|
? db().prepare(`SELECT id, text, unterzeile, bild, aktiv, reihung
|
|
FROM reaktion_banner ORDER BY reihung, id`).all()
|
|
.map((z) => ({ ...z, aktiv: !!z.aktiv }))
|
|
: undefined,
|
|
banner_arten: BANNER_ARTEN,
|
|
banner_tempi: BANNER_TEMPI,
|
|
banner_bilder: BANNER_BILDER,
|
|
ecken_plaetze: ECKEN_PLAETZE,
|
|
ecken_groessen: ECKEN_GROESSEN,
|
|
/* WAS DIE MODERATION SEHEN MUSS -- und niemand sonst. Eine Liste
|
|
von Massnahmen gegen andere ist fuer einen Zuschauer eine
|
|
Liste darueber, wer sich danebenbenommen hat. */
|
|
meldungen: MODERIERT.has(person.rolle)
|
|
? db().prepare(`SELECT m.id, m.beitrag_id, m.grund, m.erstellt,
|
|
p.name AS melder, c.text, c.name AS wer, c.person_id AS wer_id, c.weg_am
|
|
FROM reaktion_meldungen m
|
|
JOIN reaktion_chat c ON c.id = m.beitrag_id
|
|
LEFT JOIN personen p ON p.id = m.melder_id
|
|
WHERE m.erledigt_am IS NULL ORDER BY m.id`).all()
|
|
: null,
|
|
massnahmen: MODERIERT.has(person.rolle)
|
|
? db().prepare(`SELECT m.person_id, m.art, m.bis, m.grund, p.name
|
|
FROM reaktion_massnahmen m JOIN personen p ON p.id = m.person_id
|
|
ORDER BY m.erstellt DESC`).all()
|
|
: null,
|
|
dabei: dabei.length,
|
|
/* ==== WER DIE LISTE BEKOMMT -- UND WARUM GENAU DIESE ==========
|
|
|
|
Namen der Zusehenden bekommt, wer auf der BUEHNE steht (Host,
|
|
Gaeste) oder moderiert. Ein Zuschauer bekommt sie nicht: Er
|
|
soll nicht ablesen koennen, wer sonst noch zusieht -- im Live
|
|
sieht man einander am Chat, und das reicht.
|
|
|
|
DIE GAESTE STEHEN HIER NICHT AUS HOEFLICHKEIT. Wer sendet,
|
|
muss wissen, wen er anrufen soll: Zuschauer rufen grundsaetzlich
|
|
nie an (sonst rufen zwei gleichzeitig an, und beide
|
|
scheitern -- „glare"). Ohne die Liste kaeme das Bild eines
|
|
Gastes deshalb bei keinem Zuschauer an. Zwei fuer sich richtige
|
|
Regeln, die zusammen ein Loch ergeben.
|
|
|
|
DIE MODERATION STAND HIER AUCH -- SEIT DEM 28.09. NICHT MEHR.
|
|
Filipe: „nur die zwei sollen alles sehen." Wer einen Beitrag
|
|
wegnehmen darf, muss deshalb nicht wissen, wer im Saal sitzt;
|
|
das war eine Vermischung zweier Dinge, die nichts miteinander
|
|
zu tun haben. Moderiert wird weiter -- im Chat sieht man
|
|
ohnehin, wer da ist. */
|
|
zuschauer: (istHost || binGast)
|
|
? dabei.map((d) => ({ id: d.id, name: d.name, rolle: d.rolle, sicht: !!d.sicht }))
|
|
: null,
|
|
ich: {
|
|
id: person.id,
|
|
name: person.name,
|
|
rolle: person.rolle,
|
|
host: istHost,
|
|
gast: binGast,
|
|
moderiert: MODERIERT.has(person.rolle),
|
|
sicht: !!(dabei.find((d) => d.id === person.id)?.sicht),
|
|
kamera_aus: !!(dabei.find((d) => d.id === person.id)?.kamera_aus),
|
|
bildschirm: !!(dabei.find((d) => d.id === person.id)?.bildschirm),
|
|
mikro_aus: !!(dabei.find((d) => d.id === person.id)?.mikro_aus),
|
|
darf_schreiben: darfSchreiben(person, s),
|
|
/* Der Grund steht daneben -- die Seite muss ihn nicht raten. */
|
|
schreib_grund: schreibGrund(person, s),
|
|
/* WER STUMM IST, MUSS ES ERFAHREN. Ein Feld, das sich nicht
|
|
beschreiben laesst und schweigt, sieht kaputt aus -- und dann
|
|
sucht jemand den Fehler bei sich statt bei seinem Verhalten. */
|
|
massnahme: (() => {
|
|
const m = massnahmeFuer(person.id);
|
|
return m ? { art: m.art, bis: m.bis, grund: m.grund } : null;
|
|
})(),
|
|
},
|
|
/* ==== DAS ZWEITE VIDEO SIEHT NUR, WER SENDET ==================
|
|
|
|
Was als Naechstes kommt, darf jeder wissen (`naechstes`) --
|
|
das macht Lust. Was DANEBEN vorbereitet ist, nicht: Es ist die
|
|
Rueckhand fuer den Fall, dass etwas nicht laeuft, und die
|
|
verraet man nicht vorher. Dieselbe Linie wie bei der
|
|
Warteschlange. */
|
|
zweit: istHost
|
|
? (s.zweit_video
|
|
? { video: s.zweit_video, titel: s.zweit_titel || "", sekunde: s.zweit_sekunde || 0 }
|
|
: null)
|
|
: null,
|
|
/* DIE WARTESCHLANGE SEHEN NUR DIE ZWEI. Sie ist Teil der
|
|
Vorbereitung, und Filipe hat dazu gesagt: „nur die zwei sollen
|
|
alles sehen, vorbereiten und einschakten koennen." */
|
|
liste: istHost ? listeHolen() : null,
|
|
/* WAS ALS NAECHSTES KOMMT, DARF JEDER WISSEN -- und nur das.
|
|
Auf der Wartetafel steht damit etwas, das Lust macht, ohne den
|
|
ganzen Abend zu verraten. */
|
|
naechstes: (() => {
|
|
const n = db().prepare("SELECT titel, video FROM reaktion_liste"
|
|
+ " ORDER BY platz, id LIMIT 1").get();
|
|
return n ? (n.titel || n.video) : null;
|
|
})(),
|
|
liste_zahl: db().prepare("SELECT COUNT(*) AS n FROM reaktion_liste").get()?.n || 0,
|
|
liste_max: LISTE_MAX,
|
|
plaetze: SICHT_PLAETZE,
|
|
gaeste_max: GAESTE_MAX,
|
|
text_max: TEXT_MAX,
|
|
};
|
|
}
|
|
|
|
/** Darf diese Person gerade in den Live-Chat schreiben?
|
|
*
|
|
* EINE STELLE FUER ZWEI FRAGEN. Der Server muss es wissen, um
|
|
* abzulehnen; die Oberflaeche muss es wissen, um das Feld gar nicht
|
|
* erst anzubieten. Zwei Rechnungen waeren zwei Gelegenheiten, dass
|
|
* ein Feld da ist und mit 403 antwortet -- und das ist eine Falle,
|
|
* keine Bedienung. */
|
|
/** Die kurze Massnahme dieser Sendung -- oder nichts.
|
|
*
|
|
* ABGELAUFENES ZAEHLT NICHT. Eine Stummschaltung ueber zehn Minuten
|
|
* endet nach zehn Minuten, ohne dass jemand etwas tun muss. Eine,
|
|
* die von Hand aufgehoben werden muss, bleibt sonst die ganze
|
|
* Sendung stehen, weil im Live niemand daran denkt. */
|
|
function massnahmeFuer(personId) {
|
|
const m = db().prepare("SELECT * FROM reaktion_massnahmen WHERE person_id = ?")
|
|
.get(personId);
|
|
if (!m) return null;
|
|
if (!m.bis) return m;
|
|
const ende = Date.parse(m.bis);
|
|
if (Number.isFinite(ende) && ende > Date.now()) return m;
|
|
db().prepare("DELETE FROM reaktion_massnahmen WHERE person_id = ?").run(personId);
|
|
return null;
|
|
}
|
|
|
|
/** WARUM jemand nicht schreiben darf -- oder `null`, wenn er darf.
|
|
*
|
|
* Der Grund und nicht nur das Ja/Nein, und zwar aus einem Fehler
|
|
* heraus: Vorher gab es nur „darf/darf nicht", und der Aufrufer hat
|
|
* sich den Grund aus der Chat-Stufe zusammengereimt. Fuer jemanden,
|
|
* der stummgeschaltet war, waehrend der Chat offen stand, kam damit
|
|
* „Der Chat ist gerade zu" heraus -- eine Auskunft, die nachweisbar
|
|
* falsch war und ihn beim naechsten Aufmachen wieder vergeblich
|
|
* tippen liess. Wer den Grund kennt, gibt ihn weiter. */
|
|
function schreibGrund(person, stand) {
|
|
if (stand.stand === "zu") return "saal_zu";
|
|
/* ERST DIE SPERREN DES TREFFS. Wer dort eine Pause hat oder
|
|
ausgeschlossen ist, schreibt hier auch nicht -- es sind dieselben
|
|
Menschen und dieselbe Gemeinschaft. */
|
|
try {
|
|
if (treffSperreLesen(db(), person.id)) return "treff_massnahme";
|
|
} catch { /* gibt es die Tabelle nicht, gilt sie auch nicht */ }
|
|
const m = massnahmeFuer(person.id);
|
|
if (m) return m.art === "raus" ? "aus_der_sendung" : "stumm_geschaltet";
|
|
const modus = stand.chat_modus || "offen";
|
|
if (modus === "offen") return null;
|
|
/* „team": wer moderiert, darf. Das ist dieselbe Menge, die auch
|
|
Beitraege wegnehmen darf -- wer aufraeumen soll, muss dabei
|
|
reden koennen. */
|
|
if (modus === "team") return MODERIERT.has(person.rolle) ? null : "chat_nur_team";
|
|
/* „zu": nur die zwei, die die Sendung fuehren. Sonst saesse der
|
|
Saal vor einem Fenster, in dem gar nichts mehr passiert. */
|
|
return HOST_ROLLEN.has(person.rolle) ? null : "chat_zu";
|
|
}
|
|
|
|
function darfSchreiben(person, stand) {
|
|
return schreibGrund(person, stand) === null;
|
|
}
|
|
|
|
/** 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" });
|
|
|
|
const da = db().prepare("SELECT COUNT(*) AS n FROM reaktion_gaeste").get()?.n || 0;
|
|
if (da >= GAESTE_MAX) return res.status(409).json({ fehler: "gaeste_voll" });
|
|
|
|
/* Der Platz ist die kleinste freie Zahl -- nicht „Anzahl + 1".
|
|
Wer Gast 1 entfernt und dann jemanden holt, bekaeme sonst
|
|
wieder Platz 2, und Platz 1 bliebe fuer immer leer. */
|
|
const belegt = new Set(gaesteListe().map((g) => g.platz));
|
|
let platz = 1;
|
|
while (belegt.has(platz)) platz++;
|
|
|
|
db().prepare("INSERT OR REPLACE INTO reaktion_gaeste (person_id, seit, stumm, platz) VALUES (?,?,0,?)")
|
|
.run(id, jetzt(), platz);
|
|
protokolliere("reaktion_gast_dazu", {
|
|
personId: req.person.id,
|
|
detail: `${p.name} (Platz ${platz})`,
|
|
ip: echteIp(req),
|
|
});
|
|
const l = lage(req.person);
|
|
melden("reaktion", { was: "gaeste", lage: oeffentlich(l) });
|
|
stromAn([id], "reaktion", { was: "du_bist_gast", lage: oeffentlich(l) });
|
|
res.status(201).json(l);
|
|
} catch (fehler) {
|
|
console.error("[reaktion] Gast:", fehler?.message);
|
|
res.status(503).json({ fehler: "nicht_verfuegbar" });
|
|
}
|
|
});
|
|
|
|
reaktionRouter.delete("/workspace/api/reaktion/gast/:id", nurHost, (req, res) => {
|
|
try {
|
|
const id = Number(req.params.id);
|
|
const weg = db().prepare("DELETE FROM reaktion_gaeste WHERE person_id = ?").run(id);
|
|
if (!weg.changes) return res.status(404).json({ fehler: "nicht_gefunden" });
|
|
const l = lage(req.person);
|
|
melden("reaktion", { was: "gaeste", lage: oeffentlich(l) });
|
|
stromAn([id], "reaktion", { was: "du_bist_kein_gast", lage: oeffentlich(l) });
|
|
res.json(l);
|
|
} catch (fehler) {
|
|
console.error("[reaktion] Gast weg:", fehler?.message);
|
|
res.status(503).json({ fehler: "nicht_verfuegbar" });
|
|
}
|
|
});
|
|
|
|
reaktionRouter.put("/workspace/api/reaktion/gast/:id/stumm", nurHost,
|
|
express.json({ limit: "2kb" }), (req, res) => {
|
|
try {
|
|
const id = Number(req.params.id);
|
|
const stumm = req.body?.stumm ? 1 : 0;
|
|
const a = db().prepare("UPDATE reaktion_gaeste SET stumm = ? WHERE person_id = ?")
|
|
.run(stumm, id);
|
|
if (!a.changes) return res.status(404).json({ fehler: "nicht_gefunden" });
|
|
const l = lage(req.person);
|
|
melden("reaktion", { was: "gaeste", lage: oeffentlich(l) });
|
|
res.json(l);
|
|
} catch (fehler) {
|
|
console.error("[reaktion] Stumm:", fehler?.message);
|
|
res.status(503).json({ fehler: "nicht_verfuegbar" });
|
|
}
|
|
});
|
|
|
|
/* ---------- Das Video --------------------------------------------------
|
|
Der Host sagt, wo das Video steht. Alle anderen folgen.
|
|
|
|
GESCHICKT WIRD DER SPIELSTAND, NICHT DAS BILD -- die Begruendung
|
|
steht in reaktion-tabellen.js. Hier steht nur die Folge davon:
|
|
Diese Route ist die einzige, die waehrend der Sendung oft laeuft,
|
|
und sie schreibt deshalb so wenig wie moeglich. */
|
|
reaktionRouter.post("/workspace/api/reaktion/video", nurHost, express.json({ limit: "2kb" }),
|
|
(req, res) => {
|
|
try {
|
|
const laeuft = req.body?.laeuft ? 1 : 0;
|
|
const sekunde = Math.max(0, Number(req.body?.sekunde) || 0);
|
|
/* 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" });
|
|
}
|
|
});
|
|
|
|
/** Eins hoch oder runter.
|
|
*
|
|
* GETAUSCHT WIRD MIT DEM NACHBARN, nicht neu durchnummeriert. Beim
|
|
* Durchnummerieren schreibt ein Klick jede Zeile der Liste neu;
|
|
* zwei Klicks gleichzeitig -- Filipe am Rechner, VanVan am Handy --
|
|
* ergeben dann eine Reihenfolge, die keiner von beiden wollte. Ein
|
|
* Tausch beruehrt zwei Zeilen und ist in sich abgeschlossen. */
|
|
reaktionRouter.post("/workspace/api/reaktion/liste/:id/schieben", nurHost,
|
|
express.json({ limit: "2kb" }), (req, res) => {
|
|
try {
|
|
const id = Number(req.params.id) || 0;
|
|
const hoch = String(req.body?.richtung ?? "hoch") === "hoch";
|
|
const alle = listeHolen();
|
|
const wo = alle.findIndex((z) => z.id === id);
|
|
if (wo < 0) return res.status(404).json({ fehler: "nicht_in_der_liste" });
|
|
const nachbar = alle[hoch ? wo - 1 : wo + 1];
|
|
if (!nachbar) return res.json(lage(req.person)); // schon ganz oben/unten
|
|
|
|
/* `node:sqlite` kennt kein `.transaction()` -- das gibt es nur
|
|
in better-sqlite3. Hier wird von Hand geklammert, so wie im
|
|
ganzen Haus. Ohne Klammer koennte zwischen den beiden
|
|
Schreibvorgaengen gelesen werden, und dann haetten zwei
|
|
Zeilen kurz denselben Platz. */
|
|
db().exec("BEGIN IMMEDIATE");
|
|
try {
|
|
db().prepare("UPDATE reaktion_liste SET platz = ? WHERE id = ?")
|
|
.run(nachbar.platz, alle[wo].id);
|
|
db().prepare("UPDATE reaktion_liste SET platz = ? WHERE id = ?")
|
|
.run(alle[wo].platz, nachbar.id);
|
|
db().exec("COMMIT");
|
|
} catch (schief) {
|
|
try { db().exec("ROLLBACK"); } catch { /* war schon zurueck */ }
|
|
throw schief;
|
|
}
|
|
|
|
const l = lage(req.person);
|
|
melden("reaktion", { was: "liste", lage: oeffentlich(l) });
|
|
res.json(l);
|
|
} catch (fehler) {
|
|
console.error("[reaktion] Liste schieben:", fehler?.message);
|
|
res.status(503).json({ fehler: "nicht_verfuegbar" });
|
|
}
|
|
});
|
|
|
|
/** Dieses hier jetzt -- oder schlicht das naechste. */
|
|
reaktionRouter.post("/workspace/api/reaktion/weiter", nurHost, express.json({ limit: "2kb" }),
|
|
(req, res) => {
|
|
try {
|
|
const id = Number(req.body?.id) || 0;
|
|
const zeile = id
|
|
? db().prepare("SELECT * FROM reaktion_liste WHERE id = ?").get(id)
|
|
: db().prepare("SELECT * FROM reaktion_liste ORDER BY platz, id LIMIT 1").get();
|
|
if (!zeile) return res.status(409).json({ fehler: "liste_leer" });
|
|
|
|
weiterZu(zeile, req.person.id);
|
|
const l = lage(req.person);
|
|
melden("reaktion", { was: "liste", lage: oeffentlich(l) });
|
|
res.json(l);
|
|
} catch (fehler) {
|
|
console.error("[reaktion] Weiter:", fehler?.message);
|
|
res.status(503).json({ fehler: "nicht_verfuegbar" });
|
|
}
|
|
});
|
|
|
|
/* ---------- Kamera aus, Kamera an ---------------------------------------
|
|
Filipes Notiz nennt „Kamera" als erste der acht Host-Steuerungen.
|
|
Sie fehlte: Wer kurz aufstehen, trinken oder etwas holen wollte,
|
|
musste die Sendung verlassen oder sich dabei filmen lassen.
|
|
|
|
DAS BILD WIRD AM GERAET ABGESCHALTET, NICHT AM SERVER. Die Spur
|
|
wird stillgelegt (`track.enabled = false`); die Verbindung bleibt
|
|
stehen, der Ton laeuft weiter, und beim Wiedereinschalten ist das
|
|
Bild sofort da statt nach einem neuen Verbindungsaufbau. Dieser
|
|
Weg hier sagt nur allen ANDEREN Bescheid, damit im Fenster
|
|
„Kamera aus" steht und nicht ein schwarzes Rechteck, das nach
|
|
einem Fehler aussieht.
|
|
|
|
JEDER SENDER DARF DAS FUER SICH -- Host wie Gast. Ein Gast, der
|
|
sein eigenes Bild nicht abschalten kann, muesste den Host darum
|
|
bitten. */
|
|
/* ---------- Moderation im Live-Chat -------------------------------------
|
|
Filipe: „sachen wie stummschaltungen, sperrungen, meldunge und
|
|
alles moegliche im chat machen koennen falls sich jemand nicht
|
|
benimmt."
|
|
|
|
WAS HIER PASSIERT, GILT NUR FUER DIESE SENDUNG. Was laenger gelten
|
|
soll, gehoert in die Massnahmen des Treffs -- die haben eine
|
|
Begruendungspflicht, eine Frist in Tagen und stehen in der
|
|
Geschichte. Ein Fuenf-Minuten-Aerger gehoert dort nicht hinein. */
|
|
|
|
reaktionRouter.post("/workspace/api/reaktion/massnahme", express.json({ limit: "4kb" }),
|
|
(req, res) => {
|
|
try {
|
|
if (!MODERIERT.has(req.person.rolle)) {
|
|
return res.status(403).json({ fehler: "nur_moderation" });
|
|
}
|
|
const art = String(req.body?.art ?? "");
|
|
if (!MASSNAHMEN.includes(art)) return res.status(400).json({ fehler: "massnahme_unbekannt" });
|
|
|
|
const zielId = Number(req.body?.person_id) || 0;
|
|
const ziel = db().prepare("SELECT id, name, rolle FROM personen WHERE id = ?").get(zielId);
|
|
if (!ziel) return res.status(404).json({ fehler: "nicht_gefunden" });
|
|
|
|
/* NICHT GEGEN DAS TEAM. Dieselbe Regel wie im Treff: Wer einen
|
|
anderen Modi stummschalten koennte, haette einen Weg, die
|
|
Moderation selbst auszuschalten -- mitten in der Sendung und
|
|
an „Personen & Zugaenge" vorbei, wo diese Entscheidung
|
|
hingehoert. */
|
|
if (MODERIERT.has(ziel.rolle) || HOST_ROLLEN.has(ziel.rolle)) {
|
|
return res.status(403).json({ fehler: "nicht_gegen_team" });
|
|
}
|
|
|
|
let bis = null;
|
|
if (art === "stumm") {
|
|
const min = Number(req.body?.minuten);
|
|
if (!Number.isFinite(min) || min <= 0 || min > STUMM_MAX_MINUTEN) {
|
|
return res.status(400).json({ fehler: "dauer_unklar" });
|
|
}
|
|
bis = new Date(Date.now() + min * 60_000).toISOString();
|
|
}
|
|
const grund = String(req.body?.grund ?? "").trim().slice(0, 200);
|
|
|
|
db().prepare(`INSERT INTO reaktion_massnahmen (person_id, art, bis, grund, von, erstellt)
|
|
VALUES (?,?,?,?,?,?)
|
|
ON CONFLICT(person_id) DO UPDATE SET
|
|
art = excluded.art, bis = excluded.bis, grund = excluded.grund,
|
|
von = excluded.von, erstellt = excluded.erstellt`)
|
|
.run(ziel.id, art, bis, grund, req.person.id, jetzt());
|
|
|
|
/* „raus" heisst raus: aus dem Saal, aus der Gaesteliste. Sonst
|
|
bliebe die Kamera im Bild, waehrend die Person nicht mehr
|
|
dabei sein soll. */
|
|
if (art === "raus") {
|
|
db().prepare("DELETE FROM reaktion_dabei WHERE person_id = ?").run(ziel.id);
|
|
db().prepare("DELETE FROM reaktion_gaeste WHERE person_id = ?").run(ziel.id);
|
|
}
|
|
|
|
protokolliere("reaktion_massnahme", {
|
|
personId: req.person.id,
|
|
detail: `${art} gegen ${ziel.name}${bis ? ` bis ${bis.slice(11, 16)}` : ""}`,
|
|
ip: echteIp(req),
|
|
});
|
|
|
|
/* DIE PERSON ERFAEHRT ES SELBST -- und zwar sofort. Wer nicht
|
|
mehr schreiben kann und nicht weiss warum, sucht den Fehler
|
|
bei der Technik. */
|
|
stromAn([ziel.id], "reaktion", { was: "massnahme", lage: null });
|
|
melden("reaktion", { was: "moderation", lage: oeffentlich(lage(req.person)) });
|
|
res.json(lage(req.person));
|
|
} catch (fehler) {
|
|
console.error("[reaktion] Massnahme:", fehler?.message);
|
|
res.status(503).json({ fehler: "nicht_verfuegbar" });
|
|
}
|
|
});
|
|
|
|
reaktionRouter.delete("/workspace/api/reaktion/massnahme/:id(\\d+)", (req, res) => {
|
|
try {
|
|
if (!MODERIERT.has(req.person.rolle)) {
|
|
return res.status(403).json({ fehler: "nur_moderation" });
|
|
}
|
|
const n = db().prepare("DELETE FROM reaktion_massnahmen WHERE person_id = ?")
|
|
.run(Number(req.params.id));
|
|
if (!n.changes) return res.status(404).json({ fehler: "nicht_gefunden" });
|
|
stromAn([Number(req.params.id)], "reaktion", { was: "massnahme", lage: null });
|
|
melden("reaktion", { was: "moderation", lage: oeffentlich(lage(req.person)) });
|
|
res.json(lage(req.person));
|
|
} catch (fehler) {
|
|
console.error("[reaktion] Massnahme weg:", fehler?.message);
|
|
res.status(503).json({ fehler: "nicht_verfuegbar" });
|
|
}
|
|
});
|
|
|
|
/** Einen Beitrag melden. Darf jeder -- auch die Community.
|
|
*
|
|
* DAS IST DER PUNKT DARAN. Die Moderation sieht nicht alles; wer
|
|
* mitliest, sieht mehr. Ohne einen Weg fuer die Zusehenden bliebe
|
|
* Moderation auf das beschraenkt, was zufaellig jemand vom Team
|
|
* gerade gelesen hat. */
|
|
reaktionRouter.post("/workspace/api/reaktion/chat/:id(\\d+)/melden",
|
|
express.json({ limit: "4kb" }), (req, res) => {
|
|
try {
|
|
const id = Number(req.params.id);
|
|
const b = db().prepare("SELECT * FROM reaktion_chat WHERE id = ?").get(id);
|
|
if (!b) return res.status(404).json({ fehler: "nicht_gefunden" });
|
|
if (b.person_id === req.person.id) {
|
|
return res.status(400).json({ fehler: "eigener_beitrag" });
|
|
}
|
|
const grund = String(req.body?.grund ?? "").trim().slice(0, 200);
|
|
try {
|
|
db().prepare(`INSERT INTO reaktion_meldungen (beitrag_id, melder_id, grund, erstellt)
|
|
VALUES (?,?,?,?)`).run(id, req.person.id, grund, jetzt());
|
|
} catch {
|
|
/* Schon gemeldet. Kein Fehler -- wer zweimal tippt, meint
|
|
dasselbe, und eine Fehlermeldung dafuer waere eine
|
|
Bestrafung fuers Mithelfen. */
|
|
return res.json({ ok: true, schon: true });
|
|
}
|
|
/* Die Moderation erfaehrt es sofort. */
|
|
stromAn(
|
|
db().prepare(`SELECT id FROM personen WHERE rolle IN ('admin','hand','linke','modi')
|
|
AND aktiv = 1`).all().map((p) => p.id),
|
|
"reaktion", { was: "meldung" },
|
|
);
|
|
res.status(201).json({ ok: true });
|
|
} catch (fehler) {
|
|
console.error("[reaktion] Melden:", fehler?.message);
|
|
res.status(503).json({ fehler: "nicht_verfuegbar" });
|
|
}
|
|
});
|
|
|
|
reaktionRouter.post("/workspace/api/reaktion/meldung/:id(\\d+)/erledigt", (req, res) => {
|
|
try {
|
|
if (!MODERIERT.has(req.person.rolle)) {
|
|
return res.status(403).json({ fehler: "nur_moderation" });
|
|
}
|
|
const n = db().prepare(`UPDATE reaktion_meldungen SET erledigt_am = ?, erledigt_von = ?
|
|
WHERE id = ? AND erledigt_am IS NULL`)
|
|
.run(jetzt(), req.person.id, Number(req.params.id));
|
|
if (!n.changes) return res.status(409).json({ fehler: "nicht_offen" });
|
|
res.json(lage(req.person));
|
|
} catch (fehler) {
|
|
console.error("[reaktion] Meldung erledigt:", fehler?.message);
|
|
res.status(503).json({ fehler: "nicht_verfuegbar" });
|
|
}
|
|
});
|
|
|
|
reaktionRouter.post("/workspace/api/reaktion/senden", express.json({ limit: "2kb" }),
|
|
(req, res) => {
|
|
try {
|
|
const s = sendung();
|
|
if (s.stand === "zu") return res.status(409).json({ fehler: "saal_zu" });
|
|
|
|
const istHost = HOST_ROLLEN.has(req.person.rolle);
|
|
const binGast = !!db().prepare("SELECT 1 FROM reaktion_gaeste WHERE person_id = ?")
|
|
.get(req.person.id);
|
|
if (!istHost && !binGast) return res.status(403).json({ fehler: "sendest_nicht" });
|
|
|
|
/* TEILWEISE AENDERN IST ERLAUBT. Wer nur die Kamera schaltet,
|
|
schickt nur die Kamera -- sonst muesste jede Seite immer
|
|
beide Werte mitschicken und koennte dabei den anderen
|
|
versehentlich zuruecksetzen. */
|
|
const felder = [];
|
|
const werte = [];
|
|
if (req.body?.kamera_aus !== undefined) {
|
|
felder.push("kamera_aus = ?"); werte.push(req.body.kamera_aus ? 1 : 0);
|
|
}
|
|
if (req.body?.mikro_aus !== undefined) {
|
|
felder.push("mikro_aus = ?"); werte.push(req.body.mikro_aus ? 1 : 0);
|
|
}
|
|
if (req.body?.bildschirm !== undefined) {
|
|
felder.push("bildschirm = ?"); werte.push(req.body.bildschirm ? 1 : 0);
|
|
}
|
|
if (!felder.length) return res.status(400).json({ fehler: "nichts_zu_tun" });
|
|
|
|
const geschrieben = db().prepare(
|
|
`UPDATE reaktion_dabei SET ${felder.join(", ")} WHERE person_id = ?`)
|
|
.run(...werte, req.person.id);
|
|
/* WER NICHT IN DER LISTE STEHT, SITZT NICHT IM SAAL. Das ist
|
|
kein Sonderfall, sondern ein Hinweis: Die Seite hat sich
|
|
zwei Minuten nicht gemeldet. Stillschweigend nichts zu tun
|
|
waere ein Knopf, der nicht wirkt. */
|
|
if (!geschrieben.changes) return res.status(409).json({ fehler: "nicht_dabei" });
|
|
|
|
const l = lage(req.person);
|
|
melden("reaktion", { was: "senden", lage: oeffentlich(l) });
|
|
res.json(l);
|
|
} catch (fehler) {
|
|
console.error("[reaktion] Kamera:", fehler?.message);
|
|
res.status(503).json({ fehler: "nicht_verfuegbar" });
|
|
}
|
|
});
|
|
|
|
/* ---------- Der Chat als Ganzes ----------------------------------------
|
|
„Chat" aus Filipes Liste der acht Steuerungen. Gemeint ist nicht
|
|
die Moderation -- die gab es schon -- sondern der Chat selbst.
|
|
Einen Beitrag wegzunehmen, nachdem er stand, ist etwas anderes,
|
|
als ihn gar nicht erst zuzulassen. */
|
|
reaktionRouter.post("/workspace/api/reaktion/chatmodus", nurHost, express.json({ limit: "2kb" }),
|
|
(req, res) => {
|
|
try {
|
|
const modus = String(req.body?.modus ?? "");
|
|
if (!CHAT_MODI.includes(modus)) return res.status(400).json({ fehler: "chatmodus_unbekannt" });
|
|
db().prepare("UPDATE reaktion SET chat_modus = ? WHERE id = 1").run(modus);
|
|
protokolliere("reaktion_chatmodus", {
|
|
personId: req.person.id,
|
|
detail: modus,
|
|
ip: echteIp(req),
|
|
});
|
|
const l = lage(req.person);
|
|
melden("reaktion", { was: "chatmodus", lage: oeffentlich(l) });
|
|
res.json(l);
|
|
} catch (fehler) {
|
|
console.error("[reaktion] Chatmodus:", fehler?.message);
|
|
res.status(503).json({ fehler: "nicht_verfuegbar" });
|
|
}
|
|
});
|
|
|
|
reaktionRouter.post("/workspace/api/reaktion/layout", nurHost, express.json({ limit: "2kb" }),
|
|
(req, res) => {
|
|
try {
|
|
/* ==== DREI EINSTELLUNGEN, EIN WEG =========================
|
|
|
|
Anordnung, Groesse und Ecke beantworten zusammen eine
|
|
einzige Frage: „Wie sieht das Bild aus?" Drei Wege dafuer
|
|
waeren drei Rundrufe, und zwischen ihnen gaebe es
|
|
Augenblicke, in denen die Zusehenden eine Mischung sehen --
|
|
neue Anordnung, alte Ecke.
|
|
|
|
JEDES FELD IST FREIWILLIG, aber eines muss dabei sein. Ein
|
|
Aufruf, der nichts aendert, ist ein Rundruf ohne Anlass. */
|
|
const aendern = [];
|
|
const werte = [];
|
|
|
|
if (req.body?.layout !== undefined) {
|
|
const l = String(req.body.layout);
|
|
if (!LAYOUTS.includes(l)) return res.status(400).json({ fehler: "layout_unbekannt" });
|
|
aendern.push("layout = ?"); werte.push(l);
|
|
}
|
|
if (req.body?.kamera_gross !== undefined) {
|
|
const g = Number(req.body.kamera_gross);
|
|
if (!KAMERA_GROESSEN.includes(g)) {
|
|
return res.status(400).json({ fehler: "kameragroesse_unklar" });
|
|
}
|
|
aendern.push("kamera_gross = ?"); werte.push(g);
|
|
}
|
|
if (req.body?.kamera_ecke !== undefined) {
|
|
const e = String(req.body.kamera_ecke);
|
|
if (!KAMERA_ECKEN.includes(e)) {
|
|
return res.status(400).json({ fehler: "ecke_unklar" });
|
|
}
|
|
aendern.push("kamera_ecke = ?"); werte.push(e);
|
|
}
|
|
if (!aendern.length) return res.status(400).json({ fehler: "nichts_zu_tun" });
|
|
|
|
db().prepare(`UPDATE reaktion SET ${aendern.join(", ")} WHERE id = 1`).run(...werte);
|
|
const n = sendung();
|
|
const bild = {
|
|
layout: n.layout,
|
|
kamera_gross: Number(n.kamera_gross) || 1,
|
|
kamera_ecke: n.kamera_ecke || "ur",
|
|
};
|
|
melden("reaktion", { was: "layout", ...bild });
|
|
res.json({ ok: true, ...bild });
|
|
} catch (fehler) {
|
|
console.error("[reaktion] Layout:", fehler?.message);
|
|
res.status(503).json({ fehler: "nicht_verfuegbar" });
|
|
}
|
|
});
|
|
|
|
/* ---------- Die Werbeeinblendung ---------------------------------------- */
|
|
|
|
/* ==== EIN WEG FUER DAS GANZE AUSSEHEN ==============================
|
|
|
|
Art, Tempo und die beiden Ecken beantworten zusammen eine Frage:
|
|
„Was steht gerade ueber dem Bild?" Vier Wege dafuer waeren vier
|
|
Rundrufe -- und zwischen ihnen saehen die Zusehenden eine
|
|
Mischung aus neuem Band und alter Ecke.
|
|
|
|
Dasselbe Muster wie beim Bild (Anordnung, Groesse, Ecke). */
|
|
reaktionRouter.post("/workspace/api/reaktion/werbung", nurHost,
|
|
express.json({ limit: "2kb" }), (req, res) => {
|
|
try {
|
|
const aendern = [];
|
|
const werte = [];
|
|
|
|
if (req.body?.art !== undefined) {
|
|
const a = String(req.body.art);
|
|
if (!BANNER_ARTEN.includes(a)) return res.status(400).json({ fehler: "bannerart_unklar" });
|
|
aendern.push("banner_art = ?"); werte.push(a);
|
|
}
|
|
if (req.body?.tempo !== undefined) {
|
|
const t = Number(req.body.tempo);
|
|
if (!BANNER_TEMPI.includes(t)) return res.status(400).json({ fehler: "bannertempo_unklar" });
|
|
aendern.push("banner_tempo = ?"); werte.push(t);
|
|
}
|
|
for (const [feld, spalte] of [["ecke_dogi", "ecke_dogi"], ["ecke_hase", "ecke_hase"]]) {
|
|
if (req.body?.[feld] === undefined) continue;
|
|
const e = String(req.body[feld]);
|
|
if (!ECKEN_PLAETZE.includes(e)) return res.status(400).json({ fehler: "eckenplatz_unklar" });
|
|
aendern.push(`${spalte} = ?`); werte.push(e);
|
|
}
|
|
if (req.body?.ecke_gross !== undefined) {
|
|
const g = Number(req.body.ecke_gross);
|
|
if (!ECKEN_GROESSEN.includes(g)) return res.status(400).json({ fehler: "eckengroesse_unklar" });
|
|
aendern.push("ecke_gross = ?"); werte.push(g);
|
|
}
|
|
if (!aendern.length) return res.status(400).json({ fehler: "nichts_zu_tun" });
|
|
|
|
/* ZWEI BILDER AUF DEMSELBEN PLATZ waeren ein Bild auf dem
|
|
anderen. Gemeldet wird es, statt es stillschweigend zu
|
|
verschieben: Wer es nicht wollte, korrigiert; wer es
|
|
wollte, findet seine Absicht nicht heimlich geaendert. */
|
|
const probe = { ...sendung() };
|
|
for (let i = 0; i < aendern.length; i++) {
|
|
const feld = aendern[i].split(" ")[0];
|
|
probe[feld] = werte[i];
|
|
}
|
|
if (probe.ecke_dogi !== "aus" && probe.ecke_dogi === probe.ecke_hase) {
|
|
return res.status(400).json({ fehler: "ecke_belegt" });
|
|
}
|
|
|
|
db().prepare(`UPDATE reaktion SET ${aendern.join(", ")} WHERE id = 1`).run(...werte);
|
|
melden("reaktion", { was: "werbung", ...werbungJetzt() });
|
|
res.json({ ok: true, ...werbungJetzt() });
|
|
} catch (fehler) {
|
|
console.error("[reaktion] Werbung:", fehler?.message);
|
|
res.status(503).json({ fehler: "nicht_verfuegbar" });
|
|
}
|
|
});
|
|
|
|
/* =====================================================================
|
|
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 ----------------------------------------------- */
|
|
|
|
reaktionRouter.get("/workspace/api/reaktion/chat", (req, res) => {
|
|
try {
|
|
const reihen = db().prepare(`
|
|
SELECT id, person_id, name, rolle, text, erstellt
|
|
FROM reaktion_chat WHERE weg_am IS NULL ORDER BY id DESC LIMIT 120`).all();
|
|
res.json({ beitraege: reihen.reverse() });
|
|
} catch (fehler) {
|
|
console.error("[reaktion] Chat:", fehler?.message);
|
|
res.status(503).json({ fehler: "nicht_verfuegbar" });
|
|
}
|
|
});
|
|
|
|
reaktionRouter.post("/workspace/api/reaktion/chat", express.json({ limit: "8kb" }),
|
|
(req, res) => {
|
|
try {
|
|
const s = sendung();
|
|
if (s.stand === "zu") return res.status(409).json({ fehler: "saal_zu" });
|
|
/* DIE STUFE GILT HIER, NICHT NUR IN DER OBERFLAECHE. Ein Feld,
|
|
das ausgegraut ist, haelt niemanden auf, der die Schnittstelle
|
|
kennt. */
|
|
const grund = schreibGrund(req.person, s);
|
|
if (grund) return res.status(403).json({ fehler: grund });
|
|
const text = String(req.body?.text ?? "").trim().slice(0, TEXT_MAX);
|
|
if (!text) return res.status(400).json({ fehler: "leer" });
|
|
if (zuSchnell(req.person.id)) return res.status(429).json({ fehler: "zu_schnell" });
|
|
|
|
const { lastInsertRowid } = db().prepare(`INSERT INTO reaktion_chat
|
|
(person_id, name, rolle, text, erstellt) VALUES (?,?,?,?,?)`)
|
|
.run(req.person.id, req.person.name, req.person.rolle, text, jetzt());
|
|
const beitrag = db().prepare(
|
|
"SELECT id, person_id, name, rolle, text, erstellt FROM reaktion_chat WHERE id = ?")
|
|
.get(Number(lastInsertRowid));
|
|
melden("reaktion_chat", { was: "neu", beitrag });
|
|
res.status(201).json(beitrag);
|
|
} catch (fehler) {
|
|
console.error("[reaktion] Schreiben:", fehler?.message);
|
|
res.status(503).json({ fehler: "nicht_verfuegbar" });
|
|
}
|
|
});
|
|
|
|
/** Moderation: einen Beitrag wegnehmen.
|
|
*
|
|
* WEGGENOMMEN UND NICHT GELOESCHT. Wer moderiert, soll spaeter
|
|
* sagen koennen, was er weggenommen hat und warum -- eine geloeschte
|
|
* Zeile kann das nicht. */
|
|
reaktionRouter.delete("/workspace/api/reaktion/chat/:id", (req, res) => {
|
|
try {
|
|
const id = Number(req.params.id);
|
|
const b = db().prepare("SELECT * FROM reaktion_chat WHERE id = ?").get(id);
|
|
if (!b || b.weg_am) return res.status(404).json({ fehler: "nicht_gefunden" });
|
|
|
|
const eigen = b.person_id === req.person.id;
|
|
if (!eigen && !MODERIERT.has(req.person.rolle)) {
|
|
return res.status(403).json({ fehler: "nicht_erlaubt" });
|
|
}
|
|
db().prepare("UPDATE reaktion_chat SET weg_am = ?, weg_von = ? WHERE id = ?")
|
|
.run(jetzt(), req.person.id, id);
|
|
if (!eigen) {
|
|
protokolliere("reaktion_chat_weg", {
|
|
personId: req.person.id,
|
|
detail: `Beitrag ${id} von ${b.name}`,
|
|
ip: echteIp(req),
|
|
});
|
|
}
|
|
melden("reaktion_chat", { was: "weg", id });
|
|
res.json({ ok: true });
|
|
} catch (fehler) {
|
|
console.error("[reaktion] Wegnehmen:", fehler?.message);
|
|
res.status(503).json({ fehler: "nicht_verfuegbar" });
|
|
}
|
|
});
|
|
|
|
/* ---------- Die Kameras ------------------------------------------------
|
|
Nur der Hinweg. Was in `signal` steht, liest dieser Server nicht --
|
|
es geht unveraendert an den Empfaenger. Genau so macht es der
|
|
Anrufteil seit dem 18.09.
|
|
|
|
GESCHICKT WIRD NUR AN LEUTE, DIE GERADE DABEI SIND. Ohne das
|
|
koennte jemand aus dem Haus Verbindungsdaten an jeden Beliebigen
|
|
schicken, und die Reaction waere ein Weg, jemanden anzuklingeln. */
|
|
reaktionRouter.post("/workspace/api/reaktion/signal", express.json({ limit: "256kb" }),
|
|
(req, res) => {
|
|
try {
|
|
const an = Number(req.body?.an);
|
|
const daten = req.body?.daten;
|
|
if (!Number.isInteger(an) || daten === undefined) {
|
|
return res.status(400).json({ fehler: "unvollstaendig" });
|
|
}
|
|
const kreis = new Set(empfaenger());
|
|
if (!kreis.has(an) || !kreis.has(req.person.id)) {
|
|
return res.status(409).json({ fehler: "nicht_dabei" });
|
|
}
|
|
const wie_viele = stromAn([an], "reaktion_signal", {
|
|
von: req.person.id, name: req.person.name, daten,
|
|
});
|
|
res.json({ ok: true, zugestellt: wie_viele });
|
|
} catch (fehler) {
|
|
console.error("[reaktion] Signal:", fehler?.message);
|
|
res.status(503).json({ fehler: "nicht_verfuegbar" });
|
|
}
|
|
});
|
|
|
|
/* ======================================================================
|
|
HILFEN
|
|
====================================================================== */
|
|
|
|
/** Aus allem, was jemand einfuegen koennte, die YouTube-Kennung.
|
|
*
|
|
* Angenommen werden die vier Formen, die man wirklich in der
|
|
* Zwischenablage hat: die lange Adresse, die kurze, der
|
|
* Einbett-Pfad und die nackte Kennung. Alles andere wird
|
|
* abgelehnt -- eine falsche Kennung ist ein schwarzes Rechteck vor
|
|
* Publikum, und das faellt erst auf, wenn alle zusehen. */
|
|
export function videoKennung(roh) {
|
|
const t = String(roh || "").trim();
|
|
if (!t) return "";
|
|
if (/^[A-Za-z0-9_-]{11}$/.test(t)) return t;
|
|
const muster = [
|
|
/[?&]v=([A-Za-z0-9_-]{11})/,
|
|
/youtu\.be\/([A-Za-z0-9_-]{11})/,
|
|
/youtube\.com\/embed\/([A-Za-z0-9_-]{11})/,
|
|
/youtube\.com\/shorts\/([A-Za-z0-9_-]{11})/,
|
|
/youtube\.com\/live\/([A-Za-z0-9_-]{11})/,
|
|
];
|
|
for (const m of muster) {
|
|
const t2 = t.match(m);
|
|
if (t2) return t2[1];
|
|
}
|
|
return "";
|
|
}
|
|
|
|
/** Die Lage ohne die Namensliste -- fuer den Rundruf.
|
|
*
|
|
* `lage()` gibt einem Host die Namen aller Zusehenden. Genau diese
|
|
* Antwort geht per Strom an ALLE. Ohne dieses Sieb bekaeme jeder
|
|
* Zuschauer die Liste, die nur die Leitung sehen darf -- und zwar
|
|
* ohne dass es jemandem auffaellt, weil die Oberflaeche sie ja
|
|
* nicht anzeigt. */
|
|
function oeffentlich(l) {
|
|
/* WAS IM RUNDRUF NICHTS ZU SUCHEN HAT.
|
|
`zuschauer`, `ich` und `liste` gehoeren nur dem Empfaenger.
|
|
|
|
`meldungen` und `massnahmen` KAMEN AM 28.09. DAZU -- sie sind
|
|
die heikelsten Felder des ganzen Standes: In `meldungen` stehen
|
|
der gemeldete Text, der Name des Gemeldeten UND der Name dessen,
|
|
der gemeldet hat; in `massnahmen` steht, wer stumm ist und
|
|
warum. Der Rundruf wird aus dem Stand DESSEN gebaut, der gerade
|
|
etwas getan hat -- und wenn das die Moderation war, gingen beide
|
|
Listen an jeden im Saal. Wer meldet, muss sich darauf verlassen
|
|
koennen, dass das niemand sieht; sonst meldet beim naechsten Mal
|
|
keiner mehr. */
|
|
/* `zweit` kam am 28.09. dazu: das zweite, vorbereitete Video. Es
|
|
ist die Rueckhand fuer den Fall, dass etwas nicht laeuft -- die
|
|
verraet man nicht vorher, und sie gehoert genau wie die
|
|
Warteschlange nur denen, die die Sendung fuehren. */
|
|
/* `banner_alle` KAM AM 29.09. DAZU. Es ist die Verwaltungsliste
|
|
der Werbung -- mit den ABGESCHALTETEN Eintraegen darin. Die
|
|
laufenden Botschaften stehen ohnehin unter `werbung.band` und
|
|
gehoeren allen; ein Entwurf, den jemand bewusst ausgeschaltet
|
|
hat, gehoert niemandem. */
|
|
const { zuschauer, ich, liste, meldungen, massnahmen, zweit,
|
|
banner_alle: _verwaltung, ...rest } = l;
|
|
return rest;
|
|
}
|
|
|
|
/** Aufraeumen: alter Verlauf und liegengebliebene Zusehende. */
|
|
export function reaktionAufraeumen() {
|
|
try {
|
|
const grenze = new Date(Date.now() - VERLAUF_TAGE * 86400_000).toISOString();
|
|
const a = db().prepare("DELETE FROM reaktion_verlauf WHERE beendet < ?").run(grenze);
|
|
const b = db().prepare("DELETE FROM reaktion_dabei WHERE gesehen < ?")
|
|
.run(new Date(Date.now() - 600_000).toISOString());
|
|
/* ERLEDIGTE MELDUNGEN. Meistens sind sie schon mit dem Chat
|
|
verschwunden (CASCADE); das hier greift, wenn eine Sendung nie
|
|
ordentlich beendet wurde. */
|
|
const c = db().prepare("DELETE FROM reaktion_meldungen WHERE erledigt_am IS NOT NULL"
|
|
+ " AND erledigt_am < ?")
|
|
.run(new Date(Date.now() - MELDUNG_TAGE * 86400_000).toISOString());
|
|
/* Und abgelaufene Massnahmen -- sie enden ohnehin mit der
|
|
Sendung, aber eine Zeile, die niemand mehr wegraeumt, bleibt
|
|
sonst fuer immer stehen. */
|
|
const e = db().prepare("DELETE FROM reaktion_massnahmen WHERE bis IS NOT NULL AND bis < ?")
|
|
.run(jetzt());
|
|
if (a.changes || b.changes || c.changes || e.changes) {
|
|
console.log(`[reaktion] Aufgeraeumt: ${a.changes} Verlauf, ${b.changes} Zusehende, `
|
|
+ `${c.changes} Meldungen, ${e.changes} Massnahmen.`);
|
|
}
|
|
} catch (fehler) {
|
|
console.error("[reaktion] Aufraeumen:", fehler?.message);
|
|
}
|
|
}
|