Files
dogfather-universe/server/workspace-zuteilung.js
T
DogFatherGitandClaude Opus 5 5bde49e0cd Was unten im Brett steht, steht jetzt auch oben im Band
Filipe am 22.09.: "die aufgaben die man unten sieht soll man auch
oben sehen."

GEMESSEN an den echten Daten: aufgaben_zuteilung hatte NULL Zeilen,
waehrend im Brett zwei Aufgaben standen (OFFEN 1, IN ARBEIT 1). Jede
Person las oben "nichts zugeteilt". Die Aufgaben hingen am aelteren
Feld aufgaben.verantwortlich_id -- genau dem Feld, nach dem das Brett
gruppiert. Die Uebersicht las eine andere Quelle als die Liste
darunter.

resuemeeFuer() zaehlt jetzt BEIDE Wege und keinen doppelt: Gibt es zu
einer Aufgabe eine Zuteilungszeile fuer die Person, gewinnt die
Zuteilung (genauerer Zustand). Nur wo keine Zeile existiert, zaehlt
das alte Feld. Die Zuordnung status->zustand steht an EINER Stelle;
"review" zaehlt wie "arbeit", "abgebrochen" zaehlt nirgends -- so wie
im Brett auch.

Die Personenliste wird nicht mehr aufgezaehlt, sondern abgeleitet:
die Rollen des Hauses (immer, auch mit null Aufgaben -- wer frei ist,
ist die haeufigste Frage) PLUS jede aktive Person, der eine Aufgabe
gehoert. Ohne den zweiten Teil koennte im Brett eine Aufgabe stehen,
deren Mensch oben fehlt.

pruef-zuteilung 71/0 (war 65). Der neue Abschnitt legt eine Aufgabe
GENAU SO an wie die echten alten -- verantwortlich_id, keine
Zuteilungszeile -- und misst Filipes Satz direkt: "unten im Brett
stehen genauso viele wie oben (2 = 2)". Zahl in der Bedingung, nicht
nur im Meldetext.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-09-22 09:12:20 +02:00

594 lines
26 KiB
JavaScript

/* =====================================================================
AUFGABEN AN MENSCHEN — ZUTEILEN, ANNEHMEN, ABLEHNEN, BEWERTEN
=====================================================================
Auftrag vom 21.09.2026, Abschnitte 2 bis 5.
WAS DIESES MODUL BEANTWORTET — und was es bewusst NICHT tut:
Es beantwortet die Frage "wie steht diese Aufgabe bei DIESEM
Menschen". Die Frage "wie steht die Aufgabe" beantwortet weiterhin
`aufgaben.status` in workspace-aufgaben.js. Zwei Fragen, zwei Orte —
ein einziges Feld könnte bei drei Leuten nicht gleichzeitig
"angenommen" und "abgelehnt" sein.
DREI ARTEN ZU VERTEILEN, und der Unterschied zwischen den letzten
beiden ist der ganze Punkt:
einzeln eine Person, sie macht es
mehrere mehrere Personen, JEDE macht ihren Teil
pool mehrere sehen es, EINE nimmt es — danach ist es für die
anderen erledigt, damit niemand doppelt arbeitet
WER DARF WAS:
verteilen DogFather, rechte Hand, linke Hand
(darfAufgabenVerteilen — eine Quelle, nicht drei)
annehmen nur, wer sie bekommen hat
ablehnen dito, mit Begründung — Pflicht, siehe unten
übernehmen nur bei "pool", nur wer angeboten bekommen hat,
und nur solange niemand sonst sie genommen hat
bewerten nur wer verteilen darf, und erst wenn erledigt
WARUM DIE BEGRÜNDUNG BEIM ABLEHNEN PFLICHT IST: Filipe schreibt
"soll die Modi die Möglichkeit haben, eine Begründung bzw. Nachricht
zu hinterlassen". Ein leeres Nein ist für den, der die Aufgabe
verteilt hat, keine Information — er muss dann nachfragen, und genau
das sollte die Nachricht ersparen. Deshalb: mindestens ein paar
Zeichen. Wer wirklich nichts sagen will, schreibt "keine Zeit" —
auch das ist mehr als nichts.
WAS MIT `verantwortlich_id` PASSIERT (die alte Spalte):
einzeln = die eine Person
pool = leer, bis jemand übernimmt — danach der Übernehmer
mehrere = leer
Sie bleibt, weil das Aufgabenbrett, die Startseite und die Übersicht
danach gruppieren. Sie abzuschaffen hieße, drei gewachsene Ansichten
gleichzeitig umzubauen; sie stehen zu lassen und daneben die
Zuteilung zu führen ist der kleinere Eingriff — und bei "einzeln",
also dem häufigsten Fall, sagen beide dasselbe.
===================================================================== */
import express from "express";
import {
db, protokolliere, echteIp, darfAufgabenVerteilen, istDogFather, istHand,
sitzungLesen,
} from "./workspace.js";
import { TEAM_DOGI_ROLLEN } from "./crew-adresse.js";
export const zuteilungRouter = express.Router();
/* DIESELBE SCHRANKE WIE BEIM AUFGABENBRETT, und sie haengt UEBER allem
in diesem Modul: Wer nicht angemeldet ist, kommt an keine dieser
Routen. Eine Schranke je Route waere die Stelle, an der die naechste
Route sie vergisst.
`express.json` mit kleiner Grenze -- hier kommen Begruendungen und
Rueckmeldungen herein, keine Dateien. */
/* EIGENE SCHRANKE, KEIN IMPORT AUS workspace-aufgaben.js: Die Datei
dort holt sich gleich `zuteilen` und `mitZuteilung` von hier. Ein
Import in beide Richtungen waere ein Ringschluss -- der faellt in
Node nicht sofort auf, sondern erst, wenn eine der beiden Dateien
beim Laden auf etwas zugreift, das die andere noch nicht gesetzt
hat. Das sind vier Zeilen, die dort auch stehen; hier ist die
Dopplung billiger als der Ring. */
function angemeldet(req, res, next) {
const person = sitzungLesen(req);
if (!person) return res.status(401).json({ fehler: "nicht_angemeldet" });
req.person = person;
next();
}
zuteilungRouter.use("/workspace/api", angemeldet);
zuteilungRouter.use("/workspace/api", express.json({ limit: "8kb" }));
const jetzt = () => new Date().toISOString();
/** Die drei Arten. Als Menge, damit eine erfundene Art nicht
* stillschweigend durchrutscht und als NULL in der Tabelle landet. */
export const VERTEILARTEN = new Set(["einzeln", "mehrere", "pool"]);
/** Die Zustände eines Menschen zu einer Aufgabe, in der Reihenfolge,
* in der sie im Alltag durchlaufen werden. */
export const ZUSTAENDE = ["offen", "angenommen", "arbeit", "erledigt", "abgelehnt"];
/** Die drei Rückmeldungen aus Abschnitt 5. */
export const BEWERTUNGEN = new Set(["gut", "nicht_gut", "verbessern"]);
/** Klartext für die Oberfläche. Sie soll die Wörter nicht selbst
* erfinden — sonst heißt derselbe Zustand an zwei Stellen anders. */
export const ZUSTAND_TEXT = {
offen: "Noch nicht beantwortet",
angenommen: "Angenommen",
arbeit: "In Bearbeitung",
erledigt: "Erledigt",
abgelehnt: "Abgelehnt",
};
export const BEWERTUNG_TEXT = {
gut: "Gut gemacht",
nicht_gut: "Nicht gut gemacht",
verbessern: "Verbesserungsmöglichkeiten",
};
/* =====================================================================
LESEN
===================================================================== */
/** Alle Zuteilungen zu einer Menge von Aufgaben — in EINER Abfrage.
*
* EINE ABFRAGE UND NICHT EINE JE AUFGABE: Bei vierzig Aufgaben auf
* dem Brett wären das vierzig Abfragen, und die Seite würde spürbar
* langsamer, ohne dass jemand sähe, warum. */
export function zuteilungenZu(aufgabeIds) {
const ids = [...new Set((aufgabeIds || []).map(Number).filter(Number.isInteger))];
if (!ids.length) return new Map();
const platz = ids.map(() => "?").join(",");
const reihen = db().prepare(`
SELECT z.id, z.aufgabe_id, z.person_id, z.zustand, z.grund,
z.zugeteilt_am, z.geantwortet_am, z.erledigt_am,
z.bewertung, z.bewertung_text, z.bewertet_am,
p.name AS person_name, p.rolle AS person_rolle,
pb.name AS bewertet_von_name
FROM aufgaben_zuteilung z
LEFT JOIN personen p ON p.id = z.person_id
LEFT JOIN personen pb ON pb.id = z.bewertet_von
WHERE z.aufgabe_id IN (${platz})
ORDER BY p.name COLLATE NOCASE`).all(...ids);
const karte = new Map();
for (const z of reihen) {
if (!karte.has(z.aufgabe_id)) karte.set(z.aufgabe_id, []);
karte.get(z.aufgabe_id).push({
...z,
zustand_text: ZUSTAND_TEXT[z.zustand] || z.zustand,
bewertung_text_kurz: z.bewertung ? BEWERTUNG_TEXT[z.bewertung] : null,
});
}
return karte;
}
/** Hängt die Zuteilungen an eine Liste von Aufgabenzeilen.
*
* Dazu je Zeile drei abgeleitete Angaben, damit die Oberfläche nicht
* dieselbe Rechnung noch einmal macht — zwei Rechnungen für dieselbe
* Frage laufen auseinander. */
export function mitZuteilung(reihen, ich) {
const liste = Array.isArray(reihen) ? reihen : [];
const karte = zuteilungenZu(liste.map((r) => r.id));
for (const r of liste) {
const zu = karte.get(r.id) || [];
r.zuteilung = zu;
r.verteilart = r.verteilart || (zu.length > 1 ? "mehrere" : (zu.length ? "einzeln" : null));
/* MEIN eigener Stand — das ist die Angabe, aus der die persönliche
Ansicht eines Modi entsteht. */
r.meine_zuteilung = ich ? (zu.find((z) => z.person_id === ich.id) || null) : null;
/* BEI EINER POOL-AUFGABE: Hat sie schon jemand? Der Auftrag
verlangt ausdrücklich, dass die anderen das erkennen, "damit
keine doppelte Bearbeitung entsteht". */
r.uebernommen_von = r.verteilart === "pool"
? (zu.find((z) => z.zustand !== "offen" && z.zustand !== "abgelehnt")?.person_name || null)
: null;
}
return liste;
}
/* =====================================================================
SCHREIBEN — die eine Stelle, an der zugeteilt wird
===================================================================== */
/** Legt die Zuteilungen einer Aufgabe an (oder schreibt sie neu).
*
* Gibt zurück, was wirklich gesetzt wurde — der Aufrufer soll nicht
* raten müssen, ob seine Wünsche durchgekommen sind. */
export function zuteilen(aufgabeId, personIds, art, vonId) {
const id = Number(aufgabeId);
if (!Number.isInteger(id)) return { fehler: "ungueltige_aufgabe" };
const roh = [...new Set((personIds || []).map(Number).filter(Number.isInteger))];
if (!roh.length) return { gesetzt: [], art: null };
/* NUR AKTIVE MENSCHEN. Eine Aufgabe an einen gesperrten Zugang zu
hängen heißt: Sie steht im Resümee und niemand kann sie machen. */
const platz = roh.map(() => "?").join(",");
const echt = db().prepare(
`SELECT id FROM personen WHERE id IN (${platz}) AND aktiv = 1`).all(...roh).map((z) => z.id);
if (!echt.length) return { fehler: "keine_person" };
/* DIE ART WIRD ABGELEITET, WO SIE SICH ABLEITEN LÄSST. Eine Person
kann kein Pool sein — "der erste, der Zeit hat" braucht mindestens
zwei, die es sehen. Wer trotzdem "pool" schickt, bekommt
"einzeln"; das ist, was er gemeint haben muss. */
let gesetzteArt = VERTEILARTEN.has(art) ? art : (echt.length > 1 ? "mehrere" : "einzeln");
if (echt.length === 1) gesetzteArt = "einzeln";
const nun = jetzt();
const alt = db().prepare(
"SELECT person_id, zustand FROM aufgaben_zuteilung WHERE aufgabe_id = ?").all(id);
const behalten = new Map(alt.map((z) => [z.person_id, z.zustand]));
/* WER SCHON GEANTWORTET HAT, BEHÄLT SEINEN STAND. Sonst setzte ein
zweites Speichern im Formular ein "angenommen" zurück auf "offen"
— und der Mensch müsste noch einmal zusagen, ohne zu wissen,
warum. */
const setz = db().prepare(`
INSERT INTO aufgaben_zuteilung (aufgabe_id, person_id, zustand, zugeteilt_am, zugeteilt_von)
VALUES (?,?,?,?,?)
ON CONFLICT (aufgabe_id, person_id) DO NOTHING`);
for (const pid of echt) setz.run(id, pid, "offen", nun, vonId ?? null);
/* WER NICHT MEHR DABEI IST, FLIEGT RAUS — aber nur, wenn er noch
nicht geantwortet hat. Jemandem eine angenommene Aufgabe
wegzunehmen, ohne dass er es erfährt, wäre die unangenehmste Art,
Arbeit zu verlieren. */
for (const [pid, zustand] of behalten) {
if (echt.includes(pid)) continue;
if (zustand !== "offen") continue;
db().prepare("DELETE FROM aufgaben_zuteilung WHERE aufgabe_id = ? AND person_id = ?")
.run(id, pid);
}
db().prepare("UPDATE aufgaben SET verteilart = ? WHERE id = ?").run(gesetzteArt, id);
/* `verantwortlich_id` mitführen — siehe Kopf. Bei "einzeln" ist sie
die Person, sonst bleibt sie leer, bis jemand übernimmt. */
if (gesetzteArt === "einzeln") {
db().prepare("UPDATE aufgaben SET verantwortlich_id = ? WHERE id = ?").run(echt[0], id);
} else if (gesetzteArt === "mehrere") {
db().prepare("UPDATE aufgaben SET verantwortlich_id = NULL WHERE id = ?").run(id);
}
return { gesetzt: echt, art: gesetzteArt };
}
/* =====================================================================
DIE WEGE
===================================================================== */
function holen(req, res) {
const id = Number(req.params.id);
if (!Number.isInteger(id)) { res.status(400).json({ fehler: "ungueltig" }); return null; }
const a = db().prepare("SELECT id, titel, status, verteilart FROM aufgaben WHERE id = ?").get(id);
if (!a) { res.status(404).json({ fehler: "gibt_es_nicht" }); return null; }
return a;
}
/** Meine eigene Zuteilung zu dieser Aufgabe — oder nichts. */
function meine(aufgabeId, personId) {
return db().prepare(
"SELECT * FROM aufgaben_zuteilung WHERE aufgabe_id = ? AND person_id = ?")
.get(aufgabeId, personId) || null;
}
/* ---- Annehmen ------------------------------------------------------- */
zuteilungRouter.post("/workspace/api/aufgaben/:id/annehmen", (req, res) => {
try {
const a = holen(req, res); if (!a) return;
const z = meine(a.id, req.person.id);
if (!z) return res.status(403).json({ fehler: "nicht_zugeteilt" });
if (z.zustand === "erledigt") return res.status(409).json({ fehler: "schon_erledigt" });
db().prepare(`UPDATE aufgaben_zuteilung
SET zustand = 'angenommen', grund = NULL, geantwortet_am = ?
WHERE id = ?`).run(jetzt(), z.id);
/* BEIM POOL GEWINNT DER ERSTE. Sobald einer zusagt, ist sie für
die anderen weg — das ist der Zweck der Sache. */
if (a.verteilart === "pool") uebernahmeAbschliessen(a.id, req.person.id);
protokolliere("aufgabe_angenommen", {
personId: req.person.id, rolle: req.person.rolle, ip: echteIp(req),
detail: `#${a.id} ${a.titel}`.slice(0, 120),
});
res.json({ ok: true, zustand: "angenommen" });
} catch (f) {
console.error("[zuteilung] annehmen:", f?.message);
res.status(503).json({ fehler: "nicht_verfuegbar" });
}
});
/* ---- Ablehnen, mit Begründung --------------------------------------- */
zuteilungRouter.post("/workspace/api/aufgaben/:id/ablehnen", (req, res) => {
try {
const a = holen(req, res); if (!a) return;
const z = meine(a.id, req.person.id);
if (!z) return res.status(403).json({ fehler: "nicht_zugeteilt" });
const grund = String(req.body?.grund ?? "").trim();
if (grund.length < 3) return res.status(400).json({ fehler: "grund_fehlt" });
if (grund.length > 500) return res.status(400).json({ fehler: "grund_zu_lang" });
db().prepare(`UPDATE aufgaben_zuteilung
SET zustand = 'abgelehnt', grund = ?, geantwortet_am = ?
WHERE id = ?`).run(grund, jetzt(), z.id);
/* HAT SIE NIEMAND MEHR? Dann muss das sichtbar sein. Die Aufgabe
bleibt stehen (sie ist ja nicht erledigt) — aber ohne
Verantwortliche, damit sie im Resümee der Leitung auffällt. */
if (a.verteilart === "einzeln") {
db().prepare("UPDATE aufgaben SET verantwortlich_id = NULL WHERE id = ?").run(a.id);
}
protokolliere("aufgabe_abgelehnt", {
personId: req.person.id, rolle: req.person.rolle, ip: echteIp(req),
detail: `#${a.id} ${grund}`.slice(0, 120),
});
res.json({ ok: true, zustand: "abgelehnt" });
} catch (f) {
console.error("[zuteilung] ablehnen:", f?.message);
res.status(503).json({ fehler: "nicht_verfuegbar" });
}
});
/** Beim Pool: Einer hat sie, alle anderen sind raus.
*
* ALS EIGENE FUNKTION, weil zwei Wege hierher führen (annehmen und
* übernehmen) und zwei Abschriften derselben Regel auseinanderlaufen. */
function uebernahmeAbschliessen(aufgabeId, personId) {
db().prepare(`UPDATE aufgaben_zuteilung
SET zustand = 'abgelehnt', grund = 'Von jemand anderem übernommen',
geantwortet_am = ?
WHERE aufgabe_id = ? AND person_id <> ? AND zustand = 'offen'`)
.run(jetzt(), aufgabeId, personId);
db().prepare("UPDATE aufgaben SET verantwortlich_id = ? WHERE id = ?")
.run(personId, aufgabeId);
}
/* ---- Aus dem Pool übernehmen ---------------------------------------- */
zuteilungRouter.post("/workspace/api/aufgaben/:id/uebernehmen", (req, res) => {
try {
const a = holen(req, res); if (!a) return;
if (a.verteilart !== "pool") return res.status(409).json({ fehler: "kein_pool" });
const z = meine(a.id, req.person.id);
if (!z) return res.status(403).json({ fehler: "nicht_zugeteilt" });
/* HAT SCHON JEMAND? Diese Frage muss VOR dem Schreiben stehen,
sonst nehmen zwei Leute im selben Moment dieselbe Aufgabe und
beide sehen "hat geklappt". */
const schon = db().prepare(`SELECT p.name FROM aufgaben_zuteilung z
LEFT JOIN personen p ON p.id = z.person_id
WHERE z.aufgabe_id = ? AND z.person_id <> ?
AND z.zustand IN ('angenommen','arbeit','erledigt')`).get(a.id, req.person.id);
if (schon) return res.status(409).json({ fehler: "schon_vergeben", wer: schon.name });
db().prepare(`UPDATE aufgaben_zuteilung
SET zustand = 'angenommen', grund = NULL, geantwortet_am = ?
WHERE id = ?`).run(jetzt(), z.id);
uebernahmeAbschliessen(a.id, req.person.id);
protokolliere("aufgabe_uebernommen", {
personId: req.person.id, rolle: req.person.rolle, ip: echteIp(req),
detail: `#${a.id} ${a.titel}`.slice(0, 120),
});
res.json({ ok: true, zustand: "angenommen" });
} catch (f) {
console.error("[zuteilung] uebernehmen:", f?.message);
res.status(503).json({ fehler: "nicht_verfuegbar" });
}
});
/* ---- Meinen eigenen Stand setzen ------------------------------------ */
zuteilungRouter.post("/workspace/api/aufgaben/:id/mein-stand", (req, res) => {
try {
const a = holen(req, res); if (!a) return;
const z = meine(a.id, req.person.id);
if (!z) return res.status(403).json({ fehler: "nicht_zugeteilt" });
const ziel = String(req.body?.zustand ?? "").trim();
/* NUR DIE DREI, DIE MAN SELBST SETZEN DARF. "offen" zurückzudrehen
hieße, die eigene Zusage zu löschen; "abgelehnt" hat einen
eigenen Weg, weil es eine Begründung braucht. */
if (!["angenommen", "arbeit", "erledigt"].includes(ziel)) {
return res.status(400).json({ fehler: "ungueltiger_zustand" });
}
if (z.zustand === "abgelehnt") return res.status(409).json({ fehler: "abgelehnt" });
db().prepare(`UPDATE aufgaben_zuteilung
SET zustand = ?, erledigt_am = ?
WHERE id = ?`).run(ziel, ziel === "erledigt" ? jetzt() : null, z.id);
protokolliere("aufgabe_stand", {
personId: req.person.id, rolle: req.person.rolle, ip: echteIp(req),
detail: `#${a.id} ${ziel}`.slice(0, 120),
});
res.json({ ok: true, zustand: ziel });
} catch (f) {
console.error("[zuteilung] mein-stand:", f?.message);
res.status(503).json({ fehler: "nicht_verfuegbar" });
}
});
/* ---- Bewerten (Abschnitt 5) ----------------------------------------- */
zuteilungRouter.post("/workspace/api/aufgaben/:id/bewerten", (req, res) => {
try {
const a = holen(req, res); if (!a) return;
if (!darfAufgabenVerteilen(req.person)) {
return res.status(403).json({ fehler: "darf_nicht_bewerten" });
}
const personId = Number(req.body?.person_id);
const bewertung = String(req.body?.bewertung ?? "").trim();
const text = String(req.body?.text ?? "").trim();
if (!BEWERTUNGEN.has(bewertung)) return res.status(400).json({ fehler: "ungueltige_bewertung" });
/* BEI "VERBESSERUNGSMÖGLICHKEITEN" IST DER TEXT PFLICHT. Filipe:
"soll eine entsprechende Kachel bzw. ein Eingabebereich
erscheinen. Dogfather und die rechte Hand sollen dort einen
individuellen Text hinterlegen." Ein leeres "kann besser" sagt
dem, der es liest, nichts — außer dass jemand unzufrieden war. */
if (bewertung === "verbessern" && text.length < 3) {
return res.status(400).json({ fehler: "text_fehlt" });
}
if (text.length > 2000) return res.status(400).json({ fehler: "text_zu_lang" });
const z = db().prepare(
"SELECT * FROM aufgaben_zuteilung WHERE aufgabe_id = ? AND person_id = ?")
.get(a.id, personId);
if (!z) return res.status(404).json({ fehler: "nicht_zugeteilt" });
/* ERST FERTIG, DANN BEWERTEN. Eine Rückmeldung auf etwas, das noch
läuft, ist keine Bewertung, sondern eine Einmischung. */
if (z.zustand !== "erledigt") return res.status(409).json({ fehler: "noch_nicht_erledigt" });
db().prepare(`UPDATE aufgaben_zuteilung
SET bewertung = ?, bewertung_text = ?, bewertet_von = ?, bewertet_am = ?
WHERE id = ?`).run(bewertung, text || null, req.person.id, jetzt(), z.id);
protokolliere("aufgabe_bewertet", {
personId: req.person.id, rolle: req.person.rolle, ip: echteIp(req),
detail: `#${a.id} -> #${personId} ${bewertung}`.slice(0, 120),
});
res.json({ ok: true, bewertung, text: text || null });
} catch (f) {
console.error("[zuteilung] bewerten:", f?.message);
res.status(503).json({ fehler: "nicht_verfuegbar" });
}
});
/* ---- Das persönliche Resümee (Abschnitt 1 und 4) -------------------- */
/** Zählt für eine Person, wie ihre Aufgaben stehen.
*
* ABGELEITET AUS DEN ZUSTÄNDEN, nicht aus einer zweiten Tabelle: Eine
* mitgeführte Zahl ist eine Zahl von gestern, sobald jemand etwas
* ändert und die Stelle vergisst. */
/** Der Zustand einer Aufgabe, die jemandem auf dem ALTEN Weg gehoert.
*
* Die Aufgabentabelle kennt offen/arbeit/review/erledigt/abgebrochen,
* die Zuteilung kennt offen/angenommen/arbeit/erledigt/abgelehnt.
* "review" heisst: liegt zur Freigabe, ist also noch nicht vom Tisch --
* deshalb zaehlt es wie "arbeit". "abgebrochen" ist weder dran noch
* erledigt und zaehlt gar nicht mit; es erscheint auch im Brett nicht
* in den Spalten OFFEN und IN ARBEIT.
*
* Die Zuordnung steht hier an EINER Stelle. Wer sie an zwei Stellen
* schreibt, hat beim naechsten Statuswert eine, die niemand nachzieht. */
const STATUS_ALS_ZUSTAND = {
offen: "offen", arbeit: "arbeit", review: "arbeit", erledigt: "erledigt",
};
export function resuemeeFuer(personId) {
/* ZWEI QUELLEN, EIN ERGEBNIS (22.09.2026).
==========================================================
Filipe: "die aufgaben die man unten sieht soll man auch oben
sehen."
Gemessen an den echten Daten: aufgaben_zuteilung hatte NULL
Zeilen, waehrend im Brett zwei Aufgaben standen (OFFEN 1,
IN ARBEIT 1). Jede Person las oben "nichts zugeteilt" -- weil
diese Zaehlung nur die neue Tabelle kannte. Die Aufgaben hingen
am aelteren Feld `aufgaben.verantwortlich_id`, und das ist genau
das Feld, nach dem das BRETT gruppiert.
Eine Uebersicht, die eine andere Quelle liest als die Liste
darunter, widerspricht ihr frueher oder spaeter. Deshalb zaehlen
hier BEIDE Wege -- und keiner doppelt: Gibt es zu einer Aufgabe
eine Zuteilungszeile fuer diese Person, gewinnt die Zuteilung
(sie hat den genaueren Zustand: angenommen, abgelehnt, bewertet).
Nur wo keine Zeile existiert, zaehlt das alte Feld. */
const zeilen = db().prepare(`
SELECT z.zustand, z.bewertung, a.frist, a.status AS aufgabe_status
FROM aufgaben_zuteilung z
JOIN aufgaben a ON a.id = z.aufgabe_id
WHERE z.person_id = ?`).all(Number(personId));
const alte = db().prepare(`
SELECT a.status, a.frist
FROM aufgaben a
WHERE a.verantwortlich_id = ?
AND NOT EXISTS (SELECT 1 FROM aufgaben_zuteilung z
WHERE z.aufgabe_id = a.id AND z.person_id = a.verantwortlich_id)
`).all(Number(personId));
for (const a of alte) {
const zustand = STATUS_ALS_ZUSTAND[a.status];
if (!zustand) continue; /* abgebrochen: zaehlt nirgends */
zeilen.push({ zustand, bewertung: null, frist: a.frist, aufgabe_status: a.status });
}
const heute = new Date().toISOString().slice(0, 10);
const zaehl = { offen: 0, angenommen: 0, arbeit: 0, erledigt: 0, abgelehnt: 0 };
let ueberfaellig = 0, gelobt = 0, zuVerbessern = 0;
for (const z of zeilen) {
if (zaehl[z.zustand] !== undefined) zaehl[z.zustand]++;
const laeuft = z.zustand !== "erledigt" && z.zustand !== "abgelehnt";
if (laeuft && z.frist && z.frist < heute) ueberfaellig++;
if (z.bewertung === "gut") gelobt++;
if (z.bewertung === "verbessern" || z.bewertung === "nicht_gut") zuVerbessern++;
}
return {
gesamt: zeilen.length, ...zaehl, ueberfaellig, gelobt, zu_verbessern: zuVerbessern,
/* Was gerade zu tun ist — die eine Zahl, die ein Mensch morgens
wissen will. */
dran: zaehl.offen + zaehl.angenommen + zaehl.arbeit,
};
}
zuteilungRouter.get("/workspace/api/aufgaben/resuemee", (req, res) => {
try {
/* WER DARF WESSEN RESÜMEE SEHEN? Sein eigenes jeder. Fremde nur,
wer Aufgaben verteilt — das ist genau die Gruppe, die im Auftrag
"eine vollständige Gesamtübersicht" bekommen soll. */
const wunsch = Number(req.query?.person);
const fremd = Number.isInteger(wunsch) && wunsch !== req.person.id;
if (fremd && !darfAufgabenVerteilen(req.person)) {
return res.status(403).json({ fehler: "nicht_erlaubt" });
}
if (!fremd) return res.json({ person_id: req.person.id, ...resuemeeFuer(req.person.id) });
const p = db().prepare("SELECT id, name, rolle FROM personen WHERE id = ?").get(wunsch);
if (!p) return res.status(404).json({ fehler: "gibt_es_nicht" });
res.json({ person_id: p.id, name: p.name, rolle: p.rolle, ...resuemeeFuer(p.id) });
} catch (f) {
console.error("[zuteilung] resuemee:", f?.message);
res.status(503).json({ fehler: "nicht_verfuegbar" });
}
});
/** Das Resümee ALLER — für DogFather, rechte und linke Hand.
*
* Abschnitt 1: "Auf einen Blick muss erkennbar sein, wer welche
* Aufgabe hat, was erledigt wurde, was noch offen ist und wo
* Handlungsbedarf besteht." */
zuteilungRouter.get("/workspace/api/aufgaben/team-resuemee", (req, res) => {
try {
if (!darfAufgabenVerteilen(req.person)) {
return res.status(403).json({ fehler: "nicht_erlaubt" });
}
/* WER GEHÖRT IN DIE ÜBERSICHT: alle, die Aufgaben bekommen können.
Abgeleitet aus der Rollenliste dieses Hauses statt aus einer
eigenen Aufzählung — wer morgen eine Rolle hinzufügt, muss hier
nichts nachziehen. */
/* WER IN DER LISTE STEHT -- ABGELEITET, NICHT AUFGEZAEHLT.
==========================================================
Die Rollen des Hauses stehen immer da, auch mit null Aufgaben:
Wer frei ist, ist die haeufigste Frage beim Verteilen.
Dazu kommt JEDE aktive Person, der im Brett eine Aufgabe
gehoert. Ohne diesen zweiten Teil koennte im Brett eine Aufgabe
stehen, deren Mensch oben fehlt -- genau der Widerspruch, den
Filipe am 22.09. gemeldet hat, nur an anderer Stelle. So kann
das nicht wieder entstehen: Die Liste folgt den Aufgaben. */
const rollen = [...TEAM_DOGI_ROLLEN];
const platz = rollen.map(() => "?").join(",");
const leute = db().prepare(`
SELECT id, name, rolle FROM personen
WHERE aktiv = 1
AND (rolle IN (${platz})
OR id IN (SELECT verantwortlich_id FROM aufgaben
WHERE verantwortlich_id IS NOT NULL)
OR id IN (SELECT person_id FROM aufgaben_zuteilung))
AND rolle NOT IN ('creator','scout','manager','spicy','gast')
ORDER BY CASE rolle WHEN 'admin' THEN 0 WHEN 'hand' THEN 1
WHEN 'linke' THEN 2 ELSE 3 END,
name COLLATE NOCASE`).all(...rollen);
res.json({
leute: leute.map((p) => ({ ...p, ...resuemeeFuer(p.id) })),
darf_bewerten: true,
ist_dogfather: istDogFather(req.person),
ist_hand: istHand(req.person),
});
} catch (f) {
console.error("[zuteilung] team-resuemee:", f?.message);
res.status(503).json({ fehler: "nicht_verfuegbar" });
}
});