diff --git a/server/pruef-verteilen.mjs b/server/pruef-verteilen.mjs index 628022a5..8b267823 100644 --- a/server/pruef-verteilen.mjs +++ b/server/pruef-verteilen.mjs @@ -263,6 +263,102 @@ ok(vonModi.code === 403, "der leere Wert heisst „noch niemand“ und nicht nur „—“"); } +/* ===================================================================== + BEARBEITEN UND LOESCHEN, WAS SIE ANGELEGT HAT (25.09.2026) + ===================================================================== + + Filipe: „kuemmer dich bitte auch drum dass die rechte hand, wenn sie + aufgaben an die modis oder linke hand erstellt, will ich dass sie + die moeglichkeit hat die auch zu bearbeiten und zu loeschen bitte. + perfektionier das fuer sie und fuer dogfather." + + WARUM ES VORHER NICHT GING, und es sah nicht danach aus: + `creator_id` heisst nicht „wer hat sie angelegt", sondern „zu wem + gehoert sie". Verteilt die rechte Hand an einen Modi, steht dort der + MODI. Sie erfuellte damit an ihrer eigenen Aufgabe keine der drei + Bedingungen von `darfAendern` und bekam 403 -- auf einen Knopf, den + die Oberflaeche ihr trotzdem zeigte. Die Spalte `erstellt_von` gibt + es seit jeher; sie stand nur nie in dieser Regel. + + GEPRUEFT WIRD BEIDES: dass sie darf -- und dass ein Modi es NICHT + darf. Ohne die zweite Haelfte waere „sie darf" auch dann gruen, wenn + jeder alles duerfte. + ===================================================================== */ +melde("\n=== 5. Die rechte Hand an ihren eigenen Aufgaben ==="); +{ + const neu = await ruf("/workspace/api/aufgaben", kHand, "POST", + { titel: "Von der rechten Hand, zum Aendern", verantwortlich_id: idModi, + prioritaet: "mittel" }); + const id = json(neu).id; + ok(neu.code === 201 && Number.isInteger(id), + `die rechte Hand legt eine Aufgabe fuer Diene an (${neu.code}, #${id})`); + + /* DIE AUSKUNFT AN DIE OBERFLAECHE ZUERST. Sie entscheidet, ob der + Knopf ueberhaupt erscheint -- und genau sie hat gefehlt: Bisher + fragte die Seite `darf_verteilen` (eine Auskunft ueber die + PERSON), waehrend Loeschen an der AUFGABE haengt. */ + const ihre = (json(await ruf("/workspace/api/aufgaben", kHand)).aufgaben || []) + .find((a) => a.id === id); + ok(!!ihre && ihre.darf_aendern === true, + `sie bekommt gesagt, dass sie sie aendern darf (${ihre?.darf_aendern})`); + ok(!!ihre && ihre.darf_loeschen === true, + `und dass sie sie loeschen darf (${ihre?.darf_loeschen})`); + + const geaendert = await ruf(`/workspace/api/aufgaben/${id}`, kHand, "PATCH", + { titel: "Von der rechten Hand, geaendert" }); + ok(geaendert.code === 200, `und sie aendert sie wirklich (${geaendert.code})`); + + /* GEGENPROBE 1: Der Modi, bei dem sie LIEGT, darf sie aendern (das + ist sein Arbeitsstand) -- aber nicht loeschen. Verschwinden lassen + ist keine Antwort auf eine Aufgabe; ablehnen und abbrechen sind + die Wege dafuer. */ + const beiModi = (json(await ruf("/workspace/api/aufgaben", kModi)).aufgaben || []) + .find((a) => a.id === id); + ok(!!beiModi && beiModi.darf_loeschen === false, + `Gegenprobe: dem Modi wird Loeschen NICHT angeboten (${beiModi?.darf_loeschen})`); + const modiWeg = await ruf(`/workspace/api/aufgaben/${id}`, kModi, "DELETE"); + ok(modiWeg.code === 403, + ` und der Server lehnt es auch ab (${modiWeg.code})`); + + /* GEGENPROBE 2: Die Aufgabe steht danach noch da. Ein abgelehnter + Versuch, der trotzdem etwas aendert, waere der schlimmste Ausgang. */ + const d2 = new DatabaseSync(process.env.WORKSPACE_DB); + const nochDa = d2.prepare("SELECT titel FROM aufgaben WHERE id = ?").get(id); + d2.close(); + ok(!!nochDa, ` und die Aufgabe steht noch ("${nochDa?.titel}")`); + + /* UND JETZT SIE SELBST. */ + const handWeg = await ruf(`/workspace/api/aufgaben/${id}`, kHand, "DELETE"); + ok(handWeg.code === 200, `die rechte Hand loescht ihre eigene (${handWeg.code})`); + const d3 = new DatabaseSync(process.env.WORKSPACE_DB); + const weg = d3.prepare("SELECT 1 AS x FROM aufgaben WHERE id = ?").get(id); + d3.close(); + ok(!weg, " und sie ist wirklich weg"); + + /* UND DOGFATHER UNVERAENDERT -- „perfektionier das fuer sie UND fuer + dogfather". Seine Rechte kamen aus `istLeitung` und bleiben, wo + sie waren; das hier ist der Nachweis, dass der Umbau sie nicht + nebenbei mitgenommen hat. */ + const seine = await ruf("/workspace/api/aufgaben", kDogi, "POST", + { titel: "Von DogFather, zum Loeschen", verantwortlich_id: idModi2, + prioritaet: "mittel" }); + const id2 = json(seine).id; + const dogiWeg = await ruf(`/workspace/api/aufgaben/${id2}`, kDogi, "DELETE"); + ok(seine.code === 201 && dogiWeg.code === 200, + `DogFather legt an und loescht wie bisher (${seine.code} / ${dogiWeg.code})`); + + /* UND EINE FREMDE: Die rechte Hand an einer Aufgabe, die DogFather + fuer sich selbst angelegt hat. Sie hat sie nicht angelegt, sie + liegt nicht bei ihr -- also nein. Ohne diese Zeile hiesse „sie + darf ihre eigenen" in Wahrheit vielleicht „sie darf alle". */ + const fremd = await ruf("/workspace/api/aufgaben", kDogi, "POST", + { titel: "Nur fuer DogFather", verantwortlich_id: idDogi, prioritaet: "mittel" }); + const id3 = json(fremd).id; + const handFremd = await ruf(`/workspace/api/aufgaben/${id3}`, kHand, "DELETE"); + ok(fremd.code === 201 && handFremd.code === 403, + `Gegenprobe: eine fremde Aufgabe loescht sie nicht (${handFremd.code})`); +} + console.log(`\n${fehler ? fehler + " FEHLER" : "Alles in Ordnung"} — ${geprueft} Prüfungen\n`); try { rmSync(ordner, { recursive: true, force: true }); } catch { /* egal */ } process.exit(fehler ? 1 : 0); diff --git a/server/workspace-aufgaben.js b/server/workspace-aufgaben.js index 8cdb0183..26dcea6a 100644 --- a/server/workspace-aufgaben.js +++ b/server/workspace-aufgaben.js @@ -327,7 +327,53 @@ function sichtbarRoh(person) { function darfAendern(person, aufgabe) { if (istLeitung(person)) return true; - return aufgabe.creator_id === person.id || aufgabe.verantwortlich_id === person.id; + /* WER SIE ANGELEGT HAT, DARF SIE AUCH AENDERN (25.09.2026). + + Filipe: „kuemmer dich bitte auch drum dass die rechte hand, wenn + sie aufgaben an die modis oder linke hand erstellt, will ich dass + sie die moeglichkeit hat die auch zu bearbeiten und zu loeschen." + + WARUM DAS VORHER NICHT GING, obwohl es so aussah: `creator_id` + sagt nicht, WER die Aufgabe angelegt hat -- es sagt, ZU WEM sie + gehoert (so steht es am Tabellenkopf). Verteilt die rechte Hand + eine Aufgabe an einen Modi, steht dort der Modi. Sie hatte damit + an ihrer eigenen Aufgabe keine einzige der drei Bedingungen + erfuellt und bekam vom Server ein 403 -- auf einen Knopf, den die + Oberflaeche ihr trotzdem zeigte. + + `erstellt_von` GIBT ES SEIT JEHER und wird beim Anlegen gefuellt; + die Sichtbarkeitsregeln fragen sie an sechs Stellen ab. Sie stand + nur nie in dieser Zeile. + + DIE REGEL IST ALLGEMEIN, nicht auf eine Rolle gemuenzt: Wer etwas + angelegt hat, darf es auch wieder anfassen. Ein Rollenname hier + waere die naechste zweite Wahrheit -- und in diesem Haus ist genau + das in derselben Woche schon dreimal veraltet. */ + return aufgabe.creator_id === person.id + || aufgabe.verantwortlich_id === person.id + || aufgabe.erstellt_von === person.id; +} + +/* ===== UND LOESCHEN? ================================================= + + Loeschen ist das Einzige, was sich nicht zuruecknehmen laesst -- + deshalb eine eigene Frage und nicht einfach `darfAendern`. + + ZWEI BEDINGUNGEN, UND BEIDE MUESSEN STIMMEN: + + 1. Die Person darf Aufgaben ueberhaupt VERTEILEN. Damit ist + ausgeschlossen, dass jemand, dem eine Aufgabe nur zugeteilt + wurde, sie loescht statt sie abzulehnen -- ein Modi soll sie + ablehnen oder abbrechen, nicht verschwinden lassen. + 2. Die Person darf sie aendern (siehe oben). Damit ist es genau + das, was Filipe gesagt hat: ihre eigenen. + + DOGFATHER UND DIE MANAGER bleiben unveraendert: `istLeitung` macht + `darfAendern` fuer sie immer wahr, und verteilen duerfen sie + ohnehin. Die Zeile ist damit eine Erweiterung, keine Einschraenkung + -- nachgewiesen in pruef-aufgaben-loeschen mit einer Gegenprobe. */ +function darfLoeschen(person, aufgabe) { + return darfAufgabenVerteilen(person) && darfAendern(person, aufgabe); } const SPALTEN = ` @@ -346,6 +392,16 @@ const SPALTEN = ` daran haengt, ob eine uebernommene Aufgabe fuer die anderen noch offen ist. */ a.verteilart, + /* WER SIE ANGELEGT HAT (25.09.2026). Sie stand in den + Sichtbarkeitsregeln laengst, fehlte aber in dieser Spaltenliste -- + und damit in jeder Aufgabe, die darfAendern zu sehen bekam. Ohne + sie waere die neue Regel eine Zeile, die nie zutrifft: ein + Vergleich gegen undefined. + + KEINE RUECKSTRICHE IN DIESEM KOMMENTAR: Die Liste ist ein + Vorlagentext (Backticks), und ein Rueckstrich darin beendet ihn + mitten im Satz. Beim ersten Anlauf hat node genau das gemeldet. */ + a.erstellt_von, /* Die Vorlage, aus der sie entstanden ist (09.09.2026) -- damit das Vorlagenbrett zeigen kann, was schon geholt wurde. */ a.vorlage, @@ -423,7 +479,16 @@ aufgabenRouter.get("/workspace/api/aufgaben", (req, res) => { Schreiben. Zwei Stellen, die dieselbe Frage beantworten, geben irgendwann zwei Antworten -- und die falsche ist dann die sichtbare. */ - for (const r of reihen) r.darf_aendern = darfAendern(req.person, r); + for (const r of reihen) { + r.darf_aendern = darfAendern(req.person, r); + /* DIE OBERFLAECHE SOLL DEN KNOPF NUR ZEIGEN, WENN ER GEHT. + Bisher entschied sie es an `darf_verteilen` -- einer Auskunft + ueber die PERSON. Loeschen haengt aber an der AUFGABE: Die + rechte Hand darf verteilen, sah also den Knopf an jeder + Aufgabe, und der Server sagte bei fremden 403. Ein Knopf, der + eine Absage holt, ist schlimmer als keiner. */ + r.darf_loeschen = darfLoeschen(req.person, r); + } /* WER HAT DIESE AUFGABE -- UND WIE STEHT SIE BEI IHM (21.09.2026). In EINER Abfrage fuer alle Zeilen; eine je Aufgabe waeren bei @@ -1124,12 +1189,31 @@ aufgabenRouter.post("/workspace/api/aufgaben/:id/wiederaufnehmen", gleicheHerkun aufgabenRouter.delete("/workspace/api/aufgaben/:id", gleicheHerkunft, (req, res) => { try { - if (!istLeitung(req.person)) return res.status(403).json({ fehler: "nicht_erlaubt" }); const id = Number(req.params.id); if (!Number.isInteger(id)) return res.status(400).json({ fehler: "ungueltig" }); - const aufgabe = db().prepare("SELECT id, titel FROM aufgaben WHERE id = ?").get(id); + /* ERST MIT DER SICHTBARKEITSREGEL HOLEN, dann fragen, ob sie + geloescht werden darf (25.09.2026). + + Vorher stand hier `if (!istLeitung) 403` und danach ein + ungefiltertes `SELECT ... WHERE id = ?`. Zwei Dinge sind daran + jetzt anders: + + * Die rechte Hand darf ihre eigenen loeschen -- das ist der + Auftrag. Die Rollenfrage allein konnte das nicht + beantworten, weil sie die AUFGABE nicht ansieht. + * Was jemand nicht sehen darf, gibt es fuer ihn nicht: 404 + statt 403. Sonst liesse sich durch Ausprobieren + herausfinden, welche Nummern vergeben sind -- dieselbe + Ueberlegung wie beim Aendern eine Route weiter oben, wo sie + schon stand. */ + const { wo, werte } = sichtbar(req.person); + const aufgabe = db().prepare( + `SELECT a.* ${VERBUND} WHERE ${wo} AND a.id = ?`).get(...werte, id); if (!aufgabe) return res.status(404).json({ fehler: "nicht_gefunden" }); + if (!darfLoeschen(req.person, aufgabe)) { + return res.status(403).json({ fehler: "nicht_erlaubt" }); + } db().prepare("DELETE FROM aufgaben WHERE id = ?").run(id); protokolliere("aufgabe_geloescht", { diff --git a/tools/aufgaben-doppelte.mjs b/tools/aufgaben-doppelte.mjs new file mode 100644 index 00000000..32ede87e --- /dev/null +++ b/tools/aufgaben-doppelte.mjs @@ -0,0 +1,185 @@ +/* ===================================================================== + DOPPELT VERTEILTE AUFGABEN AUFRAEUMEN + ===================================================================== + + Filipe, 25.09.2026, zu einer Karte mit „Diene ×6 · Ghost ×6 · + Marina ×6 · Miss ×6" und „0 von 24": + „mach aus den 6 1 mal bitte, ich hab mich da geirrt." + + WAS PASSIERT IST: Dieselbe Vorlage wurde mehrfach an dieselben + Menschen verteilt. Das Brett zeigt das seit dem 22.09. selbst an + (das Band „N Aufgaben stehen doppelt im Brett" und das „×N" an den + Namen) -- es konnte es bisher nur nicht aufraeumen. + + --------------------------------------------------------------------- + WAS DIESES WERKZEUG TUT + + Es sucht Gruppen aus derselben VORLAGE bei derselben PERSON und + laesst von jeder Gruppe genau EINE Aufgabe stehen. + + WELCHE BLEIBT, und das ist nicht beliebig: + 1. eine erledigte, wenn es sie gibt -- getane Arbeit wirft man + nicht weg, auch nicht versehentlich; + 2. sonst eine begonnene; + 3. sonst die aelteste (kleinste Nummer) -- die, an der die + Frist und die Vorgeschichte haengen. + + --------------------------------------------------------------------- + ES TUT VON SICH AUS NICHTS + + Ohne `--wirklich` wird nur gezeigt, was passieren WUERDE. Das ist + Absicht: Dies ist ein laufendes System, und Loeschen ist das + Einzige, was sich nicht zuruecknehmen laesst. + + MIT `--wirklich` wird ZUERST eine Kopie der Datenbank angelegt und + ihr Pfad ausgegeben, und erst dann geloescht. Geht etwas schief, + steht in einer Zeile, wie man zurueckkommt. + + `PRAGMA foreign_keys = ON` ist keine Foermlichkeit: Ohne sie bleiben + die Zuteilungs-Zeilen (`aufgaben_zuteilung`) als Reste zurueck, die + auf eine Aufgabe zeigen, die es nicht mehr gibt. + + --------------------------------------------------------------------- + AUFRUFEN + + node tools/aufgaben-doppelte.mjs (nur ansehen) + node tools/aufgaben-doppelte.mjs --wirklich (aufraeumen) + + Mit WORKSPACE_DB laesst sich eine andere Datenbank angeben -- so + probiert man es zuerst auf einer Kopie aus. + ===================================================================== */ +import { DatabaseSync } from "node:sqlite"; +import { existsSync } from "node:fs"; +import { dirname, join } from "node:path"; +import { fileURLToPath } from "node:url"; + +const HIER = dirname(fileURLToPath(import.meta.url)); +const PFAD = process.env.WORKSPACE_DB + || join(HIER, "..", "..", "workspace-daten", "workspace.db"); + +if (!existsSync(PFAD)) { + console.error(`ABBRUCH: Datenbank nicht gefunden: ${PFAD}`); + console.error("Mit WORKSPACE_DB= den richtigen Ort angeben."); + process.exit(2); +} + +const wirklich = process.argv.includes("--wirklich"); +console.log(`Datenbank: ${PFAD}`); +console.log(wirklich ? "Modus: AUFRAEUMEN" : "Modus: nur ansehen (--wirklich fehlt)"); +console.log(""); + +const d = new DatabaseSync(PFAD); +d.exec("PRAGMA foreign_keys = ON"); + +/* Nur Aufgaben AUS EINER VORLAGE und nur mit einer verantwortlichen + Person. Zwei frei getippte Aufgaben mit demselben Titel sind keine + Dublette -- vielleicht macht jemand dasselbe zweimal mit Absicht. + Die Vorlage ist der Beleg, dass es derselbe Handgriff war. */ +const reihen = d.prepare(` + SELECT a.id, a.vorlage, a.verantwortlich_id, a.status, a.titel, a.erstellt, + p.name AS wer + FROM aufgaben a + LEFT JOIN personen p ON p.id = a.verantwortlich_id + WHERE a.vorlage IS NOT NULL AND a.verantwortlich_id IS NOT NULL + ORDER BY a.vorlage, a.verantwortlich_id, a.id`).all(); + +const gruppen = new Map(); +for (const r of reihen) { + const schluessel = `${r.vorlage} ${r.verantwortlich_id}`; + if (!gruppen.has(schluessel)) gruppen.set(schluessel, []); + gruppen.get(schluessel).push(r); +} + +/* Je weiter eine Aufgabe ist, desto eher bleibt sie stehen. */ +const RANG = { erledigt: 0, in_arbeit: 1, angenommen: 2, offen: 3 }; +const rangVon = (r) => (RANG[r.status] ?? 4); + +const weg = []; +let betroffen = 0; +for (const [, liste] of gruppen) { + if (liste.length < 2) continue; + betroffen++; + const sortiert = [...liste].sort((a, b) => rangVon(a) - rangVon(b) || a.id - b.id); + const bleibt = sortiert[0]; + for (const r of sortiert.slice(1)) weg.push({ ...r, statt: bleibt.id, stattStatus: bleibt.status }); +} + +if (!weg.length) { + console.log("Nichts doppelt. Es gibt nichts aufzuraeumen."); + d.close(); + process.exit(0); +} + +/* Nach Mensch und Vorlage zusammengefasst -- eine Liste mit 92 Zeilen + liest niemand, „Diene: 5 von 6 weg" schon. */ +/* DER SCHLUESSEL IST EIN PAAR, KEIN ZUSAMMENGEKLEBTER TEXT. Beim + ersten Anlauf stand hier Name + Leerzeichen + Titel und unten ein + split(" ") -- damit endete jeder Titel beim ersten Leerzeichen. + Ein Trennzeichen, das im Inhalt vorkommt, ist keins. */ +const proMensch = new Map(); +for (const r of weg) { + const k = JSON.stringify([r.wer || "?", r.titel]); + proMensch.set(k, (proMensch.get(k) || 0) + 1); +} +console.log(`${betroffen} Vorlage/Person-Paare haben Dubletten.`); +console.log(`${weg.length} Aufgaben wuerden geloescht, je eine bleibt stehen:\n`); +for (const [k, n] of [...proMensch].sort((a, b) => b[1] - a[1])) { + const [wer, titel] = JSON.parse(k); + console.log(` ${String(n).padStart(3)}x weg ${wer.padEnd(12)} ${titel.slice(0, 60)}`); +} +console.log(""); + +if (!wirklich) { + console.log("Nichts geaendert. Zum Aufraeumen denselben Befehl mit --wirklich"); + d.close(); + process.exit(0); +} + +/* ---- Erst sichern, dann loeschen ---------------------------------- */ +const stempel = new Date().toISOString().replace(/[:.]/g, "-").slice(0, 19); +const sicherung = `${PFAD}.vor-doppelte-${stempel}`; +/* `VACUUM INTO` statt `copyFileSync`: Es schreibt eine in sich + geschlossene Kopie, auch wenn gerade ein WAL danebenliegt. Eine + blosse Dateikopie kann die letzten Schreibvorgaenge verlieren -- + genau die, die man im Ernstfall braucht. */ +try { + d.exec(`VACUUM INTO '${sicherung.replace(/'/g, "''")}'`); +} catch (f) { + console.error("ABBRUCH: Sicherung fehlgeschlagen --", f?.message); + console.error("Es wurde NICHTS geloescht."); + d.close(); + process.exit(3); +} +console.log(`Sicherung: ${sicherung}`); + +const loeschen = d.prepare("DELETE FROM aufgaben WHERE id = ?"); +let n = 0; +d.exec("BEGIN"); +try { + for (const r of weg) { loeschen.run(r.id); n++; } + d.exec("COMMIT"); +} catch (f) { + d.exec("ROLLBACK"); + console.error("ABBRUCH beim Loeschen --", f?.message); + console.error("Es wurde nichts geaendert (Rollback)."); + d.close(); + process.exit(4); +} + +/* NACHZAEHLEN, NICHT GLAUBEN. Die Zahl aus der Schleife sagt, wie oft + der Befehl lief -- nicht, wie es danach aussieht. */ +const nachher = d.prepare(` + SELECT COUNT(*) AS n FROM ( + SELECT vorlage, verantwortlich_id + FROM aufgaben + WHERE vorlage IS NOT NULL AND verantwortlich_id IS NOT NULL + GROUP BY vorlage, verantwortlich_id + HAVING COUNT(*) > 1)`).get().n; + +console.log(`\n${n} Aufgaben geloescht.`); +console.log(nachher === 0 + ? "Nachgezaehlt: keine Dublette mehr uebrig." + : `ACHTUNG: es stehen noch ${nachher} Paare doppelt -- bitte melden.`); +console.log(`\nZurueck geht es mit: cp "${sicherung}" "${PFAD}"`); +d.close(); +process.exit(nachher === 0 ? 0 : 5); diff --git a/workspace/anruf-probe.html b/workspace/anruf-probe.html index e154979a..60292bd2 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`. --> - - + + - +