diff --git a/pruef-schulung.png b/pruef-schulung.png new file mode 100644 index 00000000..279cf5ee Binary files /dev/null and b/pruef-schulung.png differ diff --git a/server/index.js b/server/index.js index eee04fcf..3746f492 100644 --- a/server/index.js +++ b/server/index.js @@ -32,6 +32,7 @@ import { contentRouter } from "./workspace-content.js"; 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"; const __dirname = dirname(fileURLToPath(import.meta.url)); const SITE_DIR = join(__dirname, ".."); @@ -98,6 +99,7 @@ app.use(sicherungRouter); Der Steckbrief bringt seine eigene Schranke mit. */ app.use(steckbriefRouter); app.use(vorlagenRouter); +app.use(schulungRouter); app.use(aufgabenRouter); app.use(personenRouter); app.use(profilRouter); diff --git a/server/pruef-schulung.mjs b/server/pruef-schulung.mjs new file mode 100644 index 00000000..e8a4a4b4 --- /dev/null +++ b/server/pruef-schulung.mjs @@ -0,0 +1,342 @@ +/* Prueft die Unterweisungen mit beidseitiger Bestaetigung. + + Das Wichtigste steht am Ende: der VOLLZUG. Aus RunOne (01.09.2026) + stammt die Lehre, dass ein Weg, den man nicht rueckgaengig machen + kann, tagelang live sein und NIE gelaufen sein kann -- dort gab es + null festgeschriebene Buchungen, obwohl der Umbau seit Tagen + ausgeliefert war. Deshalb wird hier ausdruecklich durchgespielt: + bestaetigen, abschliessen, und dann eine Aenderung VERSUCHEN. Die + Ablehnung ist der eigentliche Nachweis. + + Und die Gegenprobe dazu: Ohne den Trigger MUSS die Aenderung + durchgehen. Eine Sperre, die man nicht scheitern sehen hat, ist keine + Sperre. */ +import { mkdtempSync, rmSync, mkdirSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { createHash } from "node:crypto"; + +const ordner = mkdtempSync(join(tmpdir(), "ws-schul-")); +process.env.WORKSPACE_DB = join(ordner, "workspace.db"); +process.env.PORT = "4205"; +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:4205"; +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); + +/* Ein echtes Dokument in der Bibliothek -- ohne Datei keine Unterweisung. */ +const wOrdner = join(ordner, "wissen"); +mkdirSync(wOrdner, { recursive: true }); +const dateiName = randomBytes(8).toString("hex") + ".pdf"; +const inhalt = "%PDF-1.4\nRegeln und Richtlinien\n%%EOF\n"; +writeFileSync(join(wOrdner, dateiName), inhalt); +const echterHash = createHash("sha256").update(Buffer.from(inhalt)).digest("hex"); +d.prepare(`INSERT INTO wissen + (titel, beschreibung, kategorie, stufe, geraet, name_datei, name_original, + groesse, wichtig, veroeffentlicht, hochgeladen, erstellt_von) + VALUES (?,?,?,?,?,?,?,?,0,?,?,?)`) + .run("Community-Richtlinien", "Was erlaubt ist und was nicht", "tiktok-regeln", + "einsteiger", "egal", dateiName, "Richtlinien.pdf", inhalt.length, + jetzt.slice(0, 10), jetzt, idDogi); +const wissenId = 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 kFremd = await keks("creator", "CODE-FREM-0001"); +const ruf = (pfad, k, o = {}) => fetch(BASIS + pfad, { ...o, headers: { Cookie: k, ...(o.headers || {}) } }); +const post = (pfad, k, koerper) => ruf(pfad, k, { + method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(koerper || {}) }); + +/* ---------- Anlegen -------------------------------------------------------- */ +console.log("\n=== Anlegen"); +let schulungId; +{ + const a = await post("/workspace/api/schulung", kDogi, + { titel: "Community-Richtlinien durchgehen", wissen_id: wissenId }); + ok(a.ok, `DogFather legt eine Unterweisung an (${a.status})`); + + /* Ohne Dokument geht nichts -- man kann nichts bestaetigen, was man + nicht gelesen hat. */ + const ohne = await post("/workspace/api/schulung", kDogi, { titel: "Ohne Datei" }); + ok(ohne.status === 400, `ohne Dokument wird abgelehnt (${ohne.status})`); + + const nichtErlaubt = await post("/workspace/api/schulung", kScout, + { titel: "Von einem Scout", wissen_id: wissenId }); + ok(nichtErlaubt.status === 403, `ein Scout darf keine anlegen (${nichtErlaubt.status})`); + + const liste = await (await ruf("/workspace/api/schulung", kTili)).json(); + ok(liste.schulungen.length === 1, `sie steht in der Liste (${liste.schulungen.length})`); + ok(liste.schulungen[0].datei === "Richtlinien.pdf", + `mit dem Dokument (${liste.schulungen[0].datei})`); + schulungId = liste.schulungen[0].id; +} + +/* ---------- Eine Seite reicht NICHT ---------------------------------------- */ +console.log("\n=== Erst beide zusammen ergeben einen Nachweis"); +{ + const a = await post(`/workspace/api/schulung/${schulungId}/bestaetigen`, kTili, {}); + const r = await a.json(); + ok(a.ok, `der Creator bestaetigt (${a.status})`); + ok(!!r.stand.creator_am, "sein Zeitpunkt steht"); + ok(!r.stand.betreuer_am, "der der Betreuung noch nicht"); + ok(r.stand.abgeschlossen === 0, + `und es ist NICHT abgeschlossen (${r.stand.abgeschlossen})`); + + /* Zweimal bestaetigen darf nicht gehen -- sonst liesse sich ein Datum + nachtraeglich verschieben. */ + const nochmal = await post(`/workspace/api/schulung/${schulungId}/bestaetigen`, kTili, {}); + ok(nochmal.status === 409, `zweimal bestaetigen geht nicht (${nochmal.status})`); + + /* Der Fingerabdruck der Datei muss festgehalten sein. */ + ok(r.stand.datei_hash === echterHash, + `der Fingerabdruck der Datei ist festgehalten (${r.stand.datei_hash?.slice(0, 16)}…)`); +} + +/* ---------- Der Betreuer schliesst ab -------------------------------------- */ +console.log("\n=== Abschliessen"); +{ + const a = await post(`/workspace/api/schulung/${schulungId}/bestaetigen`, kScout, + { creator_id: idTili }); + const r = await a.json(); + ok(a.ok, `die Betreuung bestaetigt (${a.status})`); + ok(r.stand.abgeschlossen === 1, `jetzt ist es abgeschlossen (${r.stand.abgeschlossen})`); + ok(!!r.stand.abgeschlossen_am, `mit Zeitpunkt (${r.stand.abgeschlossen_am})`); + ok(r.stand.betreuer_id === idScout, `und dem Namen der Betreuung (${r.stand.betreuer_id})`); + /* Beide IPs -- ohne die waere es eine Behauptung, kein Nachweis. */ + ok(!!r.stand.creator_ip && !!r.stand.betreuer_ip, "beide Zugriffe sind vermerkt"); +} + +/* ---------- DER VOLLZUG: es laesst sich nicht mehr aendern ---------------- */ +console.log("\n=== Unveraenderlich -- und zwar wirklich"); +{ + /* 1. Ueber die Schnittstelle. */ + const nochmal = await post(`/workspace/api/schulung/${schulungId}/bestaetigen`, kTili, {}); + ok(nochmal.status === 409, `erneut bestaetigen wird abgelehnt (${nochmal.status})`); + + const weg = await ruf(`/workspace/api/schulung/${schulungId}/bestaetigen`, kTili, + { method: "DELETE" }); + ok(weg.status === 409, `zuruecknehmen wird abgelehnt (${weg.status})`); + + /* 2. AN DER SCHNITTSTELLE VORBEI, direkt auf der Datenbank. Das ist + der eigentliche Nachweis: Ein Schutz, der nur im Code steht, ist + beim naechsten neuen Weg zur Datenbank wieder weg. */ + const p = new DatabaseSync(process.env.WORKSPACE_DB); + let aenderung = null, loeschung = null; + try { + p.prepare("UPDATE schulung_bestaetigt SET creator_am = '2020-01-01' WHERE schulung_id = ?") + .run(schulungId); + aenderung = "DURCHGEGANGEN"; + } catch (f) { aenderung = f.message; } + try { + p.prepare("DELETE FROM schulung_bestaetigt WHERE schulung_id = ?").run(schulungId); + loeschung = "DURCHGEGANGEN"; + } catch (f) { loeschung = f.message; } + p.close(); + + ok(/nicht geaendert/.test(aenderung), + `die DATENBANK lehnt die Aenderung ab (${aenderung.slice(0, 60)})`); + ok(/nicht geloescht/.test(loeschung), + `und das Loeschen ebenfalls (${loeschung.slice(0, 60)})`); + + /* Und der Datensatz steht unveraendert da. */ + const p2 = new DatabaseSync(process.env.WORKSPACE_DB, { readOnly: true }); + const z = p2.prepare("SELECT * FROM schulung_bestaetigt WHERE schulung_id = ?").get(schulungId); + p2.close(); + ok(z && z.abgeschlossen === 1 && !String(z.creator_am).startsWith("2020"), + `der Nachweis steht unveraendert (${z?.creator_am?.slice(0, 10)})`); +} + +/* ---------- GEGENPROBE: ohne Trigger geht es durch ------------------------ */ +console.log("\n=== Gegenprobe -- kann die Sperre ueberhaupt scheitern?"); +{ + /* Eine Sperre, die man nicht hat scheitern sehen, ist keine Sperre. + Hier wird der Trigger entfernt, dieselbe Aenderung versucht -- sie + MUSS jetzt durchgehen -- und danach alles zurueckgesetzt. */ + const p = new DatabaseSync(process.env.WORKSPACE_DB); + p.exec("DROP TRIGGER schulung_nicht_aendern"); + let ohne = null; + try { + p.prepare("UPDATE schulung_bestaetigt SET creator_ip = 'probe' WHERE schulung_id = ?") + .run(schulungId); + ohne = "durchgegangen"; + } catch (f) { ohne = f.message; } + /* Zurueck in den richtigen Zustand. */ + p.exec(`CREATE TRIGGER schulung_nicht_aendern + BEFORE UPDATE ON schulung_bestaetigt + FOR EACH ROW WHEN OLD.abgeschlossen = 1 + BEGIN SELECT RAISE(ABORT, 'Abgeschlossene Bestaetigung kann nicht geaendert werden'); END`); + p.close(); + ok(ohne === "durchgegangen", + `ohne Trigger geht dieselbe Aenderung durch (${ohne}) -- die Sperre wirkt also wirklich`); +} + +/* ---------- Wer darf was --------------------------------------------------- */ +console.log("\n=== Wer darf was"); +{ + /* Ein Creator bestaetigt nur fuer sich. */ + const fremd = await post(`/workspace/api/schulung/${schulungId}/bestaetigen`, kFremd, + { creator_id: idTili }); + /* Der Server ignoriert creator_id bei Creatorn und nimmt die eigene -- + es entsteht also ein Eintrag fuer den Fremden selbst, nicht fuer + Tili. Genau das soll passieren. */ + ok(fremd.ok, `ein anderer Creator legt damit nur seinen EIGENEN Stand an (${fremd.status})`); + const r = await fremd.json(); + ok(r.stand.creator_id === idFremd, + `und zwar wirklich seinen (${r.stand.creator_id} = ${idFremd})`); + + /* Ein Scout nicht fuer einen fremden Creator. */ + const nichtSeiner = await post(`/workspace/api/schulung/${schulungId}/bestaetigen`, kScout, + { creator_id: idFremd }); + ok(nichtSeiner.status === 404, + `ein Scout nicht fuer einen fremden Creator (${nichtSeiner.status})`); + + /* Ein Creator sieht nur seinen eigenen Stand. */ + const sicht = await (await ruf("/workspace/api/schulung", kTili)).json(); + ok(sicht.stand.every((s) => s.creator_id === idTili), + `ein Creator sieht nur seinen Stand (${sicht.stand.length} Eintraege)`); + const scoutSicht = await (await ruf("/workspace/api/schulung", kScout)).json(); + ok(scoutSicht.stand.every((s) => s.creator_id === idTili), + `ein Scout nur den seiner Creator (${scoutSicht.stand.length})`); +} + +/* ---------- Wird die Datei ausgetauscht, faellt es auf -------------------- */ +console.log("\n=== Ausgetauschte Datei"); +{ + writeFileSync(join(wOrdner, dateiName), "%PDF-1.4\nGANZ ANDERE REGELN\n%%EOF\n"); + const a = await (await ruf("/workspace/api/schulung", kDogi)).json(); + const meiner = a.stand.find((s) => s.creator_id === idTili); + ok(meiner?.datei_stimmt === false, + `die Seite meldet, dass die Datei nicht mehr die bestaetigte ist (${meiner?.datei_stimmt})`); + /* Zurueck, damit die restliche Pruefung sauber laeuft. */ + writeFileSync(join(wOrdner, dateiName), inhalt); + const b = await (await ruf("/workspace/api/schulung", kDogi)).json(); + ok(b.stand.find((s) => s.creator_id === idTili)?.datei_stimmt === true, + "und wieder in Ordnung, wenn die Datei stimmt"); +} + +/* ---------- 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(); +{ + 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="admin"]'); + await seite.fill("#code", "CODE-DOGI-0001"); + await seite.click("button[type=submit]"); + await seite.waitForURL("**/start.html", { timeout: 15000 }); + await seite.goto(BASIS + "/workspace/bereich.html?b=schutz", { waitUntil: "networkidle" }); + await seite.waitForTimeout(1100); + + /* Ein Betreuer sieht den Stand EINES Creators, und die Auswahl steht + auf dem ersten alphabetisch. Fuer die Pruefung wird gezielt Tili + gewaehlt -- der ist der einzige mit einem abgeschlossenen Nachweis. + Ein erster Anlauf hat das uebersehen und Fremds halben Stand + gemessen. */ + await seite.selectOption("#schulung-creator", String(idTili)); + await seite.waitForTimeout(700); + + const r = await seite.evaluate(() => ({ + da: !document.getElementById("schulung")?.hidden, + anzahl: document.querySelectorAll(".unterweisung").length, + stufen: [...document.querySelectorAll(".unterweisung")].map((u) => u.dataset.stufe), + unterschriften: document.querySelectorAll(".unterschrift").length, + gesetzt: document.querySelectorAll('.unterschrift[data-da="ja"]').length, + fest: document.querySelectorAll(".unterweisung__fest").length, + formular: !!document.querySelector(".schulung__neu"), + ueber: document.documentElement.scrollWidth - document.documentElement.clientWidth, + })); + ok(r.da, "der Unterweisungsblock steht im Bereich Schutz & Regeln"); + ok(r.anzahl === 1, `eine Unterweisung (${r.anzahl})`); + ok(r.stufen[0] === "fertig", `sie ist abgeschlossen (${r.stufen[0]})`); + /* Beide Unterschriften sichtbar UND beide als gesetzt markiert -- + ein Nachweis, bei dem nur einer unterschrieben hat, ist keiner. */ + ok(r.unterschriften === 2, `beide Unterschriften stehen da (${r.unterschriften})`); + ok(r.gesetzt === 2, `und beide sind gesetzt (${r.gesetzt})`); + ok(r.fest === 1, "mit dem Hinweis, dass es unveraenderlich ist"); + ok(r.formular, "DogFather sieht das Formular zum Hinzufuegen"); + ok(r.ueber <= 0, `nichts steht ueber (${r.ueber}px)`); + ok(konsole.length === 0, + "keine Fehler in der Konsole" + (konsole.length ? " -> " + konsole.join(" | ") : "")); + + await seite.screenshot({ path: "pruef-schulung.png", fullPage: false }); + await kontext.close(); +} + +/* Ein Creator sieht denselben Block -- ohne das Formular zum Anlegen. */ +{ + const kontext = await browser.newContext({ viewport: { width: 1280, height: 900 } }); + const seite = await kontext.newPage(); + await seite.goto(BASIS + "/workspace/", { waitUntil: "domcontentloaded" }); + await seite.click('.rolle[data-rolle="creator"]'); + await seite.fill("#code", "CODE-ANNA-0001"); + await seite.click("button[type=submit]"); + await seite.waitForURL("**/start.html", { timeout: 15000 }); + await seite.goto(BASIS + "/workspace/bereich.html?b=schutz", { waitUntil: "networkidle" }); + await seite.waitForTimeout(1000); + const r = await seite.evaluate(() => ({ + da: !document.getElementById("schulung")?.hidden, + formular: !!document.querySelector(".schulung__neu"), + wahl: !!document.getElementById("schulung-creator"), + })); + ok(r.da, "ein Creator sieht seine Unterweisungen"); + ok(!r.formular, "aber nicht das Formular zum Anlegen"); + ok(!r.wahl, "und keine Creator-Auswahl -- es geht ja nur um ihn"); + 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-schulung.js b/server/workspace-schulung.js new file mode 100644 index 00000000..72875f4d --- /dev/null +++ b/server/workspace-schulung.js @@ -0,0 +1,412 @@ +/* ===================================================================== + workspace-schulung.js — Unterweisungen mit beidseitiger Bestätigung. + + Der Wunsch vom 01.09.2026: "jeder Scout, Manager oder DogFather muss + mit seinen Creatorn die PDFs durchgehen, und beide drücken dann auf + verifiziert, so dass wir den Beweis haben – und das ist danach auch + nicht mehr zu ändern." + + Das ist kein Häkchen, das ist ein NACHWEIS. Wer so etwas baut, muss + drei Fragen beantworten, sonst ist er wertlos: + + 1. WER hat bestätigt? + Beide Seiten getrennt, mit Name, Zeitpunkt und IP. Eine einzelne + Bestätigung reicht nicht: "Ich habe es ihm gezeigt" und "Er hat es + mir gezeigt" sind zwei verschiedene Aussagen, und erst zusammen + ergeben sie einen Beweis. Bestätigt nur einer, steht die + Unterweisung als OFFEN da -- halbe Nachweise gibt es nicht. + + 2. WORAUF genau? + Nicht auf "die Regeln", sondern auf eine bestimmte Datei. Deshalb + wird beim Bestätigen der SHA-256 der PDF-Datei mitgespeichert. + Tauscht später jemand die Datei aus, passt der Fingerabdruck nicht + mehr -- und die Seite sagt das auch. Ohne diesen Wert wäre die + Bestätigung ein Zettel ohne Bezug. + + 3. IST ES UNVERÄNDERT? + Eine abgeschlossene Bestätigung lässt sich nicht mehr ändern und + nicht löschen -- und zwar nicht, weil der Code es nicht anbietet, + sondern weil die DATENBANK es ablehnt. Zwei Trigger sorgen dafür. + Ein Schutz, der nur im Code steht, ist beim nächsten neuen Weg zur + Datenbank wieder weg. + + Erfahrung aus RunOne (01.09.2026): Dort war die Festschreibung tagelang + live und NIE gelaufen -- null festgeschriebene Buchungen. Der zentrale + Weg war unerprobt, ausgerechnet der einzige, den man nicht rückgängig + machen kann. Deshalb prüft pruef-schulung.mjs hier ausdrücklich den + Vollzug: bestätigen, dann Änderung versuchen, und die Ablehnung + nachweisen. + ===================================================================== */ + +import express from "express"; +import { createHash } from "node:crypto"; +import { join } from "node:path"; +import { readFileSync, existsSync } from "node:fs"; +import { + db, sitzungLesen, istLeitung, istDogFather, darfCreator, betreuteIds, + protokolliere, echteIp, DATEN_ORDNER, +} from "./workspace.js"; + +export const schulungRouter = express.Router(); + +const jetzt = () => new Date().toISOString(); +const TITEL_MAX = 160; + +/* ---------- Tabellen ------------------------------------------------------ + + Werden beim ersten Zugriff angelegt. Die Trigger stehen bewusst hier + und nicht in einer Umstellung: Sie gehören zur Tabelle wie ihre + Spalten. */ + +let bereit = false; +function tabellen() { + if (bereit) return; + db().exec(` + /* Eine Unterweisung: ein Dokument, das durchgegangen werden muss. */ + CREATE TABLE IF NOT EXISTS schulungen ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + titel TEXT NOT NULL, + beschreibung TEXT, + wissen_id INTEGER REFERENCES wissen(id) ON DELETE SET NULL, + pflicht INTEGER NOT NULL DEFAULT 1, + aktiv INTEGER NOT NULL DEFAULT 1, + erstellt TEXT NOT NULL, + erstellt_von INTEGER REFERENCES personen(id) ON DELETE SET NULL + ); + + /* Eine Bestätigung je Creator und Unterweisung. + + Zwei Seiten, zwei Zeitpunkte. Erst wenn beide stehen, gilt sie -- + abgeschlossen = 1 setzt der Server, nicht der Absender. */ + CREATE TABLE IF NOT EXISTS schulung_bestaetigt ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + schulung_id INTEGER NOT NULL REFERENCES schulungen(id) ON DELETE CASCADE, + creator_id INTEGER NOT NULL REFERENCES personen(id) ON DELETE CASCADE, + betreuer_id INTEGER REFERENCES personen(id) ON DELETE SET NULL, + + creator_am TEXT, + creator_ip TEXT, + betreuer_am TEXT, + betreuer_ip TEXT, + + /* Fingerabdruck der Datei zum Zeitpunkt der Bestätigung. */ + datei_hash TEXT, + /* Wie die Datei damals hiess -- fuer den Fall, dass sie spaeter + umbenannt oder ersetzt wird. */ + datei_name TEXT, + + abgeschlossen INTEGER NOT NULL DEFAULT 0, + abgeschlossen_am TEXT, + UNIQUE (schulung_id, creator_id) + ); + CREATE INDEX IF NOT EXISTS idx_schulung_creator + ON schulung_bestaetigt (creator_id); + + /* DIE SPERRE. + + Eine abgeschlossene Bestätigung ist ein Nachweis. Sie zu ändern + hiesse, den Nachweis zu fälschen -- also lehnt die Datenbank es + ab, ganz gleich, wer es versucht und über welchen Weg. + + RAISE(ABORT) bricht die Anweisung ab und gibt den Text zurück. + Das gilt auch für ein sqlite3 auf der Kommandozeile. */ + CREATE TRIGGER IF NOT EXISTS schulung_nicht_aendern + BEFORE UPDATE ON schulung_bestaetigt + FOR EACH ROW WHEN OLD.abgeschlossen = 1 + BEGIN + SELECT RAISE(ABORT, 'Abgeschlossene Bestaetigung kann nicht geaendert werden'); + END; + + CREATE TRIGGER IF NOT EXISTS schulung_nicht_loeschen + BEFORE DELETE ON schulung_bestaetigt + FOR EACH ROW WHEN OLD.abgeschlossen = 1 + BEGIN + SELECT RAISE(ABORT, 'Abgeschlossene Bestaetigung kann nicht geloescht werden'); + END; + `); + bereit = true; +} + +/* ---------- Fingerabdruck der Datei -------------------------------------- */ + +function dateiHash(wissenId) { + if (!wissenId) return { hash: null, name: null }; + try { + const w = db().prepare("SELECT name_datei, name_original FROM wissen WHERE id = ?").get(wissenId); + if (!w) return { hash: null, name: null }; + const pfad = join(DATEN_ORDNER, "wissen", w.name_datei); + if (!existsSync(pfad)) return { hash: null, name: w.name_original }; + return { + hash: createHash("sha256").update(readFileSync(pfad)).digest("hex"), + name: w.name_original, + }; + } catch { + return { hash: null, name: null }; + } +} + +/* ---------- Schranken ----------------------------------------------------- */ + +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] Schulungstabellen:", f?.message); + return res.status(503).json({ fehler: "nicht_verfuegbar" }); + } + next(); +} +schulungRouter.use("/workspace/api/schulung", 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(); +} + +/* ---------- Lesen --------------------------------------------------------- + + Ein Creator sieht SEINEN Stand. Ein Betreuer sieht den Stand der + Creator, die er betreut. Die Leitung sieht alles. */ + +schulungRouter.get("/workspace/api/schulung", (req, res) => { + try { + const schulungen = db().prepare(` + SELECT s.*, w.name_original AS datei, w.id AS wissen, + p.name AS von + FROM schulungen s + LEFT JOIN wissen w ON w.id = s.wissen_id + LEFT JOIN personen p ON p.id = s.erstellt_von + WHERE s.aktiv = 1 + ORDER BY s.pflicht DESC, s.id`).all(); + + /* Fuer welche Creator wird der Stand gebraucht? */ + let creatorIds; + if (req.person.rolle === "creator") creatorIds = [req.person.id]; + else if (istLeitung(req.person)) { + creatorIds = db().prepare( + "SELECT id FROM personen WHERE rolle = 'creator' AND aktiv = 1").all().map((z) => z.id); + } else creatorIds = betreuteIds(req.person); + + const stand = creatorIds.length + ? db().prepare(` + SELECT b.*, pc.name AS creator_name, pb.name AS betreuer_name + FROM schulung_bestaetigt b + LEFT JOIN personen pc ON pc.id = b.creator_id + LEFT JOIN personen pb ON pb.id = b.betreuer_id + WHERE b.creator_id IN (${creatorIds.map(() => "?").join(",")})`).all(...creatorIds) + : []; + + /* Stimmt die Datei noch mit dem ueberein, was bestaetigt wurde? + Diese Frage stellt sonst niemand -- und sie ist der Unterschied + zwischen einem Nachweis und einem Zettel. */ + for (const b of stand) { + if (!b.abgeschlossen || !b.datei_hash) { b.datei_stimmt = null; continue; } + const s = schulungen.find((x) => x.id === b.schulung_id); + const aktuell = dateiHash(s?.wissen_id); + b.datei_stimmt = aktuell.hash ? aktuell.hash === b.datei_hash : null; + } + + const creatoren = creatorIds.length + ? db().prepare(`SELECT id, name FROM personen + WHERE id IN (${creatorIds.map(() => "?").join(",")}) ORDER BY name`).all(...creatorIds) + : []; + + res.json({ + schulungen, stand, creatoren, + ich: { id: req.person.id, rolle: req.person.rolle }, + darf_anlegen: istLeitung(req.person), + }); + } catch (fehler) { + console.error("[workspace] Schulungen lesen:", fehler?.message); + res.status(503).json({ fehler: "nicht_verfuegbar" }); + } +}); + +/* ---------- Anlegen (nur Leitung) ----------------------------------------- */ + +schulungRouter.post("/workspace/api/schulung", gleicheHerkunft, + express.json({ limit: "16kb" }), (req, res) => { + if (!istLeitung(req.person)) return res.status(403).json({ fehler: "nicht_erlaubt" }); + try { + const titel = String(req.body.titel ?? "").trim(); + if (titel.length < 3 || titel.length > TITEL_MAX) { + return res.status(400).json({ fehler: "Titel fehlt oder ist zu lang." }); + } + const wissenId = Number(req.body.wissen_id) || null; + /* Ohne Dokument keine Unterweisung: Man kann nichts bestaetigen, + was man nicht gelesen hat. */ + if (!wissenId) return res.status(400).json({ fehler: "Bitte ein Dokument auswählen." }); + const da = db().prepare("SELECT id FROM wissen WHERE id = ?").get(wissenId); + if (!da) return res.status(400).json({ fehler: "Dieses Dokument gibt es nicht." }); + + db().prepare(`INSERT INTO schulungen + (titel, beschreibung, wissen_id, pflicht, erstellt, erstellt_von) + VALUES (?,?,?,?,?,?)`) + .run(titel, String(req.body.beschreibung ?? "").trim().slice(0, 2000) || null, + wissenId, req.body.pflicht === false ? 0 : 1, jetzt(), req.person.id); + + protokolliere("schulung_angelegt", req.person, echteIp(req), titel); + res.json({ ok: true }); + } catch (fehler) { + console.error("[workspace] Schulung anlegen:", fehler?.message); + res.status(503).json({ fehler: "nicht_verfuegbar" }); + } + }); + +/* ---------- Bestätigen ----------------------------------------------------- + + Beide Seiten getrennt. Der Server entscheidet anhand der ROLLE des + Absenders, welche Seite gesetzt wird -- nicht der Absender selbst. + Sonst könnte ein Creator die Bestätigung seines Betreuers eintragen, + und der ganze Nachweis wäre wertlos. */ + +schulungRouter.post("/workspace/api/schulung/:id(\\d+)/bestaetigen", gleicheHerkunft, + express.json({ limit: "8kb" }), (req, res) => { + try { + const schulungId = Number(req.params.id); + const s = db().prepare("SELECT * FROM schulungen WHERE id = ? AND aktiv = 1").get(schulungId); + if (!s) return res.status(404).json({ fehler: "nicht_gefunden" }); + + /* Um welchen Creator geht es? */ + const creatorId = req.person.rolle === "creator" + ? req.person.id + : Number(req.body.creator_id) || 0; + if (!creatorId) return res.status(400).json({ fehler: "Kein Creator angegeben." }); + if (req.person.rolle !== "creator" && !darfCreator(req.person, creatorId)) { + return res.status(404).json({ fehler: "nicht_gefunden" }); + } + /* Ein Creator bestätigt nur für sich. */ + if (req.person.rolle === "creator" && creatorId !== req.person.id) { + return res.status(403).json({ fehler: "nicht_erlaubt" }); + } + + const vorhanden = db().prepare( + "SELECT * FROM schulung_bestaetigt WHERE schulung_id = ? AND creator_id = ?") + .get(schulungId, creatorId); + + if (vorhanden?.abgeschlossen) { + return res.status(409).json({ + fehler: "Diese Unterweisung ist bereits abgeschlossen und kann nicht geändert werden.", + }); + } + + const alsCreator = req.person.rolle === "creator"; + const ip = echteIp(req); + const nun = jetzt(); + + if (!vorhanden) { + const abdruck = dateiHash(s.wissen_id); + db().prepare(`INSERT INTO schulung_bestaetigt + (schulung_id, creator_id, betreuer_id, creator_am, creator_ip, + betreuer_am, betreuer_ip, datei_hash, datei_name, abgeschlossen) + VALUES (?,?,?,?,?,?,?,?,?,0)`) + .run(schulungId, creatorId, + alsCreator ? null : req.person.id, + alsCreator ? nun : null, alsCreator ? ip : null, + alsCreator ? null : nun, alsCreator ? null : ip, + abdruck.hash, abdruck.name); + } else { + /* Die jeweils andere Seite ergänzen. Eine bereits gesetzte Seite + wird NICHT überschrieben -- sonst könnte man ein Datum + nachträglich verschieben. */ + if (alsCreator) { + if (vorhanden.creator_am) { + return res.status(409).json({ fehler: "Du hast bereits bestätigt." }); + } + db().prepare( + "UPDATE schulung_bestaetigt SET creator_am = ?, creator_ip = ? WHERE id = ?") + .run(nun, ip, vorhanden.id); + } else { + if (vorhanden.betreuer_am) { + return res.status(409).json({ fehler: "Die Betreuung hat bereits bestätigt." }); + } + db().prepare( + "UPDATE schulung_bestaetigt SET betreuer_am = ?, betreuer_ip = ?, betreuer_id = ? WHERE id = ?") + .run(nun, ip, req.person.id, vorhanden.id); + } + } + + /* Abschliessen, sobald BEIDE stehen. Das entscheidet der Server + anhand der Daten -- niemand kann "abgeschlossen" mitschicken. */ + const stand = db().prepare( + "SELECT * FROM schulung_bestaetigt WHERE schulung_id = ? AND creator_id = ?") + .get(schulungId, creatorId); + + if (stand.creator_am && stand.betreuer_am && !stand.abgeschlossen) { + db().prepare( + "UPDATE schulung_bestaetigt SET abgeschlossen = 1, abgeschlossen_am = ? WHERE id = ?") + .run(nun, stand.id); + protokolliere("schulung_abgeschlossen", req.person, ip, + `${s.titel} · Creator ${creatorId}`); + } else { + protokolliere("schulung_bestaetigt", req.person, ip, + `${s.titel} · ${alsCreator ? "Creator" : "Betreuung"}`); + } + + const neu = db().prepare( + "SELECT * FROM schulung_bestaetigt WHERE schulung_id = ? AND creator_id = ?") + .get(schulungId, creatorId); + res.json({ stand: neu }); + } catch (fehler) { + console.error("[workspace] Schulung bestätigen:", fehler?.message); + res.status(503).json({ fehler: "nicht_verfuegbar" }); + } + }); + +/* ---------- Zurückziehen (nur solange NICHT abgeschlossen) ---------------- */ + +schulungRouter.delete("/workspace/api/schulung/:id(\\d+)/bestaetigen", gleicheHerkunft, + (req, res) => { + try { + const schulungId = Number(req.params.id); + const creatorId = req.person.rolle === "creator" + ? req.person.id : Number(req.query.creator_id) || 0; + if (!creatorId) return res.status(400).json({ fehler: "Kein Creator angegeben." }); + if (req.person.rolle !== "creator" && !darfCreator(req.person, creatorId)) { + return res.status(404).json({ fehler: "nicht_gefunden" }); + } + + const stand = db().prepare( + "SELECT * FROM schulung_bestaetigt WHERE schulung_id = ? AND creator_id = ?") + .get(schulungId, creatorId); + if (!stand) return res.status(404).json({ fehler: "nicht_gefunden" }); + + /* Der Trigger würde das ohnehin ablehnen. Die Prüfung hier ist + trotzdem richtig: Sie gibt eine verständliche Antwort statt + eines Datenbankfehlers. */ + if (stand.abgeschlossen) { + return res.status(409).json({ + fehler: "Abgeschlossen. Das ist der Nachweis und bleibt bestehen.", + }); + } + db().prepare("DELETE FROM schulung_bestaetigt WHERE id = ?").run(stand.id); + protokolliere("schulung_zurueckgezogen", req.person, echteIp(req), `Schulung ${schulungId}`); + res.json({ ok: true }); + } catch (fehler) { + console.error("[workspace] Schulung zurückziehen:", fehler?.message); + res.status(503).json({ fehler: "nicht_verfuegbar" }); + } + }); + +/* ---------- Unterweisung abschalten (nur DogFather) ----------------------- + + Nicht löschen: Die Bestätigungen daran hängen und sind Nachweise. + `aktiv = 0` nimmt sie aus der Liste, ohne die Vergangenheit + anzutasten. */ + +schulungRouter.delete("/workspace/api/schulung/:id(\\d+)", gleicheHerkunft, (req, res) => { + if (!istDogFather(req.person)) return res.status(403).json({ fehler: "nicht_erlaubt" }); + try { + db().prepare("UPDATE schulungen SET aktiv = 0 WHERE id = ?").run(Number(req.params.id)); + protokolliere("schulung_abgeschaltet", req.person, echteIp(req), `Schulung ${req.params.id}`); + res.json({ ok: true }); + } catch (fehler) { + console.error("[workspace] Schulung abschalten:", fehler?.message); + res.status(503).json({ fehler: "nicht_verfuegbar" }); + } +}); diff --git a/workspace/assets/css/bereich.css b/workspace/assets/css/bereich.css index 9a8b7256..21c263df 100644 --- a/workspace/assets/css/bereich.css +++ b/workspace/assets/css/bereich.css @@ -64,3 +64,114 @@ @media (max-width: 620px) { .eintrag-knoepfe { margin-left: 0; width: 100%; } } + +/* ===================================================================== + UNTERWEISUNGEN — beidseitig bestaetigen + + Drei Stufen, drei Farben. Die mittlere ("halb") ist die wichtigste: + Ein Nachweis, bei dem nur einer unterschrieben hat, ist KEIN Nachweis. + Wer das nicht sieht, haelt ihn fuer fertig -- und merkt es erst, wenn + jemand danach fragt. Deshalb bekommt sie eine eigene, warnende Farbe + und nicht einfach ein blasses Gruen. + ===================================================================== */ + +.schulung { margin: 0 0 30px; } +.schulung__kopf { + display: flex; align-items: flex-end; justify-content: space-between; + gap: 16px; flex-wrap: wrap; margin-bottom: 14px; +} +.schulung__unter { + margin: 4px 0 0; max-width: 68ch; + font-size: .86rem; line-height: 1.55; color: var(--text-leise); +} +.schulung__wahl { display: grid; gap: 4px; min-width: 0; } +.schulung__liste { display: grid; gap: 11px; } + +.unterweisung { + --stufe-ton: var(--text-still); + position: relative; overflow: hidden; + padding: 15px 17px 14px; + border: 1px solid color-mix(in srgb, var(--stufe-ton) 26%, var(--rand)); + border-radius: var(--radius-klein); + background: + linear-gradient(90deg, color-mix(in srgb, var(--stufe-ton) 8%, transparent), transparent 55%), + var(--flaeche); +} +.unterweisung::before { + content: ""; position: absolute; left: 0; top: 0; bottom: 0; width: 3px; + background: linear-gradient(180deg, var(--stufe-ton), + color-mix(in srgb, var(--stufe-ton) 30%, transparent)); +} +.unterweisung[data-stufe="offen"] { --stufe-ton: #6f89a8; } +/* Bewusst warnend: halb bestaetigt sieht nach fertig aus, ist es aber + nicht. Diese Verwechslung ist genau die, die einen Nachweis wertlos + macht. */ +.unterweisung[data-stufe="halb"] { --stufe-ton: #e0a33c; } +.unterweisung[data-stufe="fertig"] { --stufe-ton: #4dab77; } + +.unterweisung__kopf { + display: flex; align-items: flex-start; justify-content: space-between; + gap: 14px; margin-bottom: 12px; +} +.unterweisung__titel { margin: 0 0 3px; font-size: .98rem; font-weight: 650; color: var(--text); } +.unterweisung__datei { margin: 0; font-size: .8rem; color: var(--ton, var(--akzent)); } +.unterweisung__text { margin: 5px 0 0; font-size: .85rem; color: var(--text-leise); } +.unterweisung__marke { + flex: none; padding: 3px 11px; + border: 1px solid color-mix(in srgb, var(--stufe-ton) 36%, transparent); + border-radius: 999px; + background: color-mix(in srgb, var(--stufe-ton) 12%, transparent); + color: var(--stufe-ton); + font-size: .72rem; letter-spacing: .06em; +} + +/* Die beiden Unterschriften nebeneinander -- man soll auf einen Blick + sehen, WER fehlt, nicht nur DASS etwas fehlt. */ +.unterschriften { + display: grid; gap: 8px; + grid-template-columns: repeat(auto-fit, minmax(min(220px, 100%), 1fr)); + margin-bottom: 12px; +} +.unterschrift { + display: grid; gap: 2px; + padding: 9px 12px; + border: 1px solid var(--rand); border-radius: 8px; + background: rgba(0, 0, 0, .18); +} +.unterschrift[data-da="ja"] { + border-color: rgba(80, 167, 130, .32); + background: rgba(80, 167, 130, .07); +} +.unterschrift__wer { + font-size: .7rem; letter-spacing: .1em; text-transform: uppercase; + color: var(--text-still); +} +.unterschrift__wann { font-size: .84rem; color: var(--text); } +.unterschrift[data-da="nein"] .unterschrift__wann { color: var(--text-still); font-style: italic; } + +.unterweisung__fuss { display: flex; align-items: center; gap: 10px; flex-wrap: wrap; } +.unterweisung__fest { font-size: .82rem; color: #6fbd95; } +.unterweisung__wartet { font-size: .82rem; color: #e0b45c; } +/* Ausgetauschtes Dokument: Das ist der einzige Fall, in dem ein + abgeschlossener Nachweis seinen Wert verliert -- und dann muss es + auch laut sein. */ +.unterweisung__warnung { + padding: 3px 10px; + border: 1px solid rgba(255, 154, 162, .4); border-radius: 999px; + background: rgba(255, 154, 162, .1); + color: var(--warn); font-size: .8rem; font-weight: 600; +} + +.schulung__neu { + margin-top: 18px; padding: 16px 17px; + border: 1px dashed var(--rand); border-radius: var(--radius-klein); + background: var(--flaeche); +} +.schulung__neu .neu__raster { margin-top: 12px; } +.schulung__neu .knopf { margin-top: 12px; } + +@media (max-width: 560px) { + .schulung__kopf { flex-direction: column; align-items: stretch; } + .unterweisung__kopf { flex-direction: column; } + .unterweisung__marke { align-self: flex-start; } +} diff --git a/workspace/assets/js/bereich.js b/workspace/assets/js/bereich.js index bf234812..da1676f4 100644 --- a/workspace/assets/js/bereich.js +++ b/workspace/assets/js/bereich.js @@ -155,8 +155,23 @@ return; } for (const e of sichtbar) ziel.append(karte(e)); - } catch { /* umgeleitet */ } - await vorlagenZeigen(); + } catch { /* umgeleitet */ } finally { + /* IM finally, nicht dahinter. laden() steigt oben frueh aus, + wenn die Liste leer ist -- und dann wurden diese beiden + Bloecke nie gebaut. Also ausgerechnet auf der leeren Seite, + fuer die sie gedacht sind. Die Browserpruefung hat es sofort + gemeldet ("eine Unterweisung: 0"). */ + await vorlagenZeigen(); + await schulungZeigen(); + } + } + + /* Unterweisungen -- nur im Bereich Schutz & Regeln. Dort gehoeren + Richtlinien hin, und dort sucht sie auch jemand. */ + async function schulungZeigen() { + const behaelter = $('schulung'); + if (!behaelter || bereich !== "schutz" || !window.schulungBlock) return; + await window.schulungBlock(behaelter); } /* Fertige Punkte zum Uebernehmen -- derzeit nur fuer die LIVE-Analyse. diff --git a/workspace/assets/js/schulung.js b/workspace/assets/js/schulung.js new file mode 100644 index 00000000..232291d2 --- /dev/null +++ b/workspace/assets/js/schulung.js @@ -0,0 +1,267 @@ +/* =================================================================== + Unterweisungen — durchgehen und beidseitig bestätigen. + + Erscheint im Bereich "Schutz & Regeln". Der Ablauf ist bewusst + dreistufig sichtbar, damit niemand raten muss, woran es hängt: + + offen niemand hat bestätigt + halb einer von beiden -- und die Seite sagt, wer fehlt + abgeschlossen beide, mit Datum und Namen. Ab hier unveränderlich. + + Warum die halbe Stufe sichtbar ist: Ein Nachweis, bei dem nur einer + unterschrieben hat, ist kein Nachweis. Wer das nicht sieht, hält ihn + für fertig -- und merkt es erst, wenn jemand danach fragt. + =================================================================== */ +(() => { + '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 datum = (iso) => (iso + ? new Date(iso).toLocaleString('de-DE', { day: '2-digit', month: '2-digit', year: 'numeric', + hour: '2-digit', minute: '2-digit' }) + : null); + + let daten = null; + let wahl = null; /* welcher Creator wird gerade angesehen */ + + window.schulungBlock = async function (behaelter) { + if (!behaelter) return; + try { + const a = await hole('/workspace/api/schulung'); + if (!a.ok) return; + daten = await a.json(); + } catch { return; } + + behaelter.textContent = ''; + const block = el('section', 'schulung'); + + /* ---- Kopf ---- */ + const kopf = el('div', 'schulung__kopf'); + const t = el('div', null); + t.append(el('p', 'feldschild', 'Unterweisungen')); + t.append(el('p', 'schulung__unter', + 'Dokumente, die gemeinsam durchgegangen und von BEIDEN Seiten bestätigt ' + + 'werden. Eine abgeschlossene Bestätigung ist der Nachweis und lässt sich ' + + 'danach nicht mehr ändern.')); + kopf.append(t); + + /* Betreuer wählen aus, für wen sie gerade bestätigen. */ + if (daten.ich.rolle !== 'creator' && daten.creatoren.length) { + const feld = el('div', 'schulung__wahl'); + feld.append(el('label', 'feld-schild', 'Creator')); + const s = el('select'); + s.id = 'schulung-creator'; + for (const c of daten.creatoren) { + const o = document.createElement('option'); + o.value = String(c.id); + o.textContent = c.name; + s.append(o); + } + wahl = wahl && daten.creatoren.some((c) => c.id === wahl) ? wahl : daten.creatoren[0].id; + s.value = String(wahl); + s.addEventListener('change', () => { + wahl = Number(s.value); + window.schulungBlock(behaelter); + }); + feld.append(s); + kopf.append(feld); + } else { + wahl = daten.ich.id; + } + block.append(kopf); + + if (!daten.schulungen.length) { + const leer = el('div', 'leer-hinweis'); + leer.append(el('p', null, daten.darf_anlegen + ? 'Noch keine Unterweisung angelegt.' + : 'Es gibt noch keine Unterweisungen.')); + if (daten.darf_anlegen) { + leer.append(el('p', 'leer-hinweis__tipp', + 'Wähle unten ein Dokument aus der Wissens-Bibliothek – oder lade dort ' + + 'zuerst eines hoch.')); + } + block.append(leer); + } + + /* ---- Die Liste ---- */ + const liste = el('div', 'schulung__liste'); + for (const s of daten.schulungen) { + const stand = daten.stand.find((b) => b.schulung_id === s.id && b.creator_id === wahl); + const stufe = !stand ? 'offen' + : stand.abgeschlossen ? 'fertig' : 'halb'; + + const k = el('article', 'unterweisung'); + k.dataset.stufe = stufe; + + const zeile = el('div', 'unterweisung__kopf'); + const links = el('div', null); + links.append(el('h3', 'unterweisung__titel', s.titel)); + if (s.datei) links.append(el('p', 'unterweisung__datei', s.datei)); + if (s.beschreibung) links.append(el('p', 'unterweisung__text', s.beschreibung)); + zeile.append(links); + + const marke = el('span', 'unterweisung__marke', + stufe === 'fertig' ? 'Bestätigt' + : stufe === 'halb' ? 'Halb – einer fehlt' : 'Offen'); + zeile.append(marke); + k.append(zeile); + + /* ---- Die beiden Unterschriften ---- */ + const beide = el('div', 'unterschriften'); + for (const [wer, wann, name] of [ + ['Creator', stand?.creator_am, stand?.creator_name], + ['Betreuung', stand?.betreuer_am, stand?.betreuer_name], + ]) { + const u = el('div', 'unterschrift'); + u.dataset.da = wann ? 'ja' : 'nein'; + u.append(el('span', 'unterschrift__wer', wer)); + u.append(el('span', 'unterschrift__wann', + wann ? `${name || ''} · ${datum(wann)}` : 'steht noch aus')); + beide.append(u); + } + k.append(beide); + + /* ---- Was man jetzt tun kann ---- */ + const fuss = el('div', 'unterweisung__fuss'); + + if (s.wissen) { + const auf = el('a', 'schritt', 'Dokument öffnen'); + auf.href = `/workspace/api/wissen/${s.wissen}/datei`; + auf.target = '_blank'; + auf.rel = 'noopener noreferrer'; + fuss.append(auf); + } + + const alsCreator = daten.ich.rolle === 'creator'; + const schonIch = alsCreator ? stand?.creator_am : stand?.betreuer_am; + + if (stufe === 'fertig') { + const fest = el('span', 'unterweisung__fest', + `Abgeschlossen am ${datum(stand.abgeschlossen_am)} – unveränderlich.`); + fuss.append(fest); + /* Stimmt die Datei noch mit der bestätigten überein? Diese Frage + stellt sonst niemand -- und sie entscheidet, ob der Nachweis + noch etwas wert ist. */ + if (stand.datei_stimmt === false) { + fuss.append(el('span', 'unterweisung__warnung', + 'Achtung: Das Dokument wurde seit der Bestätigung ausgetauscht.')); + } + } else if (schonIch) { + fuss.append(el('span', 'unterweisung__wartet', + alsCreator ? 'Du hast bestätigt. Es fehlt noch deine Betreuung.' + : 'Du hast bestätigt. Es fehlt noch der Creator.')); + } else { + const b = el('button', 'knopf knopf--klein', 'Durchgegangen und bestätigt'); + b.type = 'button'; + b.addEventListener('click', async () => { + if (!confirm('Bestätigen, dass ihr das Dokument gemeinsam durchgegangen seid?\n\n' + + 'Sobald BEIDE bestätigt haben, ist der Nachweis endgültig und lässt sich ' + + 'nicht mehr ändern.')) return; + b.disabled = true; + b.textContent = 'wird gespeichert …'; + try { + const a = await hole(`/workspace/api/schulung/${s.id}/bestaetigen`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ creator_id: wahl }), + }); + if (!a.ok) { + const f = await a.json().catch(() => ({})); + alert(f.fehler || 'Ging nicht.'); + b.disabled = false; + b.textContent = 'Durchgegangen und bestätigt'; + return; + } + window.schulungBlock(behaelter); + } catch { b.disabled = false; } + }); + fuss.append(b); + } + k.append(fuss); + liste.append(k); + } + block.append(liste); + + /* ---- Neue Unterweisung (nur Leitung) ---- */ + if (daten.darf_anlegen) block.append(await neuFormular(behaelter)); + + behaelter.append(block); + behaelter.hidden = false; + }; + + /* Eine neue Unterweisung entsteht aus einem Dokument, das SCHON in der + Bibliothek liegt. Kein zweiter Upload-Weg: Sonst gäbe es Dateien, + die nur hier existieren und in der Bibliothek fehlen -- und niemand + wüsste, welche Fassung die richtige ist. */ + async function neuFormular(behaelter) { + const f = el('form', 'schulung__neu'); + f.append(el('p', 'feldschild', 'Unterweisung hinzufügen')); + f.append(el('p', 'schulung__unter', + 'Wähle ein Dokument aus der Wissens-Bibliothek. Liegt es dort noch nicht, ' + + 'lade es zuerst dort hoch – so gibt es jede Datei nur einmal.')); + + const raster = el('div', 'neu__raster'); + + const fTitel = el('div', null); + fTitel.append(el('label', 'feld-schild', 'Worum geht es?')); + const iTitel = el('input'); + iTitel.maxLength = 160; + iTitel.placeholder = 'z. B. Community-Richtlinien durchgehen'; + fTitel.append(iTitel); + raster.append(fTitel); + + const fDok = el('div', null); + fDok.append(el('label', 'feld-schild', 'Dokument')); + const sDok = el('select'); + sDok.append(new Option('— aus der Bibliothek wählen —', '')); + try { + const a = await hole('/workspace/api/wissen'); + if (a.ok) { + const { eintraege } = await a.json(); + for (const w of eintraege) sDok.append(new Option(w.titel, String(w.id))); + } + } catch { /* leer lassen */ } + fDok.append(sDok); + raster.append(fDok); + + f.append(raster); + + const fehler = el('p', 'fehler'); + f.append(fehler); + + const knopf = el('button', 'knopf knopf--klein', 'Hinzufügen'); + knopf.type = 'submit'; + f.append(knopf); + + f.addEventListener('submit', async (e) => { + e.preventDefault(); + fehler.textContent = ''; + knopf.disabled = true; + try { + const a = await hole('/workspace/api/schulung', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ titel: iTitel.value, wissen_id: Number(sDok.value) || null }), + }); + if (!a.ok) { + fehler.textContent = (await a.json().catch(() => ({}))).fehler || 'Ging nicht.'; + return; + } + window.schulungBlock(behaelter); + } catch { /* umgeleitet */ } finally { knopf.disabled = false; } + }); + return f; + } +})(); diff --git a/workspace/aufgaben.html b/workspace/aufgaben.html index 0e808f54..b8179863 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 9a7ee807..e8627d65 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 86ce900f..f01a2e96 100644 --- a/workspace/bereich.html +++ b/workspace/bereich.html @@ -7,10 +7,10 @@ - - - - + + + + @@ -105,16 +105,21 @@ + + +

Einträge werden geladen …

- - - - - + + + + + + diff --git a/workspace/calls.html b/workspace/calls.html index c7d6fc4d..a4ef7cc8 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 3df4b6ee..94d8b0e1 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 16a499dc..ce92d2e2 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 502f94f9..cfb3427a 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 16041525..b474d589 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 71ef9e99..cde1bed0 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 93a6dc3a..76e05ccb 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 b95f4656..6f2704c6 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 b29dca38..464f5df2 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 7a01a519..f34e0816 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 cea36c37..ce359aa6 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 1171d0d4..8c28e2c0 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 a29f6ca3..25c4b92c 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 f8655958..f7dd5814 100644 --- a/workspace/wissen.html +++ b/workspace/wissen.html @@ -7,10 +7,10 @@ - - - - + + + + @@ -175,9 +175,9 @@ - - - - + + + +