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)