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`. -->
-
-
+
+
-
+