diff --git a/server/index.js b/server/index.js index b639a5be..a4896326 100644 --- a/server/index.js +++ b/server/index.js @@ -29,6 +29,7 @@ import { rechteRouter, tafelLaden } from "./workspace-rechte.js"; import { erklaerungRouter } from "./workspace-erklaerung.js"; import { aufbewahrungRouter, aufbewahrungStarten } from "./workspace-aufbewahrung.js"; import { auskunftRouter } from "./workspace-auskunft.js"; +import { uebernahmeRouter } from "./workspace-uebernahme.js"; import { reportRouter } from "./workspace-reports.js"; import { scoutRouter } from "./workspace-scouts.js"; import { callRouter } from "./workspace-calls.js"; @@ -258,6 +259,7 @@ app.use(rechteRouter); app.use(erklaerungRouter); app.use(aufbewahrungRouter); app.use(auskunftRouter); +app.use(uebernahmeRouter); app.use(reportRouter); app.use(scoutRouter); app.use(callRouter); diff --git a/server/pruef-uebernahme.mjs b/server/pruef-uebernahme.mjs new file mode 100644 index 00000000..cb683212 --- /dev/null +++ b/server/pruef-uebernahme.mjs @@ -0,0 +1,360 @@ +/* ===================================================================== + Aus einer Idee wird Arbeit (15.09.2026) + + Stufe 9: „Idee → Aufgabe". Das ganze Haus steht auf dem Satz *„Was + hier nicht steht, passiert nicht."* Ein Ideenbrett, aus dem keine + Aufgabe werden kann, ist der eine Ort, an dem er nicht gilt. + + --------------------------------------------------------------------- + DREI FRAGEN, UND ALLE DREI HABEN EINE GEGENPROBE + + 1. Wird aus einer Idee eine Aufgabe? ... und steht wirklich alles + Richtige darin? + 2. Genau EINMAL? ... und was passiert beim + zweiten Versuch? + 3. Aus JEDEM Brett? ... nein — und genau das ist + der Punkt: Ein Beitrag + aus dem Treff gehört + einem Menschen aus der + Community. + + Dazu die Frage, die das Loeschkonzept aufwirft: Was passiert mit der + Aufgabe, wenn die Idee geloescht wird? (Sie bleibt — mit `SET NULL`.) + + Aufrufen mit: node server/pruef-uebernahme.mjs + ===================================================================== */ + +import { mkdtempSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { request as httpAnfrage } from "node:http"; +import { portMussFreiSein } from "./helfer-port.mjs"; + +const PORT = await portMussFreiSein(4414, "pruef-uebernahme"); + +const ordner = mkdtempSync(join(tmpdir(), "ws-ueb-")); +process.env.WORKSPACE_DB = join(ordner, "workspace.db"); +process.env.PORT = String(PORT); +process.env.SITE_ACCESS_SECRET = "lokaler-test"; +process.env.SITE_PUBLIC_LAUNCH_AT = "2020-01-01T00:00:00+01:00"; + +const express = (await import("express")).default; +const ec = express.response.cookie; +express.response.cookie = function (n, w, o) { return ec.call(this, n, w, { ...(o || {}), secure: false }); }; +import { notbremse } from "./helfer-notbremse.mjs"; +await import("./index.js"); +notbremse(180_000, "pruef-uebernahme"); +await new Promise((r) => setTimeout(r, 700)); + +process.on("uncaughtException", (f) => { console.error("ABSTURZ:", f); process.exit(7); }); +process.on("unhandledRejection", (f) => { console.error("ABSTURZ (Versprechen):", f); process.exit(7); }); + +let fehler = 0, geprueft = 0; +const melde = (t) => console.log(t); +const ok = (b, t) => { geprueft++; console.log((b ? " ok " : " FEHL ") + t); if (!b) fehler++; }; + +const CREW = "crew.dogfather-universe.com"; +const HAUS = "workspace.dogfather-universe.com"; + +const { DatabaseSync } = await import("node:sqlite"); +const { scryptSync, randomBytes, createHmac } = await import("node:crypto"); +const modul = await import("./workspace-uebernahme.js"); +const { BEREICHE } = await import("./workspace-bereiche.js"); +/* Welche Bretter dem Treff gehoeren, sagt workspace.js -- nicht + ein Merkmal, das ich mir an BEREICHE vorgestellt habe. Der erste + Anlauf fragte nach `BEREICHE[b].treff` und fand null Bretter: eine + Gegenprobe, die nichts gegenprueft. */ +const { TREFF_BRETTER } = await import("./workspace.js"); + +const oeffne = () => new DatabaseSync(process.env.WORKSPACE_DB); +const jetzt = new Date().toISOString(); + +let idDogi, idLuna, idIdee, idTreff, idIdeeHoch; +{ + const d = oeffne(); + const schluessel = randomBytes(32).toString("hex"); + d.prepare("INSERT INTO einstellungen (schluessel, wert, geaendert, von)" + + " VALUES (?,?,?,NULL) ON CONFLICT(schluessel) DO NOTHING") + .run("code_kennung_schluessel", schluessel, jetzt); + const anlegen = (name, rolle, code) => { + const salt = randomBytes(16).toString("hex"); + const hash = scryptSync(code, salt, 64, { N: 32768, r: 8, p: 1, maxmem: 96 * 1024 * 1024 }).toString("hex"); + d.prepare("INSERT INTO personen (name, rolle, code_hash, code_salt, code_n, code_kennung, aktiv, erstellt)" + + " VALUES (?,?,?,?,?,?,1,?)") + .run(name, rolle, hash, salt, 32768, createHmac("sha256", schluessel).update(code).digest("hex"), jetzt); + return d.prepare("SELECT last_insert_rowid() AS id").get().id; + }; + idDogi = anlegen("Filipe", "admin", "CODE-DOGI-0001"); + idLuna = anlegen("Luna", "creator", "CODE-CREA-0001"); + anlegen("Kessi", "gast", "CODE-GAST-0001"); + + const rein = d.prepare(`INSERT INTO eintraege + (bereich, art, titel, text, datum, dringlichkeit, status, creator_id, erstellt, erstellt_von) + VALUES (?,?,?,?,?,?,'offen',?,?,?)`); + rein.run("ideen", "content", "Reihe über Technik", "Drei Teile, je fünf Minuten.", + jetzt.slice(0, 10), "mittel", idLuna, jetzt, idDogi); + idIdee = d.prepare("SELECT last_insert_rowid() AS id").get().id; + rein.run("ideen", "content", "Dringende Idee", "Muss diese Woche.", + jetzt.slice(0, 10), "hoch", null, jetzt, idDogi); + idIdeeHoch = d.prepare("SELECT last_insert_rowid() AS id").get().id; + d.prepare(`INSERT INTO eintraege (bereich, art, titel, text, datum, erstellt, erstellt_von) + VALUES ('treff','hallo','Beitrag aus dem Treff','Hallo zusammen',?,?,?)`) + .run(jetzt.slice(0, 10), jetzt, idDogi); + idTreff = d.prepare("SELECT last_insert_rowid() AS id").get().id; + d.close(); +} + +function ruf(pfad, host, methode, kopf = {}, koerper = null) { + return new Promise((fertig, schief) => { + const a = httpAnfrage({ host: "127.0.0.1", port: PORT, path: pfad, method: methode, + headers: { Host: host, ...(koerper ? { "Content-Type": "application/json" } : {}), ...kopf } }, + (antwort) => { + let text = ""; + antwort.on("data", (s) => { text += s; }); + antwort.on("end", () => fertig({ code: antwort.statusCode, text, kopf: antwort.headers })); + }); + a.on("error", schief); + if (koerper) a.write(JSON.stringify(koerper)); + a.end(); + }); +} +const json = (a) => { try { return JSON.parse(a.text); } catch { return {}; } }; +async function anmelden(rolle, code, host) { + const r = await ruf("/workspace/api/anmelden", host, "POST", {}, + rolle === "gast" ? { rolle, code, alter_ok: true } : { rolle, code }); + return [].concat(r.kopf?.["set-cookie"] || []).map((c) => c.split(";")[0]).join("; "); +} +const kDogi = await anmelden("admin", "CODE-DOGI-0001", HAUS); +const kGast = await anmelden("gast", "CODE-GAST-0001", CREW); +ok(!!kDogi && !!kGast, "DogFather und ein Community-Mitglied sind angemeldet"); + +/* ======================================================================= + 1. DIE LISTE DER BRETTER IST NICHT ERFUNDEN + ======================================================================= */ +melde(""); +melde("=== 1. Aus welchen Brettern ==="); +{ + const echte = Object.keys(BEREICHE); + ok(echte.length > 5, `${echte.length} Bretter gibt es im Haus`); + + const erfunden = modul.AUS_BRETTERN.filter((b) => !echte.includes(b)); + ok(erfunden.length === 0, + erfunden.length ? `GIBT ES NICHT: ${erfunden.join(", ")}` + : `alle ${modul.AUS_BRETTERN.length} genannten Bretter gibt es wirklich`); + + /* DIE TREFF-BRETTER STEHEN NICHT DRIN, und das ist der Punkt. */ + const treffBretter = TREFF_BRETTER.filter((b) => echte.includes(b)); + ok(treffBretter.length > 0, `${treffBretter.length} Bretter gehören dem Treff`); + const drin = treffBretter.filter((b) => modul.AUS_BRETTERN.includes(b)); + ok(drin.length === 0, + drin.length ? `TREFF-BRETT DABEI: ${drin.join(", ")}` + : "keines davon ist dabei — was jemand aus der Community schreibt, gehört ihm"); +} + +/* ======================================================================= + 2. AUS EINER IDEE WIRD EINE AUFGABE + ======================================================================= */ +melde(""); +melde("=== 2. Die Übernahme ==="); +{ + const a = await ruf(`/workspace/api/eintrag/${idIdee}/zur-aufgabe`, HAUS, "POST", + { cookie: kDogi }, {}); + ok(a.code === 201, `aus der Idee wird eine Aufgabe (${a.code})`); + const neu = json(a); + ok(Number.isInteger(neu.aufgabe_id), `mit einer Nummer (${neu.aufgabe_id})`); + + const d = oeffne(); + const auf = d.prepare("SELECT * FROM aufgaben WHERE id = ?").get(neu.aufgabe_id); + const idee = d.prepare("SELECT * FROM eintraege WHERE id = ?").get(idIdee); + d.close(); + + ok(auf.titel === "Reihe über Technik", `der Titel wandert mit (${auf.titel})`); + ok(/fünf Minuten/.test(auf.beschreibung || ""), "der Text auch"); + ok(auf.status === "offen", `sie beginnt offen (${auf.status})`); + ok(auf.aus_eintrag_id === idIdee, `und weiß, woher sie kam (${auf.aus_eintrag_id})`); + ok(auf.creator_id === idLuna, + `der Creator-Bezug bleibt erhalten (${auf.creator_id === idLuna ? "Luna" : auf.creator_id})`); + ok(auf.verantwortlich_id === idDogi, + "zuständig ist, wer übernommen hat — nicht, wer die Idee aufgeschrieben hat"); + /* WAS ABSICHTLICH NICHT MITWANDERT. */ + ok(auf.frist === null, "eine Frist wird NICHT erfunden"); + + /* DIE IDEE BLEIBT STEHEN. Sie wird nicht verschoben und nicht + geloescht -- wer nachsieht, was aus einem Gedanken geworden ist, + soll den Gedanken noch finden. */ + ok(!!idee && idee.bereich === "ideen", + `und die Idee steht unverändert im Brett (${idee?.bereich})`); +} + +/* ======================================================================= + 3. GENAU EINMAL + ======================================================================= */ +melde(""); +melde("=== 3. Genau einmal ==="); +{ + const zweite = await ruf(`/workspace/api/eintrag/${idIdee}/zur-aufgabe`, HAUS, "POST", + { cookie: kDogi }, {}); + ok(zweite.code === 409, `ein zweiter Versuch wird abgelehnt (${zweite.code})`); + ok(json(zweite).fehler === "schon_uebernommen", "mit einem Grund, den die Seite versteht"); + ok(Number.isInteger(json(zweite).aufgabe_id), + `und dem Verweis auf die vorhandene Aufgabe (${json(zweite).aufgabe_id})`); + + const d = oeffne(); + const wieviele = d.prepare( + "SELECT COUNT(*) AS n FROM aufgaben WHERE aus_eintrag_id = ?").get(idIdee).n; + d.close(); + ok(wieviele === 1, `es gibt genau eine Aufgabe dazu (${wieviele})`); +} + +/* ======================================================================= + 4. NICHT AUS JEDEM BRETT + ======================================================================= */ +melde(""); +melde("=== 4. Nicht aus dem Treff ==="); +{ + const a = await ruf(`/workspace/api/eintrag/${idTreff}/zur-aufgabe`, HAUS, "POST", + { cookie: kDogi }, {}); + ok(a.code === 400, `aus einem Treff-Beitrag wird keine Aufgabe (${a.code})`); + ok(/keine Aufgabe/.test(json(a).fehler || ""), `und die Absage sagt warum (${json(a).fehler})`); + + const d = oeffne(); + const n = d.prepare("SELECT COUNT(*) AS n FROM aufgaben WHERE aus_eintrag_id = ?").get(idTreff).n; + d.close(); + ok(n === 0, "es ist auch wirklich keine entstanden"); + + const erfunden = await ruf("/workspace/api/eintrag/999999/zur-aufgabe", HAUS, "POST", + { cookie: kDogi }, {}); + ok(erfunden.code === 404, `eine erfundene Nummer ergibt 404 (${erfunden.code})`); + + const ohne = await ruf(`/workspace/api/eintrag/${idIdeeHoch}/zur-aufgabe`, HAUS, "POST", {}, {}); + ok(ohne.code === 401, `ohne Anmeldung gar nichts (${ohne.code})`); +} + +/* ======================================================================= + 5. DIE DRINGLICHKEIT WIRD ZUR PRIORITÄT + ======================================================================= */ +melde(""); +melde("=== 5. Dringlichkeit ==="); +{ + const a = await ruf(`/workspace/api/eintrag/${idIdeeHoch}/zur-aufgabe`, HAUS, "POST", + { cookie: kDogi }, {}); + ok(a.code === 201, `die dringende Idee wird übernommen (${a.code})`); + ok(json(a).prioritaet === "hoch", `und behält ihre Dringlichkeit (${json(a).prioritaet})`); + + /* GEGENPROBE -- ohne sie beweist die Zeile darueber nichts: Die + andere Idee stand auf "mittel" und muss dort geblieben sein. */ + const d = oeffne(); + const andere = d.prepare( + "SELECT prioritaet FROM aufgaben WHERE aus_eintrag_id = ?").get(idIdee); + d.close(); + ok(andere?.prioritaet === "mittel", + `die andere steht weiterhin auf mittel (${andere?.prioritaet}) — es wird nicht alles „hoch“`); +} + +/* ======================================================================= + 6. WAS DAS BRETT DAZU SAGT + ======================================================================= */ +melde(""); +melde("=== 6. Die Liste am Brett ==="); +{ + const a = json(await ruf("/workspace/api/bereich/ideen/uebernahmen", HAUS, "GET", { cookie: kDogi })); + ok(a.moeglich === true, "das Ideenbrett kennt Übernahmen"); + ok(Object.keys(a.uebernahmen || {}).length === 2, + `zwei Einträge sind übernommen (${Object.keys(a.uebernahmen || {}).join(", ")})`); + ok(a.uebernahmen[idIdee]?.status === "offen", + `und der Stand der Aufgabe steht dabei (${a.uebernahmen[idIdee]?.status})`); + + /* GEGENPROBE: Ein Treff-Brett sagt „nicht moeglich" -- daran + entscheidet die Oberflaeche, ob der Knopf ueberhaupt hingehoert. */ + const treff = json(await ruf("/workspace/api/bereich/treff/uebernahmen", HAUS, "GET", { cookie: kDogi })); + ok(treff.moeglich === false, "ein Treff-Brett kennt keine"); + ok(Object.keys(treff.uebernahmen || {}).length === 0, "und liefert auch keine"); +} + +/* ======================================================================= + 7. WIRD DIE IDEE GELÖSCHT, BLEIBT DIE ARBEIT + + Die Frage, die das Loeschkonzept aufwirft. `ON DELETE SET NULL` und + nicht CASCADE: Die Aufgabe ist inzwischen etwas Eigenes. + ======================================================================= */ +melde(""); +melde("=== 7. Wenn die Idee verschwindet ==="); +{ + const d = oeffne(); + const vorher = d.prepare("SELECT id FROM aufgaben WHERE aus_eintrag_id = ?").get(idIdee); + d.prepare("PRAGMA foreign_keys = ON").run(); + d.prepare("DELETE FROM eintraege WHERE id = ?").run(idIdee); + const nachher = d.prepare("SELECT id, aus_eintrag_id, titel FROM aufgaben WHERE id = ?") + .get(vorher.id); + d.close(); + + ok(!!nachher, "die Aufgabe gibt es noch, obwohl die Idee weg ist"); + ok(nachher.aus_eintrag_id === null, + `und der Verweis steht auf null statt ins Leere (${nachher.aus_eintrag_id})`); + ok(nachher.titel === "Reihe über Technik", "mit unverändertem Titel"); +} + +/* ======================================================================= + 8. DER KNOPF STEHT IM BROWSER — UND WIRKT + ======================================================================= */ +melde(""); +melde("=== 8. Im Browser ==="); +{ + const { chromium } = await import( + "file:///C:/Users/qciga/Documents/Obelix/Analyse/node_modules/playwright/index.mjs"); + const browser = await chromium.launch(); + const kontext = await browser.newContext({ viewport: { width: 1280, height: 900 } }); + const seite = await kontext.newPage(); + const konsole = []; + seite.on("console", (m) => { if (m.type() === "error" && !/favicon|ERR_/i.test(m.text())) konsole.push(m.text()); }); + seite.on("pageerror", (e) => konsole.push("Absturz: " + e.message)); + + /* Eine frische Idee, damit es etwas zu uebernehmen gibt. */ + const d = oeffne(); + d.prepare(`INSERT INTO eintraege + (bereich, art, titel, text, datum, dringlichkeit, status, erstellt, erstellt_von) + VALUES ('ideen','content','BILDSCHIRM-IDEE','Zum Anklicken.',?,'mittel','offen',?,?)`) + .run(jetzt.slice(0, 10), jetzt, idDogi); + d.close(); + + await seite.goto(`http://127.0.0.1:${PORT}/workspace/`, { waitUntil: "domcontentloaded" }); + await seite.click('.rolle[data-rolle="admin"]'); + await seite.fill("#code", "CODE-DOGI-0001"); + await seite.click("button[type=submit]"); + await seite.waitForURL("**/start.html", { timeout: 20000 }); + await seite.goto(`http://127.0.0.1:${PORT}/workspace/bereich.html?b=ideen`, + { waitUntil: "networkidle" }); + await seite.waitForTimeout(900); + + const knoepfe = await seite.evaluate(() => + [...document.querySelectorAll("button")].filter((b) => /zur Aufgabe/i.test(b.textContent)).length); + ok(knoepfe >= 1, `${knoepfe} Knopf/Knöpfe „zur Aufgabe“ auf dem Ideenbrett`); + + const verweise = await seite.evaluate(() => + document.querySelectorAll(".eintrag__aufgabe").length); + ok(verweise >= 1, + `${verweise} Einträge zeigen stattdessen den Verweis auf ihre Aufgabe`); + + /* DER KNOPF MUSS WIRKEN -- gemessen an der Datenbank, nicht am Text. */ + const vorher = (() => { const dd = oeffne(); + const n = dd.prepare("SELECT COUNT(*) AS n FROM aufgaben").get().n; dd.close(); return n; })(); + await seite.evaluate(() => { + const b = [...document.querySelectorAll("button")].find((x) => /zur Aufgabe/i.test(x.textContent)); + b?.click(); + }); + await seite.waitForTimeout(1200); + const nachher = (() => { const dd = oeffne(); + const n = dd.prepare("SELECT COUNT(*) AS n FROM aufgaben").get().n; dd.close(); return n; })(); + ok(nachher === vorher + 1, `nach dem Klick gibt es eine Aufgabe mehr (${vorher} → ${nachher})`); + + ok(konsole.length === 0, + konsole.length ? "Fehler im Browser: " + konsole[0].slice(0, 90) : "keine Fehler im Browser"); + await browser.close(); +} + +/* ------------------------------------------------------------------- */ +melde(""); +melde(`${geprueft} Pruefungen, ${fehler} Fehler`); +melde(fehler ? "NICHT IN ORDNUNG" : "ALLES IN ORDNUNG"); +try { rmSync(ordner, { recursive: true, force: true }); } catch { /* Aufraeumrest */ } +process.exit(fehler ? 1 : 0); diff --git a/server/workspace-uebernahme.js b/server/workspace-uebernahme.js new file mode 100644 index 00000000..dedfe970 --- /dev/null +++ b/server/workspace-uebernahme.js @@ -0,0 +1,169 @@ +/* ===================================================================== + Aus einer Idee wird Arbeit (15.09.2026) + + Stufe 9 aus dem Plan zur Perfektion: „Idee → Aufgabe/Termin". + + --------------------------------------------------------------------- + WARUM DAS MEHR IST ALS EINE BEQUEMLICHKEIT + + Das ganze Haus steht auf einem Satz: *„Was hier nicht steht, passiert + nicht."* Ein Ideenbrett, aus dem keine Aufgabe werden kann, ist der + eine Ort, an dem dieser Satz nicht gilt — dort steht etwas, und + passieren tut es trotzdem nicht. Wer eine Idee umsetzen will, tippt + sie heute ein zweites Mal ab. Und was man abtippt, tippt man + irgendwann nicht mehr ab. + + --------------------------------------------------------------------- + DREI ENTSCHEIDUNGEN + + 1. GENAU EINMAL. An der Idee steht danach, welche Aufgabe daraus + wurde. Ein zweiter Versuch führt nicht zu einer zweiten Aufgabe, + sondern zu dem Hinweis, dass es sie schon gibt. Dasselbe Muster + wie bei „zu den Talenten übernehmen" und bei `gesendet_am`. + + 2. DIE IDEE BLEIBT STEHEN. Sie wird nicht verschoben und nicht + gelöscht. Wer im Brett nachsieht, was aus einem Gedanken geworden + ist, soll den Gedanken noch finden — und daneben die Antwort. + + 3. DIE AUFGABE WEISS, WOHER SIE KAM. `aus_eintrag_id` mit echtem + Fremdschlüssel. Nicht als Text im Beschreibungsfeld: Ein Verweis, + den nur ein Mensch lesen kann, ist kein Verweis. + + --------------------------------------------------------------------- + WAS ABSICHTLICH NICHT MITWANDERT + + Die Frist. Eine Idee hat keinen Termin — wer ihr beim Übernehmen + automatisch einen gäbe, erfände ihn. Die Aufgabe entsteht ohne + Frist, und wer eine braucht, setzt sie bewusst. + ===================================================================== */ + +import express from "express"; +import { + db, sitzungLesen, protokolliere, echteIp, +} from "./workspace.js"; + +export const uebernahmeRouter = express.Router(); + +/* workspace.js fuehrt `jetzt` nicht aus -- wie in den uebrigen + Modulen steht es deshalb hier. */ +const jetzt = () => new Date().toISOString(); + +/** Aus welchen Brettern eine Aufgabe werden darf. + * + * NICHT AUS ALLEN. Ein Beitrag aus dem Treff gehört einem Menschen + * aus der Community; daraus stillschweigend eine Arbeitsaufgabe zu + * machen, wäre eine Verwendung, mit der er nicht rechnet. Hier stehen + * die Bretter des Hauses, auf denen ohnehin Arbeit geplant wird. + * + * Die Liste ist kurz und steht namentlich da — `pruef-uebernahme` + * vergleicht sie gegen die Bretter, die es wirklich gibt, damit hier + * kein Name stehen bleibt, den niemand mehr kennt. */ +export const AUS_BRETTERN = ["ideen", "live", "content", "technik", "community", "schutz", "agentur"]; + +function angemeldet(req, res, next) { + const person = sitzungLesen(req); + if (!person) return res.status(401).json({ fehler: "nicht_angemeldet" }); + req.person = person; + next(); +} + +/** Gleiche Herkunft — wie überall im Haus bei schreibenden Wegen. */ +function gleicheHerkunft(req, res, next) { + const herkunft = req.get("origin"); + if (!herkunft) return next(); + let erlaubt; + try { erlaubt = new URL(herkunft).host === req.get("host"); } catch { erlaubt = false; } + if (!erlaubt) return res.status(403).json({ fehler: "fremde_herkunft" }); + next(); +} + +uebernahmeRouter.post("/workspace/api/eintrag/:id(\\d+)/zur-aufgabe", + express.json({ limit: "8kb" }), angemeldet, gleicheHerkunft, (req, res) => { + try { + const id = Number(req.params.id); + const e = db().prepare( + `SELECT id, bereich, titel, text, creator_id, dringlichkeit, erstellt_von + FROM eintraege WHERE id = ?`).get(id); + if (!e) return res.status(404).json({ fehler: "nicht_gefunden" }); + + if (!AUS_BRETTERN.includes(e.bereich)) { + return res.status(400).json({ fehler: "Aus diesem Brett wird keine Aufgabe." }); + } + + /* SCHON EINMAL GESCHEHEN? Die Frage wird an der AUFGABE gestellt, + nicht an der Idee: Dort steht die Tatsache, und sie kann nicht + auseinanderlaufen. Eine Notiz an der Idee waere eine zweite + Wahrheit. */ + const schon = db().prepare( + "SELECT id, titel FROM aufgaben WHERE aus_eintrag_id = ?").get(id); + if (schon) { + return res.status(409).json({ fehler: "schon_uebernommen", aufgabe_id: schon.id }); + } + + const nun = jetzt(); + /* Die Dringlichkeit der Idee wird zur Prioritaet der Aufgabe -- + dieselben drei Stufen, also keine Umrechnung noetig. Fehlt sie, + gilt die Vorgabe des Bretts: mittel. */ + const prio = ["hoch", "mittel", "niedrig"].includes(e.dringlichkeit) + ? e.dringlichkeit : "mittel"; + + const r = db().prepare(`INSERT INTO aufgaben + (titel, beschreibung, status, prioritaet, creator_id, verantwortlich_id, + erstellt, erstellt_von, aus_eintrag_id) + VALUES (?,?,'offen',?,?,?,?,?,?)`) + .run( + String(e.titel || "Ohne Titel").slice(0, 200), + String(e.text || "").slice(0, 4000) || null, + prio, + e.creator_id ?? null, + /* WER UEBERNIMMT, IST ZUSTAENDIG. Nicht der, der die Idee + aufgeschrieben hat: Eine Aufgabe, die jemandem ohne sein + Zutun zugeteilt wird, wird nicht angefangen. */ + req.person.id, + nun, req.person.id, id); + + protokolliere("idee_zur_aufgabe", { + personId: req.person.id, rolle: req.person.rolle, ip: echteIp(req), + detail: `Eintrag ${id} (${e.bereich}) → Aufgabe ${r.lastInsertRowid}`, + }); + + res.status(201).json({ + aufgabe_id: Number(r.lastInsertRowid), + titel: String(e.titel || "Ohne Titel").slice(0, 200), + prioritaet: prio, + }); + } catch (fehler) { + console.error("[uebernahme]", fehler?.message); + res.status(503).json({ fehler: "nicht_verfuegbar" }); + } + }); + +/** Was aus den Einträgen eines Bretts geworden ist. + * + * Eine Abfrage für das ganze Brett statt einer je Eintrag: Bei + * zwanzig Ideen wären das zwanzig Anfragen, und die Liste stünde + * zwanzigmal kurz falsch da, bevor sie stimmt. */ +uebernahmeRouter.get("/workspace/api/bereich/:bereich/uebernahmen", + angemeldet, (req, res) => { + try { + const bereich = String(req.params.bereich || ""); + /* „moeglich" sagt der Oberflaeche, ob der Knopf ueberhaupt + hingehoert. Ohne dieses Feld muesste der Browser eine zweite + Liste der Bretter fuehren -- und die waere die, die veraltet. */ + if (!AUS_BRETTERN.includes(bereich)) { + return res.json({ moeglich: false, uebernahmen: {} }); + } + const zeilen = db().prepare( + `SELECT a.aus_eintrag_id AS eintrag_id, a.id AS aufgabe_id, a.status + FROM aufgaben a + JOIN eintraege e ON e.id = a.aus_eintrag_id + WHERE a.aus_eintrag_id IS NOT NULL AND e.bereich = ?`).all(bereich); + const raus = {}; + for (const z of zeilen) raus[z.eintrag_id] = { aufgabe_id: z.aufgabe_id, status: z.status }; + res.json({ moeglich: true, uebernahmen: raus }); + } catch (fehler) { + console.error("[uebernahme] Liste:", fehler?.message); + res.status(503).json({ fehler: "nicht_verfuegbar" }); + } + }); + diff --git a/server/workspace.js b/server/workspace.js index 9d66297f..ced75868 100644 --- a/server/workspace.js +++ b/server/workspace.js @@ -2047,6 +2047,21 @@ function umstellungen(d) { nicht gespeichert; sonst haette man zwei Wahrheiten, von denen eine veraltet. Siehe grundlageVon() in workspace-aufbewahrung.js. */ ["personen", "alter_bestaetigt_am", "TEXT"], + + /* WOHER EINE AUFGABE KAM (15.09.2026, Stufe 9: "Idee -> Aufgabe"). + + MIT deklariertem Fremdschluessel, und das ist hier nicht + Geschmack: Seit dem 15.09. prueft pruef-auskunft, dass JEDE + Spalte auf _id entweder einen Fremdschluessel hat oder + namentlich eingeordnet ist. Eine undeklarierte Spalte waere dort + sofort rot -- und genau so soll es sein, denn niemand kann einer + Zahl ansehen, worauf sie zeigt. + + ON DELETE SET NULL und nicht CASCADE: Wird die Idee spaeter + geloescht, bleibt die Arbeit. Sie ist inzwischen etwas Eigenes; + sie mit der Notiz zu entfernen, aus der sie entstand, waere die + teuerste Art, Ordnung zu machen. */ + ["aufgaben", "aus_eintrag_id", "INTEGER REFERENCES eintraege(id) ON DELETE SET NULL"], ]) { try { const vorhanden = d.prepare(`PRAGMA table_info(${tabelle})`).all().map((s) => s.name); diff --git a/workspace/app.webmanifest b/workspace/app.webmanifest index 9c07f834..e3b868a9 100644 --- a/workspace/app.webmanifest +++ b/workspace/app.webmanifest @@ -13,25 +13,25 @@ "theme_color": "#06090f", "icons": [ { - "src": "/assets/img/app-symbole/workspace-192.png?v=202609150108", + "src": "/assets/img/app-symbole/workspace-192.png?v=202609151224", "sizes": "192x192", "type": "image/png", "purpose": "any" }, { - "src": "/assets/img/app-symbole/workspace-512.png?v=202609150108", + "src": "/assets/img/app-symbole/workspace-512.png?v=202609151224", "sizes": "512x512", "type": "image/png", "purpose": "any" }, { - "src": "/assets/img/app-symbole/workspace-192-maskable.png?v=202609150108", + "src": "/assets/img/app-symbole/workspace-192-maskable.png?v=202609151224", "sizes": "192x192", "type": "image/png", "purpose": "maskable" }, { - "src": "/assets/img/app-symbole/workspace-512-maskable.png?v=202609150108", + "src": "/assets/img/app-symbole/workspace-512-maskable.png?v=202609151224", "sizes": "512x512", "type": "image/png", "purpose": "maskable" diff --git a/workspace/assets/css/bereich.css b/workspace/assets/css/bereich.css index e5fb24c3..43f2821c 100644 --- a/workspace/assets/css/bereich.css +++ b/workspace/assets/css/bereich.css @@ -650,3 +650,22 @@ color: var(--text-still); text-transform: none; letter-spacing: 0; font-weight: 600; } + +/* ===================================================================== + AUS EINER IDEE WURDE ARBEIT (15.09.2026) + + Der Verweis steht dort, wo vorher der Knopf stand -- er ersetzt ihn. + Er sieht deshalb aus wie ein Zustand und nicht wie eine Schaltflaeche: + Wer ihn sieht, soll wissen, dass etwas passiert IST, nicht dass er + etwas tun kann. + ===================================================================== */ + +.eintrag__aufgabe { + display: inline-flex; align-items: center; gap: 6px; + font-size: .78rem; font-weight: 600; + padding: 3px 10px; border-radius: 999px; + text-decoration: none; + color: var(--text-leise); + border: 1px solid color-mix(in srgb, #668e6e 50%, transparent); +} +.eintrag__aufgabe:hover { color: var(--text); border-color: #668e6e; } diff --git a/workspace/assets/js/bereich.js b/workspace/assets/js/bereich.js index 8d3251b1..5b59ca0d 100644 --- a/workspace/assets/js/bereich.js +++ b/workspace/assets/js/bereich.js @@ -426,6 +426,43 @@ if (!a.ok) { melde('Ändern hat nicht geklappt.'); return; } await laden(); })); + /* AUS EINER IDEE WIRD ARBEIT (15.09.2026, Stufe 9). + + HIER und nicht in der Treff-Knopfreihe: Die gehoert ganz zum + Treff (`if (treffBrett && lage)`), und dort waere der Knopf + genau am falschen Ort -- an den Beitraegen der Community, und an + keinem Arbeitsbrett. Zweimal hintereinander dort gelandet; + gefunden hat es beide Male die Pruefung im Browser mit "null + Knoepfe auf dem Ideenbrett", nicht das Lesen. + + Welche Bretter Uebernahmen kennen, entscheidet der Server. Eine + zweite Liste hier waere die, die veraltet. + + IST ES SCHON GESCHEHEN, steht statt des Knopfes der Verweis. Ein + Knopf, der beim zweiten Druecken "gibt es schon" sagt, laedt zum + zweiten Druecken ein. */ + const schon = uebernahmen[e.id]; + if (schon) { + const v = el('a', 'eintrag__aufgabe', 'Aufgabe · ' + schon.status); + v.href = 'aufgaben.html#a' + schon.aufgabe_id; + v.title = 'Aus diesem Eintrag ist eine Aufgabe geworden.'; + knoepfe.append(v); + } else if (uebernahmeMoeglich) { + knoepfe.append(tuKnopf('zur Aufgabe', async () => { + const a = await hole('/workspace/api/eintrag/' + e.id + '/zur-aufgabe', + { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: '{}' }); + const j = await a.json().catch(() => ({})); + if (!a.ok) { + melde(j.fehler === 'schon_uebernommen' + ? 'Daraus gibt es schon eine Aufgabe.' + : (j.fehler || 'Ging nicht.')); + return; + } + melde('Steht jetzt im Aufgabenbrett – bei dir.'); + await laden(); + })); + } + if (LEITUNG.has(ich.rolle) || e.erstellt_von === ich.id) { knoepfe.append(tuKnopf('löschen', async () => { if (!confirm(`„${e.titel}" wirklich löschen?`)) return; @@ -491,8 +528,29 @@ for (const [wert, name] of Object.entries(einstellung.arten)) ziel.append(knopf(wert, name)); } + /* WAS AUS DEN EINTRAEGEN GEWORDEN IST -- einmal fuer das ganze Brett. + Bei zwanzig Ideen waeren einzelne Abfragen zwanzig Anfragen, und die + Liste stuende zwanzigmal kurz falsch da, bevor sie stimmt. */ + let uebernahmen = {}; + let uebernahmeMoeglich = false; + + async function uebernahmenLaden() { + try { + const a = await fetch(`/workspace/api/bereich/${encodeURIComponent(bereich)}/uebernahmen`, + { credentials: 'same-origin' }); + if (!a.ok) { uebernahmen = {}; uebernahmeMoeglich = false; return; } + const d = await a.json(); + uebernahmen = d.uebernahmen || {}; + /* OB DER KNOPF HINGEHOERT, ENTSCHEIDET DER SERVER. Eine zweite + Liste der Bretter hier im Browser waere die, die veraltet -- + derselbe Fehler wie bei der Rollenreihenfolge am 11.09. */ + uebernahmeMoeglich = d.moeglich === true; + } catch { uebernahmen = {}; uebernahmeMoeglich = false; } + } + async function laden() { try { + await uebernahmenLaden(); const a = await hole(`/workspace/api/bereich/${bereich}`); if (!a.ok) { melde('Einträge konnten nicht geladen werden.'); return; } const daten = await a.json(); diff --git a/workspace/aufgaben.html b/workspace/aufgaben.html index 9715ad73..44db9bd5 100644 --- a/workspace/aufgaben.html +++ b/workspace/aufgaben.html @@ -12,19 +12,19 @@ Ton wie die Kopfleiste darunter (#06090f), damit Leiste und Seite eine Flaeche sind statt zweier. --> - - - - - - + + + + + + - + @@ -252,11 +252,11 @@ - - - - - - + + + + + + diff --git a/workspace/automation.html b/workspace/automation.html index d387ddb2..606a587f 100644 --- a/workspace/automation.html +++ b/workspace/automation.html @@ -12,20 +12,20 @@ Ton wie die Kopfleiste darunter (#06090f), damit Leiste und Seite eine Flaeche sind statt zweier. --> - - - - - - - + + + + + + + - + @@ -168,11 +168,11 @@ - - - - - - + + + + + + diff --git a/workspace/bereich.html b/workspace/bereich.html index 309a9656..c4ad39f4 100644 --- a/workspace/bereich.html +++ b/workspace/bereich.html @@ -12,20 +12,20 @@ Ton wie die Kopfleiste darunter (#06090f), damit Leiste und Seite eine Flaeche sind statt zweier. --> - - - - - - - + + + + + + + - + @@ -181,15 +181,15 @@ - - - - - - - - - - + + + + + + + + + + diff --git a/workspace/calls.html b/workspace/calls.html index 7ba7aa8d..9c9fc41d 100644 --- a/workspace/calls.html +++ b/workspace/calls.html @@ -12,20 +12,20 @@ Ton wie die Kopfleiste darunter (#06090f), damit Leiste und Seite eine Flaeche sind statt zweier. --> - - - - - - - + + + + + + + - + @@ -89,12 +89,12 @@ - - - - - - - + + + + + + + diff --git a/workspace/chat.html b/workspace/chat.html index 4e5c85f9..8b78295a 100644 --- a/workspace/chat.html +++ b/workspace/chat.html @@ -12,26 +12,26 @@ Ton wie die Kopfleiste darunter (#06090f), damit Leiste und Seite eine Flaeche sind statt zweier. --> - - - - + + + + - - - + + + - + @@ -341,11 +341,11 @@ - - - - - - + + + + + + diff --git a/workspace/content.html b/workspace/content.html index 85cf662b..7a655e96 100644 --- a/workspace/content.html +++ b/workspace/content.html @@ -12,20 +12,20 @@ Ton wie die Kopfleiste darunter (#06090f), damit Leiste und Seite eine Flaeche sind statt zweier. --> - - - - - - - + + + + + + + - + @@ -179,13 +179,13 @@ - - - - - - - - + + + + + + + + diff --git a/workspace/crew-index.html b/workspace/crew-index.html index e14c03ef..762e3b9e 100644 --- a/workspace/crew-index.html +++ b/workspace/crew-index.html @@ -36,15 +36,15 @@ - - - + + + - + @@ -339,6 +339,6 @@ - + diff --git a/workspace/crew.webmanifest b/workspace/crew.webmanifest index 93893812..c7b94067 100644 --- a/workspace/crew.webmanifest +++ b/workspace/crew.webmanifest @@ -13,25 +13,25 @@ "theme_color": "#06090f", "icons": [ { - "src": "/assets/img/app-symbole/crew-192.png?v=202609150108", + "src": "/assets/img/app-symbole/crew-192.png?v=202609151224", "sizes": "192x192", "type": "image/png", "purpose": "any" }, { - "src": "/assets/img/app-symbole/crew-512.png?v=202609150108", + "src": "/assets/img/app-symbole/crew-512.png?v=202609151224", "sizes": "512x512", "type": "image/png", "purpose": "any" }, { - "src": "/assets/img/app-symbole/crew-192-maskable.png?v=202609150108", + "src": "/assets/img/app-symbole/crew-192-maskable.png?v=202609151224", "sizes": "192x192", "type": "image/png", "purpose": "maskable" }, { - "src": "/assets/img/app-symbole/crew-512-maskable.png?v=202609150108", + "src": "/assets/img/app-symbole/crew-512-maskable.png?v=202609151224", "sizes": "512x512", "type": "image/png", "purpose": "maskable" diff --git a/workspace/dateien.html b/workspace/dateien.html index 3b91ce86..de524fbe 100644 --- a/workspace/dateien.html +++ b/workspace/dateien.html @@ -12,20 +12,20 @@ Ton wie die Kopfleiste darunter (#06090f), damit Leiste und Seite eine Flaeche sind statt zweier. --> - - - - - - - + + + + + + + - + @@ -108,11 +108,11 @@ - - - - - - + + + + + + diff --git a/workspace/entwicklung.html b/workspace/entwicklung.html index ed7539fb..41d77853 100644 --- a/workspace/entwicklung.html +++ b/workspace/entwicklung.html @@ -7,14 +7,14 @@ - - - - - - - - + + + + + + + + @@ -94,10 +94,10 @@ - - - - - + + + + + diff --git a/workspace/index.html b/workspace/index.html index 6f86f19b..e10a54eb 100644 --- a/workspace/index.html +++ b/workspace/index.html @@ -30,9 +30,9 @@ - - - + + + @@ -401,6 +401,6 @@ - + diff --git a/workspace/kalender.html b/workspace/kalender.html index 9105923a..7549d9d8 100644 --- a/workspace/kalender.html +++ b/workspace/kalender.html @@ -12,20 +12,20 @@ Ton wie die Kopfleiste darunter (#06090f), damit Leiste und Seite eine Flaeche sind statt zweier. --> - - - - - - - + + + + + + + - + @@ -377,12 +377,12 @@ - - - - - - - + + + + + + + diff --git a/workspace/leistung.html b/workspace/leistung.html index 2135802b..31b68f33 100644 --- a/workspace/leistung.html +++ b/workspace/leistung.html @@ -12,20 +12,20 @@ Ton wie die Kopfleiste darunter (#06090f), damit Leiste und Seite eine Flaeche sind statt zweier. --> - - - - - - - + + + + + + + - + @@ -423,11 +423,11 @@ - - - - - - + + + + + + diff --git a/workspace/personen.html b/workspace/personen.html index ef5e2782..522b166a 100644 --- a/workspace/personen.html +++ b/workspace/personen.html @@ -12,20 +12,20 @@ Ton wie die Kopfleiste darunter (#06090f), damit Leiste und Seite eine Flaeche sind statt zweier. --> - - - - - - - + + + + + + + - + @@ -423,11 +423,11 @@ - - - - - - + + + + + + diff --git a/workspace/profil.html b/workspace/profil.html index b98793d0..5f048118 100644 --- a/workspace/profil.html +++ b/workspace/profil.html @@ -12,20 +12,20 @@ Ton wie die Kopfleiste darunter (#06090f), damit Leiste und Seite eine Flaeche sind statt zweier. --> - - - - - - - + + + + + + + - + @@ -214,12 +214,12 @@ - - - - - - - + + + + + + + diff --git a/workspace/rechte.html b/workspace/rechte.html index 1161d160..dd80dc77 100644 --- a/workspace/rechte.html +++ b/workspace/rechte.html @@ -7,14 +7,14 @@ - - - - - - - - + + + + + + + + @@ -134,10 +134,10 @@ Beim ersten Anlauf fehlte die Zeile auf genau diesen beiden neuen Seiten. Gefunden hat das nicht das Auge, sondern pruef-css-klassen, die seit dem 07.09. jede Seite danach absucht. --> - - - - - + + + + + diff --git a/workspace/report.html b/workspace/report.html index 285ca779..7e4cde4f 100644 --- a/workspace/report.html +++ b/workspace/report.html @@ -12,20 +12,20 @@ Ton wie die Kopfleiste darunter (#06090f), damit Leiste und Seite eine Flaeche sind statt zweier. --> - - - - - - - + + + + + + + - + @@ -106,12 +106,12 @@ - - - - - - - + + + + + + + diff --git a/workspace/scouting.html b/workspace/scouting.html index d74255d2..92c2b237 100644 --- a/workspace/scouting.html +++ b/workspace/scouting.html @@ -12,20 +12,20 @@ Ton wie die Kopfleiste darunter (#06090f), damit Leiste und Seite eine Flaeche sind statt zweier. --> - - - - - - - + + + + + + + - + @@ -173,11 +173,11 @@ - - - - - - + + + + + + diff --git a/workspace/start.html b/workspace/start.html index 68acd0c6..532fbed0 100644 --- a/workspace/start.html +++ b/workspace/start.html @@ -12,21 +12,21 @@ Ton wie die Kopfleiste darunter (#06090f), damit Leiste und Seite eine Flaeche sind statt zweier. --> - - - - + + + + - - + + - + @@ -674,11 +674,11 @@

- - - - - - + + + + + + diff --git a/workspace/startcheck.html b/workspace/startcheck.html index 5428a904..c5ad2299 100644 --- a/workspace/startcheck.html +++ b/workspace/startcheck.html @@ -12,20 +12,20 @@ Ton wie die Kopfleiste darunter (#06090f), damit Leiste und Seite eine Flaeche sind statt zweier. --> - - - - - - - + + + + + + + - + @@ -85,12 +85,12 @@ - - - - - - - + + + + + + + diff --git a/workspace/steckbrief.html b/workspace/steckbrief.html index e2149577..1b87e428 100644 --- a/workspace/steckbrief.html +++ b/workspace/steckbrief.html @@ -12,20 +12,20 @@ Ton wie die Kopfleiste darunter (#06090f), damit Leiste und Seite eine Flaeche sind statt zweier. --> - - - - - - - + + + + + + + - + @@ -146,12 +146,12 @@ - - - - - - - + + + + + + + diff --git a/workspace/talente.html b/workspace/talente.html index f0ac3396..51729788 100644 --- a/workspace/talente.html +++ b/workspace/talente.html @@ -7,14 +7,14 @@ - - - - - - - - + + + + + + + + @@ -94,10 +94,10 @@ - - - - - + + + + + diff --git a/workspace/team.html b/workspace/team.html index 851277b9..5f47c3c3 100644 --- a/workspace/team.html +++ b/workspace/team.html @@ -7,20 +7,20 @@ - - - - - - - + + + + + + + - + @@ -110,11 +110,11 @@ - - - - - - + + + + + + diff --git a/workspace/teamlage.html b/workspace/teamlage.html index 150ea5eb..acec5f6d 100644 --- a/workspace/teamlage.html +++ b/workspace/teamlage.html @@ -7,20 +7,20 @@ - - - - - - - + + + + + + + - + - - - - - + + + + + diff --git a/workspace/treff-moderation.html b/workspace/treff-moderation.html index f6c83009..dd9cb753 100644 --- a/workspace/treff-moderation.html +++ b/workspace/treff-moderation.html @@ -7,14 +7,14 @@ - - - - - - - - + + + + + + + + @@ -110,10 +110,10 @@ - - - - - + + + + + diff --git a/workspace/treff-regeln.html b/workspace/treff-regeln.html index 695bddfe..b2a3531b 100644 --- a/workspace/treff-regeln.html +++ b/workspace/treff-regeln.html @@ -7,18 +7,18 @@ - - - - - - - + + + + + + + - + @@ -210,11 +210,11 @@ Beim ersten Anlauf fehlte die Zeile auf genau diesen beiden neuen Seiten. Gefunden hat das nicht das Auge, sondern pruef-css-klassen, die seit dem 07.09. jede Seite danach absucht. --> - - - - - - + + + + + + diff --git a/workspace/uebersicht.html b/workspace/uebersicht.html index 3e461759..0ec50380 100644 --- a/workspace/uebersicht.html +++ b/workspace/uebersicht.html @@ -12,22 +12,22 @@ Ton wie die Kopfleiste darunter (#06090f), damit Leiste und Seite eine Flaeche sind statt zweier. --> - - - - + + + + - - - + + + - + @@ -149,12 +149,12 @@ - + - - - - - + + + + + diff --git a/workspace/wissen.html b/workspace/wissen.html index 4be0ca0e..155fffb0 100644 --- a/workspace/wissen.html +++ b/workspace/wissen.html @@ -12,20 +12,20 @@ Ton wie die Kopfleiste darunter (#06090f), damit Leiste und Seite eine Flaeche sind statt zweier. --> - - - - - - - + + + + + + + - + @@ -190,11 +190,11 @@ - - - - - - + + + + + +