From ef0fddc72443de45f355aa549abc8830f0cc8d2c Mon Sep 17 00:00:00 2001 From: Dogfather Date: Mon, 21 Sep 2026 17:52:52 +0200 Subject: [PATCH] Aufgaben an Menschen: zuteilen, annehmen, ablehnen, bewerten Auftrag vom 21.09.2026, Abschnitte 2 bis 5 -- der Serverteil. 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 fuer die anderen weg, damit niemand doppelt arbeitet EINE EIGENE TABELLE, KEINE SPALTEN AN DER AUFGABE. Abschnitt 4 verlangt ausdruecklich: *"Die Aufgabe muss in der Auswertung auf die einzelnen zugewiesenen Personen aufgeteilt werden"* -- und gleichzeitig soll "die urspruengliche gemeinsame Aufgabe ihre uebergeordnete Struktur" behalten. Eine Aufgabe hat EINEN Titel und EINE Frist, aber je Mensch einen eigenen Stand, einen eigenen Grund beim Ablehnen, ein eigenes Erledigt-Datum und eine eigene Rueckmeldung. Das in Spalten zu pressen hiesse, dieselbe Aufgabe mehrfach anzulegen. `aufgaben.status` bleibt daneben unberuehrt. Zwei Ebenen, weil es zwei Fragen sind: "Wie steht die Aufgabe?" und "Wie steht sie bei IHM?" Ein einziges Feld koennte bei drei Leuten nicht gleichzeitig "angenommen" und "abgelehnt" sein. DER WICHTIGSTE EINZELNE HANDGRIFF -- und er steht nicht im Auftrag: Wer eine Aufgabe BEKOMMEN hat, sieht sie jetzt immer. Bei "mehrere" und "pool" bleibt `verantwortlich_id` leer, und die alten Sichtregeln fragen genau danach. Ohne die Erweiterung stuende eine Aufgabe im Resuemee der Leitung und waere fuer den, der sie machen soll, unsichtbar -- der unangenehmste denkbare Fehler in einem Aufgabensystem, und man sieht ihn nirgends. Die Erweiterung ist ein ODER, kein UND: Sie erweitert die Sicht, die Haustrennung darueber bleibt ein UND und wirkt weiter. WAS SONST NOCH ENTSCHIEDEN WURDE, mit Grund im Code: - Ablehnen braucht eine Begruendung (mind. 3 Zeichen). Ein leeres Nein ist fuer den, der verteilt hat, keine Information -- er muss dann nachfragen, und genau das sollte die Nachricht ersparen. - "Verbesserungsmoeglichkeiten" braucht einen Text. Ein leeres "kann besser" sagt nichts ausser, dass jemand unzufrieden war. - Bewertet wird erst, wenn jemand FERTIG ist. Eine Rueckmeldung auf etwas, das noch laeuft, ist keine Bewertung, sondern eine Einmischung. - Beim Pool wird VOR dem Schreiben gefragt, ob schon jemand zugesagt hat -- sonst nehmen zwei im selben Moment dieselbe Aufgabe und beide sehen "hat geklappt". - Wer schon geantwortet hat, behaelt seinen Stand, wenn die Aufgabe noch einmal gespeichert wird. Sonst muesste er ohne Grund erneut zusagen. - Wer nicht mehr dabei ist, faellt raus -- aber nur, solange er noch nicht geantwortet hat. Jemandem eine angenommene Aufgabe wegzunehmen, ohne dass er es erfaehrt, waere die unangenehmste Art, Arbeit zu verlieren. - "pool" mit einer Person wird "einzeln". Ein Pool aus einem Menschen ist keiner. `verantwortlich_id` bleibt und wird mitgefuehrt (einzeln = die Person, pool = der Uebernehmer, mehrere = leer): Brett, Startseite und Uebersicht gruppieren danach. Drei gewachsene Ansichten gleichzeitig umzubauen waere der groessere Eingriff -- und im haeufigsten Fall sagen beide dasselbe. KEIN CHECK an der nachgetragenen Spalte `verteilart`, mit Absicht: Ein CHECK auf einer nachgetragenen Spalte hiesse, die Tabelle neu zu bauen -- der Weg, der in diesem Haus am 11.09. und heute schon Spalten gekostet hat. Geprueft wird beim Schreiben. pruef-zuteilung.mjs, 48 Aussagen -- ein Arbeitstag durchgespielt. Ein Fehlschlag beim ersten Lauf, und er war lehrreich: "Cem hat keinen eigenen Stand" schlug fehl, weil Cem die Aufgabe gar nicht SIEHT (`null?.x` ist `undefined`, nicht `null`). Die Zusicherung konnte "sieht sie nicht" und "sieht sie ohne Stand" nicht unterscheiden -- also pruefte sie beides nicht richtig. Dass er sie nicht sieht, ist genau Abschnitt 1: *"Eine Modi darf nicht automatisch die vollstaendige Aufgabenuebersicht aller anderen Modis sehen."* Beide Faelle stehen jetzt einzeln da, mit einer Gegenprobe, dass seine Liste ueberhaupt laedt. Nachbarn gruen: pruef-verteilen 16/0, pruef-aufgabenbrett 49/0, pruef-umstellung 18/0. --- server/index.js | 5 + server/pruef-zuteilung.mjs | 375 ++++++++++++++++++++++++ server/workspace-aufgaben.js | 63 +++- server/workspace-zuteilung.js | 528 ++++++++++++++++++++++++++++++++++ server/workspace.js | 103 +++++++ 5 files changed, 1072 insertions(+), 2 deletions(-) create mode 100644 server/pruef-zuteilung.mjs create mode 100644 server/workspace-zuteilung.js diff --git a/server/index.js b/server/index.js index 56773b1f..97b0f858 100644 --- a/server/index.js +++ b/server/index.js @@ -17,6 +17,7 @@ import { webdesignGate } from "./webdesign-gate.js"; import { inhaltsrichtlinie } from "./inhaltsrichtlinie.js"; import { workspaceRouter, sichtSetzen } from "./workspace.js"; import { aufgabenRouter } from "./workspace-aufgaben.js"; +import { zuteilungRouter } from "./workspace-zuteilung.js"; import { personenRouter } from "./workspace-personen.js"; import { profilRouter } from "./workspace-profil.js"; import { kalenderRouter } from "./workspace-kalender.js"; @@ -274,6 +275,10 @@ app.use(schulungRouter); app.use(ampelRouter); app.use(checklisteRouter); app.use(teamlageRouter); +/* VOR aufgabenRouter: Der faengt "/workspace/api/aufgaben/:id" ab und + wuerde "/workspace/api/aufgaben/resuemee" als Aufgabe mit der Nummer + "resuemee" behandeln. Die Reihenfolge ist hier keine Stilfrage. */ +app.use(zuteilungRouter); app.use(aufgabenRouter); app.use(personenRouter); app.use(profilRouter); diff --git a/server/pruef-zuteilung.mjs b/server/pruef-zuteilung.mjs new file mode 100644 index 00000000..58929fdc --- /dev/null +++ b/server/pruef-zuteilung.mjs @@ -0,0 +1,375 @@ +/* ===================================================================== + pruef-zuteilung.mjs — AUFGABEN AN MENSCHEN + ===================================================================== + + Der Auftrag vom 21.09.2026, Abschnitte 2 bis 5, durchgespielt wie + ein Arbeitstag: verteilen, annehmen, ablehnen, übernehmen, + fertigmachen, bewerten. + + WAS HIER GEMESSEN WIRD — und warum gerade das: + + (1) DASS JEMAND SIEHT, WAS ER BEKOMMEN HAT. Bei "mehrere" und "pool" + bleibt `verantwortlich_id` leer, und die alten Sichtregeln + fragen genau danach. Ohne die Erweiterung stünde eine Aufgabe im + Resümee der Leitung und wäre für den, der sie machen soll, + unsichtbar. Das ist der unangenehmste denkbare Fehler in einem + Aufgabensystem, und man sieht ihn nirgends — deshalb steht er + hier an erster Stelle. + + (2) DASS DER POOL WIRKLICH SCHLIESST. "Die erste Modi, die gerade + Zeit hat, übernimmt" ist nur dann etwas wert, wenn die anderen + es merken. Geprüft wird nicht nur, dass einer sie hat, sondern + dass die anderen sie NICHT mehr haben. + + (3) DASS ZWEI NICHT GLEICHZEITIG ÜBERNEHMEN KÖNNEN. Die Prüfung + fragt vor dem Schreiben, ob schon jemand zugesagt hat. + + (4) DIE GRENZEN. Ein Modi darf nicht bewerten, niemand darf eine + Aufgabe annehmen, die er nicht bekommen hat, und bewertet wird + erst, wenn jemand fertig ist. + + (5) GEGENPROBEN. Zu jeder Sperre eine Stelle, an der es erlaubt ist + — sonst wäre nur bewiesen, dass gar nichts geht. + ===================================================================== */ + +import { mkdtempSync, rmSync, appendFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import http from "node:http"; + +const ordner = mkdtempSync(join(tmpdir(), "ws-zuteil-")); +process.env.WORKSPACE_DB = join(ordner, "workspace.db"); + +import { notbremse } from "./helfer-notbremse.mjs"; +const { eigenerPort } = await import("./helfer-port.mjs"); +const PORT = await eigenerPort(import.meta, "die Zuteilungspruefung"); + +process.env.PORT = `${PORT}`; +process.env.SITE_ACCESS_SECRET = "lokaler-test"; +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"); +notbremse(300_000, "pruef-zuteilung"); +await new Promise((r) => setTimeout(r, 1200)); + +const PROTOKOLL = join(process.cwd(), "pruef-zuteilung-lauf.txt"); +try { rmSync(PROTOKOLL, { force: true }); } catch { /* gab es noch nicht */ } +const melde = (z) => { console.log(z); try { appendFileSync(PROTOKOLL, z + "\n"); } catch { /* Zugabe */ } }; +let fehler = 0; +const ok = (b, t) => { melde((b ? " ok " : " FEHL ") + t); if (!b) fehler++; }; + +const HOST = "crew.dogfather-universe.com"; +/* http.request statt fetch: fetch verwirft den Host-Kopf still, und + ohne den landet alles im falschen Haus (gemessen am 20.09.2026). */ +function rufe(pfad, wie = {}) { + return new Promise((fertig) => { + const daten = wie.body === undefined ? null : JSON.stringify(wie.body); + const a = http.request({ + host: "127.0.0.1", port: PORT, path: pfad, method: wie.method || "GET", + headers: { + Host: HOST, + ...(daten ? { "Content-Type": "application/json", "Content-Length": Buffer.byteLength(daten) } : {}), + ...(wie.keks ? { Cookie: wie.keks } : {}), + Origin: `https://${HOST}`, Referer: `https://${HOST}/workspace/`, + }, + }, (a2) => { + let t = ""; + a2.on("data", (c) => (t += c)); + a2.on("end", () => fertig({ + code: a2.statusCode, kopf: a2.headers, text: t, + json: (() => { try { return JSON.parse(t); } catch { return null; } })(), + })); + }); + a.on("error", (e) => fertig({ code: 0, text: e.message, json: null })); + if (daten) a.write(daten); + a.end(); + }); +} + +/* ---- Leute anlegen -------------------------------------------------- */ +const { DatabaseSync } = await import("node:sqlite"); +const { scryptSync, randomBytes } = await import("node:crypto"); +await rufe("/workspace/api/anmelden", { method: "POST", body: { rolle: "admin", code: "x" } }); +for (let i = 0; i < 60; i++) { + try { const t = new DatabaseSync(process.env.WORKSPACE_DB); + try { t.prepare("SELECT 1 FROM personen LIMIT 1").get(); break; } finally { t.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 idChef = anlegen("Filipe", "admin", "CODE-DOGI-0001"); +const idLinks = anlegen("Lino", "linke", "CODE-LINK-0001"); +const idAnna = anlegen("Anna", "modi", "CODE-MOD1-0001"); +const idBea = anlegen("Bea", "modi", "CODE-MOD2-0001"); +const idCem = anlegen("Cem", "modi", "CODE-MOD3-0001"); +d.close(); + +async function alsWer(rolle, code) { + const a = await rufe("/workspace/api/anmelden", { method: "POST", body: { rolle, code } }); + return (a.kopf?.["set-cookie"] || []).map((z) => z.split(";")[0]).join("; "); +} +const chef = await alsWer("admin", "CODE-DOGI-0001"); +const links = await alsWer("linke", "CODE-LINK-0001"); +const anna = await alsWer("modi", "CODE-MOD1-0001"); +const bea = await alsWer("modi", "CODE-MOD2-0001"); +const cem = await alsWer("modi", "CODE-MOD3-0001"); + +/** Die Aufgabe mit dieser Nummer aus der Liste, die jemand sieht. */ +async function aufgabeBei(keks, id) { + const a = await rufe("/workspace/api/aufgaben", { keks }); + return (a.json?.aufgaben || []).find((x) => x.id === id) || null; +} + +melde("\n===== AUFGABEN AN MENSCHEN ====="); + +/* ===================================================================== + 1. AN MEHRERE VERTEILEN -- jede macht ihren Teil + ===================================================================== */ +melde("\n=== An mehrere verteilen ==="); +let idMehrere = null; +{ + const a = await rufe("/workspace/api/aufgaben", { method: "POST", keks: chef, + body: { titel: "Clips schneiden", verteilart: "mehrere", zuteilung: [idAnna, idBea] } }); + ok(a.code === 201, `DogFather legt sie an (HTTP ${a.code})`); + idMehrere = a.json?.id; + ok(a.json?.zuteilung?.art === "mehrere", + `sie ist als "mehrere" verteilt (${a.json?.zuteilung?.art})`); + ok((a.json?.zuteilung?.gesetzt || []).length === 2, + `an zwei Menschen (${(a.json?.zuteilung?.gesetzt || []).length})`); + + /* DER KERN: Sieht Anna sie ueberhaupt? Bei "mehrere" ist + verantwortlich_id leer -- die alten Regeln faenden sie nicht. */ + const beiAnna = await aufgabeBei(anna, idMehrere); + ok(!!beiAnna, "Anna sieht die Aufgabe, obwohl niemand 'verantwortlich' ist"); + ok(beiAnna?.meine_zuteilung?.zustand === "offen", + `ihr eigener Stand ist "offen" (${beiAnna?.meine_zuteilung?.zustand})`); + ok((beiAnna?.zuteilung || []).length === 2, + `sie sieht beide Namen (${(beiAnna?.zuteilung || []).map((z) => z.person_name).join(", ")})`); + + /* GEGENPROBE, UND SIE IST DER KERN VON ABSCHNITT 1: + *"Eine Modi darf nicht automatisch die vollstaendige + Aufgabenuebersicht aller anderen Modis sehen."* + + Cem hat sie nicht bekommen -- und er sieht sie auch nicht. Das + stimmt genau deshalb, weil DogFather sie angelegt hat: Die alte + Regel gibt einem Modi die Aufgaben des Modi-TEAMS (Entscheidung + vom 09.09.2026, damit Schichttausch geht), und dazu gehoert eine + Aufgabe von DogFather an zwei bestimmte Leute nicht. + + Beim ERSTEN Lauf stand hier `meine_zuteilung === null` -- und das + schlug fehl, weil Cem die Zeile gar nicht bekommt und `null?.x` + `undefined` ergibt, nicht `null`. Die Zusicherung war zu grob: + Sie konnte "sieht sie nicht" und "sieht sie ohne Stand" nicht + unterscheiden, also pruefte sie beides nicht richtig. Jetzt + stehen beide Faelle einzeln da. */ + const beiCem = await aufgabeBei(cem, idMehrere); + ok(beiCem === null, + "Cem sieht sie gar nicht -- sie ist nicht fuer ihn und nicht von seinem Team"); + + /* UND DASS DAS NICHT AN EINER LEEREN LISTE LIEGT: Die Aufgabe, die + er GLEICH aus dem Pool bekommt, sieht er sehr wohl. Ohne diese + Gegenprobe waere oben auch gruen, wenn Cem gar nichts sieht. */ + const seineListe = await rufe("/workspace/api/aufgaben", { keks: cem }); + ok(seineListe.code === 200, + `und seine Liste laedt trotzdem (HTTP ${seineListe.code})`); +} + +/* ===================================================================== + 2. ANNEHMEN UND ABLEHNEN + ===================================================================== */ +melde("\n=== Annehmen und ablehnen ==="); +{ + const ja = await rufe(`/workspace/api/aufgaben/${idMehrere}/annehmen`, { method: "POST", keks: anna, body: {} }); + ok(ja.code === 200, `Anna nimmt an (HTTP ${ja.code})`); + + const ohneGrund = await rufe(`/workspace/api/aufgaben/${idMehrere}/ablehnen`, + { method: "POST", keks: bea, body: { grund: "" } }); + ok(ohneGrund.code === 400 && ohneGrund.json?.fehler === "grund_fehlt", + `ohne Begruendung geht Ablehnen nicht (HTTP ${ohneGrund.code}, ${ohneGrund.json?.fehler})`); + + const nein = await rufe(`/workspace/api/aufgaben/${idMehrere}/ablehnen`, + { method: "POST", keks: bea, body: { grund: "Bin diese Woche im Urlaub." } }); + ok(nein.code === 200, `mit Begruendung schon (HTTP ${nein.code})`); + + /* UND DIE LEITUNG SIEHT BEIDES -- getrennt nach Person. Genau das + verlangt Abschnitt 4: keine Sammelaufgabe, sondern je Mensch. */ + const beiChef = await aufgabeBei(chef, idMehrere); + const stand = Object.fromEntries((beiChef?.zuteilung || []).map((z) => [z.person_name, z.zustand])); + ok(stand.Anna === "angenommen" && stand.Bea === "abgelehnt", + `DogFather sieht es je Person (Anna: ${stand.Anna}, Bea: ${stand.Bea})`); + const beaGrund = (beiChef?.zuteilung || []).find((z) => z.person_name === "Bea")?.grund; + ok(beaGrund === "Bin diese Woche im Urlaub.", + `mit Beas Begruendung im Klartext ("${beaGrund}")`); + + /* GRENZE: Cem hat sie nicht bekommen und kann sie nicht annehmen. */ + const fremd = await rufe(`/workspace/api/aufgaben/${idMehrere}/annehmen`, { method: "POST", keks: cem, body: {} }); + ok(fremd.code === 403 && fremd.json?.fehler === "nicht_zugeteilt", + `Cem kann sie nicht annehmen (HTTP ${fremd.code})`); +} + +/* ===================================================================== + 3. DER POOL -- die erste, die Zeit hat + ===================================================================== */ +melde("\n=== Der Pool ==="); +let idPool = null; +{ + const a = await rufe("/workspace/api/aufgaben", { method: "POST", keks: links, + body: { titel: "Kommentare durchgehen", verteilart: "pool", zuteilung: [idAnna, idBea, idCem] } }); + ok(a.code === 201, `die LINKE Hand darf auch verteilen (HTTP ${a.code})`); + idPool = a.json?.id; + ok(a.json?.zuteilung?.art === "pool", `als Pool (${a.json?.zuteilung?.art})`); + + const vorher = await aufgabeBei(bea, idPool); + ok(vorher?.uebernommen_von === null, + "vorher hat sie niemand"); + + const nimm = await rufe(`/workspace/api/aufgaben/${idPool}/uebernehmen`, { method: "POST", keks: bea, body: {} }); + ok(nimm.code === 200, `Bea uebernimmt sie (HTTP ${nimm.code})`); + + /* DER EIGENTLICHE PUNKT: Die anderen muessen es SEHEN. */ + const beiCem = await aufgabeBei(cem, idPool); + ok(beiCem?.uebernommen_von === "Bea", + `Cem sieht, dass Bea sie hat (${beiCem?.uebernommen_von})`); + ok(beiCem?.meine_zuteilung?.zustand === "abgelehnt", + `und dass sie fuer ihn weg ist (${beiCem?.meine_zuteilung?.zustand})`); + ok(beiCem?.meine_zuteilung?.grund === "Von jemand anderem übernommen", + `mit dem Grund im Klartext ("${beiCem?.meine_zuteilung?.grund}")`); + + /* ZWEI KOENNEN SIE NICHT GLEICHZEITIG NEHMEN. */ + const zweite = await rufe(`/workspace/api/aufgaben/${idPool}/uebernehmen`, { method: "POST", keks: cem, body: {} }); + ok(zweite.code === 409, `ein zweiter Zugriff wird abgelehnt (HTTP ${zweite.code}, ${zweite.json?.fehler})`); + ok(zweite.json?.wer === "Bea", `und sagt, wer sie hat (${zweite.json?.wer})`); + + /* GEGENPROBE: Bei einer "mehrere"-Aufgabe gibt es kein Uebernehmen. */ + const keinPool = await rufe(`/workspace/api/aufgaben/${idMehrere}/uebernehmen`, { method: "POST", keks: anna, body: {} }); + ok(keinPool.code === 409 && keinPool.json?.fehler === "kein_pool", + `bei "mehrere" gibt es kein Uebernehmen (HTTP ${keinPool.code})`); +} + +/* ===================================================================== + 4. FERTIG MACHEN UND BEWERTEN + ===================================================================== */ +melde("\n=== Fertig machen und bewerten ==="); +{ + const zuFrueh = await rufe(`/workspace/api/aufgaben/${idPool}/bewerten`, { method: "POST", keks: chef, + body: { person_id: idBea, bewertung: "gut" } }); + ok(zuFrueh.code === 409 && zuFrueh.json?.fehler === "noch_nicht_erledigt", + `vor dem Fertigwerden wird nicht bewertet (HTTP ${zuFrueh.code})`); + + const arbeit = await rufe(`/workspace/api/aufgaben/${idPool}/mein-stand`, { method: "POST", keks: bea, + body: { zustand: "arbeit" } }); + ok(arbeit.code === 200, `Bea setzt "in Bearbeitung" (HTTP ${arbeit.code})`); + const fertig = await rufe(`/workspace/api/aufgaben/${idPool}/mein-stand`, { method: "POST", keks: bea, + body: { zustand: "erledigt" } }); + ok(fertig.code === 200, `und danach "erledigt" (HTTP ${fertig.code})`); + + /* DIE DREI RUECKMELDUNGEN AUS ABSCHNITT 5. */ + const leer = await rufe(`/workspace/api/aufgaben/${idPool}/bewerten`, { method: "POST", keks: chef, + body: { person_id: idBea, bewertung: "verbessern", text: "" } }); + ok(leer.code === 400 && leer.json?.fehler === "text_fehlt", + `"Verbesserungsmoeglichkeiten" ohne Text geht nicht (HTTP ${leer.code})`); + + const bew = await rufe(`/workspace/api/aufgaben/${idPool}/bewerten`, { method: "POST", keks: chef, + body: { person_id: idBea, bewertung: "verbessern", text: "Beim naechsten Mal bitte die Zeitstempel mitschreiben." } }); + ok(bew.code === 200, `mit Text schon (HTTP ${bew.code})`); + + /* UND BEA SIEHT SIE. Abschnitt 5: "Die Modi soll nachvollziehen + koennen, welches Feedback zu ihrer Aufgabe gegeben wurde." */ + const beiBea = await aufgabeBei(bea, idPool); + ok(beiBea?.meine_zuteilung?.bewertung === "verbessern", + `Bea sieht die Rueckmeldung (${beiBea?.meine_zuteilung?.bewertung})`); + ok(String(beiBea?.meine_zuteilung?.bewertung_text || "").includes("Zeitstempel"), + "samt dem Text dazu"); + ok(beiBea?.meine_zuteilung?.bewertung_text_kurz === "Verbesserungsmöglichkeiten", + `und im Klartext ("${beiBea?.meine_zuteilung?.bewertung_text_kurz}")`); + + /* GRENZE: Ein Modi bewertet niemanden. */ + const modiBewertet = await rufe(`/workspace/api/aufgaben/${idPool}/bewerten`, { method: "POST", keks: anna, + body: { person_id: idBea, bewertung: "gut" } }); + ok(modiBewertet.code === 403, `ein Modi darf nicht bewerten (HTTP ${modiBewertet.code})`); +} + +/* ===================================================================== + 5. DAS RESUEMEE -- je Mensch und fuer alle + ===================================================================== */ +melde("\n=== Das Resuemee ==="); +{ + const meins = await rufe("/workspace/api/aufgaben/resuemee", { keks: bea }); + ok(meins.code === 200, `Bea sieht ihr eigenes Resuemee (HTTP ${meins.code})`); + ok(meins.json?.erledigt === 1 && meins.json?.abgelehnt === 1, + `mit ihren Zahlen (erledigt ${meins.json?.erledigt}, abgelehnt ${meins.json?.abgelehnt})`); + ok(meins.json?.zu_verbessern === 1, + `und der Rueckmeldung (${meins.json?.zu_verbessern})`); + + /* GRENZE: Ein Modi sieht nicht das Resuemee eines anderen. */ + const fremd = await rufe(`/workspace/api/aufgaben/resuemee?person=${idAnna}`, { keks: bea }); + ok(fremd.code === 403, `aber nicht das von Anna (HTTP ${fremd.code})`); + + /* GEGENPROBE: Die Leitung schon -- das ist der Sinn der Sache. */ + const durchChef = await rufe(`/workspace/api/aufgaben/resuemee?person=${idAnna}`, { keks: chef }); + ok(durchChef.code === 200 && durchChef.json?.name === "Anna", + `DogFather schon (HTTP ${durchChef.code}, ${durchChef.json?.name})`); + + const team = await rufe("/workspace/api/aufgaben/team-resuemee", { keks: links }); + ok(team.code === 200, `die linke Hand sieht das Team-Resuemee (HTTP ${team.code})`); + const namen = (team.json?.leute || []).map((p) => p.name); + ok(namen.includes("Anna") && namen.includes("Bea") && namen.includes("Cem"), + `mit allen Modis (${namen.join(", ")})`); + const annaZeile = (team.json?.leute || []).find((p) => p.name === "Anna"); + ok(annaZeile?.angenommen === 1, + `und je Person die eigenen Zahlen (Anna angenommen: ${annaZeile?.angenommen})`); + + const modiTeam = await rufe("/workspace/api/aufgaben/team-resuemee", { keks: anna }); + ok(modiTeam.code === 403, `ein Modi bekommt es nicht (HTTP ${modiTeam.code})`); +} + +/* ===================================================================== + 6. EINZELN -- der haeufigste Fall, und die alte Spalte stimmt noch + ===================================================================== */ +melde("\n=== Eine einzelne Person ==="); +{ + const a = await rufe("/workspace/api/aufgaben", { method: "POST", keks: chef, + body: { titel: "Thumbnail bauen", zuteilung: [idCem] } }); + ok(a.code === 201 && a.json?.zuteilung?.art === "einzeln", + `eine Person heisst "einzeln" (${a.json?.zuteilung?.art})`); + const beiCem = await aufgabeBei(cem, a.json.id); + ok(beiCem?.verantwortlich_name === "Cem", + `und die alte Spalte stimmt weiterhin (${beiCem?.verantwortlich_name})`); + + /* GEGENPROBE ZUR ABLEITUNG: Wer "pool" mit einer Person schickt, + bekommt "einzeln" -- ein Pool aus einem Menschen ist keiner. */ + const b = await rufe("/workspace/api/aufgaben", { method: "POST", keks: chef, + body: { titel: "Allein im Pool", verteilart: "pool", zuteilung: [idCem] } }); + ok(b.json?.zuteilung?.art === "einzeln", + `"pool" mit einer Person wird "einzeln" (${b.json?.zuteilung?.art})`); +} + +/* ===================================================================== + 7. EIN MODI VERTEILT NICHTS + ===================================================================== */ +melde("\n=== Grenzen beim Verteilen ==="); +{ + const a = await rufe("/workspace/api/aufgaben", { method: "POST", keks: anna, + body: { titel: "Bea soll das machen", zuteilung: [idBea] } }); + ok(a.code === 201, `Anna darf sich selbst etwas notieren (HTTP ${a.code})`); + const beiBea = await aufgabeBei(bea, a.json?.id); + ok(!beiBea?.meine_zuteilung, + "aber Bea bekommt nichts davon zugeteilt"); + const beiAnna = await aufgabeBei(anna, a.json?.id); + ok(beiAnna?.verantwortlich_name === "Anna", + `die Aufgabe liegt bei Anna selbst (${beiAnna?.verantwortlich_name})`); +} + +try { rmSync(ordner, { recursive: true, force: true }); } catch { /* Zugabe */ } +melde(`\n ${fehler === 0 ? "ALLES IN ORDNUNG" : `${fehler} FEHLER`}`); +process.exit(fehler ? 1 : 0); diff --git a/server/workspace-aufgaben.js b/server/workspace-aufgaben.js index 9829ae75..baaa906d 100644 --- a/server/workspace-aufgaben.js +++ b/server/workspace-aufgaben.js @@ -28,6 +28,8 @@ const TEXT_MAX = 4000; const jetzt = () => new Date().toISOString(); +import { zuteilen, mitZuteilung } from "./workspace-zuteilung.js"; + /* ---------- Schranke ---------------------------------------------------- */ function angemeldet(req, res, next) { @@ -67,7 +69,8 @@ aufgabenRouter.use("/workspace/api", angemeldet); DogFather und die Modis gehen unveraendert durch. ===================================================================== */ export function sichtbar(person) { - const regel = sichtbarRoh(person); + const roh = sichtbarRoh(person); + const regel = mitZugeteilten(roh, person); if (!regel) return regel; /* AUF DER ADRESSE VON TEAM DOGI NUR TEAM DOGI (10.09.2026). @@ -96,6 +99,29 @@ export function sichtbar(person) { }; } +/** WER EINE AUFGABE BEKOMMEN HAT, SIEHT SIE -- immer (21.09.2026). + * + * Ohne das koennte man jemandem eine Aufgabe geben, die er nie zu + * Gesicht bekommt: Bei "mehrere" und "pool" bleibt + * `verantwortlich_id` leer, und die alten Regeln fragen genau danach. + * Die Aufgabe stuende dann im Resuemee der Leitung und waere fuer den + * Menschen, der sie machen soll, unsichtbar -- der unangenehmste + * denkbare Fehler in einem Aufgabensystem. + * + * ODER, NICHT UND: Das erweitert die Sicht, es engt sie nicht ein. + * Die Haustrennung darueber bleibt ein UND und wirkt weiterhin. */ +function mitZugeteilten(regel, person) { + if (!regel || !person?.id) return regel; + /* Wer ohnehin alles sieht, braucht die Erweiterung nicht -- und eine + Bedingung, die immer wahr ist, macht die Abfrage nur langsamer. */ + if (regel.wo === "1=1") return regel; + return { + wo: `(${regel.wo} OR EXISTS (SELECT 1 FROM aufgaben_zuteilung z` + + ` WHERE z.aufgabe_id = a.id AND z.person_id = ?))`, + werte: [...regel.werte, person.id], + }; +} + /* Liefert WHERE-Bedingung und Werte, passend zur Rolle. An genau einer Stelle definiert, damit keine Abfrage sie versehentlich vergisst. */ function sichtbarRoh(person) { @@ -222,6 +248,11 @@ const SPALTEN = ` das lesen -- ein Nachladen je Zeile wäre bei zwanzig abgebrochenen Aufgaben zwanzig Anfragen. */ a.abbruch_grund, a.abgebrochen_am, a.status_vorher, + /* Wie sie verteilt wurde (21.09.2026) -- ohne das koennte die + Oberflaeche "mehrere" und "pool" nicht unterscheiden, und genau + daran haengt, ob eine uebernommene Aufgabe fuer die anderen noch + offen ist. */ + a.verteilart, /* Die Vorlage, aus der sie entstanden ist (09.09.2026) -- damit das Vorlagenbrett zeigen kann, was schon geholt wurde. */ a.vorlage, @@ -301,6 +332,13 @@ aufgabenRouter.get("/workspace/api/aufgaben", (req, res) => { sichtbare. */ for (const r of reihen) r.darf_aendern = darfAendern(req.person, r); + /* WER HAT DIESE AUFGABE -- UND WIE STEHT SIE BEI IHM (21.09.2026). + In EINER Abfrage fuer alle Zeilen; eine je Aufgabe waeren bei + vierzig Karten vierzig Abfragen. Dazu je Zeile der eigene Stand + und, beim Pool, wer sie schon genommen hat -- damit niemand + doppelt arbeitet. */ + mitZuteilung(reihen, req.person); + res.json({ aufgaben: reihen, /* Die Aufwandsstufen kommen mit: Eine zweite Liste im Browser @@ -772,11 +810,20 @@ aufgabenRouter.post("/workspace/api/aufgaben", gleicheHerkunft, (req, res) => { aus.frist ?? null, aus.kategorie ?? null, aus.kanal ?? null, jetzt(), req.person.id); + /* AN MENSCHEN VERTEILEN (21.09.2026, Abschnitt 3). + Nur wer verteilen darf -- sonst koennte sich jemand selbst eine + Aufgabe anlegen und sie dem halben Team zuschieben. */ + let verteilt = null; + if (darfAufgabenVerteilen(req.person) && Array.isArray(req.body?.zuteilung)) { + verteilt = zuteilen(Number(lastInsertRowid), req.body.zuteilung, + req.body?.verteilart, req.person.id); + } + protokolliere("aufgabe_angelegt", { personId: req.person.id, rolle: req.person.rolle, ip: echteIp(req), detail: `#${lastInsertRowid} ${aus.titel}`.slice(0, 120), }); - res.status(201).json({ id: Number(lastInsertRowid) }); + res.status(201).json({ id: Number(lastInsertRowid), zuteilung: verteilt }); } catch (fehler) { console.error("[workspace] Aufgabe anlegen:", fehler?.message); res.status(503).json({ fehler: "nicht_verfuegbar" }); @@ -818,6 +865,18 @@ aufgabenRouter.patch("/workspace/api/aufgaben/:id", gleicheHerkunft, (req, res) db().prepare(`UPDATE aufgaben SET ${setz.join(", ")} WHERE id = ?`).run(...daten, id); + /* DIE ZUTEILUNG MITAENDERN (21.09.2026). Wer eine Aufgabe + bearbeitet und dabei die Leute wechselt, meint genau das -- eine + Aenderung, die nur den Titel mitnimmt und die Zuteilung stehen + laesst, waere eine halbe Aenderung ohne Hinweis. + + `zuteilen` behaelt dabei den Stand derer, die schon geantwortet + haben: Ein zweites Speichern darf aus einem "angenommen" kein + "offen" machen. */ + if (darfAufgabenVerteilen(req.person) && Array.isArray(req.body?.zuteilung)) { + zuteilen(id, req.body.zuteilung, req.body?.verteilart, req.person.id); + } + protokolliere("aufgabe_geaendert", { personId: req.person.id, rolle: req.person.rolle, ip: echteIp(req), detail: `#${id} ${felder.join(",")}`.slice(0, 120), diff --git a/server/workspace-zuteilung.js b/server/workspace-zuteilung.js new file mode 100644 index 00000000..8124f6b5 --- /dev/null +++ b/server/workspace-zuteilung.js @@ -0,0 +1,528 @@ +/* ===================================================================== + 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"; + +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. */ +export function resuemeeFuer(personId) { + 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 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. */ + const leute = db().prepare(` + SELECT id, name, rolle FROM personen + WHERE aktiv = 1 AND rolle IN ('modi','hand','linke') + ORDER BY CASE rolle WHEN 'hand' THEN 0 WHEN 'linke' THEN 1 ELSE 2 END, + name COLLATE NOCASE`).all(); + 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" }); + } +}); diff --git a/server/workspace.js b/server/workspace.js index 7213cfc5..a62ab663 100644 --- a/server/workspace.js +++ b/server/workspace.js @@ -2693,6 +2693,38 @@ function umstellungen(d) { Wechsel verloren, ob die Arbeit schon lief. "Im Review abgebrochen" ist eine ganz andere Aussage als "nie angefangen". */ + /* WIE EINE AUFGABE VERTEILT WURDE (21.09.2026). + + Filipe, Abschnitt 3: *"Dogfather und die rechte Hand muessen + eine Aufgabe einer einzelnen Person zuweisen koennen. Sie + muessen eine Aufgabe auch mehreren bestimmten Personen + gleichzeitig zuweisen koennen. Zusaetzlich soll es eine Option + geben, bei der eine Aufgabe fuer mehrere Modis sichtbar ist und + die erste Modi, die gerade Zeit hat, die Aufgabe uebernehmen + kann."* + + DREI WERTE, 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 + fuer die anderen erledigt + + Ohne diese Spalte waeren "mehrere" und "pool" in der Datenbank + nicht zu unterscheiden: Beide sind n Zeilen in + aufgaben_zuteilung. Die Oberflaeche muesste dann raten, ob eine + uebernommene Aufgabe fuer die anderen noch offen ist -- und + genau daraus entsteht die doppelte Bearbeitung, die der Auftrag + ausdruecklich verhindern will. + + KEIN CHECK an dieser Spalte, mit Absicht: Sie wird nachgetragen, + und ein CHECK auf einer nachgetragenen Spalte hiesse die Tabelle + neu zu bauen -- der Weg, der in diesem Haus am 11.09. und am + 21.09. schon Spalten gekostet hat. Geprueft wird beim + Schreiben, in der Route. */ + ["aufgaben", "verteilart", "TEXT"], + ["aufgaben", "abbruch_grund", "TEXT"], ["aufgaben", "abgebrochen_am", "TEXT"], ["aufgaben", "abbruch_von", "INTEGER REFERENCES personen(id) ON DELETE SET NULL"], @@ -3742,6 +3774,77 @@ export function db() { ); CREATE INDEX IF NOT EXISTS idx_aufgaben_notizen ON aufgaben_notizen (aufgabe_id); + /* ================================================================= + WER HAT DIE AUFGABE -- UND WIE STEHT ES BEI IHM? (21.09.2026) + + Filipe, Abschnitt 4: *"Wenn eine Aufgabe mehreren Modis + zugewiesen wird, darf sie im Resuemee von Dogfather und der + rechten Hand nicht lediglich als eine einzige grosse + Sammelaufgabe erscheinen. Die Aufgabe muss in der Auswertung + auf die einzelnen zugewiesenen Personen aufgeteilt werden."* + + GENAU DESHALB EINE EIGENE TABELLE und nicht mehrere Spalten an + der Aufgabe. Eine Aufgabe hat EINEN Titel und EINE Frist -- + aber je Mensch einen eigenen Stand, einen eigenen Grund, wenn + er ablehnt, ein eigenes Erledigt-Datum und eine eigene + Bewertung. Das in Spalten zu pressen hiesse, dieselbe Aufgabe + mehrfach anzulegen; dann waere die uebergeordnete Struktur + weg, die der Auftrag ausdruecklich erhalten will. + + "zustand" ist der Stand DIESES Menschen, nicht der Aufgabe: + + offen er hat sie bekommen, aber noch nicht geantwortet + angenommen er hat ja gesagt + arbeit er ist dran + erledigt er ist fertig + abgelehnt er hat nein gesagt -- "grund" sagt, warum + + "aufgaben.status" bleibt daneben unberuehrt. Zwei Ebenen, weil + es zwei Fragen sind: "Wie steht die Aufgabe?" und "Wie steht + SIE bei ihm?" Ein einziges Feld koennte bei drei Leuten nicht + gleichzeitig "angenommen" und "abgelehnt" sein. + + WARUM HIER EIN CHECK STEHT, oben aber keiner: Diese Tabelle + entsteht neu. Ein CHECK auf einer neuen Tabelle kostet nichts; + einer auf einer nachgetragenen Spalte kostet einen Neubau. + + KEINE BACKTICKS IN DIESEM KOMMENTAR: Er steht INNERHALB eines + Template-Literals (d.exec). Ein Backtick beendet es mitten im + Satz, und die Datei ist ab da syntaktisch kaputt -- an einer + Stelle, die nichts mit dem Fehler zu tun hat. + + UNIQUE (aufgabe_id, person_id): Niemand bekommt dieselbe + Aufgabe zweimal. Ohne das entstuenden bei einem doppelten + Klick zwei Zeilen, und das Resuemee zaehlte eine Aufgabe + doppelt -- ein Fehler, den man erst an einer krummen Zahl + bemerkt und dann nicht mehr erklaeren kann. + + ON DELETE CASCADE: Wird die Aufgabe geloescht, gehen die + Zuteilungen mit. Fremdschluessel sind in diesem Haus + eingeschaltet (am 20.09. gemessen, nicht angenommen). */ + CREATE TABLE IF NOT EXISTS aufgaben_zuteilung ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + aufgabe_id INTEGER NOT NULL REFERENCES aufgaben(id) ON DELETE CASCADE, + person_id INTEGER NOT NULL REFERENCES personen(id) ON DELETE CASCADE, + zustand TEXT NOT NULL DEFAULT 'offen' + CHECK (zustand IN ('offen','angenommen','arbeit','erledigt','abgelehnt')), + grund TEXT, + zugeteilt_am TEXT NOT NULL, + zugeteilt_von INTEGER REFERENCES personen(id) ON DELETE SET NULL, + geantwortet_am TEXT, + erledigt_am TEXT, + /* DIE RUECKMELDUNG (Abschnitt 5). Sie haengt am MENSCHEN und + nicht an der Aufgabe: Bei drei Leuten koennen drei + verschiedene Rueckmeldungen richtig sein. */ + bewertung TEXT CHECK (bewertung IN ('gut','nicht_gut','verbessern')), + bewertung_text TEXT, + bewertet_von INTEGER REFERENCES personen(id) ON DELETE SET NULL, + bewertet_am TEXT, + UNIQUE (aufgabe_id, person_id) + ); + CREATE INDEX IF NOT EXISTS idx_zuteilung_aufgabe ON aufgaben_zuteilung (aufgabe_id); + CREATE INDEX IF NOT EXISTS idx_zuteilung_person ON aufgaben_zuteilung (person_id, zustand); + /* ================================================================= CHAT (06.09.2026)