diff --git a/server/pruef-rueckmeldung.mjs b/server/pruef-rueckmeldung.mjs new file mode 100644 index 00000000..33a43dfd --- /dev/null +++ b/server/pruef-rueckmeldung.mjs @@ -0,0 +1,309 @@ +/* RÜCKMELDUNG IN BEIDE RICHTUNGEN (10.09.2026) + + Kapitel 5 und 6 des Pflichtenhefts: + + "Nicht nur ich soll meine Modis bewerten oder ihnen Feedback geben + können. Auch die Modis sollen mir Feedback geben können. Sie sollen + mir beispielsweise sagen können: Was könnte ich verbessern?" + "Es soll nicht nur dazu dienen, Leistungen zu kontrollieren. Es soll + vor allem dabei helfen, als Team besser zu werden." + + --------------------------------------------------------------------- + DIE PRÜFUNG, DERENTWEGEN ES DIESE DATEI GIBT + + Wer beim Schreiben „nur an DogFather" wählt, verlässt sich darauf. + Diese Zusage hat genau eine Stelle, an der sie gilt -- + `sichtbarEintrag()` --, und genau zwei Arten, still zu brechen: + + Sie könnte NICHT WEIT GENUG gehen: Die Liste ist gefiltert, aber + über die Nummer der Zeile (Ändern, Löschen) kommt man trotzdem + heran. Deshalb wird hier auch von hinten geklopft. + + Sie könnte ZU WEIT gehen: `nur_leitung` ist eine neue Spalte, und + jede Zeile, die es vorher gab, hat dort NULL. In SQL ist + `NULL = 0` nicht falsch, sondern UNBEKANNT -- ohne COALESCE wäre + der gesamte alte Bestand von einer Minute auf die andere + unsichtbar gewesen. Ein leeres Brett sieht nicht nach Fehler aus; + das hätte niemand gemeldet. + + Beide Fälle stehen unten, jeder mit seiner Gegenprobe. + ===================================================================== */ + +import { mkdtempSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; + +const ordner = mkdtempSync(join(tmpdir(), "ws-rueck-")); +process.env.WORKSPACE_DB = join(ordner, "workspace.db"); + +const { portMussFreiSein } = await import("./helfer-port.mjs"); +await portMussFreiSein(4371, "die Rueckmeldungspruefung"); + +process.env.PORT = "4371"; +process.env.SITE_ACCESS_SECRET = "lokaler-test"; +process.env.SITE_PUBLIC_LAUNCH_AT = "2020-01-01T00:00:00+01:00"; + +const express = (await import("express")).default; +const ec = express.response.cookie; +express.response.cookie = function (n, w, o) { return ec.call(this, n, w, { ...(o || {}), secure: false }); }; +await import("./index.js"); +await new Promise((r) => setTimeout(r, 1000)); +const BASIS = "http://127.0.0.1:4371"; + +/* Netz und Notbremse -- index.js faengt sonst jeden Absturz ab, und der + Lauf sieht von aussen aus wie "laeuft noch". */ +for (const art of ["uncaughtException", "unhandledRejection"]) { + process.on(art, (f) => { console.error(""); console.error("ABBRUCH:", f?.stack || f); process.exit(1); }); +} +setTimeout(() => { console.error(""); console.error("ABBRUCH: 120 s vorbei."); process.exit(1); }, + 120000).unref?.(); + +let fehler = 0, geprueft = 0; +const ok = (b, t) => { geprueft++; console.log((b ? " ok " : " FEHL ") + t); if (!b) fehler++; }; + +const { DatabaseSync } = await import("node:sqlite"); +const { scryptSync, randomBytes } = await import("node:crypto"); +await fetch(BASIS + "/workspace/api/anmelden", { + method: "POST", headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ rolle: "admin", code: "x" }) }).catch(() => {}); +for (let i = 0; i < 60; i++) { + try { + const p = new DatabaseSync(process.env.WORKSPACE_DB); + try { p.prepare("SELECT 1 FROM personen LIMIT 1").get(); break; } finally { p.close(); } + } catch { await new Promise((r) => setTimeout(r, 250)); } +} +const d = new DatabaseSync(process.env.WORKSPACE_DB); +const jetzt = new Date().toISOString(); +function anlegen(name, rolle, code) { + const salt = randomBytes(16).toString("hex"); + const hash = scryptSync(code, salt, 64, { N: 32768, r: 8, p: 1, maxmem: 96 * 1024 * 1024 }).toString("hex"); + d.prepare("INSERT INTO personen (name, rolle, code_hash, code_salt, code_n, aktiv, erstellt) VALUES (?,?,?,?,?,1,?)") + .run(name, rolle, hash, salt, 32768, jetzt); + return d.prepare("SELECT last_insert_rowid() AS id").get().id; +} +const idDogi = anlegen("Filipe", "admin", "CODE-DOGI-0001"); +anlegen("Vanessa", "hand", "CODE-HAND-0001"); +const idAyla = anlegen("Ayla", "modi", "CODE-TEAM-0001"); +anlegen("Ben", "modi", "CODE-TEAM-0002"); +anlegen("Cigdem", "manager", "CODE-MANA-0001"); + +/* Team-Rollen kommen nur ueber die Crew-Wand herein, Agenturrollen nur + ueber die Agenturwand -- die Regel vom 10.09.2026. `fetch` kann den + Host-Kopf nicht setzen (undici setzt ihn aus der Adresse), deshalb + node:http. */ +const { request: httpAnfrage } = await import("node:http"); +function anmelden(rolle, code, wand = "crew.dogfather-universe.com") { + return new Promise((fertig) => { + const rumpf = JSON.stringify({ rolle, code }); + const a = httpAnfrage({ + host: "127.0.0.1", port: 4371, path: "/workspace/api/anmelden", method: "POST", + headers: { "Content-Type": "application/json", + "Content-Length": Buffer.byteLength(rumpf), Host: wand }, + }, (antwort) => { + antwort.resume(); + antwort.on("end", () => fertig({ + ok: antwort.statusCode === 200, + keks: (antwort.headers["set-cookie"] || []).map((k) => k.split(";")[0]).join("; "), + })); + }); + a.on("error", () => fertig({ ok: false, keks: "" })); + a.end(rumpf); + }); +} +const rufe = (keks, weg, koerper, methode = "GET") => + fetch(BASIS + weg, { + method: methode, + headers: { "Content-Type": "application/json", Cookie: keks }, + body: koerper === undefined ? undefined : JSON.stringify(koerper), + }); +const holen = async (keks, weg) => (await rufe(keks, weg)).json(); + +const dogi = await anmelden("admin", "CODE-DOGI-0001"); +const hand = await anmelden("hand", "CODE-HAND-0001"); +const ayla = await anmelden("modi", "CODE-TEAM-0001"); +const ben = await anmelden("modi", "CODE-TEAM-0002"); +const cigdem = await anmelden("manager", "CODE-MANA-0001", "workspace.dogfather-universe.com"); +ok([dogi, hand, ayla, ben, cigdem].every((x) => x.ok && x.keks), "alle fuenf sind angemeldet"); + +const WEG = "/workspace/api/bereich/rueckmeldung"; +const titel = async (keks) => + ((await holen(keks, WEG)).eintraege || []).map((e) => e.titel); + +/* ======================================================================= + 1. Es gibt den Bereich, und er hat die Fragen aus dem Pflichtenheft + ======================================================================= */ +console.log(""); +console.log("=== Der Bereich ==="); +{ + const a = await holen(dogi.keks, WEG); + ok(a.einstellung?.name === "Rückmeldung", `DogFather kann ihn oeffnen (${a.einstellung?.name})`); + const arten = Object.keys(a.einstellung?.arten || {}); + ok(arten.length === 9, `neun Fragen (${arten.join(", ")})`); + ok(a.einstellung?.vertraulich === true, "und er kennt die vertrauliche Zeile"); + + const b = await holen(ayla.keks, WEG); + ok(b.einstellung?.name === "Rückmeldung", "das Team kann ihn auch oeffnen"); + + /* GEGENPROBE: Die Agentur hat hier nichts zu suchen -- 404 und nicht + 403, sonst waere allein die Antwort schon eine Auskunft. */ + const c = await rufe(cigdem.keks, WEG); + ok(c.status === 404, `ein Manager kommt nicht hinein (${c.status})`); +} + +/* ======================================================================= + 2. Beide Richtungen + ======================================================================= */ +console.log(""); +console.log("=== Wer schreibt wem ==="); +let offenId = 0, geheimId = 0; +{ + const a = await rufe(ayla.keks, WEG, { + art: "gut", titel: "Die Absprachen vor dem Live helfen sehr", + datum: "2026-09-10", dringlichkeit: "niedrig", + }, "POST"); + offenId = (await a.json())?.id; + ok(a.status === 201 && offenId > 0, `ein Teammitglied schreibt fuers Team (${a.status})`); + + const b = await rufe(ayla.keks, WEG, { + art: "dogfather", titel: "Du unterbrichst uns oft mitten im Satz", + datum: "2026-09-10", dringlichkeit: "mittel", nur_leitung: 1, + }, "POST"); + geheimId = (await b.json())?.id; + ok(b.status === 201 && geheimId > 0, `und eine nur an DogFather (${b.status})`); + + const c = await rufe(dogi.keks, WEG, { + art: "behalten", titel: "Die Schichtübergabe so lassen", + datum: "2026-09-10", dringlichkeit: "niedrig", + }, "POST"); + ok(c.status === 201, `DogFather schreibt in dieselbe Sammlung (${c.status})`); + + /* Die Spalte muss wirklich gesetzt sein -- nicht nur die Antwort + stimmen. Ohne diese Zeile waere die Pruefung gruen, wenn das Feld + stillschweigend verworfen wuerde. */ + const zeile = d.prepare("SELECT nur_leitung FROM eintraege WHERE id = ?").get(geheimId); + ok(zeile?.nur_leitung === 1, `die Zeile steht als vertraulich in der Datenbank (${zeile?.nur_leitung})`); + const offen = d.prepare("SELECT nur_leitung FROM eintraege WHERE id = ?").get(offenId); + ok(offen?.nur_leitung === 0, `und die andere als offen (${offen?.nur_leitung})`); +} + +/* ======================================================================= + 3. DIE ZUSAGE: „nur DogFather" heisst nur DogFather + ======================================================================= */ +console.log(""); +console.log("=== Wer sieht was ==="); +{ + const beiDogi = await titel(dogi.keks); + ok(beiDogi.some((t) => t.startsWith("Du unterbrichst")), + `DogFather sieht die vertrauliche Zeile (${beiDogi.length} Eintraege)`); + + const beiAyla = await titel(ayla.keks); + ok(beiAyla.some((t) => t.startsWith("Du unterbrichst")), + "die Schreiberin sieht ihre eigene"); + + /* DIE EIGENTLICHE PRUEFUNG. Die rechte Hand sieht sonst ueberall + dasselbe wie DogFather -- hier nicht. */ + const beiHand = await titel(hand.keks); + ok(!beiHand.some((t) => t.startsWith("Du unterbrichst")), + `die rechte Hand sieht sie NICHT (${beiHand.join(" | ")})`); + const beiBen = await titel(ben.keks); + ok(!beiBen.some((t) => t.startsWith("Du unterbrichst")), + "ein anderes Teammitglied auch nicht"); + + /* GEGENPROBE: Sonst koennte "sieht sie nicht" auch heissen "sieht + ueberhaupt nichts". Die OFFENE Zeile muss bei beiden ankommen. */ + ok(beiHand.some((t) => t.startsWith("Die Absprachen")), + "die offene Zeile sehen beide sehr wohl"); + ok(beiBen.some((t) => t.startsWith("Die Absprachen")), + "auch das andere Teammitglied"); +} + +/* ======================================================================= + 4. Und von hinten? Die Nummer ist kein Schluessel + ======================================================================= */ +console.log(""); +console.log("=== Ueber die Nummer ==="); +{ + const a = await rufe(hand.keks, `${WEG}/${geheimId}`, { titel: "umgeschrieben" }, "PATCH"); + ok(a.status === 404, `die rechte Hand kann sie nicht aendern (${a.status})`); + const b = await rufe(hand.keks, `${WEG}/${geheimId}`, undefined, "DELETE"); + ok(b.status === 404, `und nicht loeschen (${b.status})`); + const heil = d.prepare("SELECT titel FROM eintraege WHERE id = ?").get(geheimId); + ok(heil?.titel?.startsWith("Du unterbrichst"), "die Zeile steht unveraendert da"); + + /* GEGENPROBE: An der OFFENEN Zeile darf sie arbeiten -- sonst hiesse + die 404 oben nur "diese Route geht fuer sie nie". */ + const c = await rufe(hand.keks, `${WEG}/${offenId}`, { status: "erledigt" }, "PATCH"); + ok(c.status === 200, `an der offenen Zeile darf sie arbeiten (${c.status})`); +} + +/* ======================================================================= + 5. Der alte Bestand (NULL) bleibt sichtbar + ======================================================================= */ +console.log(""); +console.log("=== Was vor der Spalte da war ==="); +{ + d.prepare(`INSERT INTO eintraege (bereich, art, titel, datum, erstellt, erstellt_von, nur_leitung) + VALUES ('rueckmeldung','idee','Aus der Zeit vor der Spalte',?,?,?,NULL)`) + .run("2026-09-01", jetzt, idAyla); + const beiHand = await titel(hand.keks); + ok(beiHand.some((t) => t === "Aus der Zeit vor der Spalte"), + "eine Zeile mit NULL gilt als offen und bleibt sichtbar"); + + /* Die Gegenprobe zur Gegenprobe: Genau diese Zeile MIT 1 waere weg. */ + d.prepare("UPDATE eintraege SET nur_leitung = 1 WHERE titel = 'Aus der Zeit vor der Spalte'").run(); + const nachher = await titel(hand.keks); + ok(!nachher.some((t) => t === "Aus der Zeit vor der Spalte"), + "dieselbe Zeile auf 1 gesetzt ist fuer sie weg -- die Regel greift wirklich"); + d.prepare("UPDATE eintraege SET nur_leitung = NULL WHERE titel = 'Aus der Zeit vor der Spalte'").run(); +} + +/* ======================================================================= + 6. In anderen Bereichen gibt es das Feld nicht + ======================================================================= */ +console.log(""); +console.log("=== Kein Versteck in anderen Bereichen ==="); +{ + const a = await rufe(ayla.keks, "/workspace/api/bereich/ideen", { + art: "content", titel: "Eine ganz normale Idee", datum: "2026-09-10", + nur_leitung: 1, + }, "POST"); + const id = (await a.json())?.id; + ok(a.status === 201, `eine Idee laesst sich anlegen (${a.status})`); + const zeile = d.prepare("SELECT nur_leitung FROM eintraege WHERE id = ?").get(id); + ok(!zeile?.nur_leitung, + `das Feld wird dort stillschweigend verworfen (${zeile?.nur_leitung})`); + const beiHand = await holen(hand.keks, "/workspace/api/bereich/ideen"); + ok((beiHand.eintraege || []).some((e) => e.id === id), + "und die Idee steht fuer alle da -- niemand kann sich dort verstecken"); +} + +/* ======================================================================= + 7. Die Kachel steht bei allen dreien -- mit demselben Namen + ======================================================================= */ +console.log(""); +console.log("=== Die Kachel ==="); +{ + const kacheln = async (keks) => { + const i = await holen(keks, "/workspace/api/ich"); + return [...(i.bereiche || []), ...(i.bereiche_zusatz || [])]; + }; + for (const [wer, keks] of [["DogFather", dogi.keks], ["die rechte Hand", hand.keks], + ["das Team", ayla.keks]]) { + const k = (await kacheln(keks)).filter((x) => x.ziel === "bereich.html?b=rueckmeldung"); + ok(k.length === 1 && k[0].name === "Rückmeldung", + `${wer}: genau eine Kachel, und sie heisst gleich (${k.map((x) => x.name).join("/") || "keine"})`); + } + /* Der Farbton muss neu sein -- zwei Kacheln in derselben Farbe sind + ein Fehler, den pruef-start-ansicht sonst erst am Bildschirm + findet. */ + const alle = await kacheln(dogi.keks); + const toene = alle.map((x) => x.ton).filter((t) => t !== undefined); + ok(new Set(toene).size === toene.length, + `kein Farbton doppelt (${toene.length} Kacheln vom Server)`); +} + +d.close(); +try { rmSync(ordner, { recursive: true, force: true }); } catch { /* egal */ } +console.log(""); +console.log(`${fehler === 0 ? "BESTANDEN" : "FEHLGESCHLAGEN"} -- ${geprueft} geprueft, ${fehler} Fehler`); +process.exit(fehler ? 1 : 0); diff --git a/server/workspace-bereiche.js b/server/workspace-bereiche.js index 28d8c633..63732c6b 100644 --- a/server/workspace-bereiche.js +++ b/server/workspace-bereiche.js @@ -146,6 +146,62 @@ export const BEREICHE = { DIE ARTEN SIND DIE DREI AUS DEM DOKUMENT, dazu "Sonstiges": Eine Idee, die in keine der drei passt, soll nicht ungeschrieben bleiben, weil das Formular sie nicht kennt. */ + /* ===================================================================== + DIE RUECKMELDUNG (10.09.2026, Kapitel 5 und 6 des Pflichtenhefts) + + Filipe: "Nicht nur ich soll meine Modis bewerten oder ihnen Feedback + geben koennen. Auch die Modis sollen mir Feedback geben koennen." + Und: "Es soll nicht nur dazu dienen, Leistungen zu kontrollieren. Es + soll vor allem dabei helfen, als Team besser zu werden." + + EIN BEREICH FUER BEIDE KAPITEL, NICHT ZWEI. Kapitel 5 (gegenseitiges + Feedback) und Kapitel 6 (gemeinsame Reflexion) stellen dieselben + Fragen -- "Was laeuft gut? Was laeuft schlecht? Was fehlt?" --, nur + einmal an eine Person und einmal an das Team. Zwei Bretter dafuer + haetten bedeutet, dass man beim Schreiben zuerst entscheiden muss, + an WEN es geht, bevor man weiss, WAS man sagen will. Hier ist es + umgekehrt: erst die Sache, dann die Richtung (siehe nur_leitung). + + DIE NEUN FRAGEN SIND SEINE, wortwoertlich aus dem Pflichtenheft + zusammengezogen. Sie stehen als "Arten" da und nicht als freies + Textfeld, weil genau das der Unterschied zwischen einer Sammlung + und einem Haufen ist: Neun Faecher kann man auswerten, tausend + Formulierungen nicht. + + KEINE ANONYMITAET, und das ist eine Entscheidung, keine Luecke. In + einem Team dieser Groesse waere sie ohnehin keine -- an drei + Saetzen erkennt jeder jeden. Ein Versprechen, das nicht haelt, ist + schlimmer als keines. Was es stattdessen gibt, ist die Wahl + zwischen "fuers Team" und "nur an DogFather". */ + rueckmeldung: { + name: "Rückmeldung", + /* Wie beim Ideen-Board: Das hier ist keine Akte ueber jemanden, + sondern etwas, das allen gehoert. */ + ober: "Gemeinsam besser werden", + arten: { + gut: "Läuft gut", + haken: "Läuft nicht gut", + behalten: "Unbedingt behalten", + dogfather: "An DogFather", + team: "Was dem Team fehlt", + regeln: "Regel ändern", + ablauf: "Besser organisieren", + idee: "Idee", + zukunft: "Wunsch für später", + }, + bewertung: false, + /* Dringlichkeit ja: "Der Ton war gestern kaputt" ist etwas anderes + als "waere schoen, irgendwann". Eine Sammlung ohne Rangfolge + wird nach vier Wochen nicht mehr gelesen. */ + dringlichkeit: true, + ohneCreatorBezug: true, + /* Schaltet das Feld "nur an DogFather" frei -- siehe pruefe() und + sichtbarEintrag(). Ohne dieses Merkmal wird es in jedem anderen + Bereich stillschweigend verworfen, wie Bewertung und + Dringlichkeit auch. */ + vertraulich: true, + }, + ideen: { name: "Ideen-Board", /* DAS WORT UEBER DEM TITEL. Ueberall sonst steht dort "Betreuung", @@ -276,7 +332,7 @@ bereicheRouter.use("/workspace/api/bereich", angemeldet); ZWEI SCHLOESSER, ABSICHTLICH: Hier haengt der ZUGANG zur Seite, sichtbar() haengt die Bedingung an die ZEILEN. Faellt eines weg, haelt das andere. */ -for (const geschuetzt of ["ideen", "angebote"]) { +for (const geschuetzt of ["ideen", "angebote", "rueckmeldung"]) { bereicheRouter.use(`/workspace/api/bereich/${geschuetzt}`, (req, res, next) => { if (siehtModis(req.person)) return next(); return res.status(404).json({ fehler: "nicht_gefunden" }); @@ -482,11 +538,41 @@ function sichtbarRoh(person) { `praefix` ist da, weil die Suche dieselbe Tabelle unter demselben Kuerzel "e" fuehrt -- geht das eines Tages auseinander, faellt es beim Aufruf auf, nicht erst im Betrieb. */ +/* VERTRAULICHE RUECKMELDUNGEN SIEHT NUR DOGFATHER -- UND DER SCHREIBER + (10.09.2026). + + Wer beim Schreiben "nur an DogFather" waehlt, muss sich darauf + verlassen koennen. Deshalb steht die Bedingung HIER, in derselben + Funktion, durch die auch das Lesen einer einzelnen Zeile, das Aendern + und das Loeschen gehen -- nicht in der Listenabfrage. Eine Regel, die + nur die Liste filtert, laesst die Zeile ueber ihre Nummer trotzdem + heraus. + + "NUR DOGFATHER" HEISST AUCH NICHT DIE RECHTE HAND. Sie sieht sonst + ueberall dasselbe wie er. Hier nicht, und zwar deshalb, weil das + Etikett sonst nicht stimmte: Eine Zusage mit einer Ausnahme im + Kleingedruckten ist keine. Sie kann selbst genauso schreiben -- auch + ueber ihn, und dann sieht er es nicht anders als jeder andere. + + `COALESCE(..., 0)`: Alle Zeilen, die es vor heute gab, haben in + dieser Spalte NULL. Ohne den Ersatzwert waere `nur_leitung = 0` + fuer sie UNBEKANNT statt wahr -- und der gesamte alte Bestand waere + von einer Minute auf die andere unsichtbar gewesen. Ein Loch, das + niemand meldet: Ein leeres Brett sieht nicht nach Fehler aus. */ +function ohneVertrauliche(regel, person, praefix) { + if (!regel || istDogFather(person)) return regel; + return { + wo: `(${regel.wo}) AND (COALESCE(${praefix}.nur_leitung, 0) = 0` + + ` OR ${praefix}.erstellt_von = ?)`, + werte: [...regel.werte, person.id], + }; +} + export function sichtbarEintrag(person, praefix = "e") { const regel = sichtbar(person); if (!regel) return regel; if (regel.wo === "1=1") return regel; // DogFather sieht ohnehin alles - if (!BEREICHE_FUER_ALLE.length) return regel; + if (!BEREICHE_FUER_ALLE.length) return ohneVertrauliche(regel, person, praefix); const liste = BEREICHE_FUER_ALLE.map(() => "?").join(", "); const mitAllen = { wo: `(${praefix}.bereich IN (${liste}) OR ${regel.wo})`, @@ -499,11 +585,11 @@ export function sichtbarEintrag(person, praefix = "e") { kaeme durch, egal wem er gehoert. Heute hat kein Modi eine Kachel dorthin; ein Aufruf an der Oberflaeche vorbei braucht sie aber nicht. Eine Regel, die nur im Formular gilt, ist keine Regel. */ - if (siehtModis(person)) return mitAllen; - return { + if (siehtModis(person)) return ohneVertrauliche(mitAllen, person, praefix); + return ohneVertrauliche({ wo: `(${mitAllen.wo}) AND ${ohneTeamDogi(praefix)}`, werte: mitAllen.werte, - }; + }, person, praefix); } const SPALTEN = ` @@ -515,6 +601,13 @@ const SPALTEN = ` /* Was DogFather dafuer tun muesste -- nur bei den Angeboten gefuellt, siehe BEREICHE.angebote. */ e.einsatz, + /* 1 = nur DogFather darf das lesen. Die Spalte MUSS mitkommen, auch + wenn die Regel unten schon dafuer sorgt, dass niemand Fremdes die + Zeile bekommt: Der Schreibende soll an seiner eigenen Zeile sehen, + dass sie vertraulich ist. Ohne diese Angabe saehe sie aus wie jede + andere -- und beim naechsten Mal schriebe er offen, was er + vertraulich meinte. */ + e.nur_leitung, ${externSql("pc.name", "e.creator_extern")} AS creator_name, pe.name AS erstellt_name, (SELECT name FROM content_saeulen s WHERE s.id = e.saeule_id) AS saeule_name, @@ -609,6 +702,13 @@ function pruefe(bereich, körper, { neu }) { if (!DRINGLICHKEITEN.includes(körper.dringlichkeit)) fehler.push("Unbekannte Dringlichkeit."); else aus.dringlichkeit = körper.dringlichkeit; } + /* Nur dort, wo der Bereich es vorsieht -- sonst koennte man einen + Schutzvorfall oder eine Content-Idee als "vertraulich" markieren + und damit vor den eigenen Leuten verstecken. */ + if (einstellung.vertraulich && körper.nur_leitung !== undefined) { + aus.nur_leitung = (körper.nur_leitung === true || körper.nur_leitung === 1 + || körper.nur_leitung === "1") ? 1 : 0; + } /* Die vier Content-Felder gibt es nur in der Content-Planung. In jedem anderen Bereich werden sie stillschweigend verworfen -- wie Bewertung @@ -771,14 +871,19 @@ bereicheRouter.post("/workspace/api/bereich/:bereich", gleicheHerkunft, (req, re INSERT INTO eintraege (bereich, art, titel, text, datum, bewertung, dringlichkeit, status, creator_id, creator_extern, erstellt, erstellt_von, hook, format, saeule_id, geplant, - event_ende, event_aufgaben, event_regeln, einsatz) - VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)`).run( + event_ende, event_aufgaben, event_regeln, einsatz, nur_leitung) + VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)`).run( bereich, aus.art, aus.titel, aus.text ?? null, aus.datum, aus.bewertung ?? null, aus.dringlichkeit ?? "mittel", aus.status ?? "offen", aus.creator_id ?? null, aus.creator_extern ?? null, jetzt(), req.person.id, aus.hook ?? null, aus.format ?? null, aus.saeule_id ?? null, aus.geplant ?? null, aus.event_ende ?? null, aus.event_aufgaben ?? null, aus.event_regeln ?? null, - aus.einsatz ?? null); + aus.einsatz ?? null, + /* 0 und nicht NULL als Vorgabe: NULL hiesse "unbekannt", und + unbekannt ist bei einer Vertraulichkeit die falsche Antwort. + Wer nichts waehlt, schreibt fuers Team -- das ist der offene + Fall, und der soll der Normalfall sein. */ + aus.nur_leitung ?? 0); protokolliere("eintrag_angelegt", { personId: req.person.id, rolle: req.person.rolle, ip: echteIp(req), diff --git a/server/workspace.js b/server/workspace.js index 6fafc5d2..f7ef6190 100644 --- a/server/workspace.js +++ b/server/workspace.js @@ -417,6 +417,22 @@ const MODI_BEREICHE = [ name: "Angebote", unter: "Vorschläge, über die DogFather entscheidet", zeichen: "agentur", ton: 17, ziel: "bereich.html?b=angebote", szene: "halle" }, + /* RUECKMELDUNG -- IN BEIDE RICHTUNGEN (10.09.2026, Kapitel 5 und 6 + des Pflichtenhefts). + + Filipe: "Nicht nur ich soll meine Modis bewerten oder ihnen + Feedback geben koennen. Auch die Modis sollen mir Feedback geben + koennen." Und: "Es soll vor allem dabei helfen, als Team besser zu + werden." + + Deshalb steht die Kachel bei ALLEN im Team an derselben Stelle und + heisst fuer alle gleich. "Feedback an DogFather" beim einen und + "Bewertung des Teams" beim anderen waeren zwei Einbahnstrassen -- + und die Richtung stuende im Namen. */ + { gruppe: "Täglich", gruppeUnter: "Was du sowieso jeden Tag aufmachst", + name: "Rückmeldung", unter: "Was gut läuft, was hakt – in beide Richtungen", + zeichen: "reports", ton: 25, ziel: "bereich.html?b=rueckmeldung", szene: "lounge" }, + { gruppe: "Rund ums Live", gruppeUnter: "Vor, während und nach der Sendung", name: "Live-Ablauf", unter: "Checkliste für vorher, mittendrin und danach", zeichen: "live", ton: 3, ziel: "bereich.html?b=live", szene: "portal" }, @@ -738,6 +754,15 @@ const ZUSATZ_BEREICHE = { name: "Ideen-Board", unter: "Gesammelt und nach Dringlichkeit sortiert", zeichen: "content", ton: 23, ziel: "bereich.html?b=ideen", szene: "portal", }, + /* DIESELBE Kachel wie im Team, nur in seiner Gruppe. Name, Ton und + Ziel sind Wort fuer Wort dieselben -- wer sie hier anders nennt, + hat zwei Namen fuer eine Sache, und das Team liest den einen, + DogFather den anderen. */ + { + ...TEAM_GRUPPE, + name: "Rückmeldung", unter: "Was gut läuft, was hakt – in beide Richtungen", + zeichen: "reports", ton: 25, ziel: "bereich.html?b=rueckmeldung", szene: "lounge", + }, ], }; @@ -1304,6 +1329,25 @@ function umstellungen(d) { "clipping" waeren sonst zwei Woerter fuer dieselbe Sache. */ ["chat_raeume", "kategorie", "TEXT"], + /* WER DARF DIESE RUECKMELDUNG LESEN? (10.09.2026) + + 0 = das ganze Team, 1 = nur DogFather. + + WARUM ES DIESE WAHL UEBERHAUPT GIBT: Filipe will beides -- eine + offene Teamkultur UND ehrliche Rueckmeldung an sich selbst. Das + ist kein Widerspruch, aber es sind zwei verschiedene Raeume. Wer + "was koenntest du besser machen" vor versammelter Mannschaft + sagen muss, sagt es nicht; wer alles nur unter vier Augen sagen + kann, hat kein Team-Gespraech. Also entscheidet der Schreibende, + Zeile fuer Zeile. + + UND "NUR DOGFATHER" HEISST NUR DOGFATHER -- die rechte Hand + ausdruecklich nicht. Sie sieht sonst ueberall dasselbe wie er; + hier nicht, und zwar weil das Etikett sonst nicht stimmen wuerde. + Eine Zusage, die im Kleingedruckten eine Ausnahme hat, ist keine. + Sie kann selbst genauso schreiben -- auch ueber ihn. */ + ["eintraege", "nur_leitung", "INTEGER"], + /* ANGEHEFTETE NACHRICHTEN (10.09.2026, Kapitel 7.2). "Ankuendigungs-Funktion: Pin-Nachrichten von Owner/rechte Hand @@ -1881,6 +1925,11 @@ function umstellungen(d) { -- und das ist etwas anderes als "erledigt". Wer beides in einen Topf wirft, kann hinterher nicht mehr sagen, was aus einem Vorschlag geworden ist. */ + /* DER BEREICH FUER DIE RUECKMELDUNG (10.09.2026, Kapitel 5 und 6). */ + checkListeErweitern(d, "eintraege", "bereich", "rueckmeldung", + ["live", "content", "technik", "community", "schutz", "agentur", "ideen", + "angebote", "rueckmeldung"], jetztStempel); + checkListeErweitern(d, "eintraege", "status", "angenommen", ["offen", "erledigt", "angenommen", "abgelehnt"], jetztStempel); diff --git a/workspace/app.webmanifest b/workspace/app.webmanifest index c82bc26c..47bb5c71 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=202609102227", + "src": "/assets/img/app-symbole/workspace-192.png?v=202609102244", "sizes": "192x192", "type": "image/png", "purpose": "any" }, { - "src": "/assets/img/app-symbole/workspace-512.png?v=202609102227", + "src": "/assets/img/app-symbole/workspace-512.png?v=202609102244", "sizes": "512x512", "type": "image/png", "purpose": "any" }, { - "src": "/assets/img/app-symbole/workspace-192-maskable.png?v=202609102227", + "src": "/assets/img/app-symbole/workspace-192-maskable.png?v=202609102244", "sizes": "192x192", "type": "image/png", "purpose": "maskable" }, { - "src": "/assets/img/app-symbole/workspace-512-maskable.png?v=202609102227", + "src": "/assets/img/app-symbole/workspace-512-maskable.png?v=202609102244", "sizes": "512x512", "type": "image/png", "purpose": "maskable" diff --git a/workspace/assets/css/bereich.css b/workspace/assets/css/bereich.css index 8678f60c..9e1bcc8e 100644 --- a/workspace/assets/css/bereich.css +++ b/workspace/assets/css/bereich.css @@ -507,3 +507,46 @@ .event-zeitraum { gap: 6px; } .event-zeitraum__spanne { flex-basis: 100%; } } + +/* ===================================================================== + RÜCKMELDUNG: „fürs Team" oder „nur an DogFather" (10.09.2026) + + Kapitel 5 des Pflichtenhefts. Der Schalter steht ÜBER der + Dringlichkeit und nicht darunter: Wer schreibt, soll wissen, wer + mitliest, BEVOR er den Text formuliert -- danach ist die Entscheidung + nur noch eine Korrektur. + ===================================================================== */ +.vertraulich-zeile { + display: flex; align-items: flex-start; gap: 10px; + padding: 10px 12px; + border: 1px solid var(--rand); border-radius: var(--radius-klein); + background: rgba(255, 255, 255, .02); + cursor: pointer; +} +.vertraulich-zeile input { + /* 18 px und nicht die Vorgabe des Browsers: Das Kästchen ist die + eigentliche Entscheidung auf diesem Formular und darf nicht das + kleinste Element darauf sein. */ + flex: none; width: 18px; height: 18px; margin-top: 1px; + accent-color: var(--akzent); +} +.vertraulich-zeile strong { + display: block; font-size: .86rem; color: var(--text); +} +.vertraulich-zeile em { + display: block; margin-top: 2px; + font-size: .76rem; font-style: normal; color: var(--text-still); +} +.vertraulich-zeile:hover { border-color: var(--rand-hell); } +.vertraulich-zeile:focus-within { + outline: 2px solid var(--akzent); outline-offset: 2px; +} + +/* Das Kennzeichen an der Zeile selbst. Bewusst eine ANDERE Farbe als + die Art daneben -- zwei gleich aussehende Fähnchen liest man als + eines. Bernstein wie beim angehefteten Chat: derselbe Gedanke + ("das hier ist besonders"), dieselbe Farbe. */ +.marke-art--nur { + background: rgba(217, 174, 98, .16); + color: #d9ae62; +} diff --git a/workspace/assets/css/start.css b/workspace/assets/css/start.css index 179fbb53..9442020d 100644 --- a/workspace/assets/css/start.css +++ b/workspace/assets/css/start.css @@ -991,6 +991,25 @@ body.start > main { position: relative; z-index: 1; } -- dunkel genug, um neben den anderen ruhig zu bleiben. */ [data-ton="24"] { --ton: #8a20cf; } /* Eingang */ +/* TON 25 -- Rueckmeldung (10.09.2026). + + NICHT AUSGESUCHT, SONDERN AUSGERECHNET. Vierundzwanzig Toene sind + viel; ein neuer landet fast zwangslaeufig neben einem vorhandenen, + und zwei Kacheln in fast derselben Farbe sind schlimmer als zwei in + derselben -- bei gleicher Farbe merkt man den Fehler, bei fast + gleicher sucht man ihn. + + Deshalb wurden alle 24 in Farbwinkel umgerechnet und die groesste + Luecke gesucht. Sie liegt zwischen 90 Grad (#7bba3a, Steckbrief) und + 160 Grad (#089868, Reports) und ist 70 Grad breit -- mehr als doppelt + so viel wie die naechstgroessere. Dieser Ton sitzt in ihrer Mitte, + also 35 Grad von beiden Nachbarn entfernt. + + Kontrast auf dem dunklen Grund: 8,9 zu 1. Und die Bedeutung passt: + Gruen ist im Haus die Farbe von "laeuft" -- eine Rueckmeldung soll + nicht nach Beschwerde aussehen. */ +[data-ton="25"] { --ton: #43c95e; } /* Rückmeldung */ + /* Rückfallwert für alles, was einen Ton braucht, aber keine Nummer hat. Steht bewusst in :where() -- damit zählt die Zeile beim Wettstreit der Regeln als NICHT vorhanden und die Nummer oben gewinnt immer. Ohne diff --git a/workspace/assets/js/bereich.js b/workspace/assets/js/bereich.js index cd79524d..d32b0b69 100644 --- a/workspace/assets/js/bereich.js +++ b/workspace/assets/js/bereich.js @@ -106,6 +106,18 @@ const kopf = el('div', 'eintrag-kopf'); kopf.append(el('span', 'marke-art', einstellung.arten[e.art] || e.art)); + /* DIE VERTRAULICHE ZEILE SAGT ES SELBST (10.09.2026). + + Sie kommt ohnehin nur bei DogFather und beim Schreiber an -- der + Server laesst sie sonst gar nicht heraus. Der Hinweis ist also + keine Sperre, sondern eine Auskunft: Ohne ihn saehe die eigene + Zeile aus wie jede andere, und beim naechsten Mal schriebe man + offen, was man vertraulich gemeint hat. */ + if (e.nur_leitung) { + const v = el('span', 'marke-art marke-art--nur', 'nur DogFather'); + v.title = 'Das liest ausser dir nur DogFather.'; + kopf.append(v); + } kopf.append(el('h3', 'eintrag-titel', e.titel)); if (einstellung.bewertung && e.bewertung) { @@ -357,6 +369,7 @@ $('feld-aufgaben').hidden = !event; $('feld-regeln').hidden = !event; if (einstellung?.dringlichkeit) $('feld-dringlich').hidden = event; + if (einstellung?.vertraulich) $('feld-vertraulich').hidden = event; $('schild-datum').textContent = event ? 'Von' : 'Datum'; $('schild-titel').textContent = event ? 'Titel des Events' : 'Überschrift'; @@ -414,6 +427,7 @@ }; const event = istEventArt(daten.art); if (einstellung.dringlichkeit && !event) daten.dringlichkeit = $('f-dringlich').value; + if (einstellung.vertraulich) daten.nur_leitung = $('f-vertraulich').checked ? 1 : 0; /* Verborgen heisst auch: nichts mitschicken. Ein Wert, der in einem unsichtbaren Feld stehenbleibt, wandert sonst beim naechsten Speichern mit -- und niemand sieht, woher er kommt. */ @@ -510,6 +524,7 @@ $('f-art').append(o); } $('feld-dringlich').hidden = !einstellung.dringlichkeit; + $('feld-vertraulich').hidden = !einstellung.vertraulich; /* ================================================================= ZWEI ZAHLEN, ANDERE NAMEN (10.09.2026) diff --git a/workspace/aufgaben.html b/workspace/aufgaben.html index d658be35..5bbd8dc5 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,10 +252,10 @@ - - - - - + + + + +