Files
dogfather-universe/server/workspace-treff.js
T
DogFatherGitandClaude Opus 5 a9d920e7d0 Vier stillgelegte Pruefungen, zwei kaputte Zeichen -- und die Wache dagegen
Gefunden beim Vorbereiten der KLIPY-Anbindung, nicht gesucht.

DER VERLORENE BACKSLASH
In fuenf Dateien stand in einem Suchmuster das BYTE 0x08 statt der
zwei Zeichen \ und b. Gemeint war die Wortgrenze; 0x08 ist das
Rueckschritt-Zeichen und kommt in keinem Text vor. Gemessen:

  /<0x08>modis?<0x08>/i.test("Die Modis sind da")  ->  false
  /\bmodis?\b/i        .test("Die Modis sind da")  ->  true

Folgen, je Stelle:

  pruef-nachwuchs         Das Muster stand in einer VERNEINUNG. Die
                          Zeile lautete damit ok(!false) -- dauerhaft
                          gruen, ohne etwas zu messen. Ausgerechnet
                          die Zeile, die das Durchsickern des
                          Rollennamens verhindern soll.
  pruef-personen-formular dasselbe Muster, dieselbe Verneinung
  pruef-nachfrage         hatDialogMitFeld war immer falsch. Seiten
                          mit einem Formular-Dialog galten als "totes
                          Gewicht" -- der Kommentar direkt darueber
                          warnt woertlich vor genau diesem Schaden.
  pruef-entwicklung       zwei von drei Alternativen tot
  workspace-treff.js      nur ein Kommentar, aber unlesbar

ZWEI KAPUTTE ZEICHEN, BEIDE SICHTBAR
  content: "<0x15>C9<0x00>A0"  in chat.css. Gemeint war U+25C9 und
  ein geschuetztes Leerzeichen. Live stand woertlich "C9 A0" vor
  jedem hervorgehobenen @rudel -- am echten Server nachgemessen.

  content: "¹3"  in chat.css UND entwicklung.css. Gemeint war ein
  Haken. Auf der gewaehlten Kachel stand "¹3"; zu sehen in Filipes
  Bildschirmfoto.

Repariert wird mit der Escape-Schreibweise ("\2713"), nicht mit dem
Zeichen selbst: Sie besteht nur aus ASCII und ueberlebt jede
Kodierung.

WAS DIESE FEHLERART BESONDERS MACHT
Niemand sieht sie beim Lesen. Der erste war committet, ausgeliefert
und lag live -- und als ich in der Datei danach suchte, meldete grep
"Binary file matches" und zeigte die Zeile gar nicht erst an. Wer den
Unterschied durchsieht, liest content: "C9 A0" und denkt sich nichts.

Deshalb neu: server/pruef-zeichen.mjs (7 Pruefungen). Sie sucht rohe
Steuerzeichen in allen ausgelieferten und allen Serverdateien, dazu
Ersatzzeichen und Kodierungstruemmer in CSS-content-Angaben. Mit
Gegenprobe in beide Richtungen -- sie weist an einer selbst gebauten
Datei nach, dass sie anschlaegt, UND dass sie bei einer sauberen
schweigt.

Die erste Fassung der Regel war zu weit: Sie meldete acht KORREKTE
Zeichen (✓, ↯, ↻, „, ⚠). Eine Warnung, die immer kommt, ist keine
Warnung mehr. Die engere Regel kommt aus dem Befund selbst -- der
Latin-1-Block U+0080..U+00BF, in dem echte Typografie nie steht.
Nachgemessen ueber alle 203 content-Angaben im Haus: keine einzige
benutzt ihn.

GEHEIMNISSE STEHEN NICHT MEHR IM PROTOKOLL
einstellungSetzen() schrieb immer Name UND Wert. Nachgemessen in der
echten Datenbank: kopie_schluessel steht vollstaendig drin,
vapid_paar wurde bei 120 Zeichen abgeschnitten -- kurz VOR dem
privaten Teil. Dass der heil blieb, lag an einer Laengengrenze, nicht
an einer Absicht. Keine offene Tuer (das Protokoll liegt hinter
nurAdmin), aber der Wert wandert in jede Sicherung.

Es ist eine REGEL und keine Liste: Wer eine Einstellung so benennt,
meint ein Geheimnis. Stehen bleibt, DASS sich etwas geaendert hat,
wann und durch wen -- nur der Wert fehlt.

EINE VERALTETE PRUEFUNG, UMGEDREHT STATT GELOESCHT
pruef-nachwuchs verlangte, dass die rechte Hand keine Zugaenge
anlegen kann. Das war bis zum 22.09. richtig; dann hat Filipe es
umgedreht ("damit sie das auch machen kann wenn er live ist"). Die
Pruefung war seither rot, ohne dass es auffiel -- sie lief nicht.
Jetzt prueft sie beides: dass die rechte Hand es darf UND dass die
Grenzen stehen (sperren 404, Protokoll 404). Eine Erlaubnis ohne
Grenze ist keine Entscheidung, sondern ein Loch.

Und tools/nachtlauf.mjs wird nachgetragen -- bisher unversioniert.

Zahlen: pruef-nachwuchs 260, pruef-entwicklung 48,
pruef-personen-formular 43, pruef-zeichen 7 (neu), pruef-ports 8.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-09-23 14:29:32 +02:00

1009 lines
44 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/* =====================================================================
DER TREFF — was die Community darf, und wer darüber wacht (11.09.2026)
=====================================================================
Auftrag (Filipe, wörtlich):
*"eine community rolle und community kategorie, wo die community
auch dann nur die sieht."*
*"wieso nur eine kachel für die community ich will dass die mehrere
kacheln haben. mit mehreren optionen."*
Die sieben Bretter stehen in workspace.js (TREFF_BEREICHE), ihre
Sichtbarkeit in workspace-bereiche.js. Hier steht, was darauf
PASSIERT: Stimmen, Anheften, Freigeben, Melden, Entfernen, Stufen,
Maßnahmen und die Brücke zu den Talenten.
---------------------------------------------------------------------
WARUM EIN EIGENES MODUL UND KEIN ANHANG AN workspace-bereiche.js
Weil die Community die einzige Rolle in diesem Haus ist, deren
Mitglieder nichts unterschrieben haben. Für alle anderen gilt: Ein
Fehler in der Rechteprüfung trifft jemanden, der ohnehin fast alles
sehen darf. Hier trifft er Fremde. Diese Regeln gehören deshalb an
eine Stelle, an der man sie am Stück lesen kann — nicht verteilt auf
vier Handler eines 1500 Zeilen langen Moduls.
---------------------------------------------------------------------
DIE DREI STUFEN WERDEN GERECHNET, NICHT GESPEICHERT
Neu → ab dem ersten Tag
Dabei → ab 7 Tagen
Stamm → ab 30 Tagen UND 3 eigenen Beiträgen
Eine gespeicherte Stufe wäre ein zweiter Ort für eine Tatsache, die
ohnehin in den Daten steht — und sie würde altern: Wer am Tag 6
eingetragen wird, bliebe „Neu", bis irgendein Vorgang sie nachzieht.
Ein Vorgang, den es nicht gibt, ist kein Vorgang.
WOZU DIE STUFEN ÜBERHAUPT: Die erste Woche ist eine Sandkiste. Wer
neu ist, darf lesen und „Will ich auch" klicken — mehr nicht. Das ist
kein Misstrauen gegen Menschen, sondern gegen Wegwerf-Zugänge: Ein
Zugang, der sieben Tage warten muss, bevor er Schaden anrichten kann,
lohnt sich für niemanden, der Schaden anrichten will.
---------------------------------------------------------------------
DAS RECHT ENTSCHEIDET HIER MIT, UND ZWAR DIE BAUWEISE
* DSA Art. 16 — MELDEN MUSS GEHEN, und die Meldung muss so genau
sein, dass man ohne Rückfrage entscheiden kann. Deshalb ist der
Grund PFLICHT und nicht ein Auswahlfeld mit „Sonstiges".
* DSA Art. 17 — WER ETWAS ENTFERNT, BEGRÜNDET ES. Die Begründung
überlebt den entfernten Beitrag: Sie steht in treff_entfernt, nicht
an der gelöschten Zeile. Genau das ist der Sinn — eine Begründung,
die mit dem Beitrag verschwindet, ist keine.
* DSA Art. 19 — Kleinstunternehmen sind von den Art. 20–28 befreit.
Die Pflichten aus Art. 16/17 gelten trotzdem, und sie sind ohnehin
das, was man aus Anstand täte.
* DSGVO Art. 8 — in Luxemburg liegt die Altersgrenze für die
Einwilligung bei 16. Der Treff setzt trotzdem **18**
(Entscheidung Filipe, 11.09.2026): Rund um einen Stream für
Erwachsene ist die Frage nicht, was erlaubt ist, sondern wen man
hereinlässt.
---------------------------------------------------------------------
WAS HIER ABSICHTLICH NICHT STEHT
KEINE DIREKTNACHRICHTEN zwischen Mitgliedern. Das ist die wichtigste
Einzelentscheidung des ganzen Bereichs. Ein öffentlicher Raum ist
moderierbar — ein privater ist es nicht. Jede Belästigung, die im
Treff passiert, passiert vor Zeugen und kann gemeldet werden. Wäre
sie in einem Postfach passiert, wüsste niemand davon, und der Treff
wäre der Ort, an dem die Adressen dafür verteilt werden.
===================================================================== */
import express from "express";
import {
db, sitzungLesen, protokolliere, echteIp,
TREFF_BRETTER, TREFF_ROLLEN, ROLLEN_NAME, istHand,
} from "./workspace.js";
/* Die Tabellen und die Sperrfrage stehen in einem Blatt ohne eigene
Importe -- workspace.js braucht beides ebenfalls, und ein Importkreis
waere die Folge. Begruendung im Kopf von treff-tabellen.js. */
import { treffSperreLesen } from "./treff-tabellen.js";
export const treffRouter = express.Router();
const jetzt = () => new Date().toISOString();
const TAG = 86400000;
/* ---------------------------------------------------------------------
WER IST TEAM, WER IST AUSSEN
Beide Mengen stehen hier ausgeschrieben und nicht als „alles außer
gast". Dieselbe Überlegung wie in crew-adresse.js: Eine Bedingung
durch Ausschluss nimmt jede künftige Rolle automatisch mit auf — und
die nächste neue Rolle wäre damit stillschweigend Moderation im
Treff. Genau dieser Fehler steckte am 11.09. schon einmal im stillen
Zugang.
------------------------------------------------------------------- */
export const TREFF_TEAM_ROLLEN = new Set(["modi", "hand", "linke", "admin"]);
const TREFF_TEAM = TREFF_TEAM_ROLLEN;
const TREFF_AUSSEN = new Set(["gast"]);
/** Darf diese Person überhaupt etwas im Treff tun? */
const imTreff = (p) => TREFF_ROLLEN.has(p?.rolle);
/* =====================================================================
DIE STUFEN
===================================================================== */
export const STUFEN = ["neu", "dabei", "stamm"];
const STUFEN_NAME = { neu: "Neu", dabei: "Dabei", stamm: "Stamm" };
/** Ab wann die nächste Stufe gilt. Beide Grenzen an einer Stelle. */
const DABEI_TAGE = 7;
const STAMM_TAGE = 30;
const STAMM_BEITRAEGE = 3;
/**
* Welche Stufe hat diese Person?
*
* Team Dogi hat keine Stufe — die Frage ergibt dort keinen Sinn, und
* eine erfundene Antwort („stamm") wäre eine, auf die sich später
* jemand verlässt. Deshalb `null`.
*/
export function stufeVon(person, dieDb = db) {
if (!person || !TREFF_AUSSEN.has(person.rolle)) return null;
const zeile = dieDb().prepare("SELECT erstellt FROM personen WHERE id = ?")
.get(person.id);
if (!zeile?.erstellt) return "neu";
const tage = (Date.now() - Date.parse(zeile.erstellt)) / TAG;
if (!Number.isFinite(tage) || tage < DABEI_TAGE) return "neu";
if (tage < STAMM_TAGE) return "dabei";
const platz = TREFF_BRETTER.map(() => "?").join(", ");
const { n } = dieDb().prepare(
`SELECT COUNT(*) AS n FROM eintraege
WHERE erstellt_von = ? AND bereich IN (${platz})`).get(person.id, ...TREFF_BRETTER);
return n >= STAMM_BEITRAEGE ? "stamm" : "dabei";
}
const stufeReicht = (hat, noetig) => STUFEN.indexOf(hat) >= STUFEN.indexOf(noetig);
/* =====================================================================
WER DARF AUF WELCHES BRETT SCHREIBEN — die Tafel
Eine Tafel und keine Abfragen in den Handlern, aus demselben Grund
wie bei der Rechtetafel in rechte.js: Wer ein achtes Brett anlegt,
muss sich entscheiden. Steht es nicht hier, darf niemand von außen
darauf schreiben — `undefined` heißt „nein", nicht „alles".
`null` bei `gast` heißt: gar nicht, auf keiner Stufe. Das ist etwas
anderes als „auf einer hohen Stufe" und wird deshalb anders
geschrieben.
===================================================================== */
export const TREFF_SCHREIBEN = {
/* Ansagen vom Team. Ein Anschlagbrett, auf das jeder schreiben darf,
ist kein Anschlagbrett, sondern ein zweiter Treff. */
anschlag: null,
/* Termine. Was ansteht, sagt das Team — sonst steht dort ein Termin,
den es nicht gibt, und jemand wartet umsonst. */
ansteht: null,
/* Das Gespräch. Ab „Dabei". */
treff: "dabei",
/* Wünsche. Schreiben ab „Dabei" — KLICKEN darf jeder ab Tag eins,
siehe „Will ich auch". Das ist Absicht: Der erste Eindruck soll
nicht „du darfst noch nichts" sein, sondern „deine Stimme zählt". */
wunsch: "dabei",
/* Clips, Bilder, Fanart. Ab „Stamm", und erst nach Freigabe sichtbar.
Zwei Schlösser, weil hier fremde Inhalte hochgeladen werden —
Urheberrecht und Anstand sind nichts, was man nachträglich klärt. */
highlight: "stamm",
/* Regeln pflegt das Team. Eine Regel, die jeder ändern darf, ist
keine. */
regeln: null,
/* „Ich hätte Lust" — die Brücke zu den Talenten. Ab „Dabei": Wer sich
nach drei Tagen bewirbt, bewirbt sich auf etwas, das er noch nicht
gesehen hat. */
mitmachen: "dabei",
};
/* Bretter, auf denen ein Eintrag erst nach Freigabe sichtbar wird.
BEI `ansteht` IST DAS DER SCHALTER „IM TREFF ZEIGEN". Das Team trägt
dort ein, was ansteht, und entscheidet je Termin, ob die Community
ihn sieht. Ohne diesen Schalter müsste es zwei Terminlisten führen —
und die zweite wäre die, die niemand pflegt.
BEI `highlight` IST ES DER URHEBERRECHTS- UND ANSTANDSFILTER. */
export const TREFF_FREIGABE_BRETTER = ["ansteht", "highlight"];
/* =====================================================================
DIE TABELLEN
===================================================================== */
/* DIE TABELLEN LEGT DER BAUPLAN AN, nicht dieses Modul.
Vorher stand hier ein "beim ersten Zugriff anlegen". Das ist an drei
Stellen im Haus bewaehrt (Ampel, Checkliste, Schulung) und hier
trotzdem falsch: Die Sperrfrage wird in sitzungLesen() gestellt --
also bevor irgendein Weg des Treffs beruehrt wurde. Beim allerersten
Aufruf haette sie gegen eine Tabelle gelaufen, die es noch nicht
gibt. Eine Sperre, die bei einer Stoerung "darf" sagt, ist die, die
im Ernstfall aufgeht.
Das SQL selbst steht in treff-tabellen.js, samt Begruendung je
Tabelle. */
/** Höchstens drei Ansagen gleichzeitig angeheftet.
*
* WARUM ÜBERHAUPT EINE GRENZE: Ein Anschlagbrett mit zwölf angehefteten
* Ansagen ist ein Anschlagbrett mit null -- gelesen wird keine davon.
* Drei ist die Zahl, die auf ein Handy passt, ohne zu scrollen. */
export const ANSCHLAG_MAX = 3;
/* =====================================================================
SPERREN — wer gerade nicht darf
===================================================================== */
/**
* Die wirksame Maßnahme gegen diese Person, oder null.
*
* DREI AUSGÄNGE, NICHT ZWEI: `null` heißt „nichts liegt vor". Kann die
* Frage nicht beantwortet werden (Tabelle fehlt, Datenbank weg), wirft
* die Funktion -- sie gibt NICHT `null` zurück. Eine Sperre, die bei
* einer Störung „darf" sagt, ist genau die, die im Ernstfall aufgeht.
*/
/** Die wirksame Massnahme gegen diese Person, oder null.
* Die Antwort selbst steht in treff-tabellen.js -- dieselbe Frage
* wird beim Anmelden und bei jeder Sitzung gestellt, und zwei
* Fassungen davon waeren eine zu viel. */
export const treffSperre = (personId) => treffSperreLesen(db(), personId);
/**
* Darf diese Person diese Maßnahme verhängen?
*
* Filipes Entscheidung vom 11.09.2026, wörtlich: *"Modis dürfen
* befristet ausschließen, dauerhaft nur ihr."*
*
* WARUM DIE DAUERHAFTE FASSUNG ANDERS BEHANDELT WIRD: Eine Pause endet
* von selbst. Ein dauerhafter Ausschluss endet nur, wenn jemand ihn
* zurücknimmt -- und dafür muss man erst merken, dass er falsch war.
* Das ist eine andere Art Entscheidung, also braucht sie eine andere
* Hand.
*/
export function darfMassnahme(person, art, dauerhaft) {
const r = person?.rolle;
if (!TREFF_TEAM.has(r)) return false;
if (art === "hinweis") return true;
if (!dauerhaft) return true;
return r === "admin";
}
/** Die längste Frist, die diese Rolle setzen darf (in Tagen). */
export const MODI_FRIST_TAGE = 7;
export function fristErlaubt(person, tage) {
if (person?.rolle === "admin" || istHand(person)) return true;
return tage > 0 && tage <= MODI_FRIST_TAGE;
}
/* =====================================================================
DIE FRAGEN, DIE ANDERE MODULE STELLEN
===================================================================== */
/**
* Darf diese Person auf dieses Brett schreiben?
*
* Gibt einen Grund zurück (Text) oder null, wenn es in Ordnung ist.
* Ein Text statt `false`, weil die Antwort dem Menschen davor gesagt
* wird -- „geht nicht" ohne Grund ist die Sorte Antwort, nach der man
* fragt, was man falsch gemacht hat.
*/
export function darfSchreiben(person, bereich) {
if (!TREFF_BRETTER.includes(bereich)) return null; // kein Treff-Brett
if (TREFF_TEAM.has(person?.rolle)) return null; // Team schreibt überall
if (!TREFF_AUSSEN.has(person?.rolle)) return "Dieser Bereich gehört zum Rudel.";
const noetig = TREFF_SCHREIBEN[bereich];
if (!noetig) return "Hier schreibt das Team.";
const sperre = treffSperre(person.id);
if (sperre) {
return sperre.art === "ausschluss"
? "Dein Zugang zum Rudel ist beendet."
: `Du hast gerade eine Pause${sperre.bis ? ` bis ${sperre.bis.slice(0, 10)}` : ""}.`;
}
const hat = stufeVon(person);
if (!stufeReicht(hat, noetig)) {
return noetig === "dabei"
? `Ab „${STUFEN_NAME.dabei}" kannst du hier schreiben – das ist nach ${DABEI_TAGE} Tagen so weit.`
: `Ab „${STUFEN_NAME.stamm}" kannst du hier etwas einstellen – nach ${STAMM_TAGE} Tagen `
+ `und ${STAMM_BEITRAEGE} eigenen Beiträgen.`;
}
return null;
}
/**
* Die Zusatzbedingung, die unfreigegebene Einträge vor der Community
* verbirgt. Für Team Dogi gilt sie nicht -- es muss ja sehen, was es
* freigeben soll.
*
* ALS SQL-BEDINGUNG UND NICHT ALS FILTER DANACH: Ein Filter in
* JavaScript hätte die Zeilen erst geholt. Sie stünden dann in der
* Antwort des Servers, in seinem Speicher und in jedem Protokoll
* dazwischen -- und beim nächsten Weg, der dieselbe Abfrage benutzt,
* ohne den Filter mitzunehmen, auch im Browser.
*/
export function freigabeBedingung(person, praefix = "e") {
if (!TREFF_AUSSEN.has(person?.rolle)) return null;
const platz = TREFF_FREIGABE_BRETTER.map(() => "?").join(", ");
return {
wo: `(${praefix}.bereich NOT IN (${platz})
OR EXISTS (SELECT 1 FROM treff_freigaben f WHERE f.eintrag_id = ${praefix}.id))`,
werte: [...TREFF_FREIGABE_BRETTER],
};
}
/* =====================================================================
DIE WEGE
===================================================================== */
function angemeldet(req, res, next) {
const person = sitzungLesen(req);
if (!person) return res.status(401).json({ fehler: "nicht_angemeldet" });
if (!imTreff(person)) return res.status(404).json({ fehler: "nicht_gefunden" });
req.person = person;
next();
}
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();
}
treffRouter.use("/workspace/api/treff", angemeldet);
/** Nur Team Dogi. 404 und nicht 403 -- wer „kein Zugriff" liest, fragt
* als Nächstes, wer denn dann. */
function nurTeam(req, res, next) {
if (TREFF_TEAM.has(req.person?.rolle)) return next();
return res.status(404).json({ fehler: "nicht_gefunden" });
}
/** Gehört dieser Eintrag zu einem Brett des Treffs? */
function treffEintrag(id) {
const e = db().prepare(
"SELECT id, bereich, titel, erstellt_von FROM eintraege WHERE id = ?").get(id);
if (!e || !TREFF_BRETTER.includes(e.bereich)) return null;
return e;
}
const nummer = (x) => { const n = Number(x); return Number.isInteger(n) && n > 0 ? n : 0; };
const text = (x, max) => String(x ?? "").trim().slice(0, max);
/* ---------- Meine Lage ------------------------------------------------ */
/* Was DIESE Person gerade darf. Die Oberfläche baut daraus ihre Knöpfe.
Sie fragt den Server, statt selbst zu rechnen: Eine Regel, die im
Browser noch einmal steht, ist die, die beim nächsten Umbau
auseinanderläuft -- und die Fassung im Browser kann man ändern. */
treffRouter.get("/workspace/api/treff/lage", (req, res) => {
try {
const p = req.person;
const stufe = stufeVon(p);
const sperre = TREFF_AUSSEN.has(p.rolle) ? treffSperre(p.id) : null;
/* ZWEI ANTWORTEN JE BRETT: ob es geht, und warum nicht.
Der Grund ist kein Schmuck. Ein Brett ohne Schreibknopf und ohne
Erklaerung wirkt kaputt -- man drueckt herum, sucht die Stelle,
gibt auf. Mit dem Satz daneben wirkt es gedacht: "Ab Dabei kannst
du hier schreiben, das ist nach sieben Tagen so weit."
UND ER KOMMT VON DER STELLE, DIE AUCH ENTSCHEIDET. darfSchreiben()
gibt den Text zurueck, den es ohnehin erzeugt -- derselbe, den der
Server bei einem Versuch als Absage schickt. Ein zweiter Satz in
der Oberflaeche waere der, der irgendwann etwas anderes behauptet
als die Regel tut. */
const schreiben = {};
const warum = {};
for (const b of TREFF_BRETTER) {
const grund = darfSchreiben(p, b);
schreiben[b] = grund === null;
if (grund) warum[b] = grund;
}
res.json({
rolle: p.rolle,
rolle_name: ROLLEN_NAME[p.rolle] ?? p.rolle,
team: TREFF_TEAM.has(p.rolle),
stufe,
stufe_name: stufe ? STUFEN_NAME[stufe] : null,
naechste_stufe: stufe === "neu" ? { name: STUFEN_NAME.dabei, tage: DABEI_TAGE }
: stufe === "dabei" ? { name: STUFEN_NAME.stamm, tage: STAMM_TAGE,
beitraege: STAMM_BEITRAEGE } : null,
sperre: sperre ? { art: sperre.art, bis: sperre.bis, grund: sperre.grund } : null,
schreiben,
warum,
anschlag_max: ANSCHLAG_MAX,
});
} catch (f) {
console.error("[treff] Lage:", f?.message);
res.status(503).json({ fehler: "nicht_verfuegbar" });
}
});
/* ---------- Die Regeln ------------------------------------------------ */
/* DAS MINDESTALTER steht seit dem 15.09.2026 in treff-tabellen.js --
dem importfreien Blatt. Grund: Seit die Tuer (workspace.js) es
ebenfalls prueft, brauchen es zwei Dateien, die einander nicht
importieren duerfen. Hier nur weitergereicht, damit der Regeltext
unveraendert dieselbe Zahl bekommt.
EINFUEHREN UND WEITERREICHEN, nicht nur weiterreichen: `export { X }
from "..."` macht den Namen fuer IMPORTEURE sichtbar, legt ihn aber
NICHT in dieser Datei an. Die Regelroute unten benutzt ihn selbst --
sie lief damit in einen ReferenceError, und zwar erst beim Aufruf.
Gefunden von pruef-alter, nicht vom Auge. */
import { MINDESTALTER } from "./treff-tabellen.js";
export { MINDESTALTER };
/* WARUM DER REGELTEXT SEINE ZAHLEN VOM SERVER HOLT.
Im Text steht "nach sieben Tagen kannst du schreiben". Stuende die
Sieben als Wort in der HTML-Datei, waere sie eine zweite Wahrheit
neben DABEI_TAGE — und beim naechsten Nachjustieren haette man eine
Regel, die anders arbeitet als sie sich beschreibt. Das ist die
schlimmere Sorte Fehler: Niemand merkt es, und wer es merkt, glaubt
dem Text.
Ein Regeltext, der sich selbst widerspricht, ist ausserdem rechtlich
das Gegenteil dessen, wofuer er da ist. */
treffRouter.get("/workspace/api/treff/regeln", (req, res) => {
res.json({
mindestalter: MINDESTALTER,
dabei_tage: DABEI_TAGE,
stamm_tage: STAMM_TAGE,
stamm_beitraege: STAMM_BEITRAEGE,
anschlag_max: ANSCHLAG_MAX,
grund_min: GRUND_MIN,
/* DER NAME KOMMT ALS DATEN MIT (16.09.2026).
Filipe hat am 11.09. ausdruecklich gewaehlt, dass im Treff der
volle Rollenname neben einem Beitrag steht. Die Regel dazu steht
an ROLLEN_NAME in workspace.js: Der Name wird AUSGELIEFERT, aber
er steht nicht als fester Text in einer Datei, die jeder abrufen
kann -- und abrufen kann sie wirklich jeder, nachgemessen am
16.09.: `talente.js` kommt ohne Anmeldung mit 200 aus dem Netz.
Im Regeltext des Treffs stand er trotzdem dreimal fest im
Skript. Jetzt kommt er von hier.
UND DER SCHLUESSEL HIESS SELBST SO: `modi_frist_tage`. Das
Wortleck-Muster ist `\bmodi\b` und trifft das nicht, weil danach
ein Unterstrich folgt -- ein Leck durch eine Luecke im Muster,
nicht durch eine Entscheidung. Deshalb heisst das Feld jetzt
nach dem, was es bedeutet. */
rolle_name: ROLLEN_NAME.modi,
pause_frist_tage: MODI_FRIST_TAGE,
stufen: STUFEN.map((s) => ({ schluessel: s, name: STUFEN_NAME[s] })),
/* Welche Bretter erst nach Freigabe erscheinen — auch das gehoert
in den Regeltext, und auch das soll nicht abschreibbar sein. */
freigabe_bretter: [...TREFF_FREIGABE_BRETTER],
});
});
/* ---------- „Will ich auch" ------------------------------------------- */
/* EIN KLICK, KEINE ZAHL IM KOPF. Die Route ist absichtlich nicht
„zähle hoch", sondern „meine Stimme liegt hier": Zweimal dasselbe zu
schicken ändert nichts (INSERT OR IGNORE), und die Antwort ist in
beiden Fällen dieselbe. Eine Route, die beim zweiten Aufruf einen
Fehler bringt, zwingt die Oberfläche dazu, den eigenen Zustand zu
kennen -- und dort ist er am schnellsten falsch. */
treffRouter.put("/workspace/api/treff/eintrag/:id/willich", gleicheHerkunft, (req, res) => {
try {
const id = nummer(req.params.id);
const e = id && treffEintrag(id);
if (!e) return res.status(404).json({ fehler: "nicht_gefunden" });
/* Gestimmt wird auf der Wunschliste. Anderswo wäre es eine zweite
Bewertung neben der, die es schon gibt. */
if (e.bereich !== "wunsch") return res.status(400).json({ fehler: "hier_nicht" });
if (TREFF_AUSSEN.has(req.person.rolle) && treffSperre(req.person.id)) {
return res.status(403).json({ fehler: "gesperrt" });
}
db().prepare(
"INSERT OR IGNORE INTO treff_stimmen (eintrag_id, person_id, zeitpunkt) VALUES (?,?,?)")
.run(id, req.person.id, jetzt());
res.json({ ok: true, ...stimmen(id, req.person.id) });
} catch (f) {
console.error("[treff] Stimme:", f?.message);
res.status(503).json({ fehler: "nicht_verfuegbar" });
}
});
treffRouter.delete("/workspace/api/treff/eintrag/:id/willich", gleicheHerkunft, (req, res) => {
try {
const id = nummer(req.params.id);
const e = id && treffEintrag(id);
if (!e) return res.status(404).json({ fehler: "nicht_gefunden" });
db().prepare("DELETE FROM treff_stimmen WHERE eintrag_id = ? AND person_id = ?")
.run(id, req.person.id);
res.json({ ok: true, ...stimmen(id, req.person.id) });
} catch (f) {
console.error("[treff] Stimme zurück:", f?.message);
res.status(503).json({ fehler: "nicht_verfuegbar" });
}
});
function stimmen(id, personId) {
const { n } = db().prepare("SELECT COUNT(*) AS n FROM treff_stimmen WHERE eintrag_id = ?")
.get(id);
const meine = db().prepare(
"SELECT 1 AS x FROM treff_stimmen WHERE eintrag_id = ? AND person_id = ?")
.get(id, personId);
return { stimmen: n, meine: !!meine };
}
/* ---------- Anheften (Anschlagbrett) ---------------------------------- */
treffRouter.put("/workspace/api/treff/eintrag/:id/anheften", gleicheHerkunft, nurTeam,
(req, res) => {
try {
const id = nummer(req.params.id);
const e = id && treffEintrag(id);
if (!e) return res.status(404).json({ fehler: "nicht_gefunden" });
if (e.bereich !== "anschlag") return res.status(400).json({ fehler: "hier_nicht" });
/* DIE ZÄHLUNG STEHT IN DERSELBEN TRANSAKTION WIE DAS EINFÜGEN.
Zwei Modis, die gleichzeitig anheften, würden sonst beide „zwei
von drei" zählen und beide einfügen -- und am Ende hingen vier.
Genau dieser Fehler ist in diesem Haus schon bei der
CHECK-Umstellung vermieden worden, aus demselben Grund. */
db().exec("BEGIN IMMEDIATE");
try {
const schon = db().prepare(
"SELECT 1 AS x FROM treff_angeheftet WHERE eintrag_id = ?").get(id);
if (!schon) {
const { n } = db().prepare("SELECT COUNT(*) AS n FROM treff_angeheftet").get();
if (n >= ANSCHLAG_MAX) {
db().exec("ROLLBACK");
return res.status(409).json({
fehler: `Es hängen schon ${ANSCHLAG_MAX} Ansagen. Nimm zuerst eine ab.`,
});
}
db().prepare(
"INSERT INTO treff_angeheftet (eintrag_id, seit, von) VALUES (?,?,?)")
.run(id, jetzt(), req.person.id);
}
db().exec("COMMIT");
} catch (f) {
try { db().exec("ROLLBACK"); } catch { /* schon zurück */ }
throw f;
}
protokolliere("treff_angeheftet", { personId: req.person.id, ip: echteIp(req),
detail: `Eintrag ${id}` });
res.json({ ok: true });
} catch (f) {
console.error("[treff] Anheften:", f?.message);
res.status(503).json({ fehler: "nicht_verfuegbar" });
}
});
treffRouter.delete("/workspace/api/treff/eintrag/:id/anheften", gleicheHerkunft, nurTeam,
(req, res) => {
try {
const id = nummer(req.params.id);
if (!id) return res.status(404).json({ fehler: "nicht_gefunden" });
db().prepare("DELETE FROM treff_angeheftet WHERE eintrag_id = ?").run(id);
res.json({ ok: true });
} catch (f) {
console.error("[treff] Abnehmen:", f?.message);
res.status(503).json({ fehler: "nicht_verfuegbar" });
}
});
/* ---------- Freigeben ------------------------------------------------- */
/* „Im Treff zeigen" bei Terminen, „Freigeben" bei Highlights -- derselbe
Vorgang, deshalb dieselbe Route. Zwei Routen wären zwei Stellen, an
denen dieselbe Rechteprüfung steht. */
treffRouter.put("/workspace/api/treff/eintrag/:id/freigeben", gleicheHerkunft, nurTeam,
(req, res) => {
try {
const id = nummer(req.params.id);
const e = id && treffEintrag(id);
if (!e) return res.status(404).json({ fehler: "nicht_gefunden" });
if (!TREFF_FREIGABE_BRETTER.includes(e.bereich)) {
return res.status(400).json({ fehler: "hier_nicht" });
}
db().prepare(
"INSERT OR IGNORE INTO treff_freigaben (eintrag_id, zeitpunkt, von) VALUES (?,?,?)")
.run(id, jetzt(), req.person.id);
protokolliere("treff_freigegeben", { personId: req.person.id, ip: echteIp(req),
detail: `${e.bereich} ${id}` });
res.json({ ok: true, freigegeben: true });
} catch (f) {
console.error("[treff] Freigeben:", f?.message);
res.status(503).json({ fehler: "nicht_verfuegbar" });
}
});
treffRouter.delete("/workspace/api/treff/eintrag/:id/freigeben", gleicheHerkunft, nurTeam,
(req, res) => {
try {
const id = nummer(req.params.id);
if (!id) return res.status(404).json({ fehler: "nicht_gefunden" });
db().prepare("DELETE FROM treff_freigaben WHERE eintrag_id = ?").run(id);
res.json({ ok: true, freigegeben: false });
} catch (f) {
console.error("[treff] Freigabe zurück:", f?.message);
res.status(503).json({ fehler: "nicht_verfuegbar" });
}
});
/* ---------- Melden (DSA Art. 16) -------------------------------------- */
export const GRUND_MIN = 10;
export const GRUND_MAX = 1000;
treffRouter.post("/workspace/api/treff/eintrag/:id/melden", gleicheHerkunft, (req, res) => {
try {
const id = nummer(req.params.id);
const e = id && treffEintrag(id);
if (!e) return res.status(404).json({ fehler: "nicht_gefunden" });
/* DER GRUND IST PFLICHT, und die Mindestlänge ist keine Schikane:
Art. 16 Abs. 2 verlangt eine Meldung, die „hinreichend genau und
angemessen begründet" ist -- also eine, nach der man entscheiden
kann, ohne zurückzufragen. „Scheiße" ist keine. */
const grund = text(req.body?.grund, GRUND_MAX);
if (grund.length < GRUND_MIN) {
return res.status(400).json({
fehler: "Bitte schreib kurz, was daran nicht in Ordnung ist – "
+ "sonst können wir es nicht prüfen.",
});
}
db().prepare(`INSERT INTO treff_meldungen (eintrag_id, melder_id, grund, zeitpunkt)
VALUES (?,?,?,?)`).run(id, req.person.id, grund, jetzt());
protokolliere("treff_meldung", { personId: req.person.id, ip: echteIp(req),
detail: `Eintrag ${id}` });
/* KEINE AUSKUNFT DARÜBER, OB SCHON JEMAND GEMELDET HAT. Sonst wäre
die Antwort ein Zähler, an dem man ablesen kann, wen sonst noch
etwas stört -- und daraus wird eine Abstimmung statt einer
Prüfung. */
res.json({ ok: true });
} catch (f) {
console.error("[treff] Melden:", f?.message);
res.status(503).json({ fehler: "nicht_verfuegbar" });
}
});
treffRouter.get("/workspace/api/treff/meldungen", nurTeam, (req, res) => {
try {
const zeilen = db().prepare(`
SELECT m.id, m.eintrag_id, m.grund, m.zeitpunkt, m.status, m.entscheid,
m.entschieden_am,
e.bereich, e.titel, e.text,
pm.name AS melder_name, pm.rolle AS melder_rolle,
pe.name AS autor_name, pe.rolle AS autor_rolle,
pd.name AS entschieden_name
FROM treff_meldungen m
LEFT JOIN eintraege e ON e.id = m.eintrag_id
LEFT JOIN personen pm ON pm.id = m.melder_id
LEFT JOIN personen pe ON pe.id = e.erstellt_von
LEFT JOIN personen pd ON pd.id = m.entschieden_von
ORDER BY CASE m.status WHEN 'offen' THEN 0 ELSE 1 END, m.id DESC
LIMIT 300`).all();
res.json({ meldungen: zeilen.map(mitRollenname) });
} catch (f) {
console.error("[treff] Meldungen:", f?.message);
res.status(503).json({ fehler: "nicht_verfuegbar" });
}
});
treffRouter.patch("/workspace/api/treff/meldung/:id", gleicheHerkunft, nurTeam, (req, res) => {
try {
const id = nummer(req.params.id);
const status = String(req.body?.status || "");
if (!id || !["erledigt", "abgelehnt"].includes(status)) {
return res.status(400).json({ fehler: "unbrauchbar" });
}
const entscheid = text(req.body?.entscheid, GRUND_MAX);
if (entscheid.length < GRUND_MIN) {
return res.status(400).json({
fehler: "Bitte halte kurz fest, was du entschieden hast – "
+ "das gehört zur Meldung dazu.",
});
}
const r = db().prepare(`UPDATE treff_meldungen
SET status = ?, entscheid = ?, entschieden_von = ?, entschieden_am = ?
WHERE id = ? AND status = 'offen'`)
.run(status, entscheid, req.person.id, jetzt(), id);
if (!r.changes) return res.status(409).json({ fehler: "schon_entschieden" });
protokolliere("treff_meldung_entschieden", { personId: req.person.id, ip: echteIp(req),
detail: `${id}: ${status}` });
res.json({ ok: true });
} catch (f) {
console.error("[treff] Meldung entscheiden:", f?.message);
res.status(503).json({ fehler: "nicht_verfuegbar" });
}
});
/* ---------- Entfernen mit Grund (DSA Art. 17) ------------------------- */
/**
* Hält fest, warum ein Beitrag entfernt wurde — BEVOR er verschwindet.
*
* Wird aus workspace-bereiche.js gerufen, nicht als eigene Route: Es
* gibt genau einen Weg, einen Eintrag zu löschen, und das soll so
* bleiben. Eine zweite Löschroute wäre die, an der die Begründung
* eines Tages fehlt.
*
* @returns {string|null} Fehlertext, oder null wenn gelöscht werden darf
*/
export function entfernenVorbereiten(person, eintrag, grund) {
if (!TREFF_BRETTER.includes(eintrag?.bereich)) return null; // kein Treff-Brett
/* Wer seinen EIGENEN Beitrag zurücknimmt, begründet nichts. Art. 17
schützt den Verfasser vor der Plattform, nicht vor sich selbst. */
if (eintrag.erstellt_von === person?.id) return null;
const g = text(grund, GRUND_MAX);
if (g.length < GRUND_MIN) {
return "Zum Entfernen gehört ein Grund – die Person erfährt ihn, "
+ "und wir müssen ihn festhalten können.";
}
const autor = eintrag.erstellt_von
? db().prepare("SELECT name FROM personen WHERE id = ?").get(eintrag.erstellt_von)
: null;
db().prepare(`INSERT INTO treff_entfernt
(eintrag_id, bereich, titel, autor_id, autor_name, grund, von, zeitpunkt)
VALUES (?,?,?,?,?,?,?,?)`)
.run(eintrag.id, eintrag.bereich, String(eintrag.titel ?? ""),
eintrag.erstellt_von ?? null, autor?.name ?? null, g, person?.id ?? null, jetzt());
return null;
}
treffRouter.get("/workspace/api/treff/entfernt", nurTeam, (req, res) => {
try {
const zeilen = db().prepare(`
SELECT t.id, t.eintrag_id, t.bereich, t.titel, t.autor_name, t.grund, t.zeitpunkt,
p.name AS von_name, p.rolle AS von_rolle
FROM treff_entfernt t LEFT JOIN personen p ON p.id = t.von
ORDER BY t.id DESC LIMIT 300`).all();
res.json({ entfernt: zeilen.map(mitRollenname) });
} catch (f) {
console.error("[treff] Entfernt:", f?.message);
res.status(503).json({ fehler: "nicht_verfuegbar" });
}
});
/* ---------- Maßnahmen ------------------------------------------------- */
treffRouter.post("/workspace/api/treff/massnahme", gleicheHerkunft, nurTeam, (req, res) => {
try {
const personId = nummer(req.body?.person_id);
const art = String(req.body?.art || "");
const grund = text(req.body?.grund, GRUND_MAX);
const tage = Number(req.body?.tage);
const dauerhaft = art === "ausschluss" && (req.body?.tage === null
|| req.body?.tage === undefined || req.body?.tage === "");
if (!personId || !["hinweis", "pause", "ausschluss"].includes(art)) {
return res.status(400).json({ fehler: "unbrauchbar" });
}
if (grund.length < GRUND_MIN) {
return res.status(400).json({
fehler: "Ohne Grund geht das nicht – die Person hat ein Recht darauf zu erfahren, warum.",
});
}
const ziel = db().prepare("SELECT id, name, rolle FROM personen WHERE id = ?").get(personId);
/* MASSNAHMEN GELTEN NUR GEGEN DIE COMMUNITY. Ein Modi, der einen
anderen Modi „ausschließen" könnte, hätte einen Weg, jemanden aus
dem Team zu entfernen -- und zwar an „Personen & Zugänge" vorbei,
wo diese Entscheidung hingehört. */
if (!ziel || !TREFF_AUSSEN.has(ziel.rolle)) {
return res.status(404).json({ fehler: "nicht_gefunden" });
}
if (!darfMassnahme(req.person, art, dauerhaft)) {
return res.status(403).json({
fehler: "Dauerhaft ausschließen kann nur DogFather.",
});
}
let bis = null;
if (!dauerhaft && art !== "hinweis") {
if (!Number.isFinite(tage) || tage <= 0 || tage > 3650) {
return res.status(400).json({ fehler: "Bitte gib an, für wie viele Tage." });
}
if (!fristErlaubt(req.person, tage)) {
return res.status(403).json({
fehler: `Als Modi kannst du bis zu ${MODI_FRIST_TAGE} Tage geben. `
+ "Länger entscheidet DogFather.",
});
}
bis = new Date(Date.now() + tage * TAG).toISOString();
}
db().prepare(`INSERT INTO treff_massnahmen (person_id, art, grund, bis, von, zeitpunkt)
VALUES (?,?,?,?,?,?)`)
.run(personId, art, grund, bis, req.person.id, jetzt());
protokolliere("treff_massnahme", { personId: req.person.id, ip: echteIp(req),
detail: `${art} gegen ${ziel.name}${bis ? ` bis ${bis.slice(0, 10)}` : " (dauerhaft)"}` });
res.json({ ok: true, art, bis });
} catch (f) {
console.error("[treff] Maßnahme:", f?.message);
res.status(503).json({ fehler: "nicht_verfuegbar" });
}
});
treffRouter.delete("/workspace/api/treff/massnahme/:id", gleicheHerkunft, nurTeam, (req, res) => {
try {
const id = nummer(req.params.id);
if (!id) return res.status(404).json({ fehler: "nicht_gefunden" });
const m = db().prepare("SELECT id, art, bis FROM treff_massnahmen WHERE id = ?").get(id);
if (!m) return res.status(404).json({ fehler: "nicht_gefunden" });
/* Wer eine dauerhafte Maßnahme nicht verhängen darf, darf sie auch
nicht aufheben. Sonst wäre die Regel darüber eine Formalie: Ein
Modi könnte einen dauerhaften Ausschluss abnehmen und einen
siebentägigen setzen. */
if (!darfMassnahme(req.person, m.art, !m.bis)) {
return res.status(403).json({ fehler: "Das kann nur DogFather aufheben." });
}
db().prepare(
"UPDATE treff_massnahmen SET aufgehoben_am = ?, aufgehoben_von = ? WHERE id = ?")
.run(jetzt(), req.person.id, id);
protokolliere("treff_massnahme_auf", { personId: req.person.id, ip: echteIp(req),
detail: String(id) });
res.json({ ok: true });
} catch (f) {
console.error("[treff] Maßnahme aufheben:", f?.message);
res.status(503).json({ fehler: "nicht_verfuegbar" });
}
});
/* =====================================================================
WAS HEREINKOMMT (17.09.2026) -- aus dem Plan im Vault, Stufe 4.
Ein Moderator musste bisher acht Bretter durchklicken, um zu sehen,
ob etwas Neues da ist. Das tut niemand achtmal am Tag -- und was
niemand ansieht, moderiert auch niemand.
ALLE BRETTER IN EINER LISTE, das Neueste zuerst. Ohne Inhalt zu
wiederholen: Titel, Brett, wer, wann. Wer mehr will, klickt.
KEINE NEUE TABELLE UND KEINE ZWEITE WAHRHEIT: Es ist dieselbe
Abfrage auf `eintraege`, nur ueber alle Community-Bretter statt eins.
Die Bretterliste kommt aus TREFF_BRETTER -- dieselbe, aus der auch
die Kacheln und die Schreibregeln kommen. Eine eigene Liste hier
waere beim naechsten Brett auseinandergelaufen, und zwar still. */
treffRouter.get("/workspace/api/treff/zulauf", nurTeam, (req, res) => {
try {
const felder = TREFF_BRETTER.map(() => "?").join(", ");
const zeilen = db().prepare(`
SELECT e.id, e.bereich, e.art, e.titel, e.status, e.erstellt,
p.name AS von_name, p.rolle AS von_rolle
FROM eintraege e
LEFT JOIN personen p ON p.id = e.erstellt_von
WHERE e.bereich IN (${felder})
ORDER BY e.erstellt DESC, e.id DESC
LIMIT 40`).all(...TREFF_BRETTER);
res.json({ zulauf: zeilen.map(mitRollenname) });
} catch (f) {
console.error("[treff] Zulauf:", f?.message);
res.status(503).json({ fehler: "nicht_verfuegbar" });
}
});
treffRouter.get("/workspace/api/treff/massnahmen", nurTeam, (req, res) => {
try {
const zeilen = db().prepare(`
SELECT m.id, m.person_id, m.art, m.grund, m.bis, m.zeitpunkt, m.aufgehoben_am,
p.name AS person_name, v.name AS von_name, v.rolle AS von_rolle
FROM treff_massnahmen m
LEFT JOIN personen p ON p.id = m.person_id
LEFT JOIN personen v ON v.id = m.von
ORDER BY m.id DESC LIMIT 300`).all();
res.json({ massnahmen: zeilen.map(mitRollenname) });
} catch (f) {
console.error("[treff] Maßnahmen:", f?.message);
res.status(503).json({ fehler: "nicht_verfuegbar" });
}
});
/* ---------- Die Mitglieder -------------------------------------------- */
/* WARUM DER TREFF SEINE EIGENE MITGLIEDERLISTE HAT UND NICHT
/api/personen BENUTZT (11.09.2026).
Der naheliegende Weg wäre gewesen, die Moderation auf „Personen &
Zugänge" zu schicken. Drei Gründe dagegen, und der dritte ist der
eigentliche:
1. Dort stehen alle — Creator, Scouts, die Agentur. Ein Modi, der
jemanden sperren will, müsste sich durch Menschen klicken, die
ihn nichts angehen.
2. Dort gibt es Knöpfe, die ein Modi nicht drücken darf. Ein
Bildschirm voller Absagen ist schlechter als einer ohne Knöpfe.
3. Was hier gebraucht wird, steht dort gar nicht: die STUFE und die
laufende Maßnahme. Beides gibt es nur im Treff.
NUR AUSSENROLLEN. Die Liste kann das Team nicht enthalten -- nicht
weil es ausgeblendet wäre, sondern weil die Abfrage es gar nicht
erst holt. Eine Maßnahme gegen jemanden aus dem Team gibt es nicht
(siehe /massnahme), und eine Liste, aus der man etwas auswählen
kann, das danach abgelehnt wird, ist eine Falle. */
treffRouter.get("/workspace/api/treff/mitglieder", nurTeam, (req, res) => {
try {
const zeilen = db().prepare(`
SELECT id, name, erstellt, letzter_login, aktiv
FROM personen WHERE rolle = 'gast'
ORDER BY name COLLATE NOCASE`).all();
const platz = TREFF_BRETTER.map(() => "?").join(", ");
const beitraege = db().prepare(
`SELECT erstellt_von AS id, COUNT(*) AS n FROM eintraege
WHERE bereich IN (${platz}) GROUP BY erstellt_von`).all(...TREFF_BRETTER);
const zahl = new Map(beitraege.map((b) => [b.id, b.n]));
res.json({
mitglieder: zeilen.map((z) => {
const sperre = treffSperre(z.id);
return {
id: z.id, name: z.name, seit: z.erstellt, zuletzt: z.letzter_login,
beitraege: zahl.get(z.id) || 0,
stufe: stufeVon({ id: z.id, rolle: "gast" }),
sperre: sperre
? { id: sperre.id, art: sperre.art, bis: sperre.bis, grund: sperre.grund }
: null,
};
}),
/* Was DIESE Person verhängen darf. Die Oberfläche baut daraus
ihre Knöpfe -- und nicht aus einer eigenen Rollenabfrage, die
auseinanderlaufen könnte. */
darf: {
pause: darfMassnahme(req.person, "pause", false),
ausschluss_befristet: darfMassnahme(req.person, "ausschluss", false),
ausschluss_dauerhaft: darfMassnahme(req.person, "ausschluss", true),
frist_tage: req.person.rolle === "modi" ? MODI_FRIST_TAGE : null,
},
});
} catch (f) {
console.error("[treff] Mitglieder:", f?.message);
res.status(503).json({ fehler: "nicht_verfuegbar" });
}
});
/* ---------- Die Brücke zu den Talenten -------------------------------- */
/* Wer auf „Mitmachen" schreibt „Ich hätte Lust", ist ein Kandidat. Diese
Route legt ihn in der Kategorie an, die es dafür schon gibt -- statt
einer zweiten Liste daneben.
EINMAL IST EINMAL: Ein zweites Übernehmen legt keinen zweiten
Kandidaten an. Geprüft wird an der Quelle (gibt es schon einen
Talent-Eintrag zu diesem Menschen?), nicht an einer Merkspalte. */
treffRouter.post("/workspace/api/treff/mitmachen/:id/uebernehmen", gleicheHerkunft, nurTeam,
(req, res) => {
try {
const id = nummer(req.params.id);
const e = id && db().prepare(
"SELECT id, bereich, art, titel, text, erstellt_von FROM eintraege WHERE id = ?").get(id);
if (!e || e.bereich !== "mitmachen") return res.status(404).json({ fehler: "nicht_gefunden" });
if (!e.erstellt_von) {
return res.status(400).json({ fehler: "Zu diesem Eintrag gibt es keine Person." });
}
const schon = db().prepare(
"SELECT id FROM eintraege WHERE bereich = 'talente' AND creator_id = ?")
.get(e.erstellt_von);
if (schon) return res.status(409).json({ fehler: "schon_uebernommen", talent_id: schon.id });
const wer = db().prepare("SELECT name FROM personen WHERE id = ?").get(e.erstellt_von);
const nun = jetzt();
const r = db().prepare(`INSERT INTO eintraege
(bereich, art, titel, text, datum, dringlichkeit, status, creator_id,
erstellt, erstellt_von, einsatz)
VALUES ('talente','empfohlen',?,?,?,'mittel','offen',?,?,?,?)`)
.run(
`${wer?.name ?? "Jemand"} hat sich im Rudel gemeldet`,
`Aus „Mitmachen": ${String(e.titel ?? "")}\n\n${String(e.text ?? "")}`.trim(),
nun.slice(0, 10), e.erstellt_von, nun, req.person.id, "Das Rudel");
protokolliere("treff_talent", { personId: req.person.id, ip: echteIp(req),
detail: `aus Eintrag ${id}` });
res.json({ ok: true, talent_id: Number(r.lastInsertRowid) });
} catch (f) {
console.error("[treff] Übernehmen:", f?.message);
res.status(503).json({ fehler: "nicht_verfuegbar" });
}
});
/* ---------------------------------------------------------------------
DER VOLLE ROLLENNAME AN JEDEM BEITRAG
Filipes Entscheidung vom 11.09.2026: „Voller Rollenname zeigen" --
an einem Beitrag im Treff steht „Modi", „Rechte Hand", „DogFather".
WARUM DER SERVER DEN NAMEN LIEFERT UND NICHT DER BROWSER: Eine
Zuordnung Rolle → Name im ausgelieferten JavaScript wäre eine Liste
aller Rollen dieses Hauses -- auf jedem Rechner der Community,
dauerhaft lesbar. Hier kommt nur der Name der Rollen an, die an
diesem Beitrag wirklich beteiligt sind.
------------------------------------------------------------------- */
function mitRollenname(zeile) {
const aus = { ...zeile };
for (const [k, v] of Object.entries(zeile)) {
if (k.endsWith("_rolle") && v) aus[`${k}_name`] = ROLLEN_NAME[v] ?? v;
}
return aus;
}
export { mitRollenname };