Filipe, 30.09.2026: „wenn wir bei highlights videos rein setzen will
ich nicht mehr dass wir sie freigeben muessen, sobald die reingesetzt
wurden sollen die sofort zu sehen sein."
=====================================================================
WAS ICH NICHT GETAN HABE, UND WARUM NICHT
=====================================================================
Die naheliegende Loesung waere gewesen, `highlight` aus
`TREFF_FREIGABE_BRETTER` zu streichen. Das waere falsch: Auf diesem
Brett laedt auch die COMMUNITY hoch -- Clips, Bilder, Fanart. Im
Quelltext steht woertlich daneben:
„Zwei Schloesser, weil hier fremde Inhalte hochgeladen werden --
Urheberrecht und Anstand sind nichts, was man nachtraeglich
klaert."
Filipe meint nicht das. Er meint: „wenn WIR videos rein setzen".
=====================================================================
DIE UNTERSCHEIDUNG STAND SCHON IM HAUS -- nur nicht im Code
=====================================================================
Derselbe Quelltext sagt ueber die zwei Freigabe-Bretter
Verschiedenes:
ansteht -> der SCHALTER „Im Treff zeigen". Das Team entscheidet
JE TERMIN, ob die Community ihn sieht.
highlight -> der Urheberrechts- und Anstandsfilter.
Ein Filter fragt „hat das jemand angesehen?". Wenn der, der ihn
bedienen darf, den Eintrag SELBST anlegt, ist die Antwort ja. Genau
diese Begruendung steht seit dem Uebernehmen aus dem Katalog im
Haus: „Eine zweite daneben waere keine Sicherheit, sondern ein
Klick."
Ein Schalter dagegen ist eine Entscheidung je Fall. Termine bleiben
deshalb unberuehrt -- sonst stuende jeder interne Termin sofort im
Treff, und danach hat niemand gefragt.
Neu: `FREIGABE_IST_FILTER` und `sofortFreigeben()` in
workspace-treff.js. DIE BEDINGUNG FRAGT DIE ROLLE, NICHT DEN WEG --
waere der Weg gefragt, waere aus dem Filter ein Loch geworden, sobald
jemand einen zweiten Weg baut. Ein Community-Mitglied ab „Stamm"
darf weiterhin einstellen; sein Eintrag wartet auf das Team.
Gerufen an ZWEI Stellen: beim Videoweg und beim Anlegen von Hand.
Beide hatten es bisher nicht.
=====================================================================
DIE VORHANDENE FREIGABEPRUEFUNG BEWEIST DAS NICHT
=====================================================================
Sie blieb nach der Aenderung gruen -- und das zu Recht: Sie legt ihre
Eintraege unmittelbar in der Datenbank an und prueft damit den
Mechanismus, nicht den Weg. Haette ich mich darauf verlassen, waere
eine Aenderung ausgeliefert worden, fuer die keine Zeile spricht.
pruef-treff (+5): DogFather legt ueber den echten Weg an -> die
Community sieht es SOFORT. Ein TERMIN bleibt verborgen. Die Regel
gibt fuer eine Rolle von aussen NICHT frei und fuer Termine
ueberhaupt nicht.
pruef-video (+3): Der Videoweg hat seinen eigenen Aufruf -- genau
dort wird einer vergessen. Geprueft wird die Freigabezeile selbst,
samt Gegenprobe „freigegeben ist nur, was auch angelegt wurde".
Meinen Abschnitt hatte ich erst HINTER das Abschalten des
nachgebauten TikTok-Dienstes gehaengt -- der Kopf der Datei warnt
woertlich davor („das Abschalten steht ganz hinten"). Gelesen habe
ich ihn, als es rot wurde.
UND `pruef-struktur` HAT MEINE EIGENE NEUE ZEILE GEFANGEN: Sie bildete
das Datum aus UTC statt Ortszeit -- zwischen Mitternacht und zwei Uhr
waere es der falsche Tag gewesen. Behoben mit `tagLokal()`, Minuten
nach dem Schreiben.
Gemessen: pruef-treff 85/0 (war 80), pruef-video 74/0 (war 71),
pruef-highlights 31/0, pruef-bereiche-lesend 37/0,
pruef-treff-werkzeuge 73/0, pruef-alle-sehen-es 43/0,
pruef-community-sicht 10/0, pruef-wege-nach-draussen 67/0,
pruef-struktur 44/0.
Co-Authored-By: Claude Opus 5 <[email protected]>
1056 lines
46 KiB
JavaScript
1056 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,
|
||
} 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(),
|
||
nun.slice(0, 10), e.erstellt_von, nun, req.person.id, "Das Rudel");
|
||
protokolliere("treff_talent", { personId: req.person.id, ip: echteIp(req),
|
||
detail: `aus Eintrag ${id}` });
|
||
res.json({ ok: true, talent_id: Number(r.lastInsertRowid) });
|
||
} catch (f) {
|
||
console.error("[treff] Übernehmen:", f?.message);
|
||
res.status(503).json({ fehler: "nicht_verfuegbar" });
|
||
}
|
||
});
|
||
|
||
/* ---------------------------------------------------------------------
|
||
DER VOLLE ROLLENNAME AN JEDEM BEITRAG
|
||
|
||
Filipes Entscheidung vom 11.09.2026: „Voller Rollenname zeigen" --
|
||
an einem Beitrag im Treff steht „Modi", „Rechte Hand", „DogFather".
|
||
|
||
WARUM DER SERVER DEN NAMEN LIEFERT UND NICHT DER BROWSER: Eine
|
||
Zuordnung Rolle → Name im ausgelieferten JavaScript wäre eine Liste
|
||
aller Rollen dieses Hauses -- auf jedem Rechner der Community,
|
||
dauerhaft lesbar. Hier kommt nur der Name der Rollen an, die an
|
||
diesem Beitrag wirklich beteiligt sind.
|
||
------------------------------------------------------------------- */
|
||
function mitRollenname(zeile) {
|
||
const aus = { ...zeile };
|
||
for (const [k, v] of Object.entries(zeile)) {
|
||
if (k.endsWith("_rolle") && v) aus[`${k}_name`] = ROLLEN_NAME[v] ?? v;
|
||
}
|
||
return aus;
|
||
}
|
||
export { mitRollenname };
|