diff --git a/server/mess-kopfleiste.mjs b/server/mess-kopfleiste.mjs new file mode 100644 index 00000000..d4c4f271 --- /dev/null +++ b/server/mess-kopfleiste.mjs @@ -0,0 +1,148 @@ +/* ===================================================================== + WIE DIE KOPFLEISTE AUF DEM HANDY STEHT -- UND WAS IM MENUE LANDET + ===================================================================== + + Filipe, 09.09.2026: „es soll alles in einer reihe sein." + + Seit dem 25.09.2026 sorgt kopf.js dafuer, indem es MISST statt zu + rechnen: Passt die Leiste nicht in eine Zeile, wandert der naechste + Kandidat in ein Mehr-Menue. Diese Messung zeigt, was dabei + herauskommt -- je Breite, mit Bild. + + SIE PRUEFT NICHTS. `pruef-handy` misst die Spanne der Oberkanten und + wird rot, wenn zwei Zeilen entstehen. Hier geht es um das, was man + nur sieht: Steht der Mehr-Knopf am richtigen Platz? Geht das Fach + auf? Sind die Woerter darin lesbar? + + Aufrufen mit: node server/mess-kopfleiste.mjs + ===================================================================== */ +import { mkdtempSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; + +const PORT = 5471; +const ordner = mkdtempSync(join(tmpdir(), "ws-kopf-")); +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(240_000, "mess-kopfleiste"); +await new Promise((r) => setTimeout(r, 900)); + +const BASIS = `http://127.0.0.1:${PORT}`; +const { DatabaseSync } = await import("node:sqlite"); +const { scryptSync, randomBytes, createHmac } = await import("node:crypto"); +const d = new DatabaseSync(process.env.WORKSPACE_DB); +const jetzt = new Date().toISOString(); +const sch = randomBytes(32).toString("hex"); +d.prepare("INSERT INTO einstellungen (schluessel, wert, geaendert, von)" + + " VALUES (?,?,?,NULL) ON CONFLICT(schluessel) DO NOTHING") + .run("code_kennung_schluessel", sch, jetzt); +const anlegen = (name, rolle, code) => { + const s = randomBytes(16).toString("hex"); + const h = scryptSync(code, s, 64, + { N: 32768, r: 8, p: 1, maxmem: 96 * 1024 * 1024 }).toString("hex"); + return d.prepare("INSERT INTO personen" + + " (name, rolle, code_hash, code_salt, code_n, code_kennung, aktiv, erstellt)" + + " VALUES (?,?,?,?,?,?,1,?) RETURNING id") + .get(name, rolle, h, s, 32768, + createHmac("sha256", sch).update(code).digest("hex"), jetzt).id; +}; +anlegen("Filipe", "admin", "CODE-DOGI-0001"); +anlegen("Rieke", "hand", "CODE-HAND-0001"); +d.close(); + +const an = await fetch(BASIS + "/workspace/api/anmelden", { + method: "POST", headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ rolle: "admin", code: "CODE-DOGI-0001" }), +}); +const keks = (an.headers.getSetCookie?.() || []).map((z) => z.split(";")[0]).join("; "); + +const pw = await import( + "file:///C:/Users/qciga/Documents/Obelix/Analyse/node_modules/playwright/index.mjs"); +const browser = await pw.chromium.launch(); + +for (const [wie, breite] of [["320", 320], ["360", 360], ["390", 390], + ["412", 412], ["768", 768], ["1280", 1280]]) { + const kontext = await browser.newContext({ + viewport: { width: breite, height: 860 }, + deviceScaleFactor: 2, isMobile: breite < 700, hasTouch: breite < 700, + }); + await kontext.addCookies(keks.split("; ").map((k) => { + const [n, ...r] = k.split("="); + return { name: n, value: r.join("="), domain: "127.0.0.1", path: "/" }; + })); + const seite = await kontext.newPage(); + await seite.goto(BASIS + "/workspace/start.html", { waitUntil: "networkidle" }); + await seite.waitForTimeout(1200); + + const lage = await seite.evaluate(() => { + const leiste = document.querySelector(".kopfleiste__rechts"); + const fach = document.getElementById("kopf-mehr-fach"); + const knopf = document.getElementById("kopf-mehr"); + const namen = (n) => [...n.children] + .filter((k) => k.id !== "kopf-mehr-fach") + .map((k) => `${String(k.className).split(" ")[0] || k.tagName.toLowerCase()}` + + `(${Math.round(k.getBoundingClientRect().width)}x` + + `${Math.round(k.getBoundingClientRect().height)})`); + const oben = [...leiste.children] + .filter((k) => k.id !== "kopf-mehr-fach" && !k.hidden + && k.getBoundingClientRect().height > 0) + .map((k) => Math.round(k.getBoundingClientRect().top)); + return { + inDerLeiste: namen(leiste), + imFach: fach ? namen(fach) : [], + mehrKnopf: knopf ? (knopf.hidden ? "versteckt" : "da") : "gibt es nicht", + spanne: oben.length ? Math.max(...oben) - Math.min(...oben) : 0, + hoehe: Math.round(document.querySelector(".kopfleiste").getBoundingClientRect().height), + }; + }); + + console.log(`\n=== ${wie} px ===`); + console.log(` Leiste (${lage.spanne} px Spanne, Kopf ${lage.hoehe} px hoch):`); + console.log(` ${lage.inDerLeiste.join(" ")}`); + console.log(` Mehr-Knopf: ${lage.mehrKnopf}`); + console.log(` im Fach: ${lage.imFach.join(" ") || "nichts"}`); + + /* Und einmal aufmachen -- ein Menue, das nicht aufgeht, ist schlimmer + als kein Menue. */ + if (lage.mehrKnopf === "da") { + await seite.click("#kopf-mehr", { timeout: 4000 }) + .catch((f) => console.log(" KLICK SCHEITERT: " + String(f.message).split(String.fromCharCode(10))[0].slice(0, 120))); + await seite.waitForTimeout(400); + const auf = await seite.evaluate(() => { + const f = document.getElementById("kopf-mehr-fach"); + if (!f || f.hidden) return null; + const r = f.getBoundingClientRect(); + return { + kasten: `${Math.round(r.width)}x${Math.round(r.height)} bei ${Math.round(r.left)}`, + ausDemBild: r.right > window.innerWidth + 1 || r.left < -1, + woerter: [...f.children].map((k) => (k.textContent || "").trim().slice(0, 16)), + zuKlein: [...f.children] + .filter((k) => k.getBoundingClientRect().height < 44) + .map((k) => (k.textContent || "").trim().slice(0, 12)), + }; + }); + if (!auf) console.log(" FACH GEHT NICHT AUF"); + else { + console.log(` Fach offen: ${auf.kasten}` + + (auf.ausDemBild ? " STEHT AUS DEM BILD" : "")); + console.log(` ${auf.woerter.join(" | ")}`); + if (auf.zuKlein.length) console.log(` ZU KLEIN: ${auf.zuKlein.join(", ")}`); + await seite.screenshot({ path: `server/mess-kopf-${wie}.png` }).catch(() => {}); + } + } + await kontext.close(); +} + +await browser.close(); +try { rmSync(ordner, { recursive: true, force: true }); } catch { /* egal */ } +process.exit(0); diff --git a/server/pruef-installieren.mjs b/server/pruef-installieren.mjs index 483ffa5e..8770ef9f 100644 --- a/server/pruef-installieren.mjs +++ b/server/pruef-installieren.mjs @@ -338,16 +338,43 @@ try { await cs.click("button[type=submit]"); await cs.waitForURL("**/start.html", { timeout: 25000 }); await cs.waitForTimeout(1300); + /* ==== ER KANN SEIT DEM 25.09.2026 IM MEHR-MENUE STEHEN ========== + + Die Kopfleiste haelt sich seither selbst einreihig: Passt sie + nicht in eine Zeile, wandert der naechste Kandidat in ein + Mehr-Menue, und der Installieren-Knopf ist der zweite auf dieser + Liste. Auf einem iPhone mit 390 px ist er deshalb zu finden -- + nur eben hinter einem Knopf. + + GEMESSEN WIRD DAMIT, WAS ZAEHLT: Ist er ERREICHBAR und, wenn man + ihn sieht, treffbar? Nicht: Steht er an einer bestimmten Stelle. + Die alte Fassung verlangte das Zweite und war deshalb rot, + obwohl der Knopf zwei Fingertipps entfernt stand. + + ZUERST WIRD AUFGEMACHT, DANN GEMESSEN -- und wenn es kein Menue + gibt, bleibt alles wie vorher. */ + await cs.evaluate(() => { + const m = document.getElementById("kopf-mehr"); + if (m && !m.hidden) m.click(); + }); + await cs.waitForTimeout(300); const beiApple = await cs.evaluate(() => { const k = document.querySelector(".install-knopf"); - return k ? { sichtbar: !k.hidden, breit: Math.round(k.getBoundingClientRect().width) } : null; + if (!k) return null; + const r = k.getBoundingClientRect(); + return { + sichtbar: !k.hidden, + breit: Math.round(r.width), hoch: Math.round(r.height), + imMenue: !!k.closest(".kopf-mehr__fach"), + }; }); /* DER WICHTIGSTE FALL: Dort gibt es `beforeinstallprompt` nicht -- der Knopf muss trotzdem dastehen und erklaeren. */ ok(beiApple?.sichtbar === true, - "auf dem iPhone steht er von Anfang an da (dort fragt der Browser nie)"); - ok((beiApple?.breit || 0) >= 44, - ` und ist auch dort treffbar (${beiApple?.breit} px breit)`); + "auf dem iPhone steht er von Anfang an da (dort fragt der Browser nie)" + + (beiApple?.imMenue ? " -- im Mehr-Menue" : "")); + ok((beiApple?.breit || 0) >= 44 && (beiApple?.hoch || 0) >= 44, + ` und ist auch dort treffbar (${beiApple?.breit} x ${beiApple?.hoch} px)`); await cs.click(".install-knopf"); await cs.waitForTimeout(700); diff --git a/server/pruef-pin-fuer-mich.mjs b/server/pruef-pin-fuer-mich.mjs new file mode 100644 index 00000000..690620fc --- /dev/null +++ b/server/pruef-pin-fuer-mich.mjs @@ -0,0 +1,385 @@ +/* ===================================================================== + EINEN AUSHANG FUER SICH WEGNEHMEN -- ODER FUER ALLE (25.09.2026) + ===================================================================== + + Filipe: + + „jeder soll das fixierte individuel für sich lösen können aber + niemals so dass es sich für alle löst. außer dogfather macht es + oder die rechte hand dan ist es bei jedem weg ansonsten sollen + alle anderen rollen es individuell für sich lösen können. + dogfather und die rechte hand sollen die option haben für sich + selbst oder für alle zu lösen." + + --------------------------------------------------------------------- + DREI FRAGEN, UND DIE MITTLERE IST DIE, UM DIE ES GEHT + + 1. Kann jeder den Aushang bei sich wegnehmen? (Und zurueckholen -- + sonst braeuchte es eine Rueckfrage davor.) + 2. BLEIBT ER DABEI BEI ALLEN ANDEREN STEHEN? Das ist der Satz + „niemals so dass es sich fuer alle loest", und er laesst sich + nur mit ZWEI Anmeldungen messen: Was der eine wegnimmt, muss + der andere weiterhin sehen. Eine Pruefung mit einer einzigen + Sitzung koennte hier gruen sein und trotzdem falsch. + 3. Und duerfen DogFather und die rechte Hand es fuer alle loesen, + waehrend alle anderen das nicht koennen? + + --------------------------------------------------------------------- + WARUM DAZU EINE VIERTE FRAGE GEHOERT + + Es haengen hoechstens DREI Aushaenge. Nimmt jemand einen fuer sich + weg, darf er nicht einfach fehlen -- der naechste muss nachruecken, + sonst sieht er zwei, waehrend ein vierter danebenliegt und nie + erscheint. Das ist der Unterschied zwischen „ausgeblendet" und + „verloren", und man sieht ihn nur, wenn man vier anheftet. + + Aufrufen mit: node server/pruef-pin-fuer-mich.mjs + ===================================================================== */ +import { mkdtempSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; + +const ordner = mkdtempSync(join(tmpdir(), "ws-pinmich-")); +process.env.WORKSPACE_DB = join(ordner, "workspace.db"); + +const { eigenerPort } = await import("./helfer-port.mjs"); +const PORT = await eigenerPort(import.meta, "die Aushang-Pruefung"); + +process.env.PORT = `${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(120_000, "pruef-pin-fuer-mich"); +await new Promise((r) => setTimeout(r, 1000)); +process.on("uncaughtException", (f) => { console.error("ABSTURZ:", f); process.exit(7); }); +process.on("unhandledRejection", (f) => { console.error("ABSTURZ (Versprechen):", f); process.exit(7); }); + +const BASIS = `http://127.0.0.1:${PORT}`; +const CREW = "crew.dogfather-universe.com"; + +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++; }; + +/* ---- Leute ---------------------------------------------------------- */ +const { DatabaseSync } = await import("node:sqlite"); +const { scryptSync, randomBytes, createHmac } = await import("node:crypto"); +const d = new DatabaseSync(process.env.WORKSPACE_DB); +const jetzt = new Date().toISOString(); +const sch = randomBytes(32).toString("hex"); +d.prepare("INSERT INTO einstellungen (schluessel, wert, geaendert, von)" + + " VALUES (?,?,?,NULL) ON CONFLICT(schluessel) DO NOTHING") + .run("code_kennung_schluessel", sch, jetzt); +const anlegen = (name, rolle, code) => { + const s = randomBytes(16).toString("hex"); + const h = scryptSync(code, s, 64, + { N: 32768, r: 8, p: 1, maxmem: 96 * 1024 * 1024 }).toString("hex"); + return d.prepare("INSERT INTO personen" + + " (name, rolle, code_hash, code_salt, code_n, code_kennung, aktiv, erstellt)" + + " VALUES (?,?,?,?,?,?,1,?) RETURNING id") + .get(name, rolle, h, s, 32768, + createHmac("sha256", sch).update(code).digest("hex"), jetzt).id; +}; +const idDogi = anlegen("Filipe", "admin", "CODE-DOGI-0001"); +const idHand = anlegen("Rieke", "hand", "CODE-HAND-0001"); +const idModi = anlegen("Kessi", "modi", "CODE-MODI-0001"); +const idModi2 = anlegen("Mara", "modi", "CODE-MODI-0002"); +const idFremd = anlegen("Tili", "modi", "CODE-TILI-0001"); +d.close(); + +/* ==== `node:http` UND NICHT `fetch` ================================ + + Diese Pruefung braucht Team-Dogi-Rollen (Modi, rechte Hand), und die + gibt es nur auf der CREW-Adresse. Dafuer muss der `Host`-Kopf + gesetzt werden -- und genau den laesst `fetch` nicht zu: Er steht + auf der Liste der verbotenen Koepfe, undici verwirft ihn stumm. + Die Anfrage kam damit auf 127.0.0.1 an, waehrend der `Origin`-Kopf + die Crew-Adresse nannte; `gleicheHerkunft` verglich beides und + antwortete mit 403 „fremde_herkunft" -- auf JEDE schreibende + Anfrage. Im ersten Lauf sah das aus, als sei die neue Route kaputt. + + Mit `node:http` setzt man den Kopf selbst, und beides passt + zusammen. Dieselbe Loesung benutzen pruef-anruf und die anderen + Pruefungen, die ueber Haeuser hinweg messen. */ +const { request: httpAnfrage } = await import("node:http"); +const roh = (keks, pfad, methode = "GET", koerper = null) => + new Promise((fertig, schief) => { + const daten = koerper === null ? null : JSON.stringify(koerper); + const a = httpAnfrage({ + host: "127.0.0.1", port: PORT, path: pfad, method: methode, + headers: { + Host: CREW, + ...(keks ? { Cookie: keks } : {}), + Origin: `https://${CREW}`, + Referer: `https://${CREW}/workspace/chat.html`, + ...(daten ? { "Content-Type": "application/json", + "Content-Length": Buffer.byteLength(daten) } : {}), + }, + }, (r) => { + let t = ""; r.on("data", (x) => { t += x; }); + r.on("end", () => fertig({ + status: r.statusCode, kopf: r.headers, text: t, + json: () => { try { return JSON.parse(t); } catch { return {}; } }, + })); + }); + a.on("error", schief); + if (daten) a.write(daten); + a.end(); + }); + +const hol = async (keks, pfad, wie = {}) => + roh(keks, pfad, wie.method || "GET", wie.body ? JSON.parse(wie.body) : null); + +const anmelden = async (rolle, code) => { + const a = await roh(null, "/workspace/api/anmelden", "POST", { rolle, code }); + if (a.status !== 200) { console.log(`ABBRUCH: Anmeldung ${rolle} ${a.status}`); process.exit(2); } + return [].concat(a.kopf["set-cookie"] || []).map((z) => z.split(";")[0]).join("; "); +}; + +const kDogi = await anmelden("admin", "CODE-DOGI-0001"); +const kHand = await anmelden("hand", "CODE-HAND-0001"); +const kModi = await anmelden("modi", "CODE-MODI-0001"); +const kModi2 = await anmelden("modi", "CODE-MODI-0002"); +const kFremd = await anmelden("modi", "CODE-TILI-0001"); +ok(!!kDogi && !!kHand && !!kModi && !!kModi2 && !!kFremd, "fuenf Leute sind angemeldet"); + +/* ---- Ein Raum mit vier Leuten --------------------------------------- */ +const gr = await hol(kDogi, "/workspace/api/chat/gruppe", { + method: "POST", body: JSON.stringify({ name: "Ansagen", mit: [idHand, idModi, idModi2] }), +}); +const raumId = (gr.json()).raum_id; +ok(gr.status === 201 && !!raumId, `ein Raum entsteht (${gr.status}, #${raumId})`); + +const schreiben = async (keks, text) => { + const a = await hol(keks, `/workspace/api/chat/raeume/${raumId}/nachrichten`, { + method: "POST", body: JSON.stringify({ text }), + }); + const j = a.json(); + return j.nachricht?.id || j.id; +}; +const anheften = (keks, id, an) => + hol(keks, `/workspace/api/chat/nachrichten/${id}/anheften`, { + method: "PUT", body: JSON.stringify({ an }), + }); +const fuerMich = (keks, id, aus) => + hol(keks, `/workspace/api/chat/nachrichten/${id}/pin-fuer-mich`, { + method: "PUT", body: JSON.stringify({ aus }), + }); +const aushaenge = async (keks) => { + const a = await hol(keks, `/workspace/api/chat/raeume/${raumId}/nachrichten`); + const j = a.json(); + return { + ids: (j.angeheftet || []).map((x) => x.id), + darfFuerAlle: Object.fromEntries((j.angeheftet || []).map((x) => [x.id, x.darf_fuer_alle])), + ausBeiMir: (j.nachrichten || []).filter((n) => n.pin_fuer_mich_aus).map((n) => n.id), + darfPinFuerAlle: j.darf_pin_fuer_alle, + }; +}; + +const n1 = await schreiben(kDogi, "Freitag um 20 Uhr geht es los."); +const a1 = await anheften(kDogi, n1, true); +ok(a1.status === 200, `DogFather heftet eine Ansage an (${a1.status})`); + +/* ======================================================================= + 1. JEDER NIMMT SIE BEI SICH WEG -- UND NUR BEI SICH + ======================================================================= */ +melde(""); +melde("=== 1. Fuer mich wegnehmen ==="); +{ + const vorher = await aushaenge(kModi); + ok(vorher.ids.includes(n1), `der Modi sieht die Ansage (${vorher.ids.join(",")})`); + + const weg = await fuerMich(kModi, n1, true); + ok(weg.status === 200, `er nimmt sie bei sich weg (${weg.status})`); + + const beiIhm = await aushaenge(kModi); + ok(!beiIhm.ids.includes(n1), + `bei ihm haengt sie nicht mehr (${beiIhm.ids.join(",") || "nichts"})`); + + /* DER KERN DES GANZEN AUFTRAGS. Ohne diese zwei Zeilen koennte das + „Wegnehmen" in Wahrheit ein Loesen fuer alle sein, und niemand + wuerde es bemerken -- bis jemand fragt, wo die Ansage hin ist. */ + const beiDogi = await aushaenge(kDogi); + const beiModi2 = await aushaenge(kModi2); + ok(beiDogi.ids.includes(n1), "bei DogFather haengt sie weiter"); + ok(beiModi2.ids.includes(n1), "und beim zweiten Modi auch"); + + ok(beiIhm.ausBeiMir.includes(n1), + "die Nachricht selbst weiss, dass ER sie weggenommen hat"); + ok(!beiModi2.ausBeiMir.includes(n1), + " und beim anderen steht das NICHT dran (Gegenprobe)"); +} + +/* ======================================================================= + 2. UND ER HOLT SIE ZURUECK + ======================================================================= */ +melde(""); +melde("=== 2. Der Rueckweg ==="); +{ + const zurueck = await fuerMich(kModi, n1, false); + ok(zurueck.status === 200, `er holt sie zurueck (${zurueck.status})`); + const jetztWieder = await aushaenge(kModi); + ok(jetztWieder.ids.includes(n1), "sie haengt wieder oben"); + ok(!jetztWieder.ausBeiMir.includes(n1), "und die Marke ist weg"); + + /* Noch einmal wegnehmen -- fuer die Pruefungen darunter. Zweimal + dasselbe darf keinen Fehler geben (ON CONFLICT DO NOTHING). */ + await fuerMich(kModi, n1, true); + const nochmal = await fuerMich(kModi, n1, true); + ok(nochmal.status === 200, `zweimal wegnehmen ist kein Fehler (${nochmal.status})`); +} + +/* ======================================================================= + 3. FUER ALLE LOESEN DARF NICHT JEDER + ======================================================================= */ +melde(""); +melde("=== 3. Fuer alle loesen ==="); +{ + const modiVersuch = await anheften(kModi, n1, false); + ok(modiVersuch.status === 403, + `ein Modi kann sie NICHT fuer alle loesen (${modiVersuch.status})`); + const grund = modiVersuch.json(); + ok(/für dich selbst/i.test(grund.fehler || ""), + ` und die Absage sagt, was stattdessen geht ("${(grund.fehler || "").slice(0, 60)}")`); + + const nochDa = await aushaenge(kModi2); + ok(nochDa.ids.includes(n1), " die Ansage haengt nach dem Versuch weiter"); + + const lage = await aushaenge(kModi); + ok(lage.darfPinFuerAlle === false, "der Modi bekommt die Wahl gar nicht angeboten"); + const lageDogi = await aushaenge(kDogi); + ok(lageDogi.darfPinFuerAlle === true, "DogFather schon"); + ok(lageDogi.darfFuerAlle[n1] === true, " und an der Ansage steht es auch"); +} + +/* ======================================================================= + 4. DIE RECHTE HAND DARF ES -- UND DANN IST SIE BEI JEDEM WEG + ======================================================================= */ +melde(""); +melde("=== 4. Die rechte Hand loest fuer alle ==="); +{ + const los = await anheften(kHand, n1, false); + ok(los.status === 200, `die rechte Hand loest fuer alle (${los.status})`); + + for (const [wer, keks] of [["DogFather", kDogi], ["Modi 2", kModi2], ["die Hand", kHand]]) { + const l = await aushaenge(keks); + ok(!l.ids.includes(n1), `bei ${wer} ist sie weg`); + } +} + +/* ======================================================================= + 5. WER SELBST ANGEHEFTET HAT, NIMMT ES AUCH ZURUECK + ======================================================================= + + Steht nicht in Filipes Satz und ist trotzdem noetig: Es haengen + hoechstens drei. Eine Gruppenleitung, die drei angeheftet hat und + keinen abnehmen darf, koennte nie wieder etwas anheften. */ +melde(""); +melde("=== 5. Wer anheftet, darf auch abheften ==="); +{ + /* Der zweite Modi bekommt die Leitung im Raum -- dann darf er + anheften. */ + const dd = new DatabaseSync(process.env.WORKSPACE_DB); + dd.prepare("UPDATE chat_teilnehmer SET leitung = 1 WHERE raum_id = ? AND person_id = ?") + .run(raumId, idModi2); + dd.close(); + + const n2 = await schreiben(kModi2, "Kurze Zwischenansage."); + const an2 = await anheften(kModi2, n2, true); + ok(an2.status === 200, `der zweite Modi darf anheften (${an2.status})`); + + const seins = await aushaenge(kModi2); + ok(seins.darfFuerAlle[n2] === true, " und darf seinen eigenen Aushang wieder loesen"); + + const fremderAushang = await schreiben(kDogi, "Und noch eine von DogFather."); + await anheften(kDogi, fremderAushang, true); + const seins2 = await aushaenge(kModi2); + ok(seins2.darfFuerAlle[fremderAushang] === false, + " aber NICHT den von DogFather (Gegenprobe)"); + + const versuch = await anheften(kModi2, fremderAushang, false); + ok(versuch.status === 403, + ` und der Versuch wird abgewiesen (${versuch.status})`); + + const eigenes = await anheften(kModi2, n2, false); + ok(eigenes.status === 200, ` seinen eigenen loest er (${eigenes.status})`); +} + +/* ======================================================================= + 6. ES HAENGEN DREI -- UND DER VIERTE RUECKT NACH + ======================================================================= */ +melde(""); +melde("=== 6. Der naechste rueckt nach ==="); +{ + /* Aufraeumen: alles loesen, was noch haengt. */ + const stand = await aushaenge(kDogi); + for (const id of stand.ids) await anheften(kDogi, id, false); + + const vier = []; + for (let i = 1; i <= 4; i++) vier.push(await schreiben(kDogi, `Ansage ${i}`)); + for (const id of vier.slice(0, 3)) await anheften(kDogi, id, true); + + const voll = await anheften(kDogi, vier[3], true); + ok(voll.status === 409, `der vierte passt nicht drauf (${voll.status})`); + + const dreiDa = await aushaenge(kModi); + ok(dreiDa.ids.length === 3, `es haengen drei (${dreiDa.ids.length})`); + + /* Jetzt nimmt der Modi einen fuer sich weg. Der vierte haengt ja + nicht -- also darf danach nichts nachruecken, und er sieht ZWEI. + Das ist die richtige Antwort: „fuer mich weg" ist kein Platz + schaffen. */ + await fuerMich(kModi, dreiDa.ids[0], true); + const zweiDa = await aushaenge(kModi); + ok(zweiDa.ids.length === 2, + `nach dem Wegnehmen sieht er zwei (${zweiDa.ids.length})`); + const beiDogiDrei = await aushaenge(kDogi); + ok(beiDogiDrei.ids.length === 3, + ` und DogFather weiterhin drei (${beiDogiDrei.ids.length})`); + + /* UND JETZT DER FALL, DER DAS NACHRUECKEN BEWEIST: Vier haengen + nicht gleichzeitig -- also loest DogFather einen fuer alle und + heftet den vierten an. Der Modi hat einen der drei bei sich + weggenommen; er muss trotzdem drei sehen, sobald es vier gibt, + von denen einer bei ihm aus ist. */ + await anheften(kDogi, dreiDa.ids[2], false); + await anheften(kDogi, vier[3], true); + const nachher = await aushaenge(kModi); + ok(nachher.ids.length === 2 && !nachher.ids.includes(dreiDa.ids[0]), + `der weggenommene bleibt weg, die anderen ruecken nach ` + + `(${nachher.ids.length}: ${nachher.ids.join(",")})`); +} + +/* ======================================================================= + 7. GEGENPROBEN + ======================================================================= */ +melde(""); +melde("=== 7. Gegenproben (jede MUSS anschlagen) ==="); +{ + const stand = await aushaenge(kDogi); + const einer = stand.ids[0]; + + const fremd = await fuerMich(kFremd, einer, true); + ok(fremd.status === 404, + `wer nicht im Raum ist, kommt nicht heran (${fremd.status})`); + + const ohne = await roh(null, `/workspace/api/chat/nachrichten/${einer}/pin-fuer-mich`, + "PUT", { aus: true }); + ok(ohne.status === 401, `ohne Anmeldung gar nichts (${ohne.status})`); + + const erfunden = await fuerMich(kDogi, 999999, true); + ok(erfunden.status === 404, `eine erfundene Nummer gibt es nicht (${erfunden.status})`); +} + +melde(""); +melde(`${geprueft} Pruefungen, ${fehler} Fehler`); +melde(fehler === 0 ? "ALLES IN ORDNUNG" : "NICHT IN ORDNUNG"); +try { rmSync(ordner, { recursive: true, force: true }); } catch { /* egal */ } +process.exit(fehler === 0 ? 0 : 1); diff --git a/server/workspace-chat.js b/server/workspace-chat.js index 77db76a1..ebc40202 100644 --- a/server/workspace-chat.js +++ b/server/workspace-chat.js @@ -1685,15 +1685,44 @@ chatRouter.get("/workspace/api/chat/raeume/:id/nachrichten", (req, res) => { `weg_am IS NULL` (eine zurueckgenommene Nachricht bleibt zurueckgenommen, auch als Aushang). */ const weggeraeumt = weg; + /* ==== WAS ICH FUER MICH WEGGENOMMEN HABE (25.09.2026) ========== + + Filipe: „jeder soll das fixierte individuel fuer sich loesen + koennen aber niemals so dass es sich fuer alle loest." + + DER VERBUND STEHT HIER UND NICHT IM BROWSER. Eine Liste, die + alles schickt und im Browser gefiltert wird, ist eine Liste, die + alles schickt -- und der naechste, der sie benutzt, vergisst den + Filter. Ausserdem stimmte dann die Grenze von drei nicht mehr: + Wer einen Aushang weggenommen hat, saehe zwei statt drei, + obwohl ein vierter danebenliegt. + + DESHALB `NOT EXISTS` UND EIN GROESSERES LIMIT: Gesucht wird + unter denen, die fuer MICH noch gelten -- und davon die + obersten drei. So ruecken die anderen nach, genau wie an einem + Brett aus Kork. */ const angeheftet = db().prepare(` SELECT n.id, n.text, n.erstellt, n.angeheftet_am, n.anhang_art, - p.name AS von, q.name AS heftete + n.angeheftet_von, p.name AS von, q.name AS heftete FROM chat_nachrichten n LEFT JOIN personen p ON p.id = n.person_id LEFT JOIN personen q ON q.id = n.angeheftet_von WHERE n.raum_id = ? AND n.angeheftet_am IS NOT NULL AND n.weg_am IS NULL AND n.id > ? - ORDER BY n.angeheftet_am DESC LIMIT ?`).all(raumId, weggeraeumt, PIN_MAX); + AND NOT EXISTS (SELECT 1 FROM chat_pin_aus x + WHERE x.nachricht_id = n.id AND x.person_id = ?) + ORDER BY n.angeheftet_am DESC LIMIT ?`) + .all(raumId, weggeraeumt, req.person.id, PIN_MAX); + + /* UND WELCHE ICH WEGGENOMMEN HABE -- damit das Menue an der + Nachricht „wieder oben anzeigen" anbieten kann. Ohne diese Liste + waere das Wegnehmen eine Einbahnstrasse, und dann braeuchte es + eine Rueckfrage davor. So braucht es keine. */ + const pinAus = new Set(db().prepare(` + SELECT x.nachricht_id AS id FROM chat_pin_aus x + JOIN chat_nachrichten n ON n.id = x.nachricht_id + WHERE x.person_id = ? AND n.raum_id = ?`) + .all(req.person.id, raumId).map((z) => z.id)); res.json({ raum: { @@ -1713,6 +1742,14 @@ chatRouter.get("/workspace/api/chat/raeume/:id/nachrichten", (req, res) => { assets/js/chat.js bekommt jeder, der die Seite oeffnet. Ein Ja/Nein verraet nichts ueber die Rollen dahinter. */ darf_anheften: darfAnheften(req.person, raumId), + /* WER DARF FUER ALLE LOESEN? Vom Server beantwortet, nicht im + Browser nachgerechnet -- dort stuende die Regel ein zweites + Mal, und die zweite Fassung waere ab der naechsten Aenderung + falsch. Dieselbe Ueberlegung wie bei darf_anheften daneben. + Das „oder wer es selbst angeheftet hat" entscheidet die Route + je Nachricht; hier steht nur, ob die Wahl ueberhaupt + angeboten wird. */ + darf_pin_fuer_alle: fuehrtTeamDogi(req.person), /* WER JEDE NACHRICHT LOESCHEN DARF -- vom Server, nicht aus der Rolle im Browser geraten. assets/js/chat.js bekommt JEDER, der die Seite oeffnet; ein `rolle === 'hand'` darin @@ -1786,6 +1823,21 @@ chatRouter.get("/workspace/api/chat/raeume/:id/nachrichten", (req, res) => { seit: a.angeheftet_am, text: String(a.text || "").slice(0, 200) || anhangWort(a.anhang_art), + /* DARF ICH DIESEN HIER FUER ALLE LOESEN? (25.09.2026) + + Je Aushang und nicht je Person, weil die Antwort davon + abhaengt, WER ihn angeheftet hat: DogFather und die rechte + Hand duerfen immer; alle anderen nur das, was sie selbst + angeheftet haben (sonst koennte eine Gruppenleitung ihre + drei Aushaenge nie wieder abnehmen -- siehe die Begruendung + an der Route). + + VOM SERVER BEANTWORTET, damit die Regel nicht im Browser ein + zweites Mal steht. Die Route prueft sie ohnehin; hier geht + es nur darum, ob der Knopf ueberhaupt angeboten wird -- ein + Knopf, der nur eine Absage kann, ist schlimmer als keiner. */ + darf_fuer_alle: fuehrtTeamDogi(req.person) + || Number(a.angeheftet_von) === Number(req.person.id), })), /* Aufsteigend zurück -- gelesen wird von oben nach unten. */ /* Die Reaktionen in EINER Abfrage fuer alle Nachrichten -- nicht @@ -1816,6 +1868,12 @@ chatRouter.get("/workspace/api/chat/raeume/:id/nachrichten", (req, res) => { erstellt: n.erstellt, selbst: n.person_id === req.person.id, angeheftet: !!n.angeheftet_am, + /* OB ICH SIE OBEN WEGGENOMMEN HABE (25.09.2026). Steht an der + Nachricht und nicht in einer zweiten Liste im Browser: Das + Menue an der Nachricht muss daraus „oben wegnehmen" oder + „wieder oben anzeigen" machen, und zwei Quellen fuer + dasselbe laufen auseinander. */ + pin_fuer_mich_aus: pinAus.has(n.id), /* WURDE HIER DAS RUDEL GERUFEN? Steht an der Nachricht, weil der Browser sonst die damalige Rolle des Absenders kennen muesste -- siehe die Spalte in workspace.js. */ @@ -3075,6 +3133,67 @@ chatRouter.put("/workspace/api/chat/raeume/:id/fixieren", gleicheHerkunft, } }); +/* ===================================================================== + EINEN AUSHANG NUR FUER SICH WEGNEHMEN (25.09.2026) + ===================================================================== + + Filipe: „jeder soll das fixierte individuel fuer sich loesen koennen + aber niemals so dass es sich fuer alle loest." + + KEIN RECHT NOETIG, NUR EIN PLATZ IM RAUM. Wer die Nachricht sehen + darf, darf sie auch aus seiner eigenen Kopfzeile nehmen -- das ist + keine Aenderung am Raum, sondern eine Einstellung an sich selbst. + Deshalb steht hier `istDrin` und nicht `darfAnheften`. + + UMKEHRBAR, und das ist der Grund fuer `aus: false`: Wer sich + verklickt, holt den Aushang ueber das Menue an der Nachricht wieder + nach oben. Ein Handgriff ohne Rueckweg braucht eine Rueckfrage; mit + Rueckweg braucht er keine, und zwei Klicks fuer „ist mir schon + bekannt" waeren einer zu viel. + + DIE NACHRICHT BLEIBT ANGEHEFTET. Hier steht nur, dass ICH sie oben + nicht mehr sehen will. Fuer alle anderen haengt sie weiter, und + wenn die Leitung sie spaeter fuer alle loest, verschwindet sie + ueberall -- diese Zeile hier wird dann bedeutungslos und faellt mit + der Nachricht weg (ON DELETE CASCADE). + ===================================================================== */ +chatRouter.put("/workspace/api/chat/nachrichten/:id/pin-fuer-mich", gleicheHerkunft, + express.json({ limit: "2kb" }), (req, res) => { + try { + const id = Number(req.params.id); + if (!Number.isInteger(id)) return res.status(400).json({ fehler: "ungueltig" }); + + const d = db(); + const n = d.prepare( + "SELECT id, raum_id FROM chat_nachrichten WHERE id = ?").get(id); + if (!n || !istDrin(n.raum_id, req.person)) { + return res.status(404).json({ fehler: "nicht_gefunden" }); + } + + /* AUSDRUECKLICH `=== true`, kein Umschalten ohne Angabe -- + dieselbe Ueberlegung wie beim Fixieren der Gespraeche: Ein + Schalter, der den Gegenwert selbst ausrechnet, kippt bei zwei + offenen Fenstern in den falschen Zustand. */ + const aus = req.body?.aus === true; + if (aus) { + d.prepare(`INSERT INTO chat_pin_aus (nachricht_id, person_id, am) + VALUES (?,?,?) ON CONFLICT DO NOTHING`) + .run(id, req.person.id, jetzt()); + } else { + d.prepare("DELETE FROM chat_pin_aus WHERE nachricht_id = ? AND person_id = ?") + .run(id, req.person.id); + } + + /* KEIN chatEreignis: Das geht nur mich etwas an. Ein Ereignis an + den Raum wuerde bei allen anderen ein Neuzeichnen ausloesen -- + fuer eine Aenderung, die sie nicht sehen. */ + res.json({ ok: true, aus }); + } catch (fehler) { + console.error("[chat] Pin fuer mich:", fehler?.message); + res.status(503).json({ fehler: "nicht_verfuegbar" }); + } + }); + /* ===================================================================== ANKUENDIGUNGEN ANHEFTEN (10.09.2026, Kapitel 7.2) @@ -3100,8 +3219,8 @@ chatRouter.put("/workspace/api/chat/nachrichten/:id/anheften", gleicheHerkunft, if (!Number.isInteger(id)) return res.status(400).json({ fehler: "ungueltig" }); const d = db(); - const n = d.prepare( - "SELECT id, raum_id, weg_am, angeheftet_am FROM chat_nachrichten WHERE id = ?").get(id); + const n = d.prepare("SELECT id, raum_id, weg_am, angeheftet_am, angeheftet_von" + + " FROM chat_nachrichten WHERE id = ?").get(id); /* Erst die Mitgliedschaft (404 -- was man nicht sehen darf, existiert nicht), dann das Recht (403 -- man sieht es, darf es aber nicht). Zwei verschiedene Antworten fuer zwei verschiedene @@ -3110,11 +3229,42 @@ chatRouter.put("/workspace/api/chat/nachrichten/:id/anheften", gleicheHerkunft, if (!n || !istDrin(n.raum_id, req.person)) { return res.status(404).json({ fehler: "nicht_gefunden" }); } - if (!darfAnheften(req.person, n.raum_id)) { - return res.status(403).json({ fehler: "Anheften darf nur die Leitung." }); - } - const an = req.body?.an !== false; + + /* ==== ANHEFTEN UND FUER-ALLE-LOESEN SIND ZWEI FRAGEN (25.09.2026) + + Filipe: „niemals so dass es sich fuer alle loest. ausser + dogfather macht es oder die rechte hand, dann ist es bei jedem + weg." + + ANHEFTEN bleibt bei der Leitung -- des Hauses oder des Raumes. + Daran aendert sich nichts. + + FUER ALLE LOESEN ist enger: DogFather und die rechte Hand, + und ausserdem der, DER ES SELBST ANGEHEFTET HAT. Die letzte + Gruppe steht nicht in Filipes Satz und ist trotzdem noetig -- + sonst entsteht eine Sackgasse: Es haengen hoechstens drei + Aushaenge, und eine Gruppenleitung, die drei angeheftet hat + und keinen mehr abnehmen darf, kann nie wieder etwas + anheften. Sie nimmt damit nur zurueck, was sie selbst getan + hat -- das ist die Kehrseite derselben Erlaubnis, keine neue. + + ALLE UEBRIGEN nehmen den Aushang fuer SICH weg. Dafuer gibt es + die Route darunter; sie braucht kein Recht, nur einen Platz im + Raum. */ + if (an) { + if (!darfAnheften(req.person, n.raum_id)) { + return res.status(403).json({ fehler: "Anheften darf nur die Leitung." }); + } + } else { + const meins = n.angeheftet_von && Number(n.angeheftet_von) === Number(req.person.id); + if (!fuehrtTeamDogi(req.person) && !meins) { + return res.status(403).json({ + fehler: "Für alle lösen dürfen nur DogFather und die rechte Hand. " + + "Für dich selbst kannst du es jederzeit wegnehmen.", + }); + } + } if (an && n.weg_am) { return res.status(400).json({ fehler: "Eine zurückgenommene Nachricht geht nicht." }); } diff --git a/server/workspace.js b/server/workspace.js index bf8eb56c..28648236 100644 --- a/server/workspace.js +++ b/server/workspace.js @@ -5359,6 +5359,49 @@ export function db() { CREATE INDEX IF NOT EXISTS idx_chat_favoriten_person ON chat_reaktion_favoriten (person_id, platz); + /* ================================================================= + EINEN AUSHANG FUER SICH WEGNEHMEN (25.09.2026) + ================================================================= + + Filipe: "jeder soll das fixierte individuel fuer sich loesen + koennen aber niemals so dass es sich fuer alle loest. ausser + dogfather macht es oder die rechte hand, dann ist es bei jedem + weg. ansonsten sollen alle anderen rollen es individuell fuer + sich loesen koennen. dogfather und die rechte hand sollen die + option haben fuer sich selbst oder fuer alle zu loesen." + + BIS HEUTE GAB ES NUR EIN LOESEN, UND DAS GALT FUER ALLE. Wer + den Knopf sah, nahm damit jedem im Raum den Aushang weg -- und + wer ihn nicht sah, musste die Ansage vom Montag bis Freitag + ueber jedem Gespraech stehen lassen. + + DIESE TABELLE IST DAS PERSOENLICHE "gesehen, weg damit". Sie + sagt NICHTS darueber, ob die Nachricht angeheftet ist -- das + steht weiter in chat_nachrichten.angeheftet_am und gilt fuer + den Raum. Zwei verschiedene Fragen, zwei verschiedene Orte: + die eine beantwortet der Raum, die andere jeder fuer sich. + + KEIN EINTRAG HEISST SICHTBAR. Nicht umgekehrt -- sonst + muesste beim Anheften fuer jeden Teilnehmer eine Zeile + entstehen, und wer spaeter dazukommt, saehe den Aushang nie. + + ON DELETE CASCADE an beiden Enden (KEINE Gegen-Apostrophe in + diesem Kommentar -- er steht in einem Template-Literal, und + einer davon wuerde es beenden; das hat heute schon einmal den + Start gekostet): Verschwindet die + Nachricht oder die Person, verschwindet die Notiz mit. Eine + Zeile, die auf nichts mehr zeigt, ist kein Datenbestand, + sondern Streu. */ + CREATE TABLE IF NOT EXISTS chat_pin_aus ( + nachricht_id INTEGER NOT NULL + REFERENCES chat_nachrichten(id) ON DELETE CASCADE, + person_id INTEGER NOT NULL REFERENCES personen(id) ON DELETE CASCADE, + am TEXT NOT NULL, + PRIMARY KEY (nachricht_id, person_id) + ); + CREATE INDEX IF NOT EXISTS idx_chat_pin_aus_person + ON chat_pin_aus (person_id, nachricht_id); + /* Creator-Profil (Onboarding aus dem Konzept). Eine Zeile je Creator, entsteht erst beim ersten Speichern. admin_notiz ist bewusst Teil dieser Tabelle, wird aber nur an diff --git a/workspace/anruf-probe.html b/workspace/anruf-probe.html index 70fba30e..5d130db6 100644 --- a/workspace/anruf-probe.html +++ b/workspace/anruf-probe.html @@ -16,8 +16,8 @@ verdeckt. Gefunden hat es pruef-struktur. Richtig sind `gate.css` (die Farbwerte des Hauses) und `haus.css`. --> - - + + - +