Filipe: "eine community rolle und community kategorie, wo die community auch dann nur die sieht" · "wieso nur eine kachel — ich will mehrere, mit mehreren optionen" · "egal welche rolle hinzugefügt wird soll immer nur das sehen was ich erlaube". DIE DRITTE ADRESSE. treff.dogfather-universe.com bekommt eine eigene Weiche (treff-adresse.js), eine eigene Zugangswand, ein eigenes Manifest und acht eigene Bühnenbilder. Ohne sie stünde dort die Wand der Agentur: Spicy-Logo, "Creator Workspace", fünf Rollenkacheln mit den Namen Admin, Manager, Scout, Creator — die komplette Struktur des Unternehmens, vor der Anmeldung, für jeden mit der Adresse. Welche Wand zu welcher Tür gehört, steht jetzt in EINER Tafel (istFremdeWand) statt als Sonderfall im Code. Bei zwei Wänden war ein Sonderfall richtig; bei drei wären es drei geworden, bei vier sechs. SIEBEN BRETTER UND EIN ACHTES NUR FÜRS TEAM. Anschlagbrett, Was ansteht, Der Treff, Wunschliste, Highlights, Regeln & Hilfe, Mitmachen — dazu "Meldungen & Maßnahmen", das ausdrücklich NICHT in TREFF_BEREICHE steht: dort wäre es lautlos bei der Community gelandet und hätte ausgesehen wie die anderen sieben. WAS AUF DEN BRETTERN PASSIERT: "Will ich auch" (eine Stimme je Person, erzwungen durch den Schlüssel, nicht durch eine Prüfung) · Anheften, höchstens drei, gezählt IN der Transaktion · Freigabe vor Sichtbarkeit für Termine und Highlights, wobei Abwesenheit der Ruhezustand ist · Melden mit Pflicht-Grund (DSA Art. 16) · Entfernen mit Grund, der den Beitrag ÜBERLEBT (DSA Art. 17) · drei Stufen, gerechnet statt gespeichert · die Bannleiter: Modi bis 7 Tage, dauerhaft nur DogFather (seine Entscheidung) · "Mitmachen" wird zum Talent, genau einmal. Der Ausschluss wirkt in sitzungLesen() — also nicht nur beim Schreiben. Wer ausgeschlossen ist, ist weg, nicht stumm. ZWEI SEITEN MEHR. treff-regeln.html holt seine ZAHLEN vom Server (Mindestalter 18, Fristen, Anschläge) — ein Regeltext, der eine andere Zahl nennt als die Regel, ist schlimmer als keiner. rechte.html rechnet "Wer sieht was" aus derselben Tafel aus, aus der die Schranke ihre Entscheidung holt. Die Community steht dort bei 3 von 23. SECHS BEFUNDE, KEINEN HAT DAS LESEN GEFUNDEN: · Ein Modi kam über den verborgenen Zugang in den Treff — die Bedingung war durch Ausschluss formuliert und nahm die neue Wand automatisch mit · /api/personen gab einem Mitglied Namen und Rolle von DogFather · Ein DELETE mit Körper wird von Nodes HTTP-Parser mit einem leeren 400 abgewiesen, bevor Express ihn sieht (gemessen). Der Grund reist jetzt in der Adresse · Der Kommentar, der erklärt, warum die Zugangswand keine Rollennamen nennt, nannte selbst einen — in einer ausgelieferten Datei · Zwei Listen für "welche Seiten sind ohne Anmeldung offen". Sie stimmten, weil beide am selben Tag entstanden · Ein Portkonflikt, den ich selbst gebaut hatte (4391 gehört pruef-chat-aufloesen) Dazu zwei feste Zahlen abgeschafft (die 20 in pruef-rechtetafel, die Kachelzahl in pruef-treff) und zwei Werkzeuge, die jetzt SUCHEN statt aufzuzählen: Der Versionsstempel findet seine Manifeste selbst — das dritte hätte sonst keinen bekommen, und die neue App hätte bis zu vier Stunden lang das falsche Zeichen getragen. Das Bildwerkzeug baut beide Zugangswände aus einer Vorlage mit zwei Werten. Zwei neue Kacheltöne, gemessen statt gewählt: #2f2f7f (Abstand 31,4 in Lab) und #7c2771 (29,7). Das schwächste Paar der vorhandenen 34 liegt bei 24,9. Geprüft: treff 69 · treff-werkzeuge 60 · rollen 312 · start-ansicht 147 · crew-adresse 129 · sicht 84 · modi-verborgen 80 · haus-trennung 62 · bereiche-lesend 37 · css-klassen 30 · zwischenspeicher 21 · alle-wege 19 · rechtetafel 19 — alles grün. Co-Authored-By: Claude Opus 5 <[email protected]>
952 lines
41 KiB
JavaScript
952 lines
41 KiB
JavaScript
/* =====================================================================
|
||
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,
|
||
} 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", "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" || person?.rolle === "hand") 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 Treff.";
|
||
|
||
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 Treff 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 (Entscheidung Filipe, 11.09.2026: 18 Jahre).
|
||
|
||
In Luxemburg liegt die Grenze der DSGVO Art. 8 bei 16 — es waere also
|
||
erlaubt, ab 16 hereinzulassen. Der Treff gehoert aber zu einem Stream
|
||
fuer Erwachsene, und dort ist die Frage nicht, was erlaubt ist,
|
||
sondern wen man hereinlaesst. */
|
||
export const MINDESTALTER = 18;
|
||
|
||
/* 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,
|
||
modi_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" });
|
||
}
|
||
});
|
||
|
||
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 Treff gemeldet`,
|
||
`Aus „Mitmachen": ${String(e.titel ?? "")}\n\n${String(e.text ?? "")}`.trim(),
|
||
nun.slice(0, 10), e.erstellt_von, nun, req.person.id, "Der Treff");
|
||
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 };
|