diff --git a/server/pruef-bewerbung-aufgaben.mjs b/server/pruef-bewerbung-aufgaben.mjs new file mode 100644 index 00000000..82cfd4bc --- /dev/null +++ b/server/pruef-bewerbung-aufgaben.mjs @@ -0,0 +1,566 @@ +/* ===================================================================== + pruef-bewerbung-aufgaben.mjs — sich bewerben statt selbst nehmen + ===================================================================== + + Filipe, 22.09.2026: „die modis und linke hand sollen da nichts + uebernehmen koennen von aufgaben, ueberhaupt ueberall sollen die + keine aufgaben selber uebernehmen die ihnen nicht zugetragen sind. + ich will dass die sich fuer aufgaben bewerben koennen aber die rechte + hand oder dogfather muessen annehmen oder ablehnen koennen und das + mit einem kommentar als moeglichkeit sogar noch zum hinzufuegen." + + Dazu sein Satz vom selben Tag, der NICHT widerspricht, sondern eine + andere Frage beantwortet: „rechte hand linke hand und dogfather sehen + alle aufgaben und koennen alle verteilen." + + ZWEI FRAGEN, DIE MAN AUSEINANDERHALTEN MUSS: + + verteilen eine Aufgabe an ANDERE geben + entscheiden bestimmen, wer sie am Ende macht + + Die linke Hand darf das erste und nicht das zweite. Sie bewirbt sich + wie ein Modi. + + WAS HIER GEMESSEN WIRD — und warum gerade das: + + (1) DASS DAS VERBOT KEINEN UMWEG HAT. Drei Wege fuehren zu „ich habe + jetzt diese Aufgabe": aus dem Pool uebernehmen, sich beim + Verteilen selbst eintragen, und (neu) eine Bewerbung annehmen. + Alle drei werden geprueft. Ein Verbot mit einem offenen Umweg + ist keins. + + (2) DASS DER ERLAUBTE WEG WIRKLICH OFFEN IST. Ein Test, der nur + Verbote misst, ist auch dann gruen, wenn gar nichts mehr geht. + + (3) DASS DIE WORTE DER PERSON UND DER KOMMENTAR DER LEITUNG IN ZWEI + FELDERN LANDEN. In dasselbe Feld waere bequemer und wuerde die + Frage mit der Antwort ueberschreiben. + + AUFRUF: node server/pruef-bewerbung-aufgaben.mjs + ===================================================================== */ + +import { mkdtempSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { request as httpAnfrage } from "node:http"; +import { eigenerPort } from "./helfer-port.mjs"; +import { notbremse } from "./helfer-notbremse.mjs"; + +const PORT = await eigenerPort(import.meta, "pruef-bewerbung-aufgaben"); +const ordner = mkdtempSync(join(tmpdir(), "ws-bew-")); +process.env.WORKSPACE_DB = join(ordner, "workspace.db"); +process.env.PORT = String(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(240_000, "pruef-bewerbung-aufgaben"); +await new Promise((r) => setTimeout(r, 900)); + +process.on("uncaughtException", (f) => { console.error("ABSTURZ:", f); process.exit(7); }); +process.on("unhandledRejection", (f) => { console.error("ABSTURZ (Versprechen):", f); process.exit(7); }); + +let fehler = 0, geprueft = 0; +const melde = (t) => console.log(t); +const ok = (b, t) => { geprueft++; console.log((b ? " ok " : " FEHL ") + t); if (!b) fehler++; }; + +const CREW = "crew.dogfather-universe.com"; + +/* ===================================================================== + 1. WER ENTSCHEIDET — die Regel selbst + ===================================================================== */ +melde("\n=== 1. Wer entscheidet, wer eine Aufgabe macht ==="); +const w = await import("./workspace.js"); +{ + const soll = { admin: true, hand: true, linke: false, modi: false, gast: false }; + for (const [rolle, erwartet] of Object.entries(soll)) { + const ist = w.entscheidetUeberAufgaben({ rolle, haus: "crew", id: 1 }); + ok(ist === erwartet, + `${rolle}: entscheidet = ${ist} (erwartet ${erwartet})`); + } + /* DIE ZWEITE FRAGE BLEIBT, WIE SIE WAR. Sein Satz „rechte hand linke + hand und dogfather koennen alle verteilen" darf durch diese + Aenderung nicht mit weggehen. */ + for (const [rolle, erwartet] of Object.entries( + { admin: true, hand: true, linke: true, modi: false, gast: false })) { + const ist = w.darfAufgabenVerteilen({ rolle, haus: "crew", id: 1 }); + ok(ist === erwartet, + `${rolle}: darf verteilen = ${ist} (erwartet ${erwartet}) — unveraendert`); + } + /* GEGENPROBE: Die zwei Fragen sind wirklich verschieden. Waeren sie + dasselbe, haette die ganze Unterscheidung keinen Sinn. */ + const p = { rolle: "linke", haus: "crew", id: 1 }; + ok(w.darfAufgabenVerteilen(p) && !w.entscheidetUeberAufgaben(p), + "die linke Hand verteilt, entscheidet aber nicht — genau der Unterschied"); +} + +/* ===================================================================== + 2. AM LAUFENDEN SERVER + ===================================================================== */ +const { DatabaseSync } = await import("node:sqlite"); +const { scryptSync, randomBytes, createHmac } = await import("node:crypto"); +const d = new DatabaseSync(process.env.WORKSPACE_DB); +const jetzt = new Date().toISOString(); +const schluessel = randomBytes(32).toString("hex"); +d.prepare("INSERT INTO einstellungen (schluessel, wert, geaendert, von)" + + " VALUES (?,?,?,NULL) ON CONFLICT(schluessel) DO NOTHING") + .run("code_kennung_schluessel", schluessel, jetzt); +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, code_kennung, aktiv, erstellt)" + + " VALUES (?,?,?,?,?,?,1,?)") + .run(name, rolle, hash, salt, 32768, createHmac("sha256", schluessel).update(code).digest("hex"), jetzt); + return d.prepare("SELECT last_insert_rowid() AS id").get().id; +} +const idChef = anlegen("Filipe", "admin", "CODE-DOGI-0001"); +const idHand = anlegen("Rieke", "hand", "CODE-HAND-0001"); +const idLinke = anlegen("Lino", "linke", "CODE-LINK-0001"); +const idModi = anlegen("Frida", "modi", "CODE-MODI-0001"); +const idModi2 = anlegen("Nele", "modi", "CODE-MODI-0002"); +d.close(); + +function roh(pfad, wie = {}) { + return new Promise((fertig, schief) => { + const daten = wie.body === undefined ? null : Buffer.from(JSON.stringify(wie.body)); + const a = httpAnfrage({ + host: "127.0.0.1", port: PORT, path: pfad, + method: wie.methode || (daten ? "POST" : "GET"), + headers: { + Host: CREW, + ...(daten ? { "Content-Type": "application/json", "Content-Length": daten.length } : {}), + ...(wie.keks ? { Cookie: wie.keks } : {}), + Origin: `https://${CREW}`, Referer: `https://${CREW}/workspace/`, + }, + }, (antwort) => { + const st = []; + antwort.on("data", (x) => st.push(x)); + antwort.on("end", () => { + const text = Buffer.concat(st).toString("utf8"); + fertig({ code: antwort.statusCode, text, + json: (() => { try { return JSON.parse(text); } catch { return null; } })() }); + }); + }); + a.on("error", schief); + if (daten) a.write(daten); + a.end(); + }); +} +async function anmelden(rolle, code) { + const r = await roh("/workspace/api/anmelden", { body: { rolle, code } }); + return { keks: [].concat(r.kopfSet || []).join("; "), code: r.code, roh: r }; +} +/* Die Kekse kommen aus dem Antwortkopf -- dafuer braucht es ihn. */ +function anmeldenMitKeks(rolle, code) { + return new Promise((fertig, schief) => { + const daten = Buffer.from(JSON.stringify({ rolle, code })); + const a = httpAnfrage({ + host: "127.0.0.1", port: PORT, path: "/workspace/api/anmelden", method: "POST", + headers: { Host: CREW, "Content-Type": "application/json", "Content-Length": daten.length, + Origin: `https://${CREW}`, Referer: `https://${CREW}/workspace/` }, + }, (antwort) => { + const st = []; + antwort.on("data", (x) => st.push(x)); + antwort.on("end", () => { + const kekse = [].concat(antwort.headers["set-cookie"] || []); + fertig({ code: antwort.statusCode, keks: kekse.map((c) => c.split(";")[0]).join("; ") }); + }); + }); + a.on("error", schief); + a.write(daten); a.end(); + }); +} + +const chef = await anmeldenMitKeks("admin", "CODE-DOGI-0001"); +const hand = await anmeldenMitKeks("hand", "CODE-HAND-0001"); +const linke = await anmeldenMitKeks("linke", "CODE-LINK-0001"); +const modi = await anmeldenMitKeks("modi", "CODE-MODI-0001"); +const modi2 = await anmeldenMitKeks("modi", "CODE-MODI-0002"); + +melde(""); +melde("=== 2. Alle fuenf kommen herein ==="); +for (const [wer, s] of [["DogFather", chef], ["rechte Hand", hand], + ["linke Hand", linke], ["Frida (Modi)", modi], ["Nele (Modi)", modi2]]) { + ok(s.code === 200 && s.keks.length > 0, `${wer} ist angemeldet (${s.code})`); +} + +const aufgabeAnlegen = (keks, titel, zuteilung, verteilart) => + roh("/workspace/api/aufgaben", { + keks, body: { titel, ...(zuteilung ? { zuteilung, verteilart } : {}) }, + }); +const liste = (keks) => roh("/workspace/api/aufgaben", { keks }); +const finde = async (keks, titel) => { + const r = await liste(keks); + const alle = Array.isArray(r.json) ? r.json : (r.json?.aufgaben || []); + return alle.find((a) => a.titel === titel) || null; +}; + +/* ===================================================================== + 3. DER VERBOTENE WEG: selbst nehmen + ===================================================================== */ +melde(""); +melde("=== 3. Aus dem Pool nimmt sich niemand mehr selbst etwas ==="); +let poolId = null; +{ + const r = await aufgabeAnlegen(chef.keks, "Pool fuer alle", + [idModi, idModi2, idLinke], "pool"); + poolId = r.json?.id; + ok(r.code === 201 && Number.isInteger(poolId), + `DogFather legt eine Pool-Aufgabe fuer drei an (${r.code})`); + + for (const [wer, s] of [["Frida (Modi)", modi], ["die linke Hand", linke]]) { + const u = await roh(`/workspace/api/aufgaben/${poolId}/uebernehmen`, { methode: "POST", keks: s.keks }); + ok(u.code === 403 && u.json?.fehler === "erst_bewerben", + `${wer} kann sie nicht uebernehmen (${u.code}/${u.json?.fehler})`); + /* UND AUCH NICHT DURCH DIE ZWEITE TUER. Beim Pool heisst + „annehmen" dasselbe wie „uebernehmen": Wer zusagt, nimmt sie + den anderen weg. Diese Zeile fehlte im ersten Entwurf, und + genau daran haette das Verbot vorbeigegriffen. */ + const an = await roh(`/workspace/api/aufgaben/${poolId}/annehmen`, { methode: "POST", keks: s.keks }); + ok(an.code === 403 && an.json?.fehler === "erst_bewerben", + `${wer} kann sie auch nicht einfach annehmen (${an.code}/${an.json?.fehler})`); + } +} + +melde(""); +melde("=== 3b. Und niemand traegt sich beim Verteilen selbst ein ==="); +{ + const r = await aufgabeAnlegen(linke.keks, "Die linke Hand fuer sich selbst", [idLinke], "einzeln"); + const zu = r.json?.zuteilung; + ok(r.code === 201 && zu?.fehler === "nicht_an_dich_selbst", + `die linke Hand kann sich nicht selbst eintragen (${zu?.fehler})`); + + /* GEGENPROBE: An JEMAND ANDEREN darf sie weiterhin verteilen. Sein + Satz „rechte hand linke hand und dogfather koennen alle verteilen" + bleibt damit gueltig. */ + const r2 = await aufgabeAnlegen(linke.keks, "Die linke Hand verteilt an Frida", [idModi], "einzeln"); + ok(r2.code === 201 && Array.isArray(r2.json?.zuteilung?.gesetzt) + && r2.json.zuteilung.gesetzt.includes(idModi), + `aber an jemand anderen schon (${JSON.stringify(r2.json?.zuteilung?.gesetzt)})`); + + /* Und DogFather darf sich selbst eintragen -- er entscheidet ja. */ + const r3 = await aufgabeAnlegen(chef.keks, "DogFather fuer sich selbst", [idChef], "einzeln"); + ok(r3.code === 201 && r3.json?.zuteilung?.gesetzt?.includes(idChef), + "DogFather darf sich selbst eintragen — er entscheidet"); +} + +/* ===================================================================== + 4. DER ERLAUBTE WEG: sich bewerben + ===================================================================== */ +melde(""); +melde("=== 4. Bewerben ==="); +{ + const b = await roh(`/workspace/api/aufgaben/${poolId}/bewerben`, { + methode: "POST", keks: modi.keks, body: { text: "Ich habe Freitag Zeit." }, + }); + ok(b.code === 200 && b.json?.zustand === "beworben", + `Frida bewirbt sich (${b.code}/${b.json?.zustand})`); + + const nochmal = await roh(`/workspace/api/aufgaben/${poolId}/bewerben`, { + methode: "POST", keks: modi.keks, body: {} }); + ok(nochmal.code === 409 && nochmal.json?.fehler === "schon_beworben", + `ein zweites Mal geht nicht (${nochmal.code}/${nochmal.json?.fehler})`); + + const bLinke = await roh(`/workspace/api/aufgaben/${poolId}/bewerben`, { + methode: "POST", keks: linke.keks, body: { text: "Kann ich uebernehmen." } }); + ok(bLinke.code === 200, `die linke Hand bewirbt sich auch (${bLinke.code})`); + + /* WER ENTSCHEIDET, BEWIRBT SICH NICHT -- er teilt sich zu. Eine + Bewerbung an sich selbst waere ein Umweg mit demselben Ergebnis. */ + const bChef = await roh(`/workspace/api/aufgaben/${poolId}/bewerben`, { + methode: "POST", keks: chef.keks, body: {} }); + ok(bChef.code === 409 && bChef.json?.fehler === "du_entscheidest_selbst", + `DogFather bewirbt sich nicht (${bChef.code}/${bChef.json?.fehler})`); + + const a = await finde(chef.keks, "Pool fuer alle"); + ok((a?.bewerbungen || []).length === 2, + `die Aufgabe zeigt ${a?.bewerbungen?.length} Bewerbungen`); + ok((a?.bewerbungen || []).some((z) => z.grund === "Ich habe Freitag Zeit."), + "und Fridas Worte stehen dabei"); +} + +/* ===================================================================== + 5. ENTSCHEIDEN — nur die rechte Hand und DogFather + ===================================================================== */ +melde(""); +melde("=== 5. Entscheiden ==="); +{ + /* Die linke Hand darf nicht -- sie hat sich ja selbst beworben. */ + const versuch = await roh(`/workspace/api/aufgaben/${poolId}/bewerbung/annehmen`, { + methode: "POST", keks: linke.keks, body: { person_id: idModi } }); + ok(versuch.code === 403, + `die linke Hand entscheidet nicht (${versuch.code}/${versuch.json?.fehler})`); + + const modiVersuch = await roh(`/workspace/api/aufgaben/${poolId}/bewerbung/annehmen`, { + methode: "POST", keks: modi.keks, body: { person_id: idModi } }); + ok(modiVersuch.code === 403, + `ein Modi auch nicht (${modiVersuch.code})`); + + /* Die rechte Hand nimmt Frida an, mit Kommentar. */ + const ja = await roh(`/workspace/api/aufgaben/${poolId}/bewerbung/annehmen`, { + methode: "POST", keks: hand.keks, + body: { person_id: idModi, kommentar: "Danke dir, mach das gern." } }); + ok(ja.code === 200 && ja.json?.zustand === "angenommen", + `die rechte Hand nimmt Frida an (${ja.code}/${ja.json?.zustand})`); + + const a = await finde(chef.keks, "Pool fuer alle"); + const fridas = (a?.zuteilung || []).find((z) => z.person_id === idModi); + ok(fridas?.zustand === "angenommen", `Fridas Stand ist "${fridas?.zustand}"`); + /* ZWEI FELDER, NICHT EINS. Der Kommentar der Leitung darf die Worte + der Person nicht ueberschreiben -- sonst weiss hinterher niemand + mehr, worum jemand gebeten hat. */ + ok(fridas?.grund === "Ich habe Freitag Zeit.", + `ihre eigenen Worte stehen noch da ("${fridas?.grund}")`); + ok(fridas?.entscheid_text === "Danke dir, mach das gern.", + `und der Kommentar daneben ("${fridas?.entscheid_text}")`); + ok(fridas?.entschieden_von_name === "Rieke", + `mit Namen, wer entschieden hat (${fridas?.entschieden_von_name})`); + + /* Die Bewerbung der linken Hand ist damit erledigt -- Pool. */ + const linkes = (a?.zuteilung || []).find((z) => z.person_id === idLinke); + ok(linkes?.zustand === "abgelehnt", + `die andere Bewerbung faellt weg, weil Pool (${linkes?.zustand})`); + /* UND IHRE WORTE BLEIBEN STEHEN. Der Hinweis „von jemand anderem + uebernommen" gehoert in das Feld der Leitung, nicht in ihres -- + sonst loescht die Antwort die Frage. */ + ok(linkes?.grund === "Kann ich uebernehmen.", + `ihre eigenen Worte bleiben ("${linkes?.grund}")`); + ok(/jemand anderem/i.test(linkes?.entscheid_text || ""), + `und der Grund steht daneben ("${linkes?.entscheid_text}")`); +} + +melde(""); +melde("=== 5b. Ablehnen, auch mit Kommentar ==="); +{ + const r = await aufgabeAnlegen(chef.keks, "Etwas fuer Nele", [idModi2], "einzeln"); + const id = r.json?.id; + /* Nele ist zugeteilt -- sie bewirbt sich nicht, sie hat sie schon. */ + const b = await roh(`/workspace/api/aufgaben/${id}/bewerben`, { + methode: "POST", keks: modi2.keks, body: {} }); + ok(b.code === 409 && b.json?.fehler === "schon_zugeteilt", + `wer sie schon hat, bewirbt sich nicht (${b.code}/${b.json?.fehler})`); + + /* Frida ist nicht zugeteilt und kann sich bewerben. */ + const b2 = await roh(`/workspace/api/aufgaben/${id}/bewerben`, { + methode: "POST", keks: modi.keks, body: { text: "Ich kenne mich da aus." } }); + ok(b2.code === 200, `Frida bewirbt sich darauf (${b2.code})`); + + const nein = await roh(`/workspace/api/aufgaben/${id}/bewerbung/ablehnen`, { + methode: "POST", keks: chef.keks, + body: { person_id: idModi, kommentar: "Nele macht das schon." } }); + ok(nein.code === 200 && nein.json?.zustand === "abgelehnt", + `DogFather lehnt ab (${nein.code}/${nein.json?.zustand})`); + + const a = await finde(chef.keks, "Etwas fuer Nele"); + const fridas = (a?.zuteilung || []).find((z) => z.person_id === idModi); + ok(fridas?.entscheid_text === "Nele macht das schon.", + `mit Kommentar ("${fridas?.entscheid_text}")`); + + /* OHNE KOMMENTAR GEHT AUCH -- „als moeglichkeit", nicht als Pflicht. */ + const b3 = await roh(`/workspace/api/aufgaben/${id}/bewerben`, { + methode: "POST", keks: modi.keks, body: {} }); + ok(b3.code === 200, "Frida bewirbt sich erneut, diesmal ohne Text"); + const nein2 = await roh(`/workspace/api/aufgaben/${id}/bewerbung/ablehnen`, { + methode: "POST", keks: chef.keks, body: { person_id: idModi } }); + ok(nein2.code === 200, "und wird ohne Kommentar abgelehnt — der ist freiwillig"); +} + +melde(""); +melde("=== 5c. Eine Bewerbung laesst sich zurueckziehen ==="); +{ + const r = await aufgabeAnlegen(chef.keks, "Noch etwas", null, null); + const id = r.json?.id; + const b = await roh(`/workspace/api/aufgaben/${id}/bewerben`, { + methode: "POST", keks: modi.keks, body: { text: "Mache ich." } }); + ok(b.code === 200, `Frida bewirbt sich (${b.code})`); + + const zurueck = await roh(`/workspace/api/aufgaben/${id}/bewerbung-zurueck`, { + methode: "POST", keks: modi.keks }); + ok(zurueck.code === 200, `und zieht sie zurueck (${zurueck.code})`); + + const a = await finde(chef.keks, "Noch etwas"); + ok((a?.bewerbungen || []).length === 0, + `danach steht keine mehr da (${a?.bewerbungen?.length})`); + /* GANZ WEG, nicht „zurueckgezogen": keine Zeile, die nur noch sagt, + dass hier mal eine war. */ + ok((a?.zuteilung || []).every((z) => z.person_id !== idModi), + "und auch keine Zeile mehr, die davon uebrig ist"); + + const nochmal = await roh(`/workspace/api/aufgaben/${id}/bewerbung-zurueck`, { + methode: "POST", keks: modi.keks }); + ok(nochmal.code === 409, `ein zweites Zurueckziehen geht nicht (${nochmal.code})`); +} + +/* ===================================================================== + 6. AM ECHTEN BILDSCHIRM + ===================================================================== + Die Abschnitte davor messen die Wege. Keiner von ihnen haette + gemerkt, wenn der Knopf „Ich bewerbe mich" gar nicht erscheint oder + wenn ein Modi weiterhin „Ich uebernehme das" sieht. + + Genau diese Luecke ist heute frueh beim Aufgaben-Formular + aufgefallen: Die Pruefung war gruen und hat den Zustand, in dem der + Fehler steckt, nie hergestellt. + + WARUM EIN HTTPS-VORBAU: Team Dogi wohnt auf + crew.dogfather-universe.com, und welche Wand gilt, entscheidet der + Host-Kopf. Chromium setzt den selbst und hebt diese Domain + ausserdem von sich aus auf https. + ===================================================================== */ +melde(""); +melde("=== 6. Am echten Bildschirm ==="); +{ + const { execFileSync } = await import("node:child_process"); + const { createServer: httpsServer } = await import("node:https"); + const { readFileSync } = await import("node:fs"); + + const schluessel = join(ordner, "pruef.key"); + const zertifikat = join(ordner, "pruef.crt"); + execFileSync("openssl", ["req", "-x509", "-newkey", "rsa:2048", "-nodes", + "-keyout", schluessel, "-out", zertifikat, "-days", "2", + "-subj", `/CN=${CREW}`, "-addext", `subjectAltName=DNS:${CREW}`], + { stdio: "ignore" }); + + const HTTPS_PORT = PORT + 1; + const vorbau = httpsServer({ + key: readFileSync(schluessel), cert: readFileSync(zertifikat), + }, (von, nach) => { + const weiter = httpAnfrage({ + host: "127.0.0.1", port: PORT, path: von.url, method: von.method, + headers: { ...von.headers, host: `${CREW}:${HTTPS_PORT}` }, + }, (antwort) => { + nach.writeHead(antwort.statusCode || 502, antwort.headers); + antwort.pipe(nach); + }); + weiter.on("error", () => { try { nach.writeHead(502); nach.end(); } catch { /* egal */ } }); + von.pipe(weiter); + }); + await new Promise((r) => vorbau.listen(HTTPS_PORT, "127.0.0.1", r)); + const SEITE = `https://${CREW}:${HTTPS_PORT}`; + + const pw = await import("file:///C:/Users/qciga/Documents/Obelix/Analyse/node_modules/playwright/index.mjs"); + const browser = await pw.chromium.launch({ + args: [`--host-resolver-rules=MAP ${CREW} 127.0.0.1`, "--ignore-certificate-errors"], + }); + + /* Eine frische Aufgabe fuer den Bildschirm -- die aus den + Abschnitten davor sind schon entschieden. */ + const frisch = await aufgabeAnlegen(chef.keks, "Fuer den Bildschirm", + [idModi, idLinke], "pool"); + const frischId = frisch.json?.id; + ok(Number.isInteger(frischId), `eine frische Pool-Aufgabe liegt (${frisch.code})`); + + const meckern = []; + async function seiteAls(rolle, code, breite = 1280) { + const ctx = await browser.newContext({ viewport: { width: breite, height: 1000 } }); + const s2 = await ctx.newPage(); + s2.on("pageerror", (e) => meckern.push(`${rolle}: ${String(e).slice(0, 110)}`)); + s2.on("console", (m) => { + if (m.type() === "error" && !/favicon|ERR_CERT|net::ERR/i.test(m.text())) { + meckern.push(`${rolle}: ${m.text().slice(0, 110)}`); + } + }); + await s2.goto(`${SEITE}/workspace/`, { waitUntil: "networkidle" }); + await s2.click(`.rolle[data-rolle="${rolle}"]`); + await s2.fill("#code", code); + await s2.click("button[type=submit]"); + await s2.waitForTimeout(900); + await s2.goto(`${SEITE}/workspace/aufgaben.html`, { waitUntil: "networkidle" }); + await s2.waitForTimeout(1600); + return { ctx, s: s2 }; + } + const knoepfeAn = (s2, titel) => s2.evaluate((t) => { + const karten = [...document.querySelectorAll(".karte, .aufgabe, li, article")]; + const k = karten.find((x) => (x.textContent || "").includes(t) + && x.querySelector(".z-karte")); + if (!k) return null; + return { + knoepfe: [...k.querySelectorAll(".z-karte button")].map((b) => b.textContent.trim()), + bewerbungen: k.querySelectorAll(".z-bewerbung").length, + text: (k.querySelector(".z-karte")?.textContent || "").replace(/\s+/g, " ").trim().slice(0, 200), + }; + }, titel); + + try { + /* ---- Ein Modi ------------------------------------------------- */ + const m = await seiteAls("modi", "CODE-MODI-0001", 390); + try { + const b = await knoepfeAn(m.s, "Fuer den Bildschirm"); + ok(!!b, "der Modi sieht die Aufgabe mit ihrem Zuteilungsblock"); + ok(!!b && b.knoepfe.some((t) => /bewerbe mich/i.test(t)), + `und den Knopf „Ich bewerbe mich" (${b?.knoepfe.join(" | ")})`); + ok(!!b && !b.knoepfe.some((t) => /übernehme/i.test(t)), + "und NICHT mehr „Ich übernehme das“"); + ok(!!b && !b.knoepfe.some((t) => /^Ablehnen$/i.test(t)), + "und kein „Ablehnen“ — eine Absage auf eine Frage, die niemand gestellt hat"); + } finally { await m.ctx.close(); } + + /* ---- Die linke Hand: darf verteilen, aber nicht entscheiden --- */ + const l = await seiteAls("linke", "CODE-LINK-0001", 1280); + try { + const b = await knoepfeAn(l.s, "Fuer den Bildschirm"); + ok(!!b && b.knoepfe.some((t) => /bewerbe mich/i.test(t)), + `die linke Hand sieht denselben Knopf (${b?.knoepfe.join(" | ")})`); + ok(!!b && !b.knoepfe.some((t) => /übernehme/i.test(t)), + "und auch sie nicht „Ich übernehme das“"); + } finally { await l.ctx.close(); } + + /* ---- Jetzt bewirbt sich der Modi wirklich --------------------- */ + const bew = await roh(`/workspace/api/aufgaben/${frischId}/bewerben`, { + methode: "POST", keks: modi.keks, body: { text: "Kann ich machen." } }); + ok(bew.code === 200, `Frida bewirbt sich (${bew.code})`); + + /* ---- Die rechte Hand sieht die Bewerbung ---------------------- */ + const h = await seiteAls("hand", "CODE-HAND-0001", 1280); + try { + const b = await knoepfeAn(h.s, "Fuer den Bildschirm"); + ok(!!b && b.bewerbungen === 1, + `die rechte Hand sieht 1 Bewerbung (${b?.bewerbungen})`); + ok(!!b && b.knoepfe.some((t) => /^Annehmen$/i.test(t)) + && b.knoepfe.some((t) => /^Ablehnen$/i.test(t)), + `mit Annehmen und Ablehnen (${b?.knoepfe.join(" | ")})`); + ok(!!b && /Kann ich machen/.test(b.text), + "und Fridas Worte stehen dabei"); + } finally { await h.ctx.close(); } + + /* ---- Der Modi sieht seinen eigenen Stand ---------------------- */ + const m2 = await seiteAls("modi", "CODE-MODI-0001", 390); + try { + const b = await knoepfeAn(m2.s, "Fuer den Bildschirm"); + ok(!!b && /beworben/i.test(b.text), + `Frida sieht, dass sie sich beworben hat ("${(b?.text || "").slice(0, 70)}…")`); + ok(!!b && b.knoepfe.some((t) => /zurückziehen/i.test(t)), + `und kann sie zurueckziehen (${b?.knoepfe.join(" | ")})`); + /* GEGENPROBE: Sie sieht KEINE Entscheidungsknoepfe an ihrer + eigenen Bewerbung -- sonst haette sie sich selbst annehmen + koennen, und das ganze Verbot waere ein Knopf weiter offen. */ + ok(!!b && b.bewerbungen === 0, + `und keinen Block zum Entscheiden (${b?.bewerbungen})`); + } finally { await m2.ctx.close(); } + + ok(meckern.length === 0, meckern.length + ? `der Browser meldet: ${meckern.slice(0, 3).join(" | ")}` + : "kein Absturz und keine Fehlermeldung im Browser"); + } finally { + await browser.close(); + await new Promise((r) => vorbau.close(r)); + } +} + +/* ===================================================================== + ERGEBNIS + ===================================================================== */ +melde(""); +if (geprueft < 55) { + console.log(` FEHL nur ${geprueft} Pruefungen gelaufen -- erwartet waren 55+`); + fehler++; +} +melde(fehler === 0 + ? `Alles in Ordnung. ${geprueft} Pruefungen, 0 Fehler.` + : `${fehler} FEHLER bei ${geprueft} Pruefungen.`); +melde(""); +try { rmSync(ordner, { recursive: true, force: true }); } catch { /* egal */ } +process.exit(fehler ? 1 : 0); diff --git a/server/pruef-zuteilung.mjs b/server/pruef-zuteilung.mjs index 56f5f37b..69cb11f7 100644 --- a/server/pruef-zuteilung.mjs +++ b/server/pruef-zuteilung.mjs @@ -233,10 +233,41 @@ let idPool = null; 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})`); + /* =================================================================== + HIER STAND BIS ZUM 22.09.2026 „Bea uebernimmt sie" -- und genau + das geht seitdem nicht mehr. - /* DER EIGENTLICHE PUNKT: Die anderen muessen es SEHEN. */ + Filipe: „die modis und linke hand sollen da nichts uebernehmen + koennen von aufgaben, ueberhaupt ueberall sollen die keine + aufgaben selber uebernehmen die ihnen nicht zugetragen sind. ich + will dass die sich fuer aufgaben bewerben koennen aber die rechte + hand oder dogfather muessen annehmen oder ablehnen koennen." + + Der Pool bleibt, sein Sinn bleibt („einer macht es, die anderen + sehen es"). Nur der Weg dahin geht jetzt ueber eine Bewerbung. + Die Pruefung darunter misst denselben EFFEKT wie vorher -- die + anderen sehen, dass sie weg ist -- nur ueber den neuen Weg. + =================================================================== */ + const nimmAlt = await rufe(`/workspace/api/aufgaben/${idPool}/uebernehmen`, { method: "POST", keks: bea, body: {} }); + ok(nimmAlt.code === 403 && nimmAlt.json?.fehler === "erst_bewerben", + `Bea kann sie sich nicht mehr selbst nehmen (HTTP ${nimmAlt.code}, ${nimmAlt.json?.fehler})`); + + const bewerb = await rufe(`/workspace/api/aufgaben/${idPool}/bewerben`, { method: "POST", keks: bea, + body: { text: "Ich mache das." } }); + ok(bewerb.code === 200, `sie bewirbt sich (HTTP ${bewerb.code})`); + + /* DIE LINKE HAND HAT SIE VERTEILT -- entscheiden darf sie trotzdem + nicht. Das ist der Unterschied zwischen den zwei Fragen. */ + const linksVersuch = await rufe(`/workspace/api/aufgaben/${idPool}/bewerbung/annehmen`, + { method: "POST", keks: links, body: { person_id: idBea } }); + ok(linksVersuch.code === 403, + `die linke Hand entscheidet nicht darueber (HTTP ${linksVersuch.code})`); + + const ja = await rufe(`/workspace/api/aufgaben/${idPool}/bewerbung/annehmen`, + { method: "POST", keks: chef, body: { person_id: idBea, kommentar: "Passt." } }); + ok(ja.code === 200, `DogFather nimmt ihre Bewerbung an (HTTP ${ja.code})`); + + /* DER EIGENTLICHE PUNKT, UNVERAENDERT: 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})`); @@ -245,9 +276,11 @@ let idPool = null; 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})`); + /* ZWEI KOENNEN SIE NICHT GLEICHZEITIG BEKOMMEN. Cem bewirbt sich + jetzt noch -- und laeuft in dieselbe Wand wie frueher beim + Uebernehmen. */ + const zweite = await rufe(`/workspace/api/aufgaben/${idPool}/bewerben`, { method: "POST", keks: cem, body: {} }); + ok(zweite.code === 409, `eine zweite Bewerbung 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. */ diff --git a/server/workspace-aufgaben.js b/server/workspace-aufgaben.js index 5c68ee4a..2fbe2885 100644 --- a/server/workspace-aufgaben.js +++ b/server/workspace-aufgaben.js @@ -235,6 +235,44 @@ function sichtbarRoh(person) { case "hand": /* Die linke Hand sieht dasselbe wie die rechte (21.09.2026). */ case "linke": { + /* ================================================================= + AUF DER ADRESSE VON TEAM DOGI SEHEN SIE ALLES (22.09.2026) + + Filipe: „rechte hand linke hand und dogfather sehen alle + aufgaben und koennen alle verteilen." + + GEFUNDEN BEIM BAUEN DER BEWERBUNG, nicht beim Lesen: Die + rechte Hand soll ueber Bewerbungen entscheiden -- und sah die + Aufgabe nicht, um die es ging. Gemessen an einer Pool-Aufgabe, + die DogFather fuer zwei Modis angelegt hat: + + DogFather sieht sie 1 Bewerbung + Modi sieht sie 1 Bewerbung + rechte Hand SIEHT SIE NICHT (Liste leer) + + Die Regel darunter sammelt die Menschen mit einer + TEAM_DOGI_ROLLE und fragt, ob einer von ihnen als Creator, + Verantwortlicher oder Ersteller eingetragen ist. Bei einer + Pool-Aufgabe bleibt `verantwortlich_id` aber leer (das ist ihr + Sinn), `creator_id` war leer, und der Ersteller ist DogFather + -- der in dieser Liste nicht steht. Alle drei Bedingungen + gingen also ins Leere. + + Eine Entscheiderin, die das zu Entscheidende nicht sieht, + waere ein Knopf ohne Gegenstand. Und es ist ohnehin das, was + Filipe gesagt hat. + + `1=1` HEISST HIER NICHT „ALLES IM HAUS": `sichtbar()` haengt + fuer `haus === "crew"` ein `AND ohneAgentur(...)` davor. Die + Trennung der zwei Haeuser bleibt damit unberuehrt -- es ist + „alles von Team Dogi", nicht „alles". + + DIE ALTE REGEL BLEIBT ALS RUECKFALL stehen, fuer den Fall, + dass eine Hand je auf einer anderen Adresse auftaucht. Sie + dort auf `1=1` zu setzen waere eine Tuer in das andere Haus. + ================================================================= */ + if (person.haus === "crew") return { wo: "1=1", werte: [] }; + const liste = [...TEAM_DOGI_ROLLEN].map((r) => `'${r}'`).join(", "); const modis = db().prepare( `SELECT id FROM personen WHERE rolle IN (${liste})`).all().map((z) => z.id); diff --git a/server/workspace-zuteilung.js b/server/workspace-zuteilung.js index 162fe0de..af25c714 100644 --- a/server/workspace-zuteilung.js +++ b/server/workspace-zuteilung.js @@ -53,7 +53,8 @@ import express from "express"; import { - db, protokolliere, echteIp, darfAufgabenVerteilen, istDogFather, istHand, + db, protokolliere, echteIp, darfAufgabenVerteilen, entscheidetUeberAufgaben, + istDogFather, istHand, sitzungLesen, } from "./workspace.js"; import { TEAM_DOGI_ROLLEN } from "./crew-adresse.js"; @@ -100,6 +101,10 @@ export const BEWERTUNGEN = new Set(["gut", "nicht_gut", "verbessern"]); * erfinden — sonst heißt derselbe Zustand an zwei Stellen anders. */ export const ZUSTAND_TEXT = { offen: "Noch nicht beantwortet", + /* „Beworben" ist kein halbes „Angenommen", sondern ein eigener + Zustand: Es liegt an jemand anderem, nicht mehr an der Person + selbst. Der Satz sagt das, statt es offen zu lassen. */ + beworben: "Beworben – wartet auf Antwort", angenommen: "Angenommen", arbeit: "In Bearbeitung", erledigt: "Erledigt", @@ -129,11 +134,14 @@ export function zuteilungenZu(aufgabeIds) { 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, + z.entscheid_text, z.entschieden_am, p.name AS person_name, p.rolle AS person_rolle, - pb.name AS bewertet_von_name + pb.name AS bewertet_von_name, + pe.name AS entschieden_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 + LEFT JOIN personen pe ON pe.id = z.entschieden_von WHERE z.aufgabe_id IN (${platz}) ORDER BY p.name COLLATE NOCASE`).all(...ids); @@ -168,8 +176,18 @@ export function mitZuteilung(reihen, ich) { 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) + ? (zu.find((z) => z.zustand !== "offen" && z.zustand !== "abgelehnt" + && z.zustand !== "beworben")?.person_name || null) : null; + + /* DIE OFFENEN BEWERBUNGEN, abgeleitet statt danebengefuehrt. + Die Oberflaeche soll nicht dieselbe Rechnung noch einmal machen + -- zwei Rechnungen fuer dieselbe Frage laufen auseinander. */ + r.bewerbungen = zu.filter((z) => z.zustand === "beworben"); + /* UND OB ICH MICH UEBERHAUPT NOCH BEWERBEN KANN. Ein Knopf, der + beim Druecken eine Absage bringt, ist schlimmer als keiner. */ + r.ich_beworben = !!(r.meine_zuteilung + && r.meine_zuteilung.zustand === "beworben"); } return liste; } @@ -196,6 +214,31 @@ export function zuteilen(aufgabeId, personIds, art, vonId) { `SELECT id FROM personen WHERE id IN (${platz}) AND aktiv = 1`).all(...roh).map((z) => z.id); if (!echt.length) return { fehler: "keine_person" }; + /* NIEMAND TRAEGT SICH SELBST EIN, DER NICHT ENTSCHEIDEN DARF + (22.09.2026). + + Ohne diese Zeile waere das Verbot von oben ein Umweg mit einem + Klick mehr: Die linke Hand darf verteilen -- sie haette sich die + Aufgabe also einfach selbst zugeteilt und damit genau das getan, + was Filipe ausgeschlossen hat („keine aufgaben selber uebernehmen + die ihnen nicht zugetragen sind"). + + DIE ROLLE WIRD NACHGESCHLAGEN statt durchgereicht: `zuteilen` + bekommt nur eine Nummer, und eine zusaetzliche Angabe im Aufruf + waere die, die beim dritten Aufrufer fehlt. Eine Abfrage mehr + kostet nichts und kann nicht vergessen werden. + + ES WIRD ABGELEHNT UND NICHT STILL HERAUSGEFILTERT: Wer sich + einträgt und sich danach nicht in der Liste findet, sucht den + Fehler bei sich. */ + const verteiler = vonId + ? db().prepare("SELECT id, rolle FROM personen WHERE id = ?").get(vonId) + : null; + if (verteiler && echt.includes(verteiler.id) + && !entscheidetUeberAufgaben(verteiler)) { + return { fehler: "nicht_an_dich_selbst" }; + } + /* 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 @@ -269,6 +312,23 @@ zuteilungRouter.post("/workspace/api/aufgaben/:id/annehmen", (req, res) => { if (!z) return res.status(403).json({ fehler: "nicht_zugeteilt" }); if (z.zustand === "erledigt") return res.status(409).json({ fehler: "schon_erledigt" }); + /* BEIM POOL IST „ANNEHMEN" DASSELBE WIE „UEBERNEHMEN" (22.09.2026). + + Das ist beim Bauen aufgefallen, nicht beim Planen: Der Weg + `uebernehmen` wurde fuer Modis und die linke Hand geschlossen -- + und `annehmen` stand daneben weit offen. Bei einer Pool-Aufgabe + heisst annehmen aber genau, sie den anderen wegzunehmen + (`uebernahmeAbschliessen` weiter unten in dieser Funktion). Ein + Verbot mit einer zweiten, offenen Tuer ist keins. + + BEI „einzeln" UND „mehrere" BLEIBT ES, WIE ES WAR. Dort wurde + die Aufgabe jemandem ZUGETRAGEN -- und genau die darf er + annehmen. Filipes Satz nennt den Unterschied selbst: „keine + aufgaben selber uebernehmen DIE IHNEN NICHT ZUGETRAGEN SIND." */ + if (a.verteilart === "pool" && !entscheidetUeberAufgaben(req.person)) { + return res.status(403).json({ fehler: "erst_bewerben" }); + } + db().prepare(`UPDATE aufgaben_zuteilung SET zustand = 'angenommen', grund = NULL, geantwortet_am = ? WHERE id = ?`).run(jetzt(), z.id); @@ -331,6 +391,27 @@ function uebernahmeAbschliessen(aufgabeId, personId) { geantwortet_am = ? WHERE aufgabe_id = ? AND person_id <> ? AND zustand = 'offen'`) .run(jetzt(), aufgabeId, personId); + + /* DIE OFFENEN BEWERBUNGEN FALLEN AUCH WEG (22.09.2026). + + Ohne diese Zeile blieb eine Bewerbung auf „beworben" stehen, + nachdem jemand anders die Aufgabe bekommen hat -- die Person + haette weiter gewartet, und in der Liste der Leitung staende eine + Entscheidung an, die es nicht mehr gibt. Gefunden hat es die + Pruefung. + + EIGENE ANWEISUNG UND NICHT DIESELBE, und der Grund ist das Feld + `grund`: Dort stehen bei einer Bewerbung die WORTE DER PERSON + („Ich habe Freitag Zeit."). Sie mit „Von jemand anderem + uebernommen" zu ueberschreiben hiesse, die Frage mit der Antwort + zu loeschen -- genau das, was die Trennung der zwei Felder + verhindern soll. Der Hinweis kommt deshalb in `entscheid_text`. */ + db().prepare(`UPDATE aufgaben_zuteilung + SET zustand = 'abgelehnt', + entscheid_text = 'Von jemand anderem übernommen', + entschieden_am = ?, geantwortet_am = ? + WHERE aufgabe_id = ? AND person_id <> ? AND zustand = 'beworben'`) + .run(jetzt(), jetzt(), aufgabeId, personId); db().prepare("UPDATE aufgaben SET verantwortlich_id = ? WHERE id = ?") .run(personId, aufgabeId); } @@ -340,6 +421,14 @@ 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" }); + /* WER NICHT ENTSCHEIDET, NIMMT SICH AUCH NICHTS (22.09.2026). + Filipe: „ueberhaupt ueberall sollen die keine aufgaben selber + uebernehmen die ihnen nicht zugetragen sind." + Fuer Modis und die linke Hand fuehrt der Weg jetzt ueber eine + Bewerbung -- der Fehlercode sagt das, statt nur nein. */ + if (!entscheidetUeberAufgaben(req.person)) { + return res.status(403).json({ fehler: "erst_bewerben" }); + } const z = meine(a.id, req.person.id); if (!z) return res.status(403).json({ fehler: "nicht_zugeteilt" }); @@ -368,6 +457,202 @@ zuteilungRouter.post("/workspace/api/aufgaben/:id/uebernehmen", (req, res) => { } }); +/* ===================================================================== + SICH BEWERBEN — und wer darueber entscheidet (22.09.2026) + ===================================================================== + Filipe: „ich will dass die sich fuer aufgaben bewerben koennen aber + die rechte hand oder dogfather muessen annehmen oder ablehnen koennen + und das mit einem kommentar als moeglichkeit sogar noch zum + hinzufuegen." + + DREI ENTSCHEIDUNGEN, die nicht auf der Hand lagen: + + (1) DER KOMMENTAR IST FREIWILLIG, die Begruendung beim ABLEHNEN einer + zugeteilten Aufgabe bleibt Pflicht. Das ist kein Widerspruch: + Dort sagt jemand ab, der gefragt wurde -- der Fragende muss + wissen, warum, sonst fragt er nach. Hier entscheidet die + Leitung ueber eine Bitte; ein „ja" braucht keine Begruendung. + Filipes Wort ist „als moeglichkeit", nicht „als pflicht". + + (2) DER TEXT DER BEWERBUNG UND DER KOMMENTAR DER LEITUNG STEHEN IN + ZWEI FELDERN. `grund` traegt die Worte der Person selbst, der + Kommentar kommt in `entscheid_text`. In dasselbe Feld waere + bequemer und wuerde die Frage mit der Antwort ueberschreiben -- + danach weiss niemand mehr, worum jemand gebeten hat. + + (3) WER SICH BEWIRBT, IST NICHT ZUGETEILT. Es entsteht eine Zeile + mit `zustand = 'beworben'` und ohne `zugeteilt_von` -- sonst + staende in der Liste, jemand haette ihm die Aufgabe gegeben. + ===================================================================== */ + +const BEWERBUNG_TEXT_MAX = 500; + +/* ---- Sich bewerben --------------------------------------------------- */ +zuteilungRouter.post("/workspace/api/aufgaben/:id/bewerben", (req, res) => { + try { + const a = holen(req, res); if (!a) return; + + /* WER SELBST ENTSCHEIDET, BEWIRBT SICH NICHT. Er teilt sich die + Aufgabe zu -- eine Bewerbung an sich selbst waere ein Umweg mit + zwei Klicks und demselben Ergebnis. */ + if (entscheidetUeberAufgaben(req.person)) { + return res.status(409).json({ fehler: "du_entscheidest_selbst" }); + } + if (a.status === "erledigt" || a.status === "abgebrochen") { + return res.status(409).json({ fehler: "schon_erledigt" }); + } + + const z = meine(a.id, req.person.id); + if (z && z.zustand === "beworben") { + return res.status(409).json({ fehler: "schon_beworben" }); + } + /* WER SIE SCHON HAT, BEWIRBT SICH NICHT -- mit einer Ausnahme, + und die ist der Kern der Sache: + + einzeln/mehrere, Zustand „offen" -> sie wurde IHM zugetragen. + Er nimmt sie an oder lehnt + sie ab. Keine Bewerbung. + pool, Zustand „offen" -> sie wurde ANGEBOTEN, nicht + zugetragen. Frueher hiess + die Antwort darauf „ich + nehme sie mir"; seit heute + heisst sie „ich bewerbe + mich". + + Ohne diese Unterscheidung haette der erlaubte Weg fuer genau die + Aufgaben nicht funktioniert, fuer die er gedacht ist. Gefunden + hat es die Pruefung, nicht das Nachdenken. */ + const angeboten = z && z.zustand === "offen" && a.verteilart === "pool"; + if (z && !angeboten && z.zustand !== "abgelehnt") { + return res.status(409).json({ fehler: "schon_zugeteilt" }); + } + + /* HAT SIE SCHON JEMAND? Dann ist eine Bewerbung eine Enttaeuschung + mit Anlauf. Dieselbe Frage wie beim Uebernehmen, damit beide + Wege dasselbe sagen. */ + 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 && a.verteilart === "pool") { + return res.status(409).json({ fehler: "schon_vergeben", wer: schon.name }); + } + + const text = String(req.body?.text ?? "").trim().slice(0, BEWERBUNG_TEXT_MAX); + const nun = jetzt(); + + if (z) { + db().prepare(`UPDATE aufgaben_zuteilung + SET zustand = 'beworben', grund = ?, geantwortet_am = ?, + entscheid_text = NULL, entschieden_von = NULL, entschieden_am = NULL + WHERE id = ?`).run(text || null, nun, z.id); + } else { + /* `zugeteilt_von` bleibt leer: Niemand hat sie ihm gegeben. */ + db().prepare(`INSERT INTO aufgaben_zuteilung + (aufgabe_id, person_id, zustand, grund, zugeteilt_am, geantwortet_am) + VALUES (?,?,'beworben',?,?,?)`) + .run(a.id, req.person.id, text || null, nun, nun); + } + + protokolliere("aufgabe_beworben", { + personId: req.person.id, rolle: req.person.rolle, ip: echteIp(req), + detail: `#${a.id} ${a.titel}`.slice(0, 120), + }); + res.json({ ok: true, zustand: "beworben" }); + } catch (f) { + console.error("[zuteilung] bewerben:", f?.message); + res.status(503).json({ fehler: "nicht_verfuegbar" }); + } +}); + +/* ---- Eine Bewerbung zuruecknehmen ------------------------------------ */ +zuteilungRouter.post("/workspace/api/aufgaben/:id/bewerbung-zurueck", (req, res) => { + try { + const a = holen(req, res); if (!a) return; + const z = meine(a.id, req.person.id); + if (!z || z.zustand !== "beworben") { + return res.status(409).json({ fehler: "keine_bewerbung" }); + } + /* GANZ WEG, nicht „zurueckgezogen". Wer es sich anders ueberlegt, + hat nichts getan, was jemand nachlesen muesste -- und eine Zeile, + die nur noch sagt „hier stand mal eine Bewerbung", waere in der + Liste der Leitung Laerm. */ + db().prepare("DELETE FROM aufgaben_zuteilung WHERE id = ?").run(z.id); + protokolliere("bewerbung_zurueckgezogen", { + personId: req.person.id, rolle: req.person.rolle, ip: echteIp(req), + detail: `#${a.id} ${a.titel}`.slice(0, 120), + }); + res.json({ ok: true }); + } catch (f) { + console.error("[zuteilung] bewerbung-zurueck:", f?.message); + res.status(503).json({ fehler: "nicht_verfuegbar" }); + } +}); + +/* ---- Ueber eine Bewerbung entscheiden -------------------------------- */ +for (const [weg, entscheidung] of [["annehmen", "ja"], ["ablehnen", "nein"]]) { + zuteilungRouter.post(`/workspace/api/aufgaben/:id/bewerbung/${weg}`, (req, res) => { + try { + const a = holen(req, res); if (!a) return; + /* NUR DIE RECHTE HAND UND DOGFATHER. Die linke Hand darf + verteilen, aber nicht ueber Bewerbungen entscheiden -- sie + bewirbt sich ja selbst. */ + if (!entscheidetUeberAufgaben(req.person)) { + return res.status(403).json({ fehler: "nicht_erlaubt" }); + } + + const wer = Number(req.body?.person_id); + if (!Number.isInteger(wer)) return res.status(400).json({ fehler: "ungueltig" }); + + const z = db().prepare(`SELECT * FROM aufgaben_zuteilung + WHERE aufgabe_id = ? AND person_id = ?`).get(a.id, wer); + if (!z || z.zustand !== "beworben") { + return res.status(409).json({ fehler: "keine_bewerbung" }); + } + + const kommentar = String(req.body?.kommentar ?? "").trim() + .slice(0, BEWERBUNG_TEXT_MAX) || null; + const nun = jetzt(); + + if (entscheidung === "ja") { + /* HAT INZWISCHEN JEMAND ANDERES? Die Frage muss VOR dem + Schreiben stehen -- sonst sagen zwei Zusagen im selben + Moment beide „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, wer); + if (schon && a.verteilart === "pool") { + return res.status(409).json({ fehler: "schon_vergeben", wer: schon.name }); + } + db().prepare(`UPDATE aufgaben_zuteilung + SET zustand = 'angenommen', geantwortet_am = ?, + entscheid_text = ?, entschieden_von = ?, entschieden_am = ? + WHERE id = ?`).run(nun, kommentar, req.person.id, nun, z.id); + if (a.verteilart === "pool") uebernahmeAbschliessen(a.id, wer); + else { + db().prepare("UPDATE aufgaben SET verantwortlich_id = ? WHERE id = ?") + .run(wer, a.id); + } + } else { + db().prepare(`UPDATE aufgaben_zuteilung + SET zustand = 'abgelehnt', geantwortet_am = ?, + entscheid_text = ?, entschieden_von = ?, entschieden_am = ? + WHERE id = ?`).run(nun, kommentar, req.person.id, nun, z.id); + } + + protokolliere(entscheidung === "ja" ? "bewerbung_angenommen" : "bewerbung_abgelehnt", { + personId: req.person.id, rolle: req.person.rolle, ip: echteIp(req), + detail: `#${a.id} fuer ${wer}`.slice(0, 120), + }); + res.json({ ok: true, zustand: entscheidung === "ja" ? "angenommen" : "abgelehnt" }); + } catch (f) { + console.error(`[zuteilung] bewerbung/${weg}:`, f?.message); + res.status(503).json({ fehler: "nicht_verfuegbar" }); + } + }); +} + /* ---- Meinen eigenen Stand setzen ------------------------------------ */ zuteilungRouter.post("/workspace/api/aufgaben/:id/mein-stand", (req, res) => { try { diff --git a/server/workspace.js b/server/workspace.js index c186d22ed..ada98600 100644 --- a/server/workspace.js +++ b/server/workspace.js @@ -289,6 +289,48 @@ export const darfAufgabenAnlegen = (person) => { export const darfAufgabenVerteilen = (person) => istLeitung(person) || istHand(person); +/* ===================================================================== + WER ENTSCHEIDET, WER EINE AUFGABE MACHT (22.09.2026) + ===================================================================== + Filipe: „die modis und linke hand sollen da nichts uebernehmen + koennen von aufgaben, ueberhaupt ueberall sollen die keine aufgaben + selber uebernehmen die ihnen nicht zugetragen sind. ich will dass die + sich fuer aufgaben bewerben koennen aber die rechte hand oder + dogfather muessen annehmen oder ablehnen koennen und das mit einem + kommentar als moeglichkeit sogar noch zum hinzufuegen." + + DAS IST EINE ANDERE FRAGE ALS `darfAufgabenVerteilen`, und die zwei + auseinanderzuhalten ist der ganze Punkt: + + verteilen -> eine Aufgabe an ANDERE geben + entscheiden -> bestimmen, wer sie am Ende macht + + Die linke Hand darf verteilen (sein Wort vom selben Tag: „rechte hand + linke hand und dogfather ... koennen alle verteilen"), aber NICHT + entscheiden, ob sie selbst eine bekommt. Sie bewirbt sich wie ein + Modi, und die rechte Hand oder DogFather sagen ja oder nein. + + GEMESSEN, WARUM ES NOETIG WAR: `istHand()` fasst die rechte UND die + linke Hand -- die linke konnte sich damit bisher Aufgaben aus dem + Pool selbst nehmen und sich beim Verteilen selbst eintragen. + + Rolle istHand darfVerteilen (vorher) + admin false true + hand true true + linke true true <-- genau das soll weg + modi false false + + DREI STELLEN HAENGEN DARAN, und alle drei fragen ab hier dieselbe + Funktion: + 1. aus dem Pool uebernehmen + 2. sich beim Verteilen selbst eintragen + 3. ueber eine Bewerbung entscheiden + + Eine Aufzaehlung der Rollen an drei Stellen waere die, bei der die + dritte beim naechsten Umbau vergessen wird. */ +export const entscheidetUeberAufgaben = (person) => + istLeitung(person) || person?.rolle === "hand"; + /* ===== WER DARF EINE ROLLE AENDERN -- UND ZU WELCHER? (20.09.2026) == Filipe: "ich will dass ich da auch die rollen der leute wechseln kann @@ -3510,6 +3552,49 @@ function umstellungen(d) { checkListeErweitern(d, "aufgaben", "status", "abgebrochen", ["offen", "arbeit", "review", "erledigt", "abgebrochen"], jetztStempel); + /* DER ZUSTAND „beworben" (22.09.2026). + + Eine Bewerbung ist keine neue Tabelle, sondern ein weiterer + Zustand derselben Zeile. Das ist nicht Sparsamkeit, sondern die + richtige Form: Die Frage „wie steht diese Aufgabe bei DIESEM + Menschen" wird schon hier beantwortet, und eine Bewerbung ist + genau eine Antwort darauf. Eine zweite Tabelle daneben haette + zwei Wahrheiten ueber dieselbe Beziehung -- und jede Liste, + jede Ansicht und jede Rueckmeldung muesste beide lesen. + + Der Ablauf: beworben -> angenommen (die rechte Hand oder + DogFather sagt ja) oder -> abgelehnt (mit Kommentar). + + `checkListeErweitern` baut die Tabelle dafuer neu -- der einzige + Weg, eine CHECK-Regel in SQLite zu aendern. Die Spaltenliste + kommt aus PRAGMA, die Indizes gehen mit, und vorher wird + gesichert. Steht seit dem 09.09. an einer Stelle, genau damit + diese Umstellung hier keine vierte Abschrift wird. */ + checkListeErweitern(d, "aufgaben_zuteilung", "zustand", "beworben", + ["offen", "angenommen", "arbeit", "erledigt", "abgelehnt", "beworben"], + jetztStempel); + + /* WER ENTSCHIEDEN HAT, UND WAS ER DAZU GESAGT HAT. + `grund` traegt weiterhin die Worte der Person selbst (beim + Ablehnen ihre Begruendung, beim Bewerben ihr Anliegen). Der + Kommentar der Leitung ist etwas anderes und gehoert nicht in + dasselbe Feld -- sonst ueberschreibt die Antwort die Frage. */ + for (const [spalte, form] of [ + ["entscheid_text", "TEXT"], + ["entschieden_von", "INTEGER REFERENCES personen(id) ON DELETE SET NULL"], + ["entschieden_am", "TEXT"], + ]) { + const da = d.prepare("PRAGMA table_info(aufgaben_zuteilung)").all() + .map((z) => z.name); + if (da.includes(spalte)) continue; + try { + d.exec(`ALTER TABLE aufgaben_zuteilung ADD COLUMN ${spalte} ${form}`); + console.log(`[workspace] Spalte '${spalte}' an der Zuteilung ergaenzt.`); + } catch (f) { + console.error(`[workspace] Spalte '${spalte}':`, f?.message); + } + } + /* ---- Der sechste Bereich: "agentur" (06.09.2026) ---- Kernprinzip 04 des Konzepts lautet woertlich "Die Agentur bleibt @@ -5841,6 +5926,18 @@ workspaceRouter.get("/workspace/api/ich", (req, res) => { Aus DERSELBEN Funktion, die auch die Route benutzt. */ darf_verteilen: darfAufgabenVerteilen(person), + /* UND OB SIE ENTSCHEIDET, WER EINE AUFGABE MACHT (22.09.2026). + + Das ist eine ANDERE Frage als `darf_verteilen`, und die + Oberflaeche braucht beide: Die linke Hand verteilt (also zeigt + ihr die Seite den Verteilen-Kasten), entscheidet aber nicht + (also sieht sie keine Annehmen/Ablehnen-Knoepfe an fremden + Bewerbungen, sondern einen Bewerben-Knopf fuer sich selbst). + + Aus DERSELBEN Funktion, die auch die Wege absichern -- eine + Rollenliste im Browser waere die zweite Wahrheit, die beim + naechsten Umbau auseinanderlaeuft. */ + darf_entscheiden: entscheidetUeberAufgaben(person), /* Damit die Oberflaeche den Knopf gar nicht erst anbietet -- ein Knopf, der mit 403 antwortet, ist schlimmer als keiner. */ darf_aufgaben_anlegen: darfAufgabenAnlegen(person), diff --git a/workspace/anruf-probe.html b/workspace/anruf-probe.html index 99fd0a53..525ca177 100644 --- a/workspace/anruf-probe.html +++ b/workspace/anruf-probe.html @@ -16,15 +16,15 @@ verdeckt. Gefunden hat es pruef-struktur. Richtig sind `gate.css` (die Farbwerte des Hauses) und `haus.css`. --> - - + + - +