/* ===================================================================== workspace-reports.js — Reports & Review (Konzept, Seite 16). Dieser Bereich fuehrt bewusst KEINE eigenen Eintraege. Er fasst zusammen, was in Aufgaben, Bereichen, Terminen und Dateien ohnehin schon steht -- "Fortschritt wird nicht gefuehlt, sondern nachvollziehbar gemacht". Zwei Dinge daraus sind hier woertlich umgesetzt: 1. "Vorher / nachher" (Historie). Jede Zahl wird mit demselben, unmittelbar davorliegenden Zeitraum verglichen. Eine Zahl allein sagt wenig -- 3 erledigte Aufgaben sind gut oder schlecht, je nachdem, ob es vorher 1 oder 9 waren. 2. "Jeder Review endet mit einer Entscheidung, nicht nur mit einer Zusammenfassung." Der Report kann deshalb direkt eine Aufgabe anlegen -- ohne die Seite zu wechseln. ===================================================================== */ import express from "express"; import { db, protokolliere, echteIp, sitzungLesen, betreuteIds, darfCreator, istLeitung, } from "./workspace.js"; export const reportRouter = express.Router(); const jetzt = () => new Date().toISOString(); const tagOffset = (tage) => new Date(Date.now() - tage * 86400_000).toISOString(); function angemeldet(req, res, next) { const person = sitzungLesen(req); if (!person) return res.status(401).json({ fehler: "nicht_angemeldet" }); /* Ein Scout ohne zugeteilte Creator hat hier nichts zu suchen -- und erfaehrt per 404 auch nicht, dass es den Bereich gibt. */ if (person.rolle === "scout" && !betreuteIds(person).length) { return res.status(404).json({ fehler: "nicht_gefunden" }); } req.person = person; next(); } reportRouter.use("/workspace/api/report", angemeldet); /* Auf welchen Creator schaut der Report? Das Management darf waehlen (oder alle sehen), ein Creator immer nur sich selbst -- unabhaengig davon, was im Aufruf steht. */ function zielCreator(req) { if (req.person.rolle === "creator") return req.person.id; const w = req.query.creator; const gewuenscht = w && w !== "alle" && Number.isInteger(Number(w)) && Number(w) > 0 ? Number(w) : null; if (istLeitung(req.person)) return gewuenscht; /* Scout: darf nur ueber die eigenen Creator einen Report ziehen. Ein fremder Wunsch wird nicht abgelehnt, sondern still auf den ersten eigenen zurueckgesetzt -- so kann man den Parameter nicht dazu benutzen, die Zuteilung anderer abzufragen. */ const meine = betreuteIds(req.person); if (gewuenscht && meine.includes(gewuenscht)) return gewuenscht; return meine[0] ?? -1; } reportRouter.get("/workspace/api/report", (req, res) => { try { const tage = Math.min(Math.max(Number(req.query.tage) || 30, 7), 180); const creator = zielCreator(req); const jetztIso = jetzt(); const heute = jetztIso.slice(0, 10); const abIso = tagOffset(tage); const davorIso = tagOffset(tage * 2); const ab = abIso.slice(0, 10); const davor = davorIso.slice(0, 10); /* Einschraenkung auf einen Creator, je Tabelle einmal formuliert. */ const nurA = creator ? " AND (a.creator_id = ? OR a.verantwortlich_id = ?)" : ""; const wertA = creator ? [creator, creator] : []; const nurE = creator ? " AND e.creator_id = ?" : ""; const wertE = creator ? [creator] : []; const nurT = creator ? " AND (t.creator_id = ? OR t.teilnehmer_id = ?)" : ""; const wertT = creator ? [creator, creator] : []; const nurD = creator ? " AND d.creator_id = ?" : ""; const wertD = creator ? [creator] : []; const zahl = (sql, werte) => db().prepare(sql).get(...werte)?.n ?? 0; /* --- Aufgaben ------------------------------------------------------- */ const erledigt = (von, bis) => zahl( `SELECT COUNT(*) AS n FROM aufgaben a WHERE a.erledigt_am IS NOT NULL AND a.erledigt_am >= ? AND a.erledigt_am < ?${nurA}`, [von, bis, ...wertA]); const angelegt = (von, bis) => zahl( `SELECT COUNT(*) AS n FROM aufgaben a WHERE a.erstellt >= ? AND a.erstellt < ?${nurA}`, [von, bis, ...wertA]); const aufgaben = { erledigt: erledigt(abIso, jetztIso), erledigt_davor: erledigt(davorIso, abIso), angelegt: angelegt(abIso, jetztIso), angelegt_davor: angelegt(davorIso, abIso), offen: zahl(`SELECT COUNT(*) AS n FROM aufgaben a WHERE a.status <> 'erledigt'${nurA}`, wertA), ueberfaellig: zahl( `SELECT COUNT(*) AS n FROM aufgaben a WHERE a.status <> 'erledigt' AND a.frist IS NOT NULL AND a.frist < ?${nurA}`, [heute, ...wertA]), /* "Was blockiert?" aus dem Konzept: was liegt am laengsten offen? */ aeltester: db().prepare( `SELECT a.titel, a.frist, a.erstellt FROM aufgaben a WHERE a.status <> 'erledigt'${nurA} ORDER BY a.erstellt LIMIT 3`).all(...wertA), }; /* --- Bereiche -------------------------------------------------------- */ const bereiche = {}; for (const b of ["live", "content", "technik", "community", "schutz"]) { bereiche[b] = { neu: zahl( `SELECT COUNT(*) AS n FROM eintraege e WHERE e.bereich = ? AND e.erstellt >= ?${nurE}`, [b, abIso, ...wertE]), neu_davor: zahl( `SELECT COUNT(*) AS n FROM eintraege e WHERE e.bereich = ? AND e.erstellt >= ? AND e.erstellt < ?${nurE}`, [b, davorIso, abIso, ...wertE]), offen: zahl( `SELECT COUNT(*) AS n FROM eintraege e WHERE e.bereich = ? AND e.status = 'offen'${nurE}`, [b, ...wertE]), dringend: zahl( `SELECT COUNT(*) AS n FROM eintraege e WHERE e.bereich = ? AND e.status = 'offen' AND e.dringlichkeit = 'hoch'${nurE}`, [b, ...wertE]), }; } /* LIVE-Schnitt: der Review-Score aus dem Konzept, im Vergleich. */ const schnitt = (von, bis) => { const r = db().prepare( `SELECT AVG(e.bewertung) AS s, COUNT(e.bewertung) AS n FROM eintraege e WHERE e.bereich = 'live' AND e.bewertung IS NOT NULL AND e.erstellt >= ? AND e.erstellt < ?${nurE}`).get(von, bis, ...wertE); return { schnitt: r?.s ? Math.round(r.s * 10) / 10 : null, anzahl: r?.n ?? 0 }; }; const live = { jetzt: schnitt(abIso, jetztIso), davor: schnitt(davorIso, abIso) }; /* --- Termine und Dateien --------------------------------------------- */ const termine = { gewesen: zahl( `SELECT COUNT(*) AS n FROM termine t WHERE t.beginn >= ? AND t.beginn < ?${nurT}`, [ab, heute, ...wertT]), kommend: zahl( `SELECT COUNT(*) AS n FROM termine t WHERE t.beginn >= ? AND t.erledigt = 0${nurT}`, [heute, ...wertT]), }; const dateien = { neu: zahl(`SELECT COUNT(*) AS n FROM dateien d WHERE d.erstellt >= ?${nurD}`, [abIso, ...wertD]), freigegeben: zahl( `SELECT COUNT(*) AS n FROM dateien d WHERE d.status = 'freigegeben'${nurD}`, wertD), offen_review: zahl( `SELECT COUNT(*) AS n FROM dateien d WHERE d.status = 'review'${nurD}`, wertD), }; /* --- Ziele aus dem Profil -------------------------------------------- */ let profil = null; if (creator) { profil = db().prepare(` SELECT plan_prio1, plan_prio2, plan_prio3, naechster_review, plan_start FROM profile WHERE person_id = ?`).get(creator) || null; /* Der Review-Termin ist Steuerungswissen -- ein Creator sieht ihn in seinem Profil auch nicht. */ if (profil && !istLeitung(req.person)) { delete profil.naechster_review; delete profil.plan_start; } } res.json({ zeitraum: { tage, ab, bis: heute }, creator, creator_name: creator ? (db().prepare("SELECT name FROM personen WHERE id = ?").get(creator)?.name ?? null) : null, aufgaben, bereiche, live, termine, dateien, profil, }); } catch (fehler) { console.error("[workspace] Report:", fehler?.message); res.status(503).json({ fehler: "nicht_verfuegbar" }); } }); /* Liste der Creator fuer die Auswahl oben. */ reportRouter.get("/workspace/api/report/creator", (req, res) => { try { if (req.person.rolle === "creator") { return res.json({ creator: [{ id: req.person.id, name: req.person.name }] }); } if (req.person.rolle === "scout") { const ids = betreuteIds(req.person); if (!ids.length) return res.json({ creator: [] }); return res.json({ creator: db().prepare( `SELECT id, name FROM personen WHERE id IN (${ids.map(() => "?").join(",")}) AND aktiv = 1 ORDER BY name`).all(...ids), }); } res.json({ creator: db().prepare( "SELECT id, name FROM personen WHERE rolle = 'creator' AND aktiv = 1 ORDER BY name").all(), }); } catch { res.status(503).json({ fehler: "nicht_verfuegbar" }); } }); /* "Jeder Review endet mit einer Entscheidung." Deshalb kann der Report direkt eine Aufgabe anlegen, ohne die Seite zu wechseln. Bewusst eine eigene, sehr enge Route statt eines Verweises auf die Aufgaben- Schnittstelle: Hier entsteht immer eine Aufgabe im betrachteten Bereich, mehr braucht es an dieser Stelle nicht. */ reportRouter.post("/workspace/api/report/entscheidung", (req, res) => { try { const herkunft = req.get("origin"); if (herkunft) { let erlaubt; try { erlaubt = new URL(herkunft).host === req.get("host"); } catch { erlaubt = false; } if (!erlaubt) return res.status(403).json({ fehler: "fremde_herkunft" }); } const titel = String(req.body?.titel ?? "").trim(); if (titel.length < 2 || titel.length > 160) { return res.status(400).json({ fehler: "Bitte eine kurze, klare Entscheidung eintragen." }); } const frist = String(req.body?.frist ?? "").trim(); if (frist && (!/^\d{4}-\d{2}-\d{2}$/.test(frist) || Number.isNaN(Date.parse(frist)))) { return res.status(400).json({ fehler: "Frist ist kein gültiges Datum." }); } let creator = null; if (istLeitung(req.person)) { const w = req.body?.creator_id; if (w) { const z = Number(w); if (!Number.isInteger(z) || z < 1) return res.status(400).json({ fehler: "Ungültige Zuordnung." }); if (!db().prepare("SELECT 1 FROM personen WHERE id = ? AND rolle = 'creator'").get(z)) { return res.status(400).json({ fehler: "Creator existiert nicht." }); } creator = z; } } else if (req.person.rolle === "scout") { /* Ein Scout haelt die Entscheidung fuer einen SEINER Creator fest, nicht fuer sich selbst -- sonst haette die Aufgabe einen Scout als "Creator" und liefe in jeder Auswertung falsch mit. */ const w = Number(req.body?.creator_id); creator = darfCreator(req.person, w) ? w : (betreuteIds(req.person)[0] ?? null); if (!creator) return res.status(400).json({ fehler: "Dir ist kein Creator zugeteilt." }); } else { creator = req.person.id; } const { lastInsertRowid } = db().prepare(` INSERT INTO aufgaben (titel, beschreibung, status, prioritaet, creator_id, verantwortlich_id, frist, erstellt, erstellt_von) VALUES (?,?,'offen','hoch',?,?,?,?,?)`).run( titel, "Aus dem Review übernommen.", creator, creator ?? req.person.id, frist || null, jetzt(), req.person.id); protokolliere("review_entscheidung", { personId: req.person.id, rolle: req.person.rolle, ip: echteIp(req), detail: `#${lastInsertRowid} ${titel}`.slice(0, 120), }); res.status(201).json({ id: Number(lastInsertRowid) }); } catch (fehler) { console.error("[workspace] Entscheidung:", fehler?.message); res.status(503).json({ fehler: "nicht_verfuegbar" }); } });