Deutsch oeffnet mit „ und schliesst mit “. An 199 Stellen, die ein
Mensch liest, stand als Schlusszeichen ein GERADES " -- „Nächstes"
statt „Nächstes“. Auf sechzehn oeffentlichen Seiten, in vierzig
Dateien des Workspace und in zwoelf Servermodulen, die Texte
verschicken.
Auf dem Bildschirm sieht man den Unterschied sofort. Beim Schreiben
nicht: Das gerade " liegt auf der Tastatur, die anderen nicht.
WARUM EIN ERSTER ANLAUF ZURUECKGENOMMEN WURDE
Ein gerades " ist an vielen Stellen SYNTAX und kein Schriftzeichen --
Grenze einer Zeichenkette, Grenze eines HTML-Attributs, Zeichen in
einem regulaeren Ausdruck. Wer stumpf ersetzt, macht aus
„<a href="https://… ein kaputtes Attribut
/^["'„»\s]+|["'“«.\s]+$/ einen kaputten Ausdruck
"… nichts „mal " + "eben …" eine kaputte Zeichenkette
DIE UNTERSCHEIDUNG LAEUFT AN MERKMALEN, NICHT AN EINER LISTE
< > = dazwischen -> HTML-Marke oder Attribut
endet auf Leerzeichen -> die Zeichenkette hoert hier auf, der
Satz geht in der naechsten Zeile weiter.
Ein deutsches Schlusszeichen steht NIE
hinter einem Leerzeichen.
Rueckstrich mittendrin -> regulaerer Ausdruck
${ ohne } -> mitten in einem Ausdruck
Eine Liste erlaubter Ausnahmen waere die naechste, die niemand
pflegt. Zwoelf Stellen bleiben dadurch stehen, alle zwoelf einzeln
angesehen und alle zwoelf zu Recht -- dort steht das richtige
Schlusszeichen ohnehin weiter unten im Satz.
Fuenf davon waren allerdings ECHTE Fehler HINTER dem Link
(`…>HasiDog</a>".`) -- die erste Regel hatte nur das Attribut
gesehen, nicht den Satz danach. Gezielt nachgezogen.
Ein maskiertes `\"` in workspace-vorlagen.js (28 Hooks) wird zu “ --
ohne Rueckstrich, denn “ begrenzt nichts.
KOMMENTARE BLEIBEN, WIE SIE SIND. Dort liest es niemand ausser mir;
eine Wache, die auch Kosmetik anmahnt, wird weggeklickt. Beim ersten
Messen fielen ausserdem acht Stellen aus buehne.html faelschlich an,
weil `/* */` in HTML (in <style> und <script>) nicht ausgeblendet
war -- jetzt schon.
DIE WACHE DAZU
pruef-struktur prueft es ab sofort mit derselben Regel: 322 Dateien
mit sichtbarem Text, 0 Funde, und die zwoelf bewussten Ausnahmen
werden GEZAEHLT und genannt (erlaubt: 12). Eine Ausnahme, die niemand
sieht, waechst -- und irgendwann steht der echte Fall darin.
GEPRUEFT
pruef-struktur 59 -> 68 Pruefungen, 0 Fehler
node --check auf allen geaenderten JS-Dateien
nachgemessen: 199 geaendert, 12 mit Grund stehen geblieben
gruen geblieben: bewerbung-aufgaben 163, nachwuchs 262,
reaktion 421, support 63, content 45, terminregel 35, treff 85
Und nachgesehen, ob eine Pruefung noch die alte Schreibweise
ERWARTET: 13 Fundstellen, alle dreizehn nur Text in ihrer eigenen
Ausgabe, keine einzige ein Vergleich mit dem Seitentext.
GEGENPROBE: In reaktion.html ein Schlusszeichen zurueckgedreht ->
„workspace/reaktion.html:546 „Nächstes"", mit Datei und Zeile. Und
die Erkennung einzeln gegen HTML-Attribut, fortgesetzte
Zeichenkette, regulaeren Ausdruck und eingesetzten Wert geprueft.
Stempel gesetzt: workspace 670 Verweise in 45 Dateien, oeffentlich
554 in 48 Seiten.
Co-Authored-By: Claude Opus 5 <[email protected]>
1058 lines
46 KiB
JavaScript
1058 lines
46 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, istHand,
|
||
heuteLokal,
|
||
} 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"];
|
||
|
||
/* ---------------------------------------------------------------------
|
||
FILTER ODER SCHALTER? (30.09.2026)
|
||
|
||
Die zwei Bretter darüber teilen sich denselben Mechanismus und
|
||
meinen Verschiedenes — der Absatz oben sagt es bereits, nur stand
|
||
es nicht im Code:
|
||
|
||
`ansteht` ist ein SCHALTER. Das Team entscheidet JE TERMIN, ob
|
||
die Community ihn sieht. Dort wäre eine automatische
|
||
Freigabe das Gegenteil des Gewollten: Jeder interne
|
||
Termin stünde sofort im Treff.
|
||
|
||
`highlight` ist ein FILTER. Er fragt „hat das jemand angesehen?"
|
||
— wegen Urheberrecht und Anstand, weil dort auch die
|
||
Community hochlädt.
|
||
|
||
Ein Filter braucht keine zweite Runde, wenn der, der ihn bedienen
|
||
darf, den Eintrag SELBST anlegt. Die Antwort auf „hat das jemand
|
||
angesehen?" ist dann schon ja. Genau so steht es beim Übernehmen
|
||
aus dem Katalog: „Eine zweite daneben wäre keine Sicherheit,
|
||
sondern ein Klick."
|
||
|
||
Filipe, 30.09.2026: „wenn wir bei highlights videos rein setzen
|
||
will ich nicht mehr dass wir sie freigeben müssen, sobald die
|
||
reingesetzt wurden sollen die sofort zu sehen sein."
|
||
------------------------------------------------------------------ */
|
||
export const FREIGABE_IST_FILTER = new Set(["highlight"]);
|
||
|
||
/** Wer freigeben darf, gibt seinen eigenen Eintrag sofort frei.
|
||
*
|
||
* DIE BEDINGUNG FRAGT DIE ROLLE, NICHT DEN WEG. Ein
|
||
* Community-Mitglied ab „Stamm" darf auf demselben Brett etwas
|
||
* einstellen — sein Eintrag geht weiterhin durch die Freigabe. Wäre
|
||
* hier der Weg gefragt, wäre aus einem Filter ein Loch geworden,
|
||
* sobald jemand einen zweiten Weg baut.
|
||
*
|
||
* @returns true, wenn freigegeben wurde.
|
||
*/
|
||
export function sofortFreigeben(bereich, person, eintragId) {
|
||
if (!FREIGABE_IST_FILTER.has(bereich)) return false;
|
||
if (!TREFF_TEAM_ROLLEN.has(person?.rolle)) return false;
|
||
db().prepare(
|
||
"INSERT OR IGNORE INTO treff_freigaben (eintrag_id, zeitpunkt, von) VALUES (?,?,?)")
|
||
.run(Number(eintragId), jetzt(), person.id);
|
||
return true;
|
||
}
|
||
|
||
/* =====================================================================
|
||
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(),
|
||
/* Der TAG in Ortszeit (01.10.2026), der Zeitstempel in UTC. */
|
||
heuteLokal(), 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 };
|