pruef-modi-wortleck war rot und meldete 17 Fundstellen -- seit Wochen,
bei jedem Lauf. Eine Warnung, die immer kommt, wird ueberlesen, und
dann auch die echte.
ERST GEMESSEN, OB DIE REGEL UEBERHAUPT NOCH GILT. Seit dem 11.09. gibt
es die eigene Adresse crew. mit einem offenen Modi-Knopf an der Wand --
es waere gut moeglich gewesen, dass die Pruefung etwas bewacht, das es
nicht mehr gibt. Sie gilt: pruef-modi-verborgen setzt mit 80 Pruefungen
durch, dass ein Manager keinen Modi sieht.
UND DABEI KAM ETWAS SCHLIMMERES HERAUS. Auf der echten Seite gemessen:
/workspace/start.html 302 (Anmeldung noetig)
/workspace/assets/js/talente.js 200 25 KB Quelltext
/workspace/assets/js/chat.js 200 85 KB
/workspace/assets/css/entwicklung.css 200 34 KB
Jede Datei unter workspace/ ist OHNE JEDE ANMELDUNG aus dem offenen
Netz abrufbar. Der verborgene Zugang stand also nicht in einer Datei,
die "jeder herunterlaedt, der angemeldet ist" -- sondern in einer, die
man einfach abrufen kann.
Die Rollentabelle in workspace.js sagt seit dem 11.09. genau das
Richtige dazu: "Der Name steht hier und NICHT in einer Datei, die jeder
herunterlaedt -- ausgeliefert wird er nur an den, der ihn selbst
traegt, und an die, die ihn sehen duerfen." Danach ist jetzt gebaut,
ueberall:
- talente.js hatte `rolle: 'modi'` fest im Text, dazu den Namen im
Codekasten und im Rechtevergleich. Die Seite FRAGT ihn jetzt ab;
/talente/lage liefert ihn, und die Route geht nur an DogFather und
die rechte Hand.
- Der Regeltext des Treffs nennt den Namen weiterhin -- Filipe hat am
11.09. ausdruecklich gewaehlt, dass die Community ihn sieht. Er
kommt jetzt als DATEN mit der Antwort, nicht als fester Text.
- Das Feld hiess selbst `modi_frist_tage`. Das Muster \bmodi\b trifft
das nicht, weil danach ein Unterstrich folgt -- ein Leck durch eine
Luecke im Muster, nicht durch eine Entscheidung. Heisst jetzt
`pause_frist_tage`.
- Die uebrigen elf Fundstellen waren Kommentare. Umformuliert, ohne
dass sie weniger erklaeren.
Ergebnis: 17 -> 0. Ohne Ausnahmeliste, ohne die Pruefung zu entschaerfen.
ZWEI LOECHER, DIE ICH DABEI SELBST GERISSEN HAETTE:
darfAnlegen wurde aus `lage` gerechnet -- aber ichHolen() laeuft VOR
laden(), also war `lage` noch null. Der Wert waere dauerhaft falsch
gewesen und der Knopf nie erschienen. Jetzt ist es eine Frage statt
eines Wertes, beantwortet beim Zeichnen. Gefunden beim Nachsehen der
Reihenfolge, nicht im Testlauf -- deshalb steht die Frage ab jetzt in
pruef-uebergang.
Und im HTML des Treffs steht als Ersatzfassung "jemandem aus dem
Team". Kommt der Name nicht an, bleibt sie stehen, die Seite sieht
vollkommen richtig aus, und niemand merkt es. Ein Platzhalter "…"
waere aufgefallen; eine richtige Ersatzfassung faellt nicht auf.
pruef-neue-seiten prueft jetzt, dass wirklich der Name dasteht.
pruef-modi-wortleck 0 Fehler (vorher 17 Fundstellen)
pruef-uebergang 65 (vorher 61), pruef-neue-seiten 97 (vorher 93)
pruef-modi-verborgen 80, pruef-nachwuchs 123, pruef-treff 66,
pruef-treff-werkzeuge 70, pruef-css-klassen -- alle 0 Fehler
Co-Authored-By: Claude Opus 5 <[email protected]>
313 lines
12 KiB
JavaScript
313 lines
12 KiB
JavaScript
/* =====================================================================
|
||
Meldungen & Maßnahmen im Treff (11.09.2026)
|
||
|
||
Drei Listen und ein Knopf. Die Regeln stehen alle auf dem Server —
|
||
wer eine Maßnahme verhängen darf, wie lange das Team sperren kann,
|
||
ob eine Meldung schon entschieden ist. Diese Datei zeichnet nur und
|
||
fragt nach.
|
||
|
||
WARUM HIER KEINE RECHTEPRÜFUNG STEHT: Sie stünde dann zweimal, und
|
||
die Fassung im Browser kann jeder ändern. Was hier passiert, ist
|
||
höchstens ein Knopf, der gar nicht erst erscheint — das ist
|
||
Bequemlichkeit, keine Sicherung. Die Sicherung antwortet mit 403.
|
||
===================================================================== */
|
||
(() => {
|
||
const $ = (id) => document.getElementById(id);
|
||
const el = (t, k, text) => {
|
||
const n = document.createElement(t);
|
||
if (k) n.className = k;
|
||
if (text !== undefined) n.textContent = text;
|
||
return n;
|
||
};
|
||
const melde = (t) => { const f = $('fehler'); if (f) { f.textContent = t; f.hidden = !t; } };
|
||
|
||
const datum = (iso) => {
|
||
if (!iso) return '';
|
||
const d = new Date(iso);
|
||
return Number.isNaN(d.getTime()) ? '' : d.toLocaleDateString('de-DE',
|
||
{ day: 'numeric', month: 'short', year: 'numeric' });
|
||
};
|
||
|
||
async function hole(pfad, optionen = {}) {
|
||
const a = await fetch(pfad, { credentials: 'same-origin', ...optionen });
|
||
if (a.status === 401) { location.assign('/workspace/'); throw new Error('abgemeldet'); }
|
||
if (a.status === 404) { location.assign('/workspace/start.html'); throw new Error('unbekannt'); }
|
||
return a;
|
||
}
|
||
|
||
function knopf(text, tu) {
|
||
const b = el('button', 'schritt', text);
|
||
b.type = 'button';
|
||
b.addEventListener('click', async () => {
|
||
b.disabled = true; melde('');
|
||
try { await tu(); } catch { /* umgeleitet */ } finally { b.disabled = false; }
|
||
});
|
||
return b;
|
||
}
|
||
|
||
/* Eine Karte für alle drei Listen. Derselbe Aufbau, damit man die
|
||
Seite nicht dreimal neu lesen lernen muss. */
|
||
function karte(kopfText, zeilen, knoepfe = []) {
|
||
const k = el('article', 'treff-fall');
|
||
k.append(el('h3', 'treff-fall__titel', kopfText));
|
||
for (const [schild, wert] of zeilen) {
|
||
if (!wert) continue;
|
||
const p = el('p', 'treff-fall__zeile');
|
||
p.append(el('span', 'treff-fall__schild', schild));
|
||
p.append(el('span', null, wert));
|
||
k.append(p);
|
||
}
|
||
if (knoepfe.length) {
|
||
const reihe = el('div', 'treff-fall__knoepfe');
|
||
for (const b of knoepfe) reihe.append(b);
|
||
k.append(reihe);
|
||
}
|
||
return k;
|
||
}
|
||
|
||
/* ---- Meldungen ---------------------------------------------------- */
|
||
async function meldungen() {
|
||
const ziel = $('meldungen');
|
||
const a = await hole('/workspace/api/treff/meldungen');
|
||
if (!a.ok) { ziel.textContent = ''; ziel.append(el('p', 'leise', 'Ging nicht.')); return; }
|
||
const liste = (await a.json()).meldungen || [];
|
||
ziel.textContent = '';
|
||
ziel.setAttribute('aria-busy', 'false');
|
||
|
||
const offen = liste.filter((m) => m.status === 'offen');
|
||
const erledigt = liste.filter((m) => m.status !== 'offen');
|
||
|
||
if (!offen.length) {
|
||
ziel.append(el('p', 'leise', 'Nichts offen. Das ist die gute Nachricht.'));
|
||
}
|
||
for (const m of offen) {
|
||
const wer = m.melder_name
|
||
? m.melder_name + (m.melder_rolle_name && m.melder_rolle !== 'gast'
|
||
? ' · ' + m.melder_rolle_name : '')
|
||
: 'jemand';
|
||
const autor = m.autor_name
|
||
? m.autor_name + (m.autor_rolle_name && m.autor_rolle !== 'gast'
|
||
? ' · ' + m.autor_rolle_name : '')
|
||
: 'unbekannt';
|
||
const k = karte(m.titel || '(Beitrag gelöscht)', [
|
||
['Grund', m.grund],
|
||
['gemeldet von', wer + ' am ' + datum(m.zeitpunkt)],
|
||
['Beitrag von', autor],
|
||
['Brett', m.bereich || '–'],
|
||
['Text', m.text],
|
||
], [
|
||
knopf('erledigt', () => entscheiden(m.id, 'erledigt')),
|
||
knopf('nichts dran', () => entscheiden(m.id, 'abgelehnt')),
|
||
/* Der Weg zum Beitrag selbst. Ohne ihn müsste man die Meldung
|
||
lesen, sich das Brett merken und dort suchen -- und genau
|
||
dabei entscheidet man nach Erinnerung statt nach dem, was
|
||
wirklich dasteht. */
|
||
...(m.bereich ? [(() => {
|
||
const a2 = el('a', 'schritt', 'zum Brett');
|
||
a2.href = 'bereich.html?b=' + encodeURIComponent(m.bereich);
|
||
return a2;
|
||
})()] : []),
|
||
]);
|
||
ziel.append(k);
|
||
}
|
||
|
||
if (erledigt.length) {
|
||
const h = el('p', 'leise', `${erledigt.length} schon entschieden:`);
|
||
ziel.append(h);
|
||
for (const m of erledigt.slice(0, 20)) {
|
||
ziel.append(karte(m.titel || '(Beitrag gelöscht)', [
|
||
['Grund', m.grund],
|
||
['Entscheidung', (m.status === 'erledigt' ? 'erledigt' : 'nichts dran')
|
||
+ (m.entscheid ? ' – ' + m.entscheid : '')],
|
||
['von', (m.entschieden_name || '') + ' am ' + datum(m.entschieden_am)],
|
||
]));
|
||
}
|
||
}
|
||
}
|
||
|
||
async function entscheiden(id, status) {
|
||
const entscheid = prompt(status === 'erledigt'
|
||
? 'Was hast du getan?\n\nEin Satz genügt – er wird zur Meldung festgehalten.'
|
||
: 'Warum ist daran nichts dran?\n\nEin Satz genügt – er wird festgehalten.');
|
||
if (entscheid === null) return;
|
||
const a = await hole('/workspace/api/treff/meldung/' + id, {
|
||
method: 'PATCH',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({ status, entscheid }),
|
||
});
|
||
if (!a.ok) {
|
||
melde((await a.json().catch(() => ({}))).fehler || 'Ging nicht.');
|
||
return;
|
||
}
|
||
await meldungen();
|
||
}
|
||
|
||
/* ---- Entfernt ----------------------------------------------------- */
|
||
async function entfernt() {
|
||
const ziel = $('entfernt');
|
||
const a = await hole('/workspace/api/treff/entfernt');
|
||
if (!a.ok) { ziel.textContent = ''; return; }
|
||
const liste = (await a.json()).entfernt || [];
|
||
ziel.textContent = '';
|
||
ziel.setAttribute('aria-busy', 'false');
|
||
if (!liste.length) { ziel.append(el('p', 'leise', 'Noch nichts entfernt.')); return; }
|
||
for (const e of liste.slice(0, 50)) {
|
||
ziel.append(karte(e.titel, [
|
||
['Grund', e.grund],
|
||
['war von', e.autor_name || 'unbekannt'],
|
||
['entfernt von', (e.von_name || '')
|
||
+ (e.von_rolle_name ? ' · ' + e.von_rolle_name : '') + ' am ' + datum(e.zeitpunkt)],
|
||
['Brett', e.bereich],
|
||
]));
|
||
}
|
||
}
|
||
|
||
/* ---- Mitglieder ---------------------------------------------------- */
|
||
|
||
/* DIE STUFE STEHT NUR HIER. Im Treff selbst taucht sie nirgends auf
|
||
-- weder neben dem Namen noch an einem Beitrag. Das ist eine
|
||
bewusste Entscheidung aus dem Plan (Teil H): Eine sichtbare Stufe
|
||
macht aus einem Schutz eine Rangordnung, und aus „Neu" wird ein
|
||
Abzeichen, das man los sein will. Für die Moderation ist sie
|
||
dagegen die wichtigste Angabe überhaupt. */
|
||
async function mitglieder() {
|
||
const ziel = $('mitglieder');
|
||
const a = await hole('/workspace/api/treff/mitglieder');
|
||
if (!a.ok) { ziel.textContent = ''; return; }
|
||
const { mitglieder: liste, darf } = await a.json();
|
||
ziel.textContent = '';
|
||
ziel.setAttribute('aria-busy', 'false');
|
||
if (!liste.length) {
|
||
ziel.append(el('p', 'leise',
|
||
'Noch niemand. Zugänge legst du in „Personen & Zugänge" an, Rolle Community.'));
|
||
return;
|
||
}
|
||
const STUFE = { neu: 'Neu', dabei: 'Dabei', stamm: 'Stamm' };
|
||
for (const m of liste) {
|
||
const knoepfe = [];
|
||
if (!m.sperre) {
|
||
if (darf?.pause) knoepfe.push(knopf('Pause', () => massnahme(m, 'pause', darf)));
|
||
if (darf?.ausschluss_befristet) {
|
||
knoepfe.push(knopf('Ausschluss', () => massnahme(m, 'ausschluss', darf)));
|
||
}
|
||
knoepfe.push(knopf('Hinweis', () => massnahme(m, 'hinweis', darf)));
|
||
}
|
||
const k = karte(m.name, [
|
||
['Stufe', STUFE[m.stufe] || m.stufe],
|
||
['dabei seit', datum(m.seit)],
|
||
['Beiträge', String(m.beitraege)],
|
||
['zuletzt da', m.zuletzt ? datum(m.zuletzt) : 'noch nie'],
|
||
['gesperrt', m.sperre
|
||
? `${m.sperre.art === 'pause' ? 'Pause' : 'Ausschluss'} `
|
||
+ (m.sperre.bis ? 'bis ' + datum(m.sperre.bis) : 'dauerhaft')
|
||
+ ' – ' + m.sperre.grund
|
||
: ''],
|
||
], knoepfe);
|
||
if (m.sperre) k.dataset.vorbei = 'ja';
|
||
ziel.append(k);
|
||
}
|
||
}
|
||
|
||
/* EIN VORGANG, ZWEI FRAGEN — und die Reihenfolge ist wichtig.
|
||
|
||
Zuerst der GRUND, dann die Dauer. Umgekehrt wäre bequemer zu
|
||
tippen und falsch herum gedacht: Wer zuerst „wie lange?" gefragt
|
||
wird, hat die Strafe schon festgelegt, bevor er sie begründet hat.
|
||
So steht am Anfang die Frage, was eigentlich passiert ist. */
|
||
async function massnahme(m, art, darf) {
|
||
const grund = prompt(art === 'hinweis'
|
||
? `Hinweis an ${m.name} – worum geht es?`
|
||
: `${art === 'pause' ? 'Pause' : 'Ausschluss'} für ${m.name} – warum?\n\n`
|
||
+ 'Der Grund wird festgehalten, und die Person hat ein Recht darauf.');
|
||
if (grund === null) return;
|
||
|
||
let tage;
|
||
if (art !== 'hinweis') {
|
||
const hinweis = darf?.frist_tage
|
||
? `Du kannst bis zu ${darf.frist_tage} Tage geben.`
|
||
: 'Leer lassen = dauerhaft (nur bei Ausschluss).';
|
||
const ein = prompt(`Für wie viele Tage?\n\n${hinweis}`,
|
||
darf?.frist_tage ? String(darf.frist_tage) : '7');
|
||
if (ein === null) return;
|
||
tage = ein.trim() === '' ? null : Number(ein);
|
||
if (tage !== null && !(tage > 0)) { melde('Das ist keine Anzahl Tage.'); return; }
|
||
if (tage === null && art === 'pause') { melde('Eine Pause braucht eine Dauer.'); return; }
|
||
}
|
||
|
||
const a = await hole('/workspace/api/treff/massnahme', {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({ person_id: m.id, art, grund, tage }),
|
||
});
|
||
if (!a.ok) {
|
||
melde((await a.json().catch(() => ({}))).fehler || 'Ging nicht.');
|
||
return;
|
||
}
|
||
await mitglieder();
|
||
await massnahmen();
|
||
}
|
||
|
||
/* ---- Maßnahmen ---------------------------------------------------- */
|
||
async function massnahmen() {
|
||
const ziel = $('massnahmen');
|
||
const a = await hole('/workspace/api/treff/massnahmen');
|
||
if (!a.ok) { ziel.textContent = ''; return; }
|
||
const liste = (await a.json()).massnahmen || [];
|
||
ziel.textContent = '';
|
||
ziel.setAttribute('aria-busy', 'false');
|
||
if (!liste.length) { ziel.append(el('p', 'leise', 'Noch keine Maßnahme.')); return; }
|
||
|
||
const NAME = { hinweis: 'Hinweis', pause: 'Pause', ausschluss: 'Ausschluss' };
|
||
for (const m of liste.slice(0, 50)) {
|
||
const laeuft = !m.aufgehoben_am
|
||
&& (!m.bis || Date.parse(m.bis) > Date.now());
|
||
const k = karte(`${NAME[m.art] || m.art} · ${m.person_name || 'unbekannt'}`, [
|
||
['Grund', m.grund],
|
||
['gilt', m.aufgehoben_am ? 'aufgehoben am ' + datum(m.aufgehoben_am)
|
||
: m.bis ? 'bis ' + datum(m.bis) : 'dauerhaft'],
|
||
['von', (m.von_name || '') + (m.von_rolle_name ? ' · ' + m.von_rolle_name : '')
|
||
+ ' am ' + datum(m.zeitpunkt)],
|
||
], laeuft && m.art !== 'hinweis' ? [
|
||
knopf('aufheben', async () => {
|
||
if (!confirm(`Maßnahme gegen ${m.person_name} aufheben?`)) return;
|
||
const r = await hole('/workspace/api/treff/massnahme/' + m.id, { method: 'DELETE' });
|
||
if (!r.ok) {
|
||
melde((await r.json().catch(() => ({}))).fehler || 'Ging nicht.');
|
||
return;
|
||
}
|
||
await massnahmen();
|
||
}),
|
||
] : []);
|
||
if (!laeuft) k.dataset.vorbei = 'ja';
|
||
ziel.append(k);
|
||
}
|
||
}
|
||
|
||
/* ---- Die Leiter in Worten ------------------------------------------
|
||
Die Zahlen kommen vom Server, aus demselben Grund wie im Regeltext:
|
||
Eine Frist, die in der Oberfläche anders steht als im Code, ist
|
||
schlimmer als gar keine Angabe. */
|
||
async function leiter() {
|
||
try {
|
||
const a = await fetch('/workspace/api/treff/regeln', { credentials: 'same-origin' });
|
||
if (!a.ok) return;
|
||
const r = await a.json();
|
||
$('leiter').textContent =
|
||
`Hinweis, dann Pause, dann Ausschluss – in dieser Reihenfolge. Ein ${r.rolle_name} kann `
|
||
+ `bis zu ${r.pause_frist_tage} Tage geben; alles darüber und jeder dauerhafte `
|
||
+ `Ausschluss ist DogFathers Entscheidung. Wer eine dauerhafte Maßnahme nicht `
|
||
+ `verhängen darf, kann sie auch nicht aufheben.`;
|
||
} catch { /* dann bleibt "…" stehen, und das sieht man */ }
|
||
}
|
||
|
||
(async () => {
|
||
try {
|
||
await leiter();
|
||
await meldungen();
|
||
await entfernt();
|
||
await mitglieder();
|
||
await massnahmen();
|
||
} catch { /* umgeleitet */ }
|
||
})();
|
||
})();
|