From e736a12cff5335018b93b83b80547f936523eb4a Mon Sep 17 00:00:00 2001 From: Dogfather Date: Wed, 23 Sep 2026 10:59:51 +0200 Subject: [PATCH] Vorlagenbrett: Modis bewerben sich, DogFather und die rechte Hand entscheiden 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." WAS SICH AENDERT ---------------- Auf dem Vorlagenbrett steht fuer einen Modi jetzt "Bewerben" statt "Uebernehmen". Wer sich beworben hat, sieht das an der Karte -- samt dem Satz, WER antwortet, und einem Weg zurueck. DogFather und die rechte Hand sehen die Bewerbung an derselben Karte, mit Namen und dem Wort dazu, und daneben "Annehmen" und "Ablehnen". Beide fragen nach einer Notiz. "ALSO NUR" GILT AUCH AM SERVER, nicht nur im Browser: Die alte Tuer antwortet einem Modi mit 403 und dem Satz, was stattdessen geht. Ein ausgeblendeter Knopf ist eine Bitte, abgelehnt wird in der Route. DIE LINKE HAND STEHT ABSICHTLICH NICHT BEI DEN ENTSCHEIDERN ------------------------------------------------------------ Sie gehoert seit dem 22.09. ueberall dazu ("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). Sie darf weiter VERTEILEN; das hat er nicht angefasst. Sie ist deshalb die schaerfste Probe in der Pruefung: Wer statt "darf entscheiden" nur "darf verteilen" abfragt, laesst sie mitentscheiden -- und niemandem faellt es auf, weil alles funktioniert. DIE AUFGABE ENTSTEHT ERST MIT DER ZUSAGE ---------------------------------------- Der naheliegende Weg waere gewesen, beim Bewerben gleich die Aufgabe anzulegen und die vorhandene Bewerbung aus aufgaben_zuteilung daranzuhaengen. Dann stuende nach zwoelf Absagen zwoelfmal Arbeit auf dem Brett, die niemand bestellt hat -- und um das einzufangen, muesste das Ablehnen Aufgaben LOESCHEN. Loeschen als Nebenwirkung einer Absage ist genau die Sorte Regel, die irgendwann das Falsche trifft. Also eine eigene, kleine Tabelle (vorlagen_bewerbungen). Bis jemand ja sagt, gibt es nur eine Zeile. Die Woerter sind dieselben wie drueben (zustand, entscheid_text, entschieden_von) -- zwei Namen fuer dieselbe Sache waeren zwei Sprachen im selben Haus. Und die Zusage legt die Aufgabe ueber DIESELBE Funktion an wie das Uebernehmen (katalogAufgabeAnlegen, neu, aus dem Katalog-Zweig herausgeloest). Damit sieht eine erbetene Aufgabe aus wie eine verteilte: gleiche Frist, gleiche Kategorie, gleiche Kennung. Ein zweiter Weg waere ein zweiter Satz Regeln. KLEINIGKEITEN, DIE SONST WEHTUN ------------------------------- * "Alle 12 uebernehmen" gibt es nur fuer die, die verteilen. Ein "Alle bewerben" waere der schnellste Weg, zwoelf Bitten auf einmal loszuschicken -- und damit zwoelf Entscheidungen fuer jemand anderen. * Wer eine Aufgabe schon hat, bekommt keinen Bewerben-Knopf. Der Server lehnt das ohnehin ab; ein Knopf, der eine Absage holt, ist schlimmer als keiner. * Nach einer Absage darf man sich wieder bewerben. Der eindeutige Index gilt deshalb nur fuer OFFENE Bewerbungen -- ueber alle Zustaende waere eine Absage ein Bann. * Gesucht wird ueber den SCHLUESSEL der Vorlage, nicht ueber die Nummer in der Liste. Die Nummer verschiebt sich, sobald jemand eine Vorlage einfuegt -- genau dieser Fehler ist am 16.09. schon einmal passiert. GEPRUEFT -------- pruef-modi-katalog: 95 Pruefungen, 0 Fehler (vorher 49). Die Pruefung ist beim Umbau ROT geworden -- 9 Zeilen, alle dort, wo ein Modi sich selbst etwas nahm. Richtig so, sie hat die Aenderung bemerkt. Sie steht jetzt auf dem neuen Weg und misst ihn ganz: * der Modi kommt an die alte Tuer nicht mehr heran (403, erst_bewerben) * die Bewerbung legt NOCH KEINE Aufgabe an * die linke Hand darf verteilen, aber nicht entscheiden (403) * der Bewerber selbst erst recht nicht (403) * die Zusage erzeugt die Aufgabe -- mit Kategorie, Frist, Besitzer * die Notizen stehen in der Datenbank, samt WER entschieden hat (direkt gelesen: ein Feld, das der Server annimmt und nirgends speichert, saehe von aussen genauso aus) * nach einer Absage geht es wieder * am Bildschirm: alle Knoepfe heissen "Bewerben", kein einziger "Uebernehmen" mehr, die wartende Karte nennt, wer antwortet -- und DogFather klickt sich durch Annehmen samt Notizfeld, bis die Aufgabe auf dem Brett steht Die Gegenprobe in Abschnitt 7 lief mit dem Zugang des Modis und haette ab heute nur noch bewiesen, dass die Rechtepruefung greift -- sie benutzt jetzt DogFather. Genau so verliert eine Pruefung still ihren Sinn. pruef-aufgaben-vorlagen: unveraendert gruen. ZWEI FUNDE NEBENHER, BEIDE AELTER ALS DIESE AENDERUNG -- gemessen, nicht vermutet (mit gestashten Aenderungen gegengeprueft): * pruef-modi-wortleck ist seit dem 22.09. rot: Der Rollenname steht in team.css und teamlage.js, also in Dateien, die jeder bekommt. * pruef-zuteilung stuerzt seit laengerem ab (#neu-oeffnen ist verborgen). Beides kommt als naechstes, getrennt. Co-Authored-By: Claude Opus 5 --- server/pruef-modi-katalog.mjs | 398 +++++++++++++++++++++++++++--- server/workspace-vorlagen.js | 316 ++++++++++++++++++++++-- server/workspace.js | 63 +++++ workspace/anruf-probe.html | 10 +- workspace/app.webmanifest | 8 +- workspace/assets/css/aufgaben.css | 68 +++++ workspace/assets/js/aufgaben.js | 270 ++++++++++++++++++-- workspace/assets/js/meldung.js | 16 ++ workspace/aufgaben.html | 36 +-- workspace/automation.html | 32 +-- workspace/befinden.html | 32 +-- workspace/bereich.html | 44 ++-- workspace/bewerben.html | 36 +-- workspace/bewerbungen.html | 34 +-- workspace/calls.html | 34 +-- workspace/chat.html | 38 +-- workspace/content.html | 34 +-- workspace/crew-index.html | 12 +- workspace/crew.webmanifest | 8 +- workspace/dateien.html | 34 +-- workspace/entwicklung.html | 32 +-- workspace/hilfe.html | 28 +-- workspace/index.html | 10 +- workspace/kalender.html | 34 +-- workspace/leistung.html | 32 +-- workspace/material.html | 32 +-- workspace/personen.html | 32 +-- workspace/profil.html | 36 +-- workspace/rechte.html | 32 +-- workspace/report.html | 36 +-- workspace/scouting.html | 32 +-- workspace/start.html | 32 +-- workspace/startcheck.html | 34 +-- workspace/steckbrief.html | 36 +-- workspace/talente.html | 34 +-- workspace/team.html | 32 +-- workspace/teamlage.html | 32 +-- workspace/teilen.html | 28 +-- workspace/treff-moderation.html | 30 +-- workspace/treff-regeln.html | 30 +-- workspace/uebersicht.html | 32 +-- workspace/unsere-seiten.html | 28 +-- workspace/werdegang.html | 30 +-- workspace/willkommen.html | 30 +-- workspace/wissen.html | 34 +-- 45 files changed, 1652 insertions(+), 649 deletions(-) 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`. --> - - + + - +