diff --git a/server/pruef-modi-katalog.mjs b/server/pruef-modi-katalog.mjs
index 9af1bb0b..82fd2a82 100644
--- a/server/pruef-modi-katalog.mjs
+++ b/server/pruef-modi-katalog.mjs
@@ -76,6 +76,14 @@ const idDogi = anlegen("Filipe", "admin", "CODE-DOGI-0001");
const idMan = anlegen("Schulle", "manager", "CODE-MAN-0001");
const idCreat = anlegen("Tili", "creator", "CODE-ANNA-0001");
const idModi = anlegen("Marina", "modi", "CODE-MODI-0001");
+/* DIE BEIDEN HAENDE (23.09.2026). Ohne sie laesst sich die Grenze, die
+ Filipe gezogen hat, gar nicht messen: "nur dogfather und die rechte
+ hand sollen annehmen oder ablehnen koennen". Die linke Hand darf
+ verteilen -- und genau deshalb ist sie die schaerfste Gegenprobe:
+ Wer nur "darf verteilen" prueft, laesst sie mitentscheiden. */
+const idHand = anlegen("Rieke", "hand", "CODE-HAND-0001");
+const idLinke = anlegen("Funny", "linke", "CODE-LINK-0001");
+void idHand; void idLinke;
d.close();
void idDogi;
void idMan;
@@ -100,6 +108,10 @@ const modiAn = await anmelden("creator", "CODE-MODI-0001");
const dogiAn = await anmelden("admin", "CODE-DOGI-0001");
const manAn = await anmelden("manager", "CODE-MAN-0001");
const creatAn = await anmelden("creator", "CODE-ANNA-0001");
+/* Ueber den stillen Zugang -- welche Kachel man anfasst, ist
+ gleichgueltig, der Code allein entscheidet (siehe die Anmelderoute). */
+const handAn = await anmelden("creator", "CODE-HAND-0001");
+const linkeAn = await anmelden("creator", "CODE-LINK-0001");
/* ---------- 1. Ist er vollstaendig da? ------------------------------- */
console.log("\n=== 1. Der Katalog, wie ihn das Dokument beschreibt ===\n");
@@ -196,42 +208,223 @@ for (const [name, an] of [["Manager", manAn], ["Creator", creatAn]]) {
"und der Modi bekommt umgekehrt den Creator-Katalog nicht");
}
-/* ---------- 3. Uebernehmen ------------------------------------------- */
-console.log("\n=== 3. Aus der Vorlage wird eine echte Aufgabe ===\n");
+/* ---------- 3. Ein Modi kann sich nur BEWERBEN ------------------------
+
+ Filipe, 23.09.2026: "die modis sollen bei all diesen voschlaegen
+ auch nur bewerben koennen. die aufgaben aus der vorlage, da sollen
+ die modis sich nur bewerben koennen und nur dogfather und die rechte
+ hand sollen annehmen oder ablehnen koennen, mit einem text als
+ notiz."
+
+ BIS HEUTE STAND HIER DAS GEGENTEIL: "uebernommen (200)", "und er
+ selbst ist verantwortlich". Diese Pruefung ist beim Umbau ROT
+ geworden -- richtig so, sie hat die Aenderung bemerkt.
+
+ DREI DINGE WERDEN GEMESSEN, und das dritte ist das, was Filipe
+ ausdruecklich wollte:
+ 1. Der Modi kommt an die alte Tuer nicht mehr heran.
+ 2. Die Bewerbung kommt an -- und legt NOCH KEINE Aufgabe an.
+ 3. Erst die Zusage macht daraus Arbeit, mit Notiz daran.
+ --------------------------------------------------------------------- */
+console.log("\n=== 3. Ein Modi kann sich nur bewerben ===\n");
+
+let bewerbungsNr = null, bewerbungsVorlage = null;
{
const nr = katalog.aufgaben.findIndex((v) => v.gruppe === "chat");
const vorlage = katalog.aufgaben[nr];
- const a = await hole(modiAn.keks, "/workspace/api/vorlagen/uebernehmen", {
+ bewerbungsVorlage = vorlage;
+
+ /* ---- 1. Die alte Tuer ist zu -------------------------------------- */
+ const alt = await hole(modiAn.keks, "/workspace/api/vorlagen/uebernehmen", {
method: "POST", headers: jsonKopf,
body: JSON.stringify({ art: "katalog", nr, gruppe: "chat" }),
});
- ok(a.status === 200 && a.daten?.angelegt === 1, `übernommen (${a.status})`);
+ ok(alt.status === 403, `selbst uebernehmen geht nicht mehr (${alt.status})`);
+ /* DER SATZ IST EIN WEGWEISER, KEINE ABSAGE. Wer "Das darfst du
+ nicht" liest, haelt sich fuer ausgeschlossen -- es gibt aber einen
+ Weg, er fuehrt nur ueber jemanden. */
+ ok(alt.daten?.fehler === "erst_bewerben",
+ ` und sagt, was stattdessen geht (${alt.daten?.fehler})`);
- const b = await hole(modiAn.keks, "/workspace/api/aufgaben");
- const neu = (b.daten?.aufgaben || []).find((x) => x.vorlage === vorlage.schluessel);
- ok(!!neu, `sie liegt auf seinem Brett (${neu?.titel || "nicht gefunden"})`);
- /* DIE KATEGORIE IST DER HALBE WERT DER VORLAGE. Ginge sie unterwegs
- verloren, muesste man 60-mal von Hand einsortieren, was im Dokument
- danebensteht -- und niemand merkte es, weil die Aufgabe ja da ist. */
- /* DIE KATEGORIE IST DER HALBE WERT DER VORLAGE -- und sie heisst im
- ausgelieferten Katalog `gruppe`, weil die Oberflaeche damit
- zeichnet. Ginge sie unterwegs verloren, muesste man von Hand
- einsortieren, was danebensteht. */
- ok(neu?.kategorie === vorlage.gruppe,
- `mit ihrer Kategorie (${neu?.kategorie} / erwartet ${vorlage.gruppe})`);
- ok(!!neu?.frist, `und mit Frist (${neu?.frist})`);
- ok(neu?.verantwortlich_id === idModi, "und er selbst ist verantwortlich");
+ const keine = await hole(modiAn.keks, "/workspace/api/aufgaben");
+ const gibtsNicht = (keine.daten?.aufgaben || [])
+ .some((x) => x.vorlage === vorlage.schluessel);
+ ok(!gibtsNicht, " und es liegt nichts auf seinem Brett");
+
+ /* ---- 2. Die Bewerbung ---------------------------------------------- */
+ const b = await hole(modiAn.keks, "/workspace/api/vorlagen/bewerben", {
+ method: "POST", headers: jsonKopf,
+ body: JSON.stringify({ vorlage: vorlage.schluessel, text: "ich habe Freitag Zeit" }),
+ });
+ ok(b.status === 201, `die Bewerbung kommt an (${b.status})`);
+
+ /* DAS IST DER KERN DER BAUWEISE: Bis jemand ja sagt, entsteht keine
+ Arbeit. Waere die Aufgabe schon da, stuenden nach zwoelf Absagen
+ zwoelf herrenlose Aufgaben auf dem Brett -- und das Ablehnen
+ muesste Aufgaben LOESCHEN, um das wieder einzufangen. */
+ const nochNicht = await hole(modiAn.keks, "/workspace/api/aufgaben");
+ ok(!(nochNicht.daten?.aufgaben || []).some((x) => x.vorlage === vorlage.schluessel),
+ " und legt noch KEINE Aufgabe an — das tut erst die Zusage");
+
+ const meine = await hole(modiAn.keks, "/workspace/api/vorlagen");
+ const meineB = (meine.daten?.bewerbungen || [])
+ .find((x) => x.vorlage === vorlage.schluessel);
+ ok(!!meineB, "er sieht seine eigene Bewerbung");
+ ok(meineB?.selbst === true && meineB?.text === "ich habe Freitag Zeit",
+ ` samt seinem Wort dazu ("${meineB?.text}")`);
+ ok(meine.daten?.darf_entscheiden === false,
+ "und der Server sagt ihm, dass er nicht entscheidet");
+ bewerbungsNr = meineB?.id ?? null;
+
+ /* Zweimal dieselbe Bitte ist eine Bitte. */
+ const doppelt = await hole(modiAn.keks, "/workspace/api/vorlagen/bewerben", {
+ method: "POST", headers: jsonKopf,
+ body: JSON.stringify({ vorlage: vorlage.schluessel }),
+ });
+ ok(doppelt.status === 409 && doppelt.daten?.fehler === "schon_beworben",
+ `zweimal dieselbe Bewerbung wird abgewiesen (${doppelt.status})`);
}
-/* Eine ganze Etappe auf einmal. */
+/* ---------- 3b. Wer entscheidet -- und wer nicht ----------------------
+
+ DIE LINKE HAND IST HIER DIE SCHAERFSTE PROBE. Sie darf verteilen und
+ gehoert sonst ueberall dazu; Filipe hat bei der Entscheidung aber
+ genau zwei genannt. Wer statt "darf entscheiden" nur "darf
+ verteilen" prueft, laesst sie durch -- und niemand merkt es, weil
+ alles funktioniert.
+ --------------------------------------------------------------------- */
+console.log("\n=== 3b. Wer über die Bewerbung entscheidet ===\n");
{
- const wieViele = katalog.aufgaben.filter((v) => v.gruppe === "technik").length;
- const a = await hole(modiAn.keks, "/workspace/api/vorlagen/uebernehmen", {
+ ok(Number.isInteger(bewerbungsNr), `die Bewerbung hat eine Nummer (${bewerbungsNr})`);
+
+ const alsLinke = await hole(linkeAn.keks, "/workspace/api/vorlagen");
+ ok(alsLinke.daten?.darf_verteilen === true,
+ "die linke Hand darf verteilen (sie bekommt den Übernehmen-Weg)");
+ ok(alsLinke.daten?.darf_entscheiden === false,
+ " aber NICHT entscheiden — woertlich nach Filipes Aufzaehlung");
+ const versuch = await hole(linkeAn.keks, "/workspace/api/vorlagen/bewerbung/annehmen", {
method: "POST", headers: jsonKopf,
- body: JSON.stringify({ art: "katalog_alle", gruppe: "technik" }),
+ body: JSON.stringify({ id: bewerbungsNr, kommentar: "mach ich" }),
});
- ok(a.status === 200 && a.daten?.angelegt === wieViele,
- `eine ganze Kategorie auf einmal: ${a.daten?.angelegt} von ${wieViele}`);
+ ok(versuch.status === 403,
+ ` und am Knopf vorbei geht es auch nicht (${versuch.status})`);
+
+ /* GEGENPROBE: Der Modi selbst erst recht nicht -- sonst naehme er
+ seine eigene Bewerbung an. */
+ const selbst = await hole(modiAn.keks, "/workspace/api/vorlagen/bewerbung/annehmen", {
+ method: "POST", headers: jsonKopf,
+ body: JSON.stringify({ id: bewerbungsNr }),
+ });
+ ok(selbst.status === 403, `und der Bewerber selbst schon gar nicht (${selbst.status})`);
+
+ /* Die rechte Hand sieht sie -- mit Namen, denn sie entscheidet ueber
+ einen Menschen und nicht ueber einen Listeneintrag. */
+ const alsHand = await hole(handAn.keks, "/workspace/api/vorlagen");
+ const sieht = (alsHand.daten?.bewerbungen || []).find((x) => x.id === bewerbungsNr);
+ ok(alsHand.daten?.darf_entscheiden === true, "die rechte Hand entscheidet");
+ ok(!!sieht && sieht.person_name === "Marina",
+ ` und sieht, wer sich beworben hat (${sieht?.person_name})`);
+ ok(sieht?.selbst === false, " als fremde Bewerbung, nicht als eigene");
+}
+
+/* ---------- 3c. Die Zusage macht daraus Arbeit ------------------------ */
+console.log("\n=== 3c. Annehmen und ablehnen — mit Notiz ===\n");
+{
+ const a = await hole(handAn.keks, "/workspace/api/vorlagen/bewerbung/annehmen", {
+ method: "POST", headers: jsonKopf,
+ body: JSON.stringify({ id: bewerbungsNr, kommentar: "danke dir, mach das gern" }),
+ });
+ ok(a.status === 200 && a.daten?.zustand === "angenommen",
+ `die rechte Hand sagt zu (${a.status})`);
+ ok(Number.isInteger(a.daten?.aufgabe_id),
+ ` und JETZT entsteht die Aufgabe (#${a.daten?.aufgabe_id})`);
+
+ const brett = await hole(modiAn.keks, "/workspace/api/aufgaben");
+ const neu = (brett.daten?.aufgaben || [])
+ .find((x) => x.vorlage === bewerbungsVorlage.schluessel);
+ ok(!!neu, "sie liegt auf seinem Brett");
+ /* SIE MUSS AUSSEHEN WIE EINE VERTEILTE. Ein zweiter Weg zum Anlegen
+ waere ein zweiter Satz Regeln -- deshalb geht die Zusage durch
+ dieselbe Funktion wie das Uebernehmen. */
+ ok(neu?.kategorie === bewerbungsVorlage.gruppe,
+ `mit ihrer Kategorie (${neu?.kategorie} / erwartet ${bewerbungsVorlage.gruppe})`);
+ ok(!!neu?.frist, `und mit Frist (${neu?.frist})`);
+ ok(neu?.verantwortlich_id === idModi, "und der Bewerber ist verantwortlich");
+
+ /* Die Bewerbung ist damit vom Tisch -- sonst entschiede gleich noch
+ jemand ein zweites Mal darueber. */
+ const danach = await hole(handAn.keks, "/workspace/api/vorlagen");
+ ok(!(danach.daten?.bewerbungen || []).some((x) => x.id === bewerbungsNr),
+ "und sie wartet nicht mehr auf eine Antwort");
+ const nochmal = await hole(handAn.keks, "/workspace/api/vorlagen/bewerbung/annehmen", {
+ method: "POST", headers: jsonKopf,
+ body: JSON.stringify({ id: bewerbungsNr }),
+ });
+ ok(nochmal.status === 409 && nochmal.daten?.fehler === "keine_bewerbung",
+ `zweimal entscheiden geht nicht (${nochmal.status})`);
+
+ /* ---- UND DIE ABSAGE, mit Notiz ------------------------------------ */
+ const nr2 = katalog.aufgaben.findIndex((v) => v.gruppe === "events");
+ const v2 = katalog.aufgaben[nr2];
+ await hole(modiAn.keks, "/workspace/api/vorlagen/bewerben", {
+ method: "POST", headers: jsonKopf,
+ body: JSON.stringify({ vorlage: v2.schluessel }),
+ });
+ const offen2 = (await hole(handAn.keks, "/workspace/api/vorlagen"))
+ .daten?.bewerbungen?.find((x) => x.vorlage === v2.schluessel);
+ ok(!!offen2, "eine zweite Bewerbung liegt vor");
+ ok(offen2?.text === null,
+ " auch ohne Wort dazu — der Text ist freiwillig, wie bei den Aufgaben");
+
+ const ab = await hole(handAn.keks, "/workspace/api/vorlagen/bewerbung/ablehnen", {
+ method: "POST", headers: jsonKopf,
+ body: JSON.stringify({ id: offen2.id, kommentar: "das macht diesmal jemand anders" }),
+ });
+ ok(ab.status === 200 && ab.daten?.zustand === "abgelehnt",
+ `und wird abgelehnt (${ab.status})`);
+ ok(ab.daten?.aufgabe_id === null,
+ " ohne dass Arbeit entsteht, die niemand bestellt hat");
+
+ /* DIE NOTIZ BLEIBT STEHEN -- das ist der Teil, den Filipe
+ ausdruecklich genannt hat ("mit einem text als notiz"). Gelesen
+ wird direkt aus der Datenbank: Ein Feld, das der Server zwar
+ annimmt, aber nirgends speichert, saehe von aussen genauso aus. */
+ const { DatabaseSync: DB2 } = await import("node:sqlite");
+ const d2 = new DB2(process.env.WORKSPACE_DB, { readOnly: true });
+ const zeilen = d2.prepare(`SELECT zustand, entscheid_text, entschieden_von, aufgabe_id
+ FROM vorlagen_bewerbungen ORDER BY id`).all();
+ d2.close();
+ const zu = zeilen.find((z) => z.zustand === "angenommen");
+ const weg = zeilen.find((z) => z.zustand === "abgelehnt");
+ ok(zu?.entscheid_text === "danke dir, mach das gern",
+ `die Notiz der Zusage steht in der Datenbank ("${zu?.entscheid_text}")`);
+ ok(weg?.entscheid_text === "das macht diesmal jemand anders",
+ `und die der Absage auch ("${weg?.entscheid_text}")`);
+ ok(zu?.entschieden_von === idHand && weg?.entschieden_von === idHand,
+ "bei beiden steht, WER entschieden hat");
+ ok(Number.isInteger(zu?.aufgabe_id) && weg?.aufgabe_id === null,
+ "und nur an der Zusage haengt eine Aufgabe");
+
+ /* ---- Nach einer Absage darf man es wieder versuchen ---------------- */
+ const nochmalBewerben = await hole(modiAn.keks, "/workspace/api/vorlagen/bewerben", {
+ method: "POST", headers: jsonKopf,
+ body: JSON.stringify({ vorlage: v2.schluessel, text: "doch noch Zeit" }),
+ });
+ ok(nochmalBewerben.status === 201,
+ `nach einer Absage kann man sich wieder bewerben (${nochmalBewerben.status})`);
+ /* GEGENPROBE zur Sperre oben: Waere der Index ueber ALLE Zustaende
+ eindeutig, kaeme hier 409 -- und eine Absage waere ein Bann. */
+ const wiederOffen = (await hole(handAn.keks, "/workspace/api/vorlagen"))
+ .daten?.bewerbungen?.filter((x) => x.vorlage === v2.schluessel).length;
+ ok(wiederOffen === 1, `und sie wartet wieder auf Antwort (${wiederOffen})`);
+
+ /* Und auf etwas, das man schon hat, bewirbt man sich nicht. */
+ const doppelt = await hole(modiAn.keks, "/workspace/api/vorlagen/bewerben", {
+ method: "POST", headers: jsonKopf,
+ body: JSON.stringify({ vorlage: bewerbungsVorlage.schluessel }),
+ });
+ ok(doppelt.status === 409 && doppelt.daten?.fehler === "liegt_schon_bei_dir",
+ `auf etwas, das schon auf dem Brett liegt, bewirbt man sich nicht (${doppelt.status})`);
}
/* ---------- 4. DogFather traegt fuer jemanden ein -------------------- */
@@ -406,6 +599,60 @@ console.log("\n=== 6. Das Brett im echten Browser ===\n");
gesucht wurde -- etwa wenn `innerText` leer bliebe. */
ok(ersatzschreibungen(`${bild.text} waehrend`).length === 1,
"und ein eingeschmuggeltes waehrend wuerde auffallen");
+ /* ==== AM BILDSCHIRM: BEWERBEN STATT UEBERNEHMEN (23.09.2026) ===
+
+ Filipe: „die modis sollen bei all diesen voschlaegen auch nur
+ bewerben koennen."
+
+ ALLES BISHERIGE HAT DEN SERVER GEPRUEFT. Hier geht es um die
+ andere Haelfte: Steht der richtige Knopf da? Ein Modi, der
+ „Uebernehmen" liest und dann eine Absage bekommt, haelt die
+ Seite fuer kaputt. */
+ const knoepfe = await seite.evaluate(() =>
+ [...document.querySelectorAll(".vorlagenbrett__karte .knopf")]
+ .map((k) => k.textContent.trim()));
+ /* DIE ZAHL GEHOERT IN DIE BEDINGUNG. Bei null Karten waere
+ `every()` wahr, ohne einen Knopf angesehen zu haben -- genau der
+ Fehler, der am 01.09. dreimal gruen dastand. */
+ ok(knoepfe.length >= 10 && knoepfe.every((t) => t === "Bewerben"),
+ `alle ${knoepfe.length} Knoepfe heissen "Bewerben" (${[...new Set(knoepfe)].join(", ")})`);
+ /* GEGENPROBE: „Uebernehmen" kommt nirgends mehr vor -- weder als
+ Knopf noch als „Alle 12 uebernehmen" darunter. */
+ const uebernahme = await seite.evaluate(() =>
+ [...document.querySelectorAll("#vorlagenbrett button")]
+ .filter((k) => /übernehmen/i.test(k.textContent)).length);
+ ok(uebernahme === 0,
+ `Gegenprobe: kein einziger Uebernehmen-Knopf mehr (${uebernahme})`);
+
+ /* Die schon beantragte Karte sagt, dass sie wartet -- und WER
+ antwortet. Sonst wartet man auf niemanden.
+
+ ERST ZUR RICHTIGEN KATEGORIE. Die offene Bewerbung liegt bei
+ „Events", das Brett startet bei der ersten Kategorie. Ohne
+ diesen Klick maesse die Pruefung eine Karte, die gar nicht auf
+ dem Bildschirm steht -- und waere rot, ohne dass etwas kaputt
+ ist. (Beim Bauen genau so passiert.) */
+ await seite.evaluate(() => {
+ const reihe = document.querySelector('.vorlagenbrett .stufenleiter');
+ const k = [...(reihe?.querySelectorAll('button') || [])]
+ .find((b) => /Events/i.test(b.textContent));
+ if (k) k.click();
+ });
+ await seite.waitForTimeout(700);
+ const wartet = await seite.evaluate(() => {
+ const w = document.querySelector(".vorlagenbrett__warte");
+ const karte = w?.closest(".vorlagenbrett__karte");
+ return w ? {
+ satz: w.textContent.trim(),
+ zurueck: !![...karte.querySelectorAll("button")]
+ .find((b) => /zurückziehen/i.test(b.textContent)),
+ } : null;
+ });
+ ok(!!wartet, "die Karte, auf die er sich beworben hat, sagt es");
+ ok(/rechte hand/i.test(wartet?.satz || ""),
+ ` und nennt, wer antwortet ("${wartet?.satz}")`);
+ ok(wartet?.zurueck === true, " mit einem Weg zurueck");
+
ok(konsole.length === 0,
konsole.length ? `Fehler in der Konsole: ${konsole[0]}` : "keine Fehler in der Konsole");
/* DER PFAD GEHOERT AN DIESE DATEI, nicht ans Arbeitsverzeichnis.
@@ -528,6 +775,85 @@ console.log("");
ok(danach.karten === vorher + 1,
"die Aufgabe liegt auf dem Brett (" + vorher + " -> " + danach.karten + ")");
ok(danach.fuerMarina > 0, "und zwar bei Marina (" + danach.fuerMarina + ")");
+
+ /* ==== DIE BEWERBUNG AM BILDSCHIRM ENTSCHEIDEN (23.09.2026) ======
+
+ Filipe: „nur dogfather und die rechte hand sollen annehmen oder
+ ablehnen koennen, mit einem text als notiz."
+
+ DER SERVERTEIL IST OBEN GEPRUEFT. Hier geht es um das, was ein
+ Mensch tut: Sieht er die Bewerbung, steht der Name dabei, und
+ bekommt er ein Feld fuer seine Notiz?
+
+ ZUR RICHTIGEN KATEGORIE -- die offene Bewerbung liegt bei
+ „Events". Ohne den Klick maesse die Pruefung eine Karte, die gar
+ nicht auf dem Bildschirm steht. */
+ await seite.evaluate(() => {
+ const reihe = document.querySelector(".vorlagenbrett .stufenleiter");
+ const k = [...(reihe?.querySelectorAll("button") || [])]
+ .find((b) => /Events/i.test(b.textContent));
+ if (k) k.click();
+ });
+ await seite.waitForTimeout(800);
+
+ const bw = await seite.evaluate(() => {
+ const reihe = document.querySelector(".vorlagenbrett__bewerbung");
+ if (!reihe) return null;
+ const karte = reihe.closest(".vorlagenbrett__karte");
+ return {
+ name: reihe.querySelector(".vorlagenbrett__bname")?.textContent.trim() || "",
+ wort: reihe.querySelector(".vorlagenbrett__bwort")?.textContent.trim() || "",
+ schild: karte.querySelector(".vorlagenbrett__bschild")?.textContent.trim() || "",
+ knoepfe: [...reihe.querySelectorAll("button")].map((b) => b.textContent.trim()),
+ auffaellig: karte.dataset.beworben === "ja",
+ titel: karte.querySelector(".vorlagenbrett__titel")?.textContent.trim() || "",
+ };
+ });
+ ok(!!bw, "DogFather sieht die Bewerbung an der Karte");
+ ok(bw?.name === "Marina", `mit dem Namen dessen, der sie geschrieben hat (${bw?.name})`);
+ /* MAN ENTSCHEIDET UEBER EINEN MENSCHEN, nicht ueber einen
+ Listeneintrag -- deshalb stehen beide Knoepfe an SEINER Zeile. */
+ ok(JSON.stringify(bw?.knoepfe) === JSON.stringify(["Annehmen", "Ablehnen"]),
+ `und beiden Wegen daneben (${(bw?.knoepfe || []).join(", ")})`);
+ ok(bw?.wort.includes("doch noch Zeit"),
+ `sein Wort dazu steht dabei (${bw?.wort})`);
+ /* Eine Karte mit Bewerbung ist der einzige Grund, dieses Brett
+ gerade zu oeffnen -- sie darf auffallen. */
+ ok(bw?.auffaellig === true, "und die Karte faellt auf");
+
+ /* ---- Jetzt wirklich druecken, samt Notiz ---------------------- */
+ const vorherK = await seite.evaluate(() => document.querySelectorAll(".karte").length);
+ await seite.evaluate(() => {
+ [...document.querySelectorAll(".vorlagenbrett__bewerbung button")]
+ .find((b) => b.textContent.trim() === "Annehmen")?.click();
+ });
+ await seite.waitForTimeout(600);
+ /* DER KASTEN MUSS BEANTWORTET WERDEN. Beim ersten Anlauf einer
+ aehnlichen Pruefung habe ich ihn stehen lassen und dann gemeldet,
+ der Weg sei kaputt -- er war es nie, ich hatte nur nicht geklickt. */
+ const kastenDa = await seite.evaluate(() =>
+ !!document.querySelector("dialog.nachfrage[open]"));
+ ok(kastenDa, "es kommt ein Kasten für die Notiz — kein stilles Durchdrücken");
+ const feldDa = await seite.evaluate(() => {
+ const f = document.getElementById("nachfrage-grund");
+ return !!f && !f.closest("[hidden]");
+ });
+ ok(feldDa, " mit einem Feld für den Text — „mit einem text als notiz“");
+ await seite.fill("#nachfrage-grund", "danke dir, mach das gern");
+ await seite.click('dialog.nachfrage [data-teil="ja"]');
+ await seite.waitForTimeout(2200);
+
+ const nachher = await seite.evaluate(() => ({
+ karten: document.querySelectorAll(".karte").length,
+ nochBeworben: document.querySelectorAll(".vorlagenbrett__bewerbung").length,
+ }));
+ ok(nachher.karten === vorherK + 1,
+ `die Aufgabe entsteht mit der Zusage (${vorherK} -> ${nachher.karten})`);
+ /* UND DIE BEWERBUNG IST VOM TISCH. Bliebe sie stehen, entschiede
+ gleich noch jemand ein zweites Mal ueber dieselbe Bitte. */
+ ok(nachher.nochBeworben === 0,
+ `und die Bewerbung wartet nicht mehr (${nachher.nochBeworben})`);
+
ok(konsole.length === 0,
konsole.length ? "Fehler in der Konsole: " + konsole[0].slice(0, 80)
: "keine Fehler in der Konsole");
@@ -540,16 +866,32 @@ console.log("\n=== 7. Gegenprobe: kann das hier ueberhaupt scheitern? ===\n");
/* Eine Vorlagennummer, die es nicht gibt. Kaeme dafuer ein 200,
wuerden die Haken oben nichts beweisen -- dann sagt der Server zu
allem ja. */
- const a = await hole(modiAn.keks, "/workspace/api/vorlagen/uebernehmen", {
+ /* MIT DEM ZUGANG VON DOGFATHER, nicht mit dem des Modis: Seit dem
+ 23.09. bekommt ein Modi auf dieser Route ohnehin 403 -- die
+ Gegenprobe haette also ab da nur noch bewiesen, dass die
+ Rechtepruefung greift, und nichts mehr ueber erfundene Nummern
+ gesagt. Genau so verliert eine Pruefung still ihren Sinn. */
+ const a = await hole(dogiAn.keks, "/workspace/api/vorlagen/uebernehmen", {
method: "POST", headers: jsonKopf,
- body: JSON.stringify({ art: "katalog", nr: 9999, gruppe: "aufbau" }),
+ body: JSON.stringify({ art: "katalog", nr: 9999, gruppe: "aufbau", person_id: idModi }),
});
ok(a.status === 400, `eine erfundene Vorlagennummer wird abgelehnt (${a.status})`);
- const b = await hole(modiAn.keks, "/workspace/api/vorlagen/uebernehmen", {
+ const b = await hole(dogiAn.keks, "/workspace/api/vorlagen/uebernehmen", {
method: "POST", headers: jsonKopf,
- body: JSON.stringify({ art: "katalog_alle", gruppe: "gibtesnicht" }),
+ body: JSON.stringify({ art: "katalog_alle", gruppe: "gibtesnicht", person_id: idModi }),
});
ok(b.status === 400, `und eine erfundene Etappe ebenso (${b.status})`);
+ /* Und eine erfundene Bewerbungsnummer. */
+ const c = await hole(handAn.keks, "/workspace/api/vorlagen/bewerbung/annehmen", {
+ method: "POST", headers: jsonKopf,
+ body: JSON.stringify({ id: 999999 }),
+ });
+ ok(c.status === 409, `eine erfundene Bewerbung ebenso (${c.status})`);
+ const e = await hole(modiAn.keks, "/workspace/api/vorlagen/bewerben", {
+ method: "POST", headers: jsonKopf,
+ body: JSON.stringify({ vorlage: "gibt-es-nicht" }),
+ });
+ ok(e.status === 400, `und eine erfundene Vorlage in der Bewerbung (${e.status})`);
}
console.log(`\n${fehler ? "FEHLGESCHLAGEN" : "BESTANDEN"} -- `
diff --git a/server/workspace-vorlagen.js b/server/workspace-vorlagen.js
index d85a998b..a5bc3b27 100644
--- a/server/workspace-vorlagen.js
+++ b/server/workspace-vorlagen.js
@@ -36,8 +36,18 @@
import express from "express";
import {
- db, sitzungLesen, istLeitung, darfCreator, protokolliere, echteIp, kategorienFuer,
- termineSichtbar, MODI_KATEGORIEN, MODI_STUFEN,
+ db,
+ sitzungLesen,
+ istLeitung,
+ darfCreator,
+ protokolliere,
+ echteIp,
+ kategorienFuer,
+ termineSichtbar,
+ MODI_KATEGORIEN,
+ MODI_STUFEN,
+ entscheidetUeberAufgaben,
+ darfAufgabenVerteilen,
} from "./workspace.js";
export const vorlagenRouter = express.Router();
@@ -1431,6 +1441,26 @@ vorlagenRouter.get("/workspace/api/vorlagen", (req, res) => {
aufbauplan: kategorienFuer(req.person).length
? { gruppen: MODI_GRUPPEN, aufgaben: MODI_KATALOG }
: null,
+ /* ==== DIE BEWERBUNGEN KOMMEN MIT (23.09.2026) =================
+
+ Sie stehen in DERSELBEN Antwort wie der Katalog und nicht in
+ einer zweiten Abfrage. Das Brett zeichnet sich bei jedem
+ Kategoriewechsel neu; eine eigene Abfrage daneben waere ein
+ zweiter Weg, auf dem die beiden auseinanderlaufen koennen --
+ und man saehe eine Karte als "beworben", die es nicht mehr ist.
+
+ WER WAS SIEHT, entscheidet vorlagenBewerbungenFuer(): Wer
+ entscheidet, bekommt alle offenen; alle anderen nur ihre
+ eigenen. Im Browser zu filtern hiesse, die fremden Namen
+ trotzdem auszuliefern.
+
+ UND OB ICH ENTSCHEIDEN DARF, sagt ebenfalls der Server. Die
+ Oberflaeche vergleicht keine Rollennamen -- assets/js/aufgaben.js
+ bekommt jeder, der die Seite oeffnet. */
+ bewerbungen: kategorienFuer(req.person).length
+ ? vorlagenBewerbungenFuer(req.person) : null,
+ darf_entscheiden: entscheidetUeberAufgaben(req.person),
+ darf_verteilen: darfAufgabenVerteilen(req.person),
});
});
@@ -1454,6 +1484,253 @@ function gleicheHerkunft(req, res, next) {
next();
}
+/* =====================================================================
+ AUS EINER VORLAGE WIRD EINE AUFGABE -- an EINER Stelle (23.09.2026)
+
+ Dieselben acht Zeilen standen im Zweig "katalog" der Uebernehmen-
+ Route. Seit eine Zusage auf eine Bewerbung dasselbe tut, braucht es
+ sie zweimal -- und zwei Fassungen desselben INSERT sind zwei
+ Gelegenheiten, dass eine davon beim naechsten Feld nicht mitgeht.
+
+ DIE FRIST WIRD IN ORTSZEIT GERECHNET. Ueber toISOString kaeme
+ zwischen Mitternacht und zwei Uhr der Vortag heraus -- derselbe
+ Fehler, der im Haus schon einmal eine Pruefung falsch rot gemacht
+ hat.
+
+ DIE KENNUNG DER VORLAGE WANDERT MIT. Nur so kann das Brett spaeter
+ zeigen, was schon uebernommen ist -- der Titel taugt dafuer nicht,
+ er darf geaendert werden. */
+function katalogAufgabeAnlegen(v, zielId, vonId) {
+ const d = new Date(Date.now() + (v.tage || 7) * 86400_000);
+ const frist = `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}`
+ + `-${String(d.getDate()).padStart(2, "0")}`;
+ db().prepare(`INSERT INTO aufgaben
+ (titel, beschreibung, status, prioritaet, verantwortlich_id, frist,
+ kategorie, vorlage, erstellt, erstellt_von)
+ VALUES (?,?,'offen',?,?,?,?,?,?,?)`)
+ .run(v.titel, v.text, v.tage <= 1 ? "hoch" : "mittel",
+ zielId, frist, v.kategorie, v.schluessel, jetzt(), vonId);
+ return db().prepare("SELECT last_insert_rowid() AS id").get().id;
+}
+
+/* Wie lang darf ein Wort zu einer Bewerbung sein? Dieselbe Groesse wie
+ drueben bei den Aufgaben -- ein Satz, kein Aufsatz. */
+const BEWERBUNG_TEXT_MAX = 600;
+
+/** Die offenen Bewerbungen, so wie sie die Seite braucht.
+ *
+ * WER WAS SIEHT, ENTSCHEIDET HIER DER SERVER: Wer entscheidet, sieht
+ * alle offenen; alle anderen sehen nur die eigenen. Im Browser zu
+ * filtern hiesse, die fremden Namen trotzdem auszuliefern -- und
+ * assets/js/aufgaben.js bekommt jeder, der die Seite oeffnet.
+ */
+function vorlagenBewerbungenFuer(person) {
+ const darf = entscheidetUeberAufgaben(person);
+ const zeilen = db().prepare(`
+ SELECT b.id, b.vorlage, b.kategorie, b.person_id, b.text, b.zustand,
+ b.entscheid_text, b.entschieden_am, b.erstellt, p.name AS person_name
+ FROM vorlagen_bewerbungen b
+ LEFT JOIN personen p ON p.id = b.person_id
+ WHERE b.zustand = 'beworben' ${darf ? "" : "AND b.person_id = ?"}
+ ORDER BY b.id`).all(...(darf ? [] : [person.id]));
+ return zeilen.map((z) => ({
+ id: z.id, vorlage: z.vorlage, kategorie: z.kategorie,
+ person_id: z.person_id, person_name: z.person_name || "Jemand",
+ text: z.text || null, erstellt: z.erstellt,
+ selbst: z.person_id === person.id,
+ }));
+}
+
+/* =====================================================================
+ SICH AUF EINE VORLAGE BEWERBEN (23.09.2026)
+
+ Filipe: "die modis sollen bei all diesen voschlaegen auch nur
+ bewerben koennen. die aufgaben aus der vorlage, da sollen die modis
+ sich nur bewerben koennen und nur dogfather und die rechte hand
+ sollen annehmen oder ablehnen koennen, mit einem text als notiz."
+
+ WER DARF WAS -- und die zweite Zeile ist die, die er ausdruecklich
+ gesagt hat:
+
+ bewerben wer NICHT verteilen darf (die Modis)
+ entscheiden DogFather und die rechte Hand
+ uebernehmen wer verteilen darf, wie bisher
+
+ DIE LINKE HAND STEHT ABSICHTLICH NICHT BEI DEN ENTSCHEIDERN. Sie
+ gehoert sonst ueberall dazu (Auftrag vom 22.09.: "ich will dass die
+ linke hand auch ueberall zu sehen ist"), hier hat Filipe genau zwei
+ genannt. Das ist keine Vergesslichkeit von mir, sondern seine
+ Aufzaehlung -- und dieselbe Grenze zieht das Haus schon bei den
+ Aufgaben-Bewerbungen (entscheidetUeberAufgaben).
+
+ DER TEXT IST FREIWILLIG BEIM BEWERBEN und freiwillig beim
+ Entscheiden. Beim ABLEHNEN einer zugeteilten Aufgabe ist eine
+ Begruendung Pflicht -- dort sagt jemand ab, der gefragt wurde. Hier
+ bittet jemand; ein "bitte" braucht keine Begruendung. Filipes Wort
+ ist "mit einem text als notiz", also eine Moeglichkeit, keine Huerde.
+ ===================================================================== */
+
+/** Die Vorlage zu einer Kennung -- oder null.
+ * Gesucht wird ueber den SCHLUESSEL und nicht ueber die Nummer in der
+ * Liste: Die Nummer verschiebt sich, sobald jemand eine Vorlage
+ * einfuegt, und dann bewirbt man sich auf etwas anderes, als man
+ * gesehen hat. Genau dieser Fehler ist am 16.09. schon einmal
+ * passiert. */
+function vorlageZuSchluessel(schluessel) {
+ return MODI_AUFGABEN_FLACH.find((v) => v.schluessel === schluessel) || null;
+}
+
+vorlagenRouter.post("/workspace/api/vorlagen/bewerben", gleicheHerkunft,
+ express.json({ limit: "4kb" }), (req, res) => {
+ try {
+ if (!kategorienFuer(req.person).length) {
+ /* Wortgleich mit einer erfundenen Vorlage -- wer den Katalog
+ nicht bekommt, soll nicht erfahren, dass es ihn gibt. */
+ return res.status(400).json({ fehler: "Unbekannte Vorlage." });
+ }
+ /* WER VERTEILEN DARF, BEWIRBT SICH NICHT. Er traegt sich ein --
+ eine Bitte an sich selbst waere ein Vorgang ohne Gegenueber,
+ und im schlimmsten Fall entscheidet jemand ueber die eigene
+ Bewerbung. */
+ if (darfAufgabenVerteilen(req.person)) {
+ return res.status(403).json({ fehler: "du_entscheidest_selbst" });
+ }
+ const v = vorlageZuSchluessel(String(req.body?.vorlage || ""));
+ if (!v) return res.status(400).json({ fehler: "Unbekannte Vorlage." });
+
+ const text = String(req.body?.text ?? "").trim().slice(0, BEWERBUNG_TEXT_MAX) || null;
+ const d = db();
+
+ /* SCHON BEWORBEN? Die Datenbank sagt es ohnehin (der Index ist
+ eindeutig) -- aber eine saubere Antwort ist besser als ein
+ Fehler aus der Tiefe. */
+ const schon = d.prepare(`SELECT id FROM vorlagen_bewerbungen
+ WHERE vorlage = ? AND person_id = ? AND zustand = 'beworben'`)
+ .get(v.schluessel, req.person.id);
+ if (schon) return res.status(409).json({ fehler: "schon_beworben" });
+
+ /* UND LIEGT SIE SCHON AUF DEM BRETT? Sich auf etwas zu bewerben,
+ das man bereits hat, ist eine Bitte ins Leere. */
+ const liegtSchon = d.prepare(`SELECT id FROM aufgaben
+ WHERE vorlage = ? AND verantwortlich_id = ? AND status <> 'erledigt'`)
+ .get(v.schluessel, req.person.id);
+ if (liegtSchon) return res.status(409).json({ fehler: "liegt_schon_bei_dir" });
+
+ d.prepare(`INSERT INTO vorlagen_bewerbungen
+ (vorlage, kategorie, person_id, text, zustand, erstellt)
+ VALUES (?,?,?,?,'beworben',?)`)
+ .run(v.schluessel, v.kategorie, req.person.id, text, jetzt());
+
+ protokolliere("vorlage_beworben", {
+ personId: req.person.id, rolle: req.person.rolle, ip: echteIp(req),
+ detail: `${v.schluessel} ${v.titel}`.slice(0, 120),
+ });
+ res.status(201).json({ ok: true, bewerbungen: vorlagenBewerbungenFuer(req.person) });
+ } catch (f) {
+ console.error("[vorlagen] bewerben:", f?.message);
+ res.status(503).json({ fehler: "nicht_verfuegbar" });
+ }
+ });
+
+/* ---- Die eigene Bewerbung zuruecknehmen ------------------------------
+ Sie wird GELOESCHT und nicht auf "zurueckgezogen" gesetzt: Wer sich
+ nicht mehr bewirbt, 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. Dieselbe Entscheidung wie bei
+ den Aufgaben-Bewerbungen. */
+vorlagenRouter.post("/workspace/api/vorlagen/bewerbung-zurueck", gleicheHerkunft,
+ express.json({ limit: "2kb" }), (req, res) => {
+ try {
+ const id = Number(req.body?.id);
+ if (!Number.isInteger(id)) return res.status(400).json({ fehler: "ungueltig" });
+ const b = db().prepare("SELECT * FROM vorlagen_bewerbungen WHERE id = ?").get(id);
+ /* NICHT MEINE = GIBT ES NICHT. Ein 403 verriete, dass es sie
+ gibt und wem sie gehoert. */
+ if (!b || b.person_id !== req.person.id || b.zustand !== "beworben") {
+ return res.status(404).json({ fehler: "nicht_gefunden" });
+ }
+ db().prepare("DELETE FROM vorlagen_bewerbungen WHERE id = ?").run(id);
+ protokolliere("vorlage_bewerbung_zurueck", {
+ personId: req.person.id, rolle: req.person.rolle, ip: echteIp(req),
+ detail: String(b.vorlage).slice(0, 120),
+ });
+ res.json({ ok: true, bewerbungen: vorlagenBewerbungenFuer(req.person) });
+ } catch (f) {
+ console.error("[vorlagen] bewerbung-zurueck:", f?.message);
+ res.status(503).json({ fehler: "nicht_verfuegbar" });
+ }
+ });
+
+/* ---- Entscheiden: annehmen oder ablehnen ------------------------------
+
+ EINE SCHLEIFE FUER BEIDE WEGE. Zwei fast gleiche Routen waeren zwei
+ Gelegenheiten, dass eine die Rechtepruefung oder das Protokoll
+ vergisst -- und es ist immer die zweite.
+
+ BEI DER ZUSAGE ENTSTEHT DIE AUFGABE, und zwar ueber dieselbe
+ Funktion wie beim Uebernehmen (katalogAufgabeAnlegen). Damit sieht
+ eine erbetene Aufgabe genauso aus wie eine verteilte: gleiche Frist,
+ gleiche Kategorie, gleiche Kennung. Ein zweiter Weg waere ein
+ zweiter Satz Regeln. */
+for (const [weg, ja] of [["annehmen", true], ["ablehnen", false]]) {
+ vorlagenRouter.post(`/workspace/api/vorlagen/bewerbung/${weg}`, gleicheHerkunft,
+ express.json({ limit: "4kb" }), (req, res) => {
+ try {
+ /* NUR DOGFATHER UND DIE RECHTE HAND -- woertlich nach Filipes
+ Aufzaehlung, und dieselbe Schranke wie bei den
+ Aufgaben-Bewerbungen. */
+ if (!entscheidetUeberAufgaben(req.person)) {
+ return res.status(403).json({ fehler: "nicht_erlaubt" });
+ }
+ const id = Number(req.body?.id);
+ if (!Number.isInteger(id)) return res.status(400).json({ fehler: "ungueltig" });
+
+ const d = db();
+ const b = d.prepare("SELECT * FROM vorlagen_bewerbungen WHERE id = ?").get(id);
+ if (!b || b.zustand !== "beworben") {
+ return res.status(409).json({ fehler: "keine_bewerbung" });
+ }
+ const v = vorlageZuSchluessel(b.vorlage);
+ if (!v) return res.status(409).json({ fehler: "vorlage_weg" });
+
+ /* GIBT ES DIE PERSON NOCH? Zwischen Bewerbung und Antwort
+ koennen Tage liegen. Eine Aufgabe an einen abgeschalteten
+ Zugang waere Arbeit, die nie jemand sieht. */
+ const wer = d.prepare(
+ "SELECT id, name FROM personen WHERE id = ? AND aktiv = 1").get(b.person_id);
+ if (!wer) return res.status(409).json({ fehler: "person_weg" });
+
+ const kommentar = String(req.body?.kommentar ?? "").trim()
+ .slice(0, BEWERBUNG_TEXT_MAX) || null;
+ const nun = jetzt();
+ let aufgabeId = null;
+
+ if (ja) {
+ aufgabeId = katalogAufgabeAnlegen(v, wer.id, req.person.id);
+ }
+ d.prepare(`UPDATE vorlagen_bewerbungen
+ SET zustand = ?, entscheid_text = ?, entschieden_von = ?,
+ entschieden_am = ?, aufgabe_id = ?
+ WHERE id = ?`)
+ .run(ja ? "angenommen" : "abgelehnt", kommentar, req.person.id,
+ nun, aufgabeId, id);
+
+ protokolliere(ja ? "vorlage_bewerbung_angenommen" : "vorlage_bewerbung_abgelehnt", {
+ personId: req.person.id, rolle: req.person.rolle, ip: echteIp(req),
+ detail: `${b.vorlage} fuer ${wer.name}`.slice(0, 120),
+ });
+ res.json({
+ ok: true, zustand: ja ? "angenommen" : "abgelehnt",
+ aufgabe_id: aufgabeId,
+ bewerbungen: vorlagenBewerbungenFuer(req.person),
+ });
+ } catch (f) {
+ console.error(`[vorlagen] bewerbung/${weg}:`, f?.message);
+ res.status(503).json({ fehler: "nicht_verfuegbar" });
+ }
+ });
+}
+
vorlagenRouter.post("/workspace/api/vorlagen/uebernehmen", gleicheHerkunft,
express.json({ limit: "16kb" }), (req, res) => {
try {
@@ -1572,9 +1849,26 @@ vorlagenRouter.post("/workspace/api/vorlagen/uebernehmen", gleicheHerkunft,
bekommt, soll nicht erfahren, dass es ihn gibt. */
return res.status(400).json({ fehler: "Unbekannte Art." });
}
- const zielId = req.person.rolle === "modi"
- ? req.person.id
- : Number(req.body.person_id) || null;
+ /* ==== WER SICH HIER SELBST ETWAS NIMMT (23.09.2026) ======
+
+ Filipe: "die modis sollen bei all diesen voschlaegen auch
+ nur bewerben koennen."
+
+ Bis heute stand hier: Ein Modi nimmt sich die Aufgabe
+ selbst. Jetzt geht das ueber eine Bewerbung -- und zwar
+ HIER abgelehnt und nicht nur im Browser ausgeblendet. Ein
+ ausgeblendeter Knopf ist eine Bitte; abgelehnt wird an
+ dieser Stelle.
+
+ GEPRUEFT WIRD "DARF VERTEILEN" UND NICHT "IST MODI": Die
+ Frage ist nicht, wer jemand IST, sondern was er darf. So
+ gilt die Regel auch fuer jede Rolle, die morgen dazukommt --
+ und in dieser Datei steht kein Rollenname, der nicht
+ hineingehoert. */
+ if (!darfAufgabenVerteilen(req.person)) {
+ return res.status(403).json({ fehler: "erst_bewerben" });
+ }
+ const zielId = Number(req.body.person_id) || null;
if (!zielId) return res.status(400).json({ fehler: "Bitte zuerst eine Person wählen." });
/* Nur an jemanden, fuer den die Kategorien ueberhaupt gelten --
sonst landete eine Modi-Aufgabe samt Kategorie an einem
@@ -1607,17 +1901,7 @@ vorlagenRouter.post("/workspace/api/vorlagen/uebernehmen", gleicheHerkunft,
return res.status(400).json({ fehler: "Unbekannte Vorlage." });
}
for (const v of auswahl) {
- /* Frist in ORTSZEIT gerechnet. Ueber toISOString kaeme
- zwischen Mitternacht und zwei Uhr der Vortag heraus. */
- const d = new Date(Date.now() + (v.tage || 7) * 86400_000);
- const frist = `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}`
- + `-${String(d.getDate()).padStart(2, "0")}`;
- db().prepare(`INSERT INTO aufgaben
- (titel, beschreibung, status, prioritaet, verantwortlich_id, frist,
- kategorie, vorlage, erstellt, erstellt_von)
- VALUES (?,?,'offen',?,?,?,?,?,?,?)`)
- .run(v.titel, v.text, v.tage <= 1 ? "hoch" : "mittel",
- zielPerson.id, frist, v.kategorie, v.schluessel, jetzt(), req.person.id);
+ katalogAufgabeAnlegen(v, zielPerson.id, req.person.id);
angelegt.push(v.titel);
}
diff --git a/server/workspace.js b/server/workspace.js
index a02dfb98..a0a81c19 100644
--- a/server/workspace.js
+++ b/server/workspace.js
@@ -4542,6 +4542,69 @@ export function db() {
KEIN ON DELETE CASCADE AUF personen: Wer das Haus verlaesst,
nimmt die GIFs des Teams nicht mit. "von_id" wird null, die
Kachel bleibt. */
+ /* BEWERBUNGEN AUF EINE VORLAGE (23.09.2026).
+
+ Filipe: "die modis sollen bei all diesen voschlaegen auch nur
+ bewerben koennen. die aufgaben aus der vorlage, da sollen die
+ modis sich nur bewerben koennen und nur dogfather und die
+ rechte hand sollen annehmen oder ablehnen koennen, mit einem
+ text als notiz."
+
+ WARUM NICHT DIE VORHANDENE BEWERBUNG AUS aufgaben_zuteilung:
+ Die beantwortet "wie steht DIESE Aufgabe bei DIESEM Menschen"
+ -- sie braucht also eine Aufgabe, die es gibt. Hier gibt es
+ noch keine. Es ist eine Bitte um etwas, das erst entstehen
+ soll.
+
+ Der naheliegende Weg waere gewesen, beim Bewerben gleich die
+ Aufgabe anzulegen und die vorhandene Bewerbung daranzuhaengen.
+ Dann steht nach zwoelf Absagen zwoelfmal Arbeit auf dem Brett,
+ die niemand bestellt hat -- und um das zu vermeiden, muesste
+ das Ablehnen Aufgaben wieder LOESCHEN. Loeschen als Nebenwirkung
+ einer Absage ist genau die Sorte Regel, die irgendwann das
+ Falsche trifft.
+
+ Also: Die Aufgabe entsteht erst mit der Zusage. Bis dahin gibt
+ es nur diese Zeile.
+
+ DIE WORTE SIND DIESELBEN WIE DRUEBEN (zustand, entscheid_text,
+ entschieden_von, entschieden_am). Zwei Namen fuer dieselbe
+ Sache waeren zwei Sprachen im selben Haus.
+
+ "aufgabe_id" steht nach der Zusage darin -- damit laesst sich
+ von der Bewerbung aus zeigen, was aus ihr geworden ist. Kein
+ Fremdschluessel: Wird die Aufgabe spaeter geloescht, bleibt die
+ Bewerbung als Vorgang stehen; sie hat stattgefunden.
+
+ ON DELETE CASCADE auf personen: Wer das Haus verlaesst,
+ hinterlaesst keine Bewerbung, auf die jemand noch antworten
+ soll. */
+ CREATE TABLE IF NOT EXISTS vorlagen_bewerbungen (
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
+ vorlage TEXT NOT NULL,
+ kategorie TEXT,
+ person_id INTEGER NOT NULL REFERENCES personen(id) ON DELETE CASCADE,
+ text TEXT,
+ zustand TEXT NOT NULL DEFAULT 'beworben',
+ entscheid_text TEXT,
+ entschieden_von INTEGER REFERENCES personen(id) ON DELETE SET NULL,
+ entschieden_am TEXT,
+ aufgabe_id INTEGER,
+ erstellt TEXT NOT NULL
+ );
+ /* EINMAL BEWERBEN, NICHT DREIMAL -- und zwar von der Datenbank
+ durchgesetzt, nicht von einer Abfrage davor, die man vergessen
+ kann.
+
+ NUR AUF OFFENE: Wer abgelehnt wurde, darf sich spaeter wieder
+ bewerben (Lage aendert sich, Woche aendert sich). Ein Index
+ ueber ALLE Zustaende haette genau das verboten. */
+ CREATE UNIQUE INDEX IF NOT EXISTS idx_vorlagen_bewerbung_offen
+ ON vorlagen_bewerbungen (vorlage, person_id) WHERE zustand = 'beworben';
+ /* Die Frage im Betrieb lautet "was wartet gerade auf Antwort?" */
+ CREATE INDEX IF NOT EXISTS idx_vorlagen_bewerbung_zustand
+ ON vorlagen_bewerbungen (zustand, id);
+
CREATE TABLE IF NOT EXISTS chat_gifs (
id INTEGER PRIMARY KEY AUTOINCREMENT,
datei TEXT NOT NULL,
diff --git a/workspace/anruf-probe.html b/workspace/anruf-probe.html
index f3869d62..d7310796 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`. -->
-
-
+
+
-
+