diff --git a/pruef-ampel.png b/pruef-ampel.png new file mode 100644 index 00000000..4473ec12 Binary files /dev/null and b/pruef-ampel.png differ diff --git a/pruef-schulung.png b/pruef-schulung.png index 279cf5ee..5a74deb8 100644 Binary files a/pruef-schulung.png and b/pruef-schulung.png differ diff --git a/server/index.js b/server/index.js index 3746f492..e5d9dc4e 100644 --- a/server/index.js +++ b/server/index.js @@ -33,6 +33,7 @@ import { sicherungRouter, sicherungStarten } from "./workspace-sicherung.js"; import { steckbriefRouter } from "./workspace-steckbrief.js"; import { vorlagenRouter } from "./workspace-vorlagen.js"; import { schulungRouter } from "./workspace-schulung.js"; +import { ampelRouter } from "./workspace-ampel.js"; const __dirname = dirname(fileURLToPath(import.meta.url)); const SITE_DIR = join(__dirname, ".."); @@ -100,6 +101,7 @@ app.use(sicherungRouter); app.use(steckbriefRouter); app.use(vorlagenRouter); app.use(schulungRouter); +app.use(ampelRouter); app.use(aufgabenRouter); app.use(personenRouter); app.use(profilRouter); diff --git a/server/pruef-ampel.mjs b/server/pruef-ampel.mjs new file mode 100644 index 00000000..60ad2cec --- /dev/null +++ b/server/pruef-ampel.mjs @@ -0,0 +1,269 @@ +/* Prueft Bewertung und Rueckmeldungen. + + Der wichtigste Fall steht in der Mitte: Ein Creator darf sich NICHT + selbst bewerten. Koennte er es, stuende alles auf gruen und die + ganze Ampel waere wertlos. Und "noch verbessern" ohne Begruendung + muss abgelehnt werden -- eine Bewertung, mit der der Creator nichts + anfangen kann, ist nicht streng, sondern nur entmutigend. */ +import { mkdtempSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; + +const ordner = mkdtempSync(join(tmpdir(), "ws-ampel-")); +process.env.WORKSPACE_DB = join(ordner, "workspace.db"); +process.env.PORT = "4206"; +process.env.SITE_ACCESS_SECRET = "lokaler-test"; +process.env.SITE_ACCESS_CODE_DOGI = "DOGI-TEST-1"; +process.env.SITE_PUBLIC_LAUNCH_AT = "2020-01-01T00:00:00+01:00"; + +const express = (await import("express")).default; +const ec = express.response.cookie; +express.response.cookie = function (n, w, o) { return ec.call(this, n, w, { ...(o || {}), secure: false }); }; +await import("./index.js"); +await new Promise((r) => setTimeout(r, 700)); +const BASIS = "http://127.0.0.1:4206"; +setTimeout(() => { console.log("ABBRUCH"); process.exit(1); }, 250_000).unref?.(); + +let fehler = 0; +const ok = (b, t) => { console.log((b ? " ok " : " FEHL ") + t); if (!b) fehler++; }; + +await fetch(BASIS + "/workspace/api/anmelden", { + method: "POST", headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ rolle: "admin", code: "x" }) }).catch(() => {}); + +const { DatabaseSync } = await import("node:sqlite"); +const { scryptSync, randomBytes } = await import("node:crypto"); +for (let i = 0; i < 60; i++) { + try { + const p = new DatabaseSync(process.env.WORKSPACE_DB); + try { p.prepare("SELECT 1 FROM personen LIMIT 1").get(); break; } finally { p.close(); } + } catch { await new Promise((r) => setTimeout(r, 250)); } +} + +const d = new DatabaseSync(process.env.WORKSPACE_DB); +const jetzt = new Date().toISOString(); +function anlegen(name, rolle, code) { + const salt = randomBytes(16).toString("hex"); + const hash = scryptSync(code, salt, 64, { N: 32768, r: 8, p: 1, maxmem: 96 * 1024 * 1024 }).toString("hex"); + d.prepare("INSERT INTO personen (name, rolle, code_hash, code_salt, code_n, aktiv, erstellt) VALUES (?,?,?,?,?,1,?)") + .run(name, rolle, hash, salt, 32768, jetzt); + return d.prepare("SELECT last_insert_rowid() AS id").get().id; +} +const idDogi = anlegen("Filipe", "admin", "CODE-DOGI-0001"); +const idScout = anlegen("Patrick", "scout", "CODE-SCOU-0001"); +const idTili = anlegen("Tili", "creator", "CODE-ANNA-0001"); +const idFremd = anlegen("Fremd", "creator", "CODE-FREM-0001"); +d.prepare("INSERT INTO betreuung (creator_id, betreuer_id, seit) VALUES (?,?,?)").run(idTili, idScout, jetzt); + +d.prepare(`INSERT INTO eintraege (bereich, art, titel, text, datum, status, creator_id, erstellt, erstellt_von) + VALUES ('live','vorbereitung','Ton geprüft','Eine Minute aufnehmen.',?,'offen',?,?,?)`) + .run(jetzt.slice(0, 10), idTili, jetzt, idScout); +const eintragId = d.prepare("SELECT last_insert_rowid() AS id").get().id; + +d.prepare(`INSERT INTO eintraege (bereich, art, titel, text, datum, status, creator_id, erstellt, erstellt_von) + VALUES ('live','vorbereitung','Fremder Eintrag','x',?,'offen',?,?,?)`) + .run(jetzt.slice(0, 10), idFremd, jetzt, idDogi); +const fremdEintrag = d.prepare("SELECT last_insert_rowid() AS id").get().id; +d.close(); + +const keks = async (rolle, code) => { + const a = await fetch(BASIS + "/workspace/api/anmelden", { + method: "POST", headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ rolle, code }) }); + return (a.headers.getSetCookie?.() || []).map((k) => k.split(";")[0]).join("; "); +}; +const kDogi = await keks("admin", "CODE-DOGI-0001"); +const kScout = await keks("scout", "CODE-SCOU-0001"); +const kTili = await keks("creator", "CODE-ANNA-0001"); +const ruf = (pfad, k, o = {}) => fetch(BASIS + pfad, { ...o, headers: { Cookie: k, ...(o.headers || {}) } }); +const senden = (pfad, k, art, koerper) => ruf(pfad, k, { + method: art, headers: { "Content-Type": "application/json" }, body: JSON.stringify(koerper || {}) }); + +/* ---------- Bewerten ------------------------------------------------------ */ +console.log("\n=== Bewerten darf nur die Betreuung"); +{ + /* DER WICHTIGSTE FALL. Koennte ein Creator sich selbst auf gruen + setzen, waere die ganze Ampel wertlos. */ + const selbst = await senden(`/workspace/api/eintrag/${eintragId}/ampel`, kTili, "PUT", + { stufe: "gut" }); + ok(selbst.status === 403, + `ein Creator kann sich NICHT selbst bewerten (${selbst.status})`); + const f = await selbst.json(); + ok(/Betreuung/.test(f.fehler || ""), `und erfaehrt, wer es kann ("${f.fehler}")`); + + const durchBetreuer = await senden(`/workspace/api/eintrag/${eintragId}/ampel`, kScout, "PUT", + { stufe: "gut" }); + ok(durchBetreuer.ok, `sein Betreuer kann es (${durchBetreuer.status})`); + const r = await durchBetreuer.json(); + ok(r.ampel.stufe === "gut", `die Stufe steht (${r.ampel.stufe})`); + ok(r.ampel.von === idScout, `mit dem Namen des Bewertenden (${r.ampel.von})`); + ok(!!r.ampel.am, "und dem Zeitpunkt"); + + /* Ein Scout nicht bei einem fremden Creator. */ + const fremd = await senden(`/workspace/api/eintrag/${fremdEintrag}/ampel`, kScout, "PUT", + { stufe: "gut" }); + ok(fremd.status === 404, `nicht bei einem fremden Creator (${fremd.status})`); +} + +console.log("\n=== 'Noch verbessern' braucht einen Grund"); +{ + /* Eine Bewertung, mit der der Creator nichts anfangen kann, ist nicht + streng, sondern nur entmutigend. */ + const ohne = await senden(`/workspace/api/eintrag/${eintragId}/ampel`, kScout, "PUT", + { stufe: "verbessern" }); + ok(ohne.status === 400, `ohne Begruendung abgelehnt (${ohne.status})`); + const f = await ohne.json(); + ok(/verbessert/.test(f.fehler || ""), `mit einer Begruendung, warum ("${f.fehler}")`); + + const mit = await senden(`/workspace/api/eintrag/${eintragId}/ampel`, kScout, "PUT", + { stufe: "verbessern", begruendung: "Der Ton rauscht im Hintergrund." }); + ok(mit.ok, `mit Begruendung geht es (${mit.status})`); + const r = await mit.json(); + ok(r.ampel.begruendung === "Der Ton rauscht im Hintergrund.", "und sie wird gespeichert"); + /* "gut" darf ohne Grund gesetzt werden -- da gibt es nichts zu + erklaeren. */ + const gut = await senden(`/workspace/api/eintrag/${eintragId}/ampel`, kScout, "PUT", + { stufe: "gut" }); + ok(gut.ok, `"Passt so" braucht keinen Grund (${gut.status})`); +} + +/* ---------- Der Creator SIEHT die Bewertung ------------------------------- */ +console.log("\n=== Der Creator sieht, wo er steht"); +{ + await senden(`/workspace/api/eintrag/${eintragId}/ampel`, kScout, "PUT", + { stufe: "verbessern", begruendung: "Mikro naeher heranstellen." }); + const a = await (await ruf(`/workspace/api/eintrag/${eintragId}/rueckmeldung`, kTili)).json(); + ok(a.ampel?.stufe === "verbessern", `er sieht die Stufe (${a.ampel?.stufe})`); + ok(a.ampel?.begruendung === "Mikro naeher heranstellen.", "und den Grund"); + ok(a.darf_bewerten === false, `aber darf sie nicht setzen (${a.darf_bewerten})`); +} + +/* ---------- Nachrichten --------------------------------------------------- */ +console.log("\n=== Nachrichten -- beide Seiten"); +{ + const vomCreator = await senden(`/workspace/api/eintrag/${eintragId}/nachricht`, kTili, "POST", + { text: "Ich habe ein anderes Mikro bestellt." }); + ok(vomCreator.ok, `ein Creator DARF antworten (${vomCreator.status})`); + + const vomBetreuer = await senden(`/workspace/api/eintrag/${eintragId}/nachricht`, kScout, "POST", + { text: "Gut, sag Bescheid wenn es da ist." }); + ok(vomBetreuer.ok, `die Betreuung auch (${vomBetreuer.status})`); + const { nachrichten } = await vomBetreuer.json(); + ok(nachrichten.length === 2, `beide stehen im Verlauf (${nachrichten.length})`); + ok(nachrichten[0].von_name === "Tili" && nachrichten[1].von_name === "Patrick", + "in der richtigen Reihenfolge und mit Namen"); + + const leer = await senden(`/workspace/api/eintrag/${eintragId}/nachricht`, kTili, "POST", + { text: " " }); + ok(leer.status === 400, `eine leere Nachricht wird abgelehnt (${leer.status})`); + + /* Nur die EIGENE laesst sich zuruecknehmen. Fremde zu loeschen hiesse, + ein Gespraech nachtraeglich umzuschreiben. */ + const meine = nachrichten.find((n) => n.von === idTili); + const fremdeWeg = await ruf(`/workspace/api/eintrag/${eintragId}/nachricht/${meine.id}`, + kScout, { method: "DELETE" }); + ok(fremdeWeg.status === 403, + `eine fremde Nachricht laesst sich nicht loeschen (${fremdeWeg.status})`); + + const eigeneWeg = await ruf(`/workspace/api/eintrag/${eintragId}/nachricht/${meine.id}`, + kTili, { method: "DELETE" }); + ok(eigeneWeg.ok, `die eigene schon (${eigeneWeg.status})`); + + /* Auch die Leitung kann fremde Nachrichten nicht loeschen -- ein + Gespraech gehoert denen, die es gefuehrt haben. */ + const rest = await (await ruf(`/workspace/api/eintrag/${eintragId}/rueckmeldung`, kDogi)).json(); + const vonPatrick = rest.nachrichten.find((n) => n.von === idScout); + const dogiWeg = await ruf(`/workspace/api/eintrag/${eintragId}/nachricht/${vonPatrick.id}`, + kDogi, { method: "DELETE" }); + ok(dogiWeg.status === 403, + `auch DogFather nicht (${dogiWeg.status})`); +} + +/* ---------- Fremde Eintraege bleiben unsichtbar --------------------------- */ +console.log("\n=== Fremdes bleibt fremd"); +{ + const a = await ruf(`/workspace/api/eintrag/${fremdEintrag}/rueckmeldung`, kTili); + ok(a.status === 404, + `ein Creator kommt nicht an einen fremden Eintrag -- und erfaehrt nicht, dass es ihn gibt (${a.status})`); + const b = await senden(`/workspace/api/eintrag/${fremdEintrag}/nachricht`, kTili, "POST", + { text: "hallo" }); + ok(b.status === 404, `und kann dort auch nichts schreiben (${b.status})`); +} + +/* ---------- Sammelabfrage -------------------------------------------------- */ +console.log("\n=== Sammelabfrage"); +{ + const a = await (await ruf( + `/workspace/api/eintrag/ampeln?ids=${eintragId},${fremdEintrag}`, kTili)).json(); + ok(!!a.ampeln[eintragId], "der eigene Eintrag ist dabei"); + ok(!a.ampeln[fremdEintrag], "der fremde nicht -- auch nicht in der Sammelabfrage"); +} + +/* ---------- Im Browser ----------------------------------------------------- */ +console.log("\n=== Die Seite"); +const { chromium } = await import( + "file:///C:/Users/qciga/Documents/Obelix/Analyse/node_modules/playwright/index.mjs"); +const browser = await chromium.launch(); + +for (const [rolle, code, name, darfBewerten] of [ + ["scout", "CODE-SCOU-0001", "Patrick", true], + ["creator", "CODE-ANNA-0001", "Tili", false], +]) { + const kontext = await browser.newContext({ viewport: { width: 1280, height: 1000 } }); + const konsole = []; + const seite = await kontext.newPage(); + seite.on("console", (m) => { if (m.type() === "error") konsole.push(m.text()); }); + seite.on("pageerror", (e) => konsole.push("pageerror: " + e.message)); + + await seite.goto(BASIS + "/workspace/", { waitUntil: "domcontentloaded" }); + await seite.click(`.rolle[data-rolle="${rolle}"]`); + await seite.fill("#code", code); + await seite.click("button[type=submit]"); + await seite.waitForURL("**/start.html", { timeout: 15000 }); + await seite.goto(BASIS + "/workspace/bereich.html?b=live", { waitUntil: "networkidle" }); + await seite.waitForTimeout(1100); + + const r = await seite.evaluate(() => ({ + ampeln: document.querySelectorAll(".ampel").length, + stufe: document.querySelector(".ampel")?.dataset.stufe, + marke: document.querySelector(".ampel__marke")?.textContent, + grund: document.querySelector(".ampel__grund")?.textContent, + knopf: !!document.querySelector(".ampel .schritt"), + })); + ok(r.ampeln >= 1, `${name}: die Ampel steht an der Karte (${r.ampeln})`); + ok(r.stufe === "verbessern", ` mit der richtigen Stufe (${r.stufe})`); + ok(/verbessern/i.test(r.marke || ""), ` und lesbarem Text ("${r.marke}")`); + ok(/Mikro/.test(r.grund || ""), ` der Grund steht dabei ("${r.grund}")`); + + /* Aufklappen: Wer bewerten darf, sieht die Knoepfe -- wer nicht, den + Hinweis. Einem Creator einen Knopf zu zeigen, der beim Klicken 403 + sagt, waere schlechter als kein Knopf. */ + await seite.click(".ampel .schritt"); + await seite.waitForTimeout(600); + const auf = await seite.evaluate(() => ({ + kasten: !!document.querySelector(".rueckmeldung"), + bewerten: document.querySelectorAll(".ampel__knopf").length, + hinweis: !!document.querySelector(".rueckmeldung__hinweis"), + nachrichten: document.querySelectorAll(".nachricht").length, + schreibfeld: !!document.querySelector(".nachricht-neu textarea"), + })); + ok(auf.kasten, " der Rueckmeldebereich klappt auf"); + if (darfBewerten) { + ok(auf.bewerten === 2, ` ${name} sieht die Bewertungsknoepfe (${auf.bewerten})`); + ok(!auf.hinweis, " und keinen Hinweis, dass er nicht darf"); + } else { + ok(auf.bewerten === 0, ` ${name} sieht KEINE Bewertungsknoepfe (${auf.bewerten})`); + ok(auf.hinweis, " sondern den Hinweis, wer bewertet"); + } + ok(auf.schreibfeld, " aber beide koennen schreiben"); + ok(auf.nachrichten >= 1, ` und sehen den Verlauf (${auf.nachrichten})`); + ok(konsole.length === 0, ` keine Fehler in der Konsole${konsole.length ? " -> " + konsole.join(" | ") : ""}`); + + if (rolle === "scout") await seite.screenshot({ path: "pruef-ampel.png", fullPage: false }); + await kontext.close(); +} + +await browser.close(); +try { rmSync(ordner, { recursive: true, force: true }); } catch { /* egal */ } +console.log(fehler ? `\n${fehler} Prüfung(en) fehlgeschlagen.` : "\nAlles in Ordnung."); +process.exit(fehler ? 1 : 0); diff --git a/server/workspace-ampel.js b/server/workspace-ampel.js new file mode 100644 index 00000000..216138e6 --- /dev/null +++ b/server/workspace-ampel.js @@ -0,0 +1,276 @@ +/* ===================================================================== + workspace-ampel.js — Bewertung und Rückmeldungen an einem Eintrag. + + Der Wunsch vom 01.09.2026 zur LIVE-Analyse: "der Creator sieht, ob es + gut ist oder schlecht. Und wenn nicht gut, dann muss es verbessert + werden – und nur der Ansprechpartner kann das ändern. Die Creator + können nur Nachrichten hinterlassen zum Kommunizieren." + + Das sind zwei getrennte Dinge, und sie getrennt zu halten ist der + ganze Punkt: + + DIE AMPEL ist eine Beurteilung. Sie gehört dem Betreuer, und nur + er setzt sie. Könnte der Creator sie selbst auf grün + stellen, wäre sie wertlos. + + DIE NACHRICHT ist ein Gespräch. Sie gehört beiden. Ein Creator, der + auf eine Bewertung nicht antworten kann, bekommt ein + Urteil statt einer Betreuung. + + Drei Stufen, nicht fünf. Eine Zahl von 1 bis 5 klingt genauer und ist + es nicht: Niemand kann den Unterschied zwischen 3 und 4 erklären, und + am Ende steht überall 3. Die Frage lautet "reicht das schon?", und + darauf gibt es drei ehrliche Antworten: + + gut so lassen + verbessern noch nicht gut genug -- mit dem Satz, was fehlt + offen noch nicht angesehen + + "verbessern" verlangt eine Begründung. Eine Bewertung ohne Grund ist + für den Creator nicht umsetzbar, sondern nur entmutigend -- und der + Server lehnt sie deshalb ab. + ===================================================================== */ + +import express from "express"; +import { + db, sitzungLesen, istLeitung, darfCreator, protokolliere, echteIp, +} from "./workspace.js"; + +export const ampelRouter = express.Router(); + +const jetzt = () => new Date().toISOString(); +const STUFEN = new Set(["gut", "verbessern", "offen"]); +const NACHRICHT_MAX = 2000; + +let bereit = false; +function tabellen() { + if (bereit) return; + db().exec(` + /* Die Ampel haengt am Eintrag, steht aber in einer eigenen Tabelle: + Sie hat einen eigenen Verfasser und einen eigenen Zeitpunkt, und + beides gehoert nicht in die Zeile des Eintrags. */ + CREATE TABLE IF NOT EXISTS eintrag_ampel ( + eintrag_id INTEGER PRIMARY KEY REFERENCES eintraege(id) ON DELETE CASCADE, + stufe TEXT NOT NULL CHECK (stufe IN ('gut','verbessern','offen')), + begruendung TEXT, + von INTEGER REFERENCES personen(id) ON DELETE SET NULL, + am TEXT NOT NULL + ); + + /* Rueckmeldungen. Jeder Beteiligte darf schreiben -- auch der + Creator, fuer den das oft der einzige Weg ist, etwas zu sagen. */ + CREATE TABLE IF NOT EXISTS eintrag_nachricht ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + eintrag_id INTEGER NOT NULL REFERENCES eintraege(id) ON DELETE CASCADE, + text TEXT NOT NULL, + von INTEGER REFERENCES personen(id) ON DELETE SET NULL, + am TEXT NOT NULL + ); + CREATE INDEX IF NOT EXISTS idx_nachricht_eintrag + ON eintrag_nachricht (eintrag_id); + `); + bereit = true; +} + +function angemeldet(req, res, next) { + const person = sitzungLesen(req); + if (!person) return res.status(401).json({ fehler: "nicht_angemeldet" }); + req.person = person; + try { tabellen(); } catch (f) { + console.error("[workspace] Ampeltabellen:", f?.message); + return res.status(503).json({ fehler: "nicht_verfuegbar" }); + } + next(); +} +ampelRouter.use("/workspace/api/eintrag", angemeldet); + +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(); +} + +/** Darf die Person diesen Eintrag ueberhaupt sehen? Dieselbe Regel wie + * im Bereichsmodul -- sie hier zu wiederholen waere der sichere Weg, + * dass die beiden irgendwann auseinanderlaufen, deshalb wird sie aus + * denselben Bausteinen gebildet. */ +function sichtbarerEintrag(person, id) { + const e = db().prepare("SELECT id, creator_id, bereich FROM eintraege WHERE id = ?").get(id); + if (!e) return null; + if (istLeitung(person)) return e; + if (person.rolle === "creator") return e.creator_id === person.id ? e : null; + return e.creator_id && darfCreator(person, e.creator_id) ? e : null; +} + +/* ---------- Lesen ---------------------------------------------------------- */ + +ampelRouter.get("/workspace/api/eintrag/:id(\\d+)/rueckmeldung", (req, res) => { + try { + const e = sichtbarerEintrag(req.person, Number(req.params.id)); + if (!e) return res.status(404).json({ fehler: "nicht_gefunden" }); + + const ampel = db().prepare(` + SELECT a.*, p.name AS von_name + FROM eintrag_ampel a LEFT JOIN personen p ON p.id = a.von + WHERE a.eintrag_id = ?`).get(e.id) || null; + + const nachrichten = db().prepare(` + SELECT n.*, p.name AS von_name, p.rolle AS von_rolle + FROM eintrag_nachricht n LEFT JOIN personen p ON p.id = n.von + WHERE n.eintrag_id = ? ORDER BY n.id`).all(e.id); + + res.json({ + ampel, nachrichten, + /* Die Oberflaeche soll nicht selbst raten muessen, wer was darf -- + sonst zeigt sie einem Creator einen Knopf, der beim Klicken 403 + sagt. Das ist schlechter als kein Knopf. */ + darf_bewerten: req.person.rolle !== "creator", + ich: req.person.id, + }); + } catch (fehler) { + console.error("[workspace] Rückmeldung lesen:", fehler?.message); + res.status(503).json({ fehler: "nicht_verfuegbar" }); + } +}); + +/* ---------- Bewerten (NUR die Betreuung) ---------------------------------- */ + +ampelRouter.put("/workspace/api/eintrag/:id(\\d+)/ampel", gleicheHerkunft, + express.json({ limit: "8kb" }), (req, res) => { + try { + /* Ein Creator bewertet sich nicht selbst. Waere das moeglich, + waere die Ampel wertlos -- jeder stuende auf gruen. */ + if (req.person.rolle === "creator") { + return res.status(403).json({ + fehler: "Die Bewertung setzt deine Betreuung. Du kannst hier antworten.", + }); + } + const e = sichtbarerEintrag(req.person, Number(req.params.id)); + if (!e) return res.status(404).json({ fehler: "nicht_gefunden" }); + + const stufe = String(req.body.stufe || ""); + if (!STUFEN.has(stufe)) return res.status(400).json({ fehler: "Unbekannte Stufe." }); + + const begruendung = String(req.body.begruendung ?? "").trim().slice(0, NACHRICHT_MAX); + /* "verbessern" ohne Grund ist fuer den Creator nicht umsetzbar -- + nur entmutigend. Deshalb Pflicht. */ + if (stufe === "verbessern" && begruendung.length < 5) { + return res.status(400).json({ + fehler: "Bitte kurz schreiben, was verbessert werden soll – sonst kann er nichts damit anfangen.", + }); + } + + db().prepare(`INSERT INTO eintrag_ampel (eintrag_id, stufe, begruendung, von, am) + VALUES (?,?,?,?,?) + ON CONFLICT(eintrag_id) DO UPDATE SET + stufe = excluded.stufe, begruendung = excluded.begruendung, + von = excluded.von, am = excluded.am`) + .run(e.id, stufe, begruendung || null, req.person.id, jetzt()); + + protokolliere("ampel_gesetzt", req.person, echteIp(req), `Eintrag ${e.id}: ${stufe}`); + + const neu = db().prepare(` + SELECT a.*, p.name AS von_name FROM eintrag_ampel a + LEFT JOIN personen p ON p.id = a.von WHERE a.eintrag_id = ?`).get(e.id); + res.json({ ampel: neu }); + } catch (fehler) { + console.error("[workspace] Ampel setzen:", fehler?.message); + res.status(503).json({ fehler: "nicht_verfuegbar" }); + } + }); + +/* ---------- Nachricht schreiben (JEDER Beteiligte) ------------------------ */ + +ampelRouter.post("/workspace/api/eintrag/:id(\\d+)/nachricht", gleicheHerkunft, + express.json({ limit: "8kb" }), (req, res) => { + try { + const e = sichtbarerEintrag(req.person, Number(req.params.id)); + if (!e) return res.status(404).json({ fehler: "nicht_gefunden" }); + + const text = String(req.body.text ?? "").trim(); + if (text.length < 2) return res.status(400).json({ fehler: "Die Nachricht ist leer." }); + if (text.length > NACHRICHT_MAX) { + return res.status(400).json({ fehler: "Die Nachricht ist zu lang." }); + } + + db().prepare("INSERT INTO eintrag_nachricht (eintrag_id, text, von, am) VALUES (?,?,?,?)") + .run(e.id, text, req.person.id, jetzt()); + protokolliere("nachricht", req.person, echteIp(req), `Eintrag ${e.id}`); + + const alle = db().prepare(` + SELECT n.*, p.name AS von_name, p.rolle AS von_rolle + FROM eintrag_nachricht n LEFT JOIN personen p ON p.id = n.von + WHERE n.eintrag_id = ? ORDER BY n.id`).all(e.id); + res.json({ nachrichten: alle }); + } catch (fehler) { + console.error("[workspace] Nachricht:", fehler?.message); + res.status(503).json({ fehler: "nicht_verfuegbar" }); + } + }); + +/* ---------- Eigene Nachricht zurücknehmen -------------------------------- + + Nur die eigene, und nur die eigene. Fremde Nachrichten zu löschen + hiesse, ein Gespräch nachträglich umzuschreiben -- selbst für die + Leitung nicht. Wer etwas richtigstellen will, schreibt eine neue. */ + +ampelRouter.delete("/workspace/api/eintrag/:id(\\d+)/nachricht/:nid(\\d+)", gleicheHerkunft, + (req, res) => { + try { + const e = sichtbarerEintrag(req.person, Number(req.params.id)); + if (!e) return res.status(404).json({ fehler: "nicht_gefunden" }); + + const n = db().prepare("SELECT * FROM eintrag_nachricht WHERE id = ? AND eintrag_id = ?") + .get(Number(req.params.nid), e.id); + if (!n) return res.status(404).json({ fehler: "nicht_gefunden" }); + if (n.von !== req.person.id) { + return res.status(403).json({ + fehler: "Fremde Nachrichten bleiben stehen. Schreib eine neue, wenn etwas richtigzustellen ist.", + }); + } + db().prepare("DELETE FROM eintrag_nachricht WHERE id = ?").run(n.id); + res.json({ ok: true }); + } catch (fehler) { + console.error("[workspace] Nachricht löschen:", fehler?.message); + res.status(503).json({ fehler: "nicht_verfuegbar" }); + } + }); + +/* ---------- Sammelabfrage für eine Liste --------------------------------- + + Damit die Seite nicht für zwanzig Einträge zwanzig Abfragen schickt. + Geliefert wird nur, was die Person ohnehin sehen darf -- die Prüfung + läuft je Eintrag, nicht einmal pauschal. */ + +ampelRouter.get("/workspace/api/eintrag/ampeln", (req, res) => { + try { + const ids = String(req.query.ids || "").split(",") + .map((x) => Number(x)).filter((x) => Number.isInteger(x) && x > 0).slice(0, 200); + if (!ids.length) return res.json({ ampeln: {}, anzahlNachrichten: {} }); + + const erlaubt = ids.filter((id) => sichtbarerEintrag(req.person, id)); + if (!erlaubt.length) return res.json({ ampeln: {}, anzahlNachrichten: {} }); + + const platz = erlaubt.map(() => "?").join(","); + const ampeln = {}; + for (const a of db().prepare(` + SELECT a.*, p.name AS von_name FROM eintrag_ampel a + LEFT JOIN personen p ON p.id = a.von + WHERE a.eintrag_id IN (${platz})`).all(...erlaubt)) { + ampeln[a.eintrag_id] = a; + } + const anzahlNachrichten = {}; + for (const z of db().prepare(` + SELECT eintrag_id, COUNT(*) AS n FROM eintrag_nachricht + WHERE eintrag_id IN (${platz}) GROUP BY eintrag_id`).all(...erlaubt)) { + anzahlNachrichten[z.eintrag_id] = z.n; + } + res.json({ ampeln, anzahlNachrichten, darf_bewerten: req.person.rolle !== "creator" }); + } catch (fehler) { + console.error("[workspace] Ampeln lesen:", fehler?.message); + res.status(503).json({ fehler: "nicht_verfuegbar" }); + } +}); diff --git a/server/workspace-vorlagen.js b/server/workspace-vorlagen.js index a94a076f..7ffd2c47 100644 --- a/server/workspace-vorlagen.js +++ b/server/workspace-vorlagen.js @@ -43,6 +43,10 @@ export const vorlagenRouter = express.Router(); const jetzt = () => new Date().toISOString(); +/* Welche Bereiche haben Punktelisten? Eine Zuordnung statt drei + Sonderfaellen im Code. */ +const BEREICH_PUNKTE = {}; + /* ===================================================================== CONTENT-IDEEN @@ -172,6 +176,66 @@ export const LIVE_PUNKTE = { Gespräch nach Fragebogen ist ein Verhör, kein Gespräch. ===================================================================== */ +/* ===================================================================== + COMMUNITY-PUNKTE + + Moderation ist die Arbeit, die niemand sieht, solange sie + funktioniert. Die Punkte hier sind so gewaehlt, dass sie VOR dem + Ernstfall abgehakt werden koennen -- danach ist es zu spaet. + ===================================================================== */ + +export const COMMUNITY_PUNKTE = { + moderation: [ + { titel: "Regeln stehen sichtbar", text: "Kurz, verstaendlich, an einer Stelle, die man findet. Drei Saetze reichen.", hilfe: false }, + { titel: "Jemand kann eingreifen", text: "Waehrend des Streams ist eine zweite Person mit Rechten da -- oder es ist klar, dass keine da ist.", hilfe: true }, + { titel: "Wortfilter eingerichtet", text: "Die offensichtlichen Sachen automatisch weg, damit man sich um den Rest kuemmern kann.", hilfe: true }, + { titel: "Was passiert bei einem Verstoss?", text: "Verwarnen, stummschalten, sperren -- vorher entscheiden, nicht im Moment.", hilfe: false }, + { titel: "Umgang mit Minderjaehrigen", text: "Wie erkennt man sie, und was gilt dann? Das ist keine Ermessensfrage.", hilfe: true }, + ], + aktion: [ + { titel: "Neue begruessen", text: "Wer zum ersten Mal da ist, wird angesprochen. Kostet zwei Sekunden und entscheidet, ob jemand bleibt.", hilfe: false }, + { titel: "Stammleute erkennen", text: "Namen merken oder notieren. Wiedererkannt zu werden ist der halbe Grund wiederzukommen.", hilfe: false }, + { titel: "Etwas geben, das nur hier gibt", text: "Ein Insider, ein Ritual, ein Spitzname. Community entsteht aus Gemeinsamem.", hilfe: false }, + { titel: "Ausserhalb des Streams antworten", text: "Kommentare und Nachrichten -- die Community lebt auch zwischen den Sendungen.", hilfe: false }, + ], + konflikt: [ + { titel: "Ruhig bleiben und nicht oeffentlich streiten", text: "Im Chat gewinnt niemand. Kurz ansagen, dann privat klaeren.", hilfe: true }, + { titel: "Was ist genau passiert?", text: "Zeitpunkt, Beteiligte, Wortlaut. Ohne das ist jede Entscheidung angreifbar.", hilfe: false }, + { titel: "Beweise sichern", text: "Bildschirmfoto oder Ausschnitt, solange es noch da ist. Chats verschwinden.", hilfe: true }, + { titel: "Betreuung einbeziehen", text: "Bei allem, was groesser ist als ein blöder Kommentar. Dafuer ist sie da.", hilfe: true }, + { titel: "Danach: was hat gefehlt?", text: "Regel unklar, niemand zum Eingreifen da, zu spaet gemerkt -- daraus wird der naechste Punkt oben.", hilfe: false }, + ], +}; + +/* ===================================================================== + TECHNIK-PUNKTE + + Technik faellt immer im ungeeignetsten Moment aus. Die Punkte sind + deshalb danach sortiert, was am haeufigsten schiefgeht -- und Ton + steht vorn, weil schlechter Ton der Grund Nummer eins ist, warum + Leute einen Stream verlassen. + ===================================================================== */ + +export const TECHNIK_PUNKTE = { + setup: [ + { titel: "Mikrofon richtig eingestellt", text: "Nicht uebersteuert, nicht zu leise, kein Rauschen. Einmal aufnehmen und anhoeren.", hilfe: true }, + { titel: "Kamera scharf und gut belichtet", text: "Gesicht heller als der Hintergrund. Automatik oft abschalten -- sie pumpt.", hilfe: true }, + { titel: "Szenen im Streaming-Programm angelegt", text: "Start, Vollbild, Pause, Ende. Wer waehrend der Sendung baut, sendet Chaos.", hilfe: true }, + { titel: "Bitrate zur Leitung passend", text: "Lieber stabil und etwas weicher als scharf mit Aussetzern.", hilfe: true }, + { titel: "Alles einmal komplett getestet", text: "Eine Probesendung von fuenf Minuten, aufgezeichnet und angesehen.", hilfe: true }, + ], + problem: [ + { titel: "Ton weg oder verzerrt", text: "Wann genau, welches Geraet, was lief gleichzeitig? Ohne das findet es niemand.", hilfe: true }, + { titel: "Bild ruckelt oder friert ein", text: "Auslastung, Leitung, Aufloesung -- eins nach dem anderen ausschliessen.", hilfe: true }, + { titel: "Stream bricht ab", text: "Zeitpunkt notieren und im Protokoll des Programms nachsehen.", hilfe: true }, + { titel: "Ton und Bild laufen auseinander", text: "Meist ein Puffer. Wann faellt es auf, sofort oder erst spaeter?", hilfe: true }, + ], + loesung: [ + { titel: "Was hat es behoben?", text: "Genau die eine Aenderung aufschreiben, nicht alles was probiert wurde.", hilfe: false }, + { titel: "Kommt es wieder?", text: "Beim naechsten Mal nachsehen. Einmal weg ist nicht geloest.", hilfe: false }, + { titel: "Einstellung gesichert", text: "Damit sie nach einem Update nicht wieder weg ist.", hilfe: false }, + ], +}; export const CALL_LEITFAEDEN = [ { titel: "Erstgespräch", text: "Wer bist du, was machst du, was willst du erreichen? · Was läuft schon gut? · " @@ -191,6 +255,12 @@ export const CALL_LEITFAEDEN = [ + "Termin für das nächste Review festhalten" }, ]; +/* Erst hier gefuellt -- die Listen stehen weiter oben und muessen + definiert sein, bevor sie eingetragen werden. */ +BEREICH_PUNKTE.live = LIVE_PUNKTE; +BEREICH_PUNKTE.community = COMMUNITY_PUNKTE; +BEREICH_PUNKTE.technik = TECHNIK_PUNKTE; + /* ---------- Schnittstelle ------------------------------------------------ */ function angemeldet(req, res, next) { @@ -205,6 +275,8 @@ vorlagenRouter.get("/workspace/api/vorlagen", (req, res) => { res.json({ content: CONTENT_IDEEN, live: LIVE_PUNKTE, + community: COMMUNITY_PUNKTE, + technik: TECHNIK_PUNKTE, calls: CALL_LEITFAEDEN, }); }); @@ -266,19 +338,23 @@ vorlagenRouter.post("/workspace/api/vorlagen/uebernehmen", gleicheHerkunft, einfuegen("content", "idee", v.titel, v.text, { hook: v.hook, format: v.format }); } - } else if (art === "live") { + } else if (BEREICH_PUNKTE[art]) { + /* live, community, technik -- alle drei funktionieren gleich. + Drei fast gleiche Bloecke waeren der sichere Weg dazu, dass + der dritte irgendwann anders reagiert als die ersten beiden. */ const unterart = String(req.body.unterart || ""); - const liste = LIVE_PUNKTE[unterart]; + const liste = BEREICH_PUNKTE[art][unterart]; if (!liste) return res.status(400).json({ fehler: "Unbekannter Abschnitt." }); const v = liste[nr]; if (!v) return res.status(400).json({ fehler: "Unbekannte Vorlage." }); - einfuegen("live", unterart, v.titel, v.text); + einfuegen(art, unterart, v.titel, v.text); - } else if (art === "live_alle") { + } else if (BEREICH_PUNKTE[art.replace(/_alle$/, "")] && art.endsWith("_alle")) { + const bereich = art.replace(/_alle$/, ""); const unterart = String(req.body.unterart || ""); - const liste = LIVE_PUNKTE[unterart]; + const liste = BEREICH_PUNKTE[bereich][unterart]; if (!liste) return res.status(400).json({ fehler: "Unbekannter Abschnitt." }); - for (const v of liste) einfuegen("live", unterart, v.titel, v.text); + for (const v of liste) einfuegen(bereich, unterart, v.titel, v.text); } else { return res.status(400).json({ fehler: "Unbekannte Art." }); diff --git a/workspace/assets/css/bereich.css b/workspace/assets/css/bereich.css index 21c263df..d8b911a6 100644 --- a/workspace/assets/css/bereich.css +++ b/workspace/assets/css/bereich.css @@ -175,3 +175,131 @@ .unterweisung__kopf { flex-direction: column; } .unterweisung__marke { align-self: flex-start; } } + +/* ===================================================================== + AMPEL UND RUECKMELDUNG + + Drei Stufen, drei Farben. Die Stufe steht IMMER da -- auch fuer den + Creator, auch wenn sie "noch nicht angesehen" lautet. Das ist der + ganze Sinn: Er soll sehen, wo er steht, ohne fragen zu muessen. + ===================================================================== */ + +.ampel { + --a-ton: #6f89a8; + display: flex; align-items: center; gap: 10px; flex-wrap: wrap; + margin-top: 11px; padding-top: 11px; + border-top: 1px solid var(--rand); +} +.ampel[data-stufe="gut"] { --a-ton: #4dab77; } +.ampel[data-stufe="verbessern"] { --a-ton: #e0a33c; } +.ampel[data-stufe="offen"] { --a-ton: #6f89a8; } + +.ampel__marke { + display: inline-flex; align-items: center; gap: 7px; + padding: 3px 11px; + border: 1px solid color-mix(in srgb, var(--a-ton) 38%, transparent); + border-radius: 999px; + background: color-mix(in srgb, var(--a-ton) 13%, transparent); + color: var(--a-ton); + font-size: .76rem; font-weight: 600; +} +.ampel__marke::before { + content: ""; width: 8px; height: 8px; border-radius: 50%; + background: var(--a-ton); + box-shadow: 0 0 8px color-mix(in srgb, var(--a-ton) 70%, transparent); +} +.ampel__von { font-size: .76rem; color: var(--text-still); } +/* Der Grund steht auf einer eigenen Zeile und in voller Breite -- + "noch verbessern" ohne Grund ist nicht umsetzbar, nur entmutigend. + Deshalb darf er nicht in eine Ecke gequetscht werden. */ +.ampel__grund { + flex-basis: 100%; margin: 4px 0 0; + padding: 8px 11px; + border-left: 2px solid var(--a-ton); + border-radius: 0 6px 6px 0; + background: color-mix(in srgb, var(--a-ton) 8%, transparent); + font-size: .85rem; line-height: 1.5; color: var(--text); +} +.ampel .schritt { margin-left: auto; } + +/* ---------- Der ausgeklappte Bereich ------------------------------------ */ +.rueckmeldung { + margin-top: 11px; padding: 13px 14px; + border: 1px solid var(--rand); border-radius: var(--radius-klein); + background: rgba(0, 0, 0, .2); +} +.rueckmeldung__hinweis { + margin: 0 0 12px; font-size: .84rem; color: var(--text-leise); +} +.rueckmeldung__bewerten { margin-bottom: 14px; } +.ampel__wahl { display: flex; gap: 7px; flex-wrap: wrap; margin-top: 8px; } +.ampel__knopf { + padding: 8px 14px; + border: 1px solid var(--rand); border-radius: 999px; + background: var(--flaeche); + color: var(--text-leise); + font: inherit; font-size: .84rem; cursor: pointer; + transition: border-color .18s ease, color .18s ease, background .18s ease; +} +.ampel__knopf[data-stufe="gut"]:hover, +.ampel__knopf[data-stufe="gut"][data-an="ja"] { + border-color: rgba(77, 171, 119, .5); color: #6fbd95; + background: rgba(77, 171, 119, .12); +} +.ampel__knopf[data-stufe="verbessern"]:hover, +.ampel__knopf[data-stufe="verbessern"][data-an="ja"] { + border-color: rgba(224, 163, 60, .5); color: #e0b45c; + background: rgba(224, 163, 60, .12); +} + +/* ---------- Das Gespraech ------------------------------------------------ */ +.nachrichten { display: grid; gap: 8px; margin-bottom: 12px; } +.nachrichten__leer { margin: 0; font-size: .84rem; color: var(--text-still); } +.nachricht { + padding: 9px 12px; + border: 1px solid var(--rand); border-radius: 9px; + background: var(--flaeche); +} +/* Eigene Nachrichten heben sich ab -- in einem Gespraech will man auf + einen Blick sehen, was man selbst gesagt hat. */ +.nachricht[data-eigen="ja"] { + border-color: color-mix(in srgb, var(--akzent) 26%, transparent); + background: color-mix(in srgb, var(--akzent) 7%, var(--flaeche)); +} +.nachricht__kopf { + display: flex; align-items: center; gap: 9px; + margin-bottom: 4px; +} +.nachricht__von { font-size: .8rem; font-weight: 650; color: var(--text); } +.nachricht__wann { font-size: .74rem; color: var(--text-still); } +.nachricht__weg { + margin-left: auto; + width: 24px; height: 24px; + border: 0; border-radius: 6px; + background: none; color: var(--text-still); + font-size: 1rem; line-height: 1; cursor: pointer; +} +.nachricht__weg:hover { color: var(--warn); background: rgba(255, 154, 162, .1); } +.nachricht__text { + margin: 0; font-size: .87rem; line-height: 1.55; color: var(--text); + white-space: pre-wrap; overflow-wrap: anywhere; +} + +.nachricht-neu { display: grid; gap: 8px; } +.nachricht-neu textarea { + width: 100%; resize: vertical; min-height: 44px; + padding: 10px 12px; + border: 1px solid var(--rand); border-radius: 9px; + background: rgba(0, 0, 0, .25); color: var(--text); + font: inherit; font-size: .88rem; +} +.nachricht-neu textarea:focus { + outline: none; + border-color: color-mix(in srgb, var(--akzent) 50%, transparent); +} +.nachricht-neu .knopf { justify-self: start; } + +@media (max-width: 560px) { + .ampel .schritt { margin-left: 0; } + .ampel__knopf { flex: 1 1 auto; text-align: center; } +} diff --git a/workspace/assets/js/ampel.js b/workspace/assets/js/ampel.js new file mode 100644 index 00000000..1d060165 --- /dev/null +++ b/workspace/assets/js/ampel.js @@ -0,0 +1,232 @@ +/* =================================================================== + Ampel und Rückmeldungen an einem Eintrag. + + Zwei Dinge, bewusst getrennt gehalten: + + DIE AMPEL ist eine Beurteilung. Sie gehört dem Betreuer. + Ein Creator sieht sie, kann sie aber nicht setzen -- + sonst stünde alles auf grün. + DIE NACHRICHT ist ein Gespräch. Sie gehört beiden. Ein Creator, + der auf eine Bewertung nicht antworten kann, bekommt + ein Urteil statt einer Betreuung. + + Drei Stufen, nicht fünf. "Reicht das schon?" hat drei ehrliche + Antworten: ja, noch nicht, noch nicht angesehen. Eine Zahl von 1 bis 5 + klingt genauer und ist es nicht -- am Ende steht überall die 3. + =================================================================== */ +(() => { + 'use strict'; + + const el = (tag, klasse, text) => { + const k = document.createElement(tag); + if (klasse) k.className = klasse; + if (text !== undefined) k.textContent = text; + return k; + }; + + 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'); } + return a; + } + + const wann = (iso) => (iso + ? new Date(iso).toLocaleString('de-DE', + { day: '2-digit', month: '2-digit', hour: '2-digit', minute: '2-digit' }) + : ''); + + const STUFEN = [ + ['gut', 'Passt so'], + ['verbessern', 'Noch verbessern'], + ]; + + /** Hängt Ampel und Rückmeldungen an eine Eintragskarte. + * karte: das Element, an das angehängt wird + * id: die Nummer des Eintrags + * vorschau: { ampel, anzahl } aus der Sammelabfrage -- damit die + * Liste nicht für jeden Eintrag einzeln nachfragt */ + window.ampelAnhaengen = function (karte, id, vorschau = {}) { + const zeile = el('div', 'ampel'); + const stufe = vorschau.ampel?.stufe || 'offen'; + zeile.dataset.stufe = stufe; + + /* Der Zustand, immer sichtbar -- auch für den Creator. Das ist der + ganze Sinn: Er soll sehen, wo er steht, ohne fragen zu müssen. */ + const marke = el('span', 'ampel__marke', + stufe === 'gut' ? 'Passt so' + : stufe === 'verbessern' ? 'Noch verbessern' : 'Noch nicht angesehen'); + zeile.append(marke); + + if (vorschau.ampel?.von_name) { + zeile.append(el('span', 'ampel__von', + `${vorschau.ampel.von_name} · ${wann(vorschau.ampel.am)}`)); + } + + /* Die Begründung steht DANEBEN, nicht versteckt. "Noch verbessern" + ohne Grund ist nicht umsetzbar, nur entmutigend. */ + if (vorschau.ampel?.begruendung) { + zeile.append(el('p', 'ampel__grund', vorschau.ampel.begruendung)); + } + + const auf = el('button', 'schritt', + vorschau.anzahl ? `Rückmeldung (${vorschau.anzahl})` : 'Rückmeldung'); + auf.type = 'button'; + auf.addEventListener('click', () => oeffnen(karte, id, auf)); + zeile.append(auf); + + karte.append(zeile); + }; + + /* ---------- Der ausklappbare Bereich ---------------------------------- */ + + async function oeffnen(karte, id, knopf) { + const vorhanden = karte.querySelector('.rueckmeldung'); + if (vorhanden) { vorhanden.remove(); knopf.dataset.offen = ''; return; } + knopf.dataset.offen = 'ja'; + + let daten; + try { + const a = await hole(`/workspace/api/eintrag/${id}/rueckmeldung`); + if (!a.ok) return; + daten = await a.json(); + } catch { return; } + + const kasten = el('div', 'rueckmeldung'); + + /* ---- Bewerten: nur die Betreuung ---- */ + if (daten.darf_bewerten) { + const b = el('div', 'rueckmeldung__bewerten'); + b.append(el('p', 'feldschild', 'Bewertung')); + + const knoepfe = el('div', 'ampel__wahl'); + for (const [wert, name] of STUFEN) { + const k = el('button', 'ampel__knopf', name); + k.type = 'button'; + k.dataset.stufe = wert; + if (daten.ampel?.stufe === wert) k.dataset.an = 'ja'; + k.addEventListener('click', async () => { + let grund = daten.ampel?.begruendung || ''; + /* "Noch verbessern" verlangt einen Grund -- der Server lehnt + es sonst ab, und das ist richtig so. Hier wird deshalb + gleich danach gefragt statt eine Fehlermeldung zu zeigen. */ + if (wert === 'verbessern') { + grund = prompt('Was genau soll verbessert werden?\n\n' + + 'Ohne diesen Satz kann der Creator nichts damit anfangen.', grund) || ''; + if (grund.trim().length < 5) return; + } + k.disabled = true; + try { + const a = await hole(`/workspace/api/eintrag/${id}/ampel`, { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ stufe: wert, begruendung: grund }), + }); + if (!a.ok) { + alert((await a.json().catch(() => ({}))).fehler || 'Ging nicht.'); + k.disabled = false; + return; + } + /* Die Karte neu aufbauen lassen -- die Seite weiss selbst + am besten, wie sie aussieht. */ + document.dispatchEvent(new CustomEvent('ampel-geaendert', { detail: { id } })); + } catch { k.disabled = false; } + }); + knoepfe.append(k); + } + b.append(knoepfe); + kasten.append(b); + } else { + kasten.append(el('p', 'rueckmeldung__hinweis', + 'Die Bewertung setzt deine Betreuung. Hier kannst du antworten und Fragen stellen.')); + } + + /* ---- Das Gespräch ---- */ + const verlauf = el('div', 'nachrichten'); + zeichneNachrichten(verlauf, daten.nachrichten, daten.ich, id); + kasten.append(verlauf); + + /* ---- Schreiben: JEDER Beteiligte ---- */ + const form = el('form', 'nachricht-neu'); + const feld = el('textarea'); + feld.rows = 2; + feld.maxLength = 2000; + feld.placeholder = daten.darf_bewerten + ? 'Rückmeldung an den Creator …' + : 'Frage oder Antwort an deine Betreuung …'; + form.append(feld); + const senden = el('button', 'knopf knopf--klein', 'Senden'); + senden.type = 'submit'; + form.append(senden); + + form.addEventListener('submit', async (e) => { + e.preventDefault(); + if (!feld.value.trim()) return; + senden.disabled = true; + try { + const a = await hole(`/workspace/api/eintrag/${id}/nachricht`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ text: feld.value }), + }); + if (!a.ok) { + alert((await a.json().catch(() => ({}))).fehler || 'Ging nicht.'); + return; + } + const { nachrichten } = await a.json(); + feld.value = ''; + zeichneNachrichten(verlauf, nachrichten, daten.ich, id); + } catch { /* umgeleitet */ } finally { senden.disabled = false; } + }); + kasten.append(form); + + karte.append(kasten); + } + + function zeichneNachrichten(ziel, liste, ichId, eintragId) { + ziel.textContent = ''; + if (!liste.length) { + ziel.append(el('p', 'nachrichten__leer', 'Noch keine Rückmeldung.')); + return; + } + for (const n of liste) { + const z = el('div', 'nachricht'); + if (n.von === ichId) z.dataset.eigen = 'ja'; + z.dataset.rolle = n.von_rolle || ''; + + const kopf = el('div', 'nachricht__kopf'); + kopf.append(el('span', 'nachricht__von', n.von_name || 'Unbekannt')); + kopf.append(el('span', 'nachricht__wann', wann(n.am))); + + /* Nur die eigene lässt sich zurücknehmen. Fremde zu löschen hieße, + ein Gespräch nachträglich umzuschreiben. */ + if (n.von === ichId) { + const weg = el('button', 'nachricht__weg', '×'); + weg.type = 'button'; + weg.title = 'Eigene Nachricht zurücknehmen'; + weg.addEventListener('click', async () => { + if (!confirm('Diese Nachricht zurücknehmen?')) return; + try { + const a = await hole(`/workspace/api/eintrag/${eintragId}/nachricht/${n.id}`, + { method: 'DELETE' }); + if (a.ok) z.remove(); + } catch { /* umgeleitet */ } + }); + kopf.append(weg); + } + z.append(kopf); + z.append(el('p', 'nachricht__text', n.text)); + ziel.append(z); + } + } + + /** Holt Ampeln und Nachrichtenzahlen für eine ganze Liste auf einmal. + * Zwanzig Einträge einzeln abzufragen wären zwanzig Anfragen. */ + window.ampelnHolen = async function (ids) { + if (!ids.length) return { ampeln: {}, anzahlNachrichten: {} }; + try { + const a = await hole('/workspace/api/eintrag/ampeln?ids=' + ids.join(',')); + if (!a.ok) return { ampeln: {}, anzahlNachrichten: {} }; + return await a.json(); + } catch { return { ampeln: {}, anzahlNachrichten: {} }; } + }; +})(); diff --git a/workspace/assets/js/bereich.js b/workspace/assets/js/bereich.js index da1676f4..e532afed 100644 --- a/workspace/assets/js/bereich.js +++ b/workspace/assets/js/bereich.js @@ -154,7 +154,31 @@ : 'Noch kein Eintrag in diesem Bereich.')); return; } - for (const e of sichtbar) ziel.append(karte(e)); + const karten = new Map(); + for (const e of sichtbar) { + const k = karte(e); + karten.set(e.id, k); + ziel.append(k); + } + + /* Bewertung und Rueckmeldungen. In EINER Abfrage fuer die ganze + Liste -- zwanzig Eintraege einzeln zu fragen waeren zwanzig + Anfragen, und die Seite ruckelte sichtbar beim Aufbau. + + Nur in den Bereichen, in denen eine Beurteilung ueberhaupt + Sinn ergibt: LIVE, Community und Technik. Bei Content sind es + eigene Ideen des Creators -- die bewertet niemand mit einer + Ampel. */ + if (["live", "community", "technik", "schutz"].includes(bereich) + && window.ampelAnhaengen && karten.size) { + const stand = await window.ampelnHolen([...karten.keys()]); + for (const [id, k] of karten) { + window.ampelAnhaengen(k, id, { + ampel: stand.ampeln?.[id] || null, + anzahl: stand.anzahlNachrichten?.[id] || 0, + }); + } + } } catch { /* umgeleitet */ } finally { /* IM finally, nicht dahinter. laden() steigt oben frueh aus, wenn die Liste leer ist -- und dann wurden diese beiden @@ -255,6 +279,12 @@ (async () => { bereich = new URLSearchParams(location.search).get('b') || ''; + + /* Wird eine Bewertung gesetzt, baut sich die Liste neu auf. Der + Zuhoerer haengt am Dokument und wird genau einmal gesetzt -- + laden() laeuft mehrfach, und zwei Zuhoerer wuerden die Liste + zweimal neu bauen. */ + document.addEventListener('ampel-geaendert', () => laden()); if (!/^[a-z]+$/.test(bereich)) { location.assign('/workspace/start.html'); return; } try { diff --git a/workspace/aufgaben.html b/workspace/aufgaben.html index b8179863..0b2ef6e5 100644 --- a/workspace/aufgaben.html +++ b/workspace/aufgaben.html @@ -7,9 +7,9 @@ - - - + + + @@ -139,9 +139,9 @@ - - - - + + + + diff --git a/workspace/automation.html b/workspace/automation.html index e8627d65..5a1502e8 100644 --- a/workspace/automation.html +++ b/workspace/automation.html @@ -7,10 +7,10 @@ - - - - + + + + @@ -132,9 +132,9 @@ - - - - + + + + diff --git a/workspace/bereich.html b/workspace/bereich.html index f01a2e96..573e394d 100644 --- a/workspace/bereich.html +++ b/workspace/bereich.html @@ -7,10 +7,10 @@ - - - - + + + + @@ -115,11 +115,12 @@ - - - - - - + + + + + + + diff --git a/workspace/calls.html b/workspace/calls.html index a4ef7cc8..8587cab0 100644 --- a/workspace/calls.html +++ b/workspace/calls.html @@ -7,10 +7,10 @@ - - - - + + + + @@ -54,10 +54,10 @@ - - - - - + + + + + diff --git a/workspace/content.html b/workspace/content.html index 94d8b0e1..81e172e4 100644 --- a/workspace/content.html +++ b/workspace/content.html @@ -7,10 +7,10 @@ - - - - + + + + @@ -162,10 +162,10 @@ - - - - - + + + + + diff --git a/workspace/dateien.html b/workspace/dateien.html index ce92d2e2..045663f8 100644 --- a/workspace/dateien.html +++ b/workspace/dateien.html @@ -7,10 +7,10 @@ - - - - + + + + @@ -93,9 +93,9 @@ - - - - + + + + diff --git a/workspace/index.html b/workspace/index.html index cfb3427a..abdab9c5 100644 --- a/workspace/index.html +++ b/workspace/index.html @@ -25,7 +25,7 @@ type="image/webp" media="(max-width: 899px)" /> - + @@ -143,6 +143,6 @@

- + diff --git a/workspace/kalender.html b/workspace/kalender.html index b474d589..00b8d84c 100644 --- a/workspace/kalender.html +++ b/workspace/kalender.html @@ -7,10 +7,10 @@ - - - - + + + + @@ -147,9 +147,9 @@ - - - - + + + + diff --git a/workspace/personen.html b/workspace/personen.html index cde1bed0..73d71cae 100644 --- a/workspace/personen.html +++ b/workspace/personen.html @@ -7,10 +7,10 @@ - - - - + + + + @@ -121,9 +121,9 @@ - - - - + + + + diff --git a/workspace/profil.html b/workspace/profil.html index 76e05ccb..4285ae61 100644 --- a/workspace/profil.html +++ b/workspace/profil.html @@ -7,10 +7,10 @@ - - - - + + + + @@ -175,10 +175,10 @@ - - - - - + + + + + diff --git a/workspace/report.html b/workspace/report.html index 6f2704c6..f9852940 100644 --- a/workspace/report.html +++ b/workspace/report.html @@ -7,10 +7,10 @@ - - - - + + + + @@ -91,10 +91,10 @@ - - - - - + + + + + diff --git a/workspace/scouting.html b/workspace/scouting.html index 464f5df2..87ae0398 100644 --- a/workspace/scouting.html +++ b/workspace/scouting.html @@ -7,10 +7,10 @@ - - - - + + + + @@ -124,9 +124,9 @@ - - - - + + + + diff --git a/workspace/start.html b/workspace/start.html index f34e0816..f0d3ec6d 100644 --- a/workspace/start.html +++ b/workspace/start.html @@ -7,8 +7,8 @@ - - + + @@ -99,9 +99,9 @@

- - - - + + + + diff --git a/workspace/startcheck.html b/workspace/startcheck.html index ce359aa6..e37d975c 100644 --- a/workspace/startcheck.html +++ b/workspace/startcheck.html @@ -7,10 +7,10 @@ - - - - + + + + @@ -70,10 +70,10 @@ - - - - - + + + + + diff --git a/workspace/steckbrief.html b/workspace/steckbrief.html index 8c28e2c0..65e963a3 100644 --- a/workspace/steckbrief.html +++ b/workspace/steckbrief.html @@ -7,10 +7,10 @@ - - - - + + + + @@ -85,9 +85,9 @@ - - - - + + + + diff --git a/workspace/uebersicht.html b/workspace/uebersicht.html index 25c4b92c..33d52ca7 100644 --- a/workspace/uebersicht.html +++ b/workspace/uebersicht.html @@ -7,9 +7,9 @@ - - - + + + @@ -57,9 +57,9 @@ - + - - + + diff --git a/workspace/wissen.html b/workspace/wissen.html index f7dd5814..3ccca839 100644 --- a/workspace/wissen.html +++ b/workspace/wissen.html @@ -7,10 +7,10 @@ - - - - + + + + @@ -175,9 +175,9 @@ - - - - + + + +