DREI TEILE. 1) ROLLE "MANAGER" Ein Manager darf alles, was DogFather darf -- mit genau zwei Vorbehalten: Er kann keine Leitung ANLEGEN und an keiner Leitung etwas AENDERN. Sonst koennte er sich einen zweiten Vollzugang schaffen oder DogFather aussperren. "Nur DogFather hat alle endgueltigen Rechte" heisst genau das. Umgesetzt ueber istLeitung() an EINER Stelle statt 44 einzelner Vergleiche auf "admin" im Server und 26 im Browser. DATENBANK-UMSTELLUNG: CREATE TABLE IF NOT EXISTS fasst eine vorhandene Tabelle nicht an -- die CHECK-Regel stand also weiter auf den alten drei Rollen, und ein Manager waere daran gescheitert, obwohl der Code stimmt. SQLite kann eine CHECK-Regel nicht aendern, also: neue Tabelle, Daten hinueber, alte weg, umbenennen. Davor schreibt der Server eine vollstaendige Sicherung (VACUUM INTO, in sich konsistent). Ohne Sicherung wird NICHT umgestellt. Geprueft nach der Umstellung: alle 13 Tabellen mit gleicher Zeilenzahl, PRAGMA integrity_check ok, keine verwaisten Verweise. Die einzige Abweichung war eine Sitzung mehr -- die eigene Anmeldung, die die Umstellung ausgeloest hat. 2) EIN SICHERHEITSLOCH, DAS DER TEST GEFUNDEN HAT Der erste Entwurf sicherte "Person anlegen" und "Person sperren" ab -- und liess "neuer Zugangscode" offen. Ein Manager konnte DogFather einen neuen Code ausstellen, bekam ihn angezeigt und haette ihn damit aus seinem eigenen Konto ausgesperrt. Im Test aufgefallen, weil ich den negativen Fall durchgespielt habe. Behoben nicht durch eine dritte Einzelpruefung, sondern durch eine Schranke an JEDEM Weg mit einer :id. Der naechste Weg, der dazukommt, ist damit automatisch mitgeschuetzt. Nachgeprueft: Manager bekommt 403 beim Code-Erneuern und Sperren von DogFather UND von sich selbst, darf aber Creator und Scouts verwalten. 3) FOLGEFEHLER DER MASSENERSETZUNG Die Regel "niemals den letzten aktiven DogFather sperren" hatte durch die Umstellung auf istLeitung() ploetzlich auch Manager blockiert -- gezaehlt werden aber nur DogFather-Zugaenge. Jetzt istDogFather(). Geprueft: DogFather kann einen Manager sperren, sich selbst nicht. 4) REIHENFOLGE UND ROLLENWAHL Ueberall DogFather, Manager, Scout, Creator. "ORDER BY rolle" waere alphabetisch gewesen (admin, creator, manager, scout) -- also fast genau falsch herum. Jetzt ein gemeinsamer Sortierausdruck aus workspace.js. Das Auswahlmenue fuer die Rolle ist weg. Es kam als weisses Windows-Menue mitten in einer dunklen Oberflaeche und schnitt "Creator" zu "Crea" ab -- gestalten laesst sich ein aufgeklapptes Systemmenue nicht. Ersetzt durch vier sichtbare Schalter mit Symbol, Farbe je Rolle und einer Zeile, was die Rolle bedeutet. Bei "Manager" gegen "DogFather" ist das der Unterschied zwischen Raten und Wissen. DogFather und Manager stehen dort nur zur Wahl, wenn DogFather selbst davorsitzt -- ein Knopf, der immer scheitert, gehoert nicht hin. Nebenbei: Das Namensfeld war auf eine von zwoelf Spalten gequetscht, weil seine Umgebung keine .feld-Klasse trug. Alle Formulare daraufhin durchsucht, keine weiteren Faelle.
278 lines
12 KiB
JavaScript
278 lines
12 KiB
JavaScript
/* =====================================================================
|
|
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" });
|
|
}
|
|
});
|