Files
dogfather-universe/server/pruef-freie-namen.mjs
T
DogFatherGitandClaude Opus 5 540b5d8838 Freie Namen, ein Call-Knopf, und drei Seiten, die fuer Scouts kaputt waren
DREI SACHEN AUF EINMAL, alle aus derselben Sitzung.

1. AUSSUCHEN ODER SELBST EINTRAGEN
   Wunsch: "ich will da auch sachen selber noch eintragen koennen, also
   aussuchen und selbst eintragen."

   Jede Personenauswahl (Kalender, Aufgaben, Bereiche, Content, Dateien)
   nimmt jetzt auch einen getippten Namen an -- eine Agentur, eine Marke,
   einen Gast ohne Konto. Dazu bekommt JEDE Auswahl ab acht Eintraegen
   ein Suchfeld: tippen statt scrollen.

   Gebaut IM vorhandenen Auswahl-Bauteil (wahl.js), nicht daneben. Der
   erste Anlauf war ein zweites Bauteil -- es hat sich prompt mit dem
   ersten gebissen, beide haben denselben <select> eingepackt. Ein
   zweites haette ausserdem anders ausgesehen und waere beim naechsten
   Umbau nur an einer von zwei Stellen nachgezogen worden.

   Der freie Name steht in einer EIGENEN Spalte je Feld; die Verknuepfung
   bleibt leer. Entweder eine Person ODER ein Name, nie beides.

   Filipe hat ausdruecklich auch bei den Creator-Feldern freie Namen
   gewollt, nachdem der Nachteil benannt war: Der Eintrag gehoert dann zu
   keinem Konto. Damit daraus kein STILLER Ausfall wird, faellt jede
   Abfrage, die bisher den Namen der verknuepften Person las, jetzt auf
   den freien Text zurueck (externSql) -- gekennzeichnet als "(extern)".
   Der Eintrag verschwindet dadurch aus keiner Liste, keiner Suche und
   keiner Uebersicht.

2. WO FUEHRE ICH DEN CALL?
   Den Knopf gab es, aber nur wenn jemand von Hand einen Link ins
   Ortsfeld getippt hatte UND das Gespraech noch bevorstand. Stand dort
   "Hier", war nichts zum Anklicken da.

   Jetzt hat das Team einen festen Call-Raum (Discord-Sprachkanal), den
   das Management einmal hinterlegt. Danach hat JEDER Call den Knopf --
   und er bleibt, solange das Gespraech laufen kann, nicht nur bis zur
   Startzeit. Ein eigener Link am Termin schlaegt den festen Raum.

3. WAS EINE ROLLE SIEHT, MUSS AUCH FUNKTIONIEREN
   Gemeldet: "cigdem kriegt als manager gewisse sachen nicht auf die sie
   sieht, check jede rolle ab."

   Neue Pruefung server/pruef-rollen.mjs schickt SECHS Rollen-Zustaende
   ueber alle 16 Seiten und misst Konsolenfehler, fehlgeschlagene
   Serveraufrufe, tote Verweise, haengende Ladeanzeigen und wortlos leere
   Seiten. 96 Durchgaenge.

   Der sechste Zustand ist der wichtige: eine Rolle OHNE zugeteilte
   Creator -- der Normalfall am ersten Tag und Cigdems echte Lage.
   Genau dort fielen die Seiten durch, waehrend dieselben Seiten MIT
   Zuteilung tadellos waren.

   VIER ECHTE FEHLER GEFUNDEN UND BEHOBEN:

   a) Eine Aufgabe, die eine Managerin ohne Creator anlegte, war fuer sie
      im selben Moment unsichtbar -- creator_id und verantwortlich_id
      leer, und "von mir selbst angelegt" stand in keiner
      Sichtbarkeitsregel. Nur DogFather sah sie noch. Kein Fehler, keine
      Meldung, die Aufgabe war einfach weg. Dasselbe bei den
      Bereichseintraegen. Beide Regeln kennen jetzt erstellt_von.
      Niemand sieht dadurch etwas Fremdes -- nur das Eigene.

   b) Ein Scout ohne zugeteilten Creator bekam auf die GESAMTE
      Report-Seite 404, obwohl sie fuer ihn verlinkt ist. Die Seite
      antwortet jetzt sauber und leer, statt sich zu verweigern.

   c) Start-Check und Report blieben fuer immer auf "wird geladen"
      stehen, wenn es nichts zu laden gab.

   d) Das Creator-Profil war fuer Scouts ohne Zuteilung wortlos leer --
      der erklaerende Satz stand nur in der grauen Unterzeile.

   Ausserdem meldete die bestehende Lesbarkeitspruefung zwei neue
   Beschriftungen von mir als zu klein fuers Handy (10,88 statt 11,5 px).
   Behoben, und dieselbe Groesse an der Serien-Karte gleich mit -- dort
   waere es erst aufgefallen, sobald jemand eine Wiederholung anlegt.

GEPRUEFT: pruef-rollen 97, pruef-freie-namen 32 (mit Gegenproben:
Ben sieht Cigdems Aufgabe NICHT; die Saeulen-Zuordnung nimmt
ausdruecklich KEINEN freien Namen). Alle bestehenden Laeufe gruen:
Startansicht 133, Kalender 84, Serien 67, Protokoll 52, Handy 50, Sicht
48, Ampel 47, Content 45, Aufgabenbrett 44, Sprung 43,
Personen-Loeschen 40, Bereiche 37, Uebersicht 33, Workspace-Seiten 32,
Formulare 19, Betreuung 18, Grosscheck 15, Lesbarkeit 14.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-09-02 20:10:09 +02:00

295 lines
14 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/* =====================================================================
Prüfung: aussuchen ODER selbst eintragen (02.09.2026).
Wunsch: "ich will da auch sachen selber noch eintragen können, also
aussuchen und selbst eintragen." — an allen fünf Stellen mit
Personenauswahl.
Der gefährliche Teil ist NICHT, ob sich ein Name eintippen lässt.
Gefährlich ist, was danach mit dem Eintrag passiert: Er gehört zu
keiner Person mehr, und die halbe Anwendung sucht Einträge über
genau diese Verknüpfung. Ein frei benannter Eintrag könnte deshalb
lautlos verschwinden — aus der Liste, aus der Suche, aus der
Übersicht. Niemand bekäme eine Fehlermeldung; es wäre einfach nichts
mehr da.
Deshalb prüft diese Datei vor allem eins: DASS NICHTS VERSCHWINDET.
Dazu kommt die Wiederentdeckung eines Fehlers, der schon vorher da
war: Legte eine Managerin eine Aufgabe ohne Creator an, sah sie sie
selbst nicht mehr. Auch das steht hier — samt Gegenprobe.
===================================================================== */
import { mkdtempSync, rmSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
const ordner = mkdtempSync(join(tmpdir(), "ws-frei-"));
process.env.WORKSPACE_DB = join(ordner, "workspace.db");
process.env.PORT = "4192";
process.env.SITE_ACCESS_SECRET = "lokaler-test";
process.env.SITE_ACCESS_CODE_DOGI = "DOGI-TEST-1";
process.env.SITE_ACCESS_CODE_VANVAN = "VANVAN-TEST-1";
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, 700));
const BASIS = "http://127.0.0.1:4192";
setTimeout(() => { console.log("ABBRUCH"); process.exit(1); }, 240_000).unref?.();
let fehler = 0;
let 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");
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");
const idCigdem = anlegen("Cigdem", "manager", "CODE-CIGDEM-01");
const idBen = anlegen("Ben", "scout", "CODE-BEN-00001");
const idTili = anlegen("Tili", "creator", "CODE-TILI-0001");
/* Cigdem betreut niemanden -- genau der Fall, in dem der alte Fehler
zuschlug. */
d.prepare("INSERT INTO betreuung (creator_id, betreuer_id, seit) VALUES (?,?,?)")
.run(idTili, idBen, jetzt);
const zahl = (sql, ...w) => d.prepare(sql).get(...w)?.n ?? 0;
const { chromium } = await import(
"file:///C:/Users/qciga/Documents/Obelix/Analyse/node_modules/playwright/index.mjs");
const browser = await chromium.launch();
async function anmelden(rolle, code, breite = 1440) {
const kontext = await browser.newContext({ viewport: { width: breite, height: 1000 } });
const seite = await kontext.newPage();
await seite.goto(BASIS + "/workspace/", { waitUntil: "domcontentloaded" });
await seite.click(`.rolle[data-rolle="${rolle}"]`);
await seite.fill("#code", code);
await seite.click("button[type=submit]");
await seite.waitForURL("**/start.html", { timeout: 15000 });
return seite;
}
const api = (seite, pfad, optionen) => seite.evaluate(async ([p, o]) => {
const a = await fetch(p, {
credentials: "same-origin",
...(o || {}),
headers: o?.koerper ? { "Content-Type": "application/json" } : (o?.kopf || undefined),
body: o?.koerper ? JSON.stringify(o.koerper) : undefined,
});
return { status: a.status, daten: await a.json().catch(() => ({})) };
}, [pfad, optionen]);
const seiteDogi = await anmelden("admin", "CODE-DOGI-0001");
const seiteCigdem = await anmelden("manager", "CODE-CIGDEM-01");
/* ===================================================================
TEIL 1 — Der alte Fehler: eine Aufgabe, die sich selbst versteckt
=================================================================== */
console.log("\n— Eine Aufgabe ohne Creator bleibt sichtbar —");
const ohne = await api(seiteCigdem, "/workspace/api/aufgaben", {
method: "POST",
koerper: { titel: "Ohne Zuordnung angelegt", prioritaet: "hoch" },
});
ok(ohne.status === 201, "Cigdem legt eine Aufgabe ohne Creator an");
const sichtCigdem = await api(seiteCigdem, "/workspace/api/aufgaben");
ok(sichtCigdem.daten.aufgaben?.some((a) => a.id === ohne.daten.id),
"und SIEHT sie danach auch – genau das ging vorher verloren");
/* GEGENPROBE: Es sieht sie nicht plötzlich jeder. Wäre die Regel zu
weit geraten, wäre die Zeile darüber ebenfalls grün — und niemand
hätte gemerkt, dass Cigdem jetzt fremde Aufgaben sieht. */
const seiteBen = await anmelden("scout", "CODE-BEN-00001");
const sichtBen = await api(seiteBen, "/workspace/api/aufgaben");
ok(!sichtBen.daten.aufgaben?.some((a) => a.id === ohne.daten.id),
"GEGENPROBE: Ben sieht Cigdems Aufgabe NICHT – die Regel ist nicht zu weit");
/* ===================================================================
TEIL 2 — Freie Namen: nichts darf verschwinden
=================================================================== */
console.log("\n— Aufgabe mit frei eingetragenem Namen —");
const frei = await api(seiteDogi, "/workspace/api/aufgaben", {
method: "POST",
koerper: {
titel: "Thumbnails von der Agentur", prioritaet: "mittel",
creator_extern: "Marke XY", verantwortlich_extern: "Agentur Müller",
},
});
ok(frei.status === 201, "eine Aufgabe mit zwei freien Namen lässt sich anlegen");
ok(zahl("SELECT COUNT(*) AS n FROM aufgaben WHERE id = ? AND creator_id IS NULL AND creator_extern = 'Marke XY'",
frei.daten.id) === 1,
"der Name steht in der eigenen Spalte, die Verknüpfung bleibt leer");
const liste = await api(seiteDogi, "/workspace/api/aufgaben");
const eintrag = liste.daten.aufgaben.find((a) => a.id === frei.daten.id);
ok(!!eintrag, "die Aufgabe steht in der Liste – sie ist nicht verschwunden");
ok(eintrag?.creator_name === "Marke XY (extern)",
`der Name wird angezeigt und als extern gekennzeichnet ("${eintrag?.creator_name}")`);
ok(eintrag?.verantwortlich_name === "Agentur Müller (extern)",
`auch der Verantwortliche ("${eintrag?.verantwortlich_name}")`);
const suche = await api(seiteDogi, "/workspace/api/suche?q=Thumbnails");
const gefunden = JSON.stringify(suche.daten);
ok(gefunden.includes("Thumbnails von der Agentur"),
"die Suche findet sie – der häufigste Ort, an dem so etwas lautlos fehlt");
const uebersicht = await api(seiteDogi, "/workspace/api/uebersicht");
ok((uebersicht.daten.offen ?? 0) >= 1, "die Übersicht zählt sie mit");
console.log("\n— Termin mit frei eingetragenem Namen —");
const morgen = new Date(Date.now() + 86400_000).toISOString().slice(0, 10);
const termin = await api(seiteDogi, "/workspace/api/termine", {
method: "POST",
koerper: {
titel: "Call mit der Agentur", art: "call", beginn: morgen + "T14:00",
dauer_min: 30, teilnehmer_extern: "Agentur Müller",
},
});
ok(termin.status === 201, "ein Termin mit freiem Namen lässt sich anlegen");
const termine = await api(seiteDogi, `/workspace/api/termine?von=${morgen}&tage=2`);
const t = termine.daten.termine?.find((x) => x.id === termin.daten.id);
ok(!!t, "er steht im Kalender");
ok(t?.teilnehmer_name === "Agentur Müller (extern)",
`mit Namen und Kennzeichnung ("${t?.teilnehmer_name}")`);
console.log("\n— Wiederholung mit frei eingetragenem Namen —");
const serie = await api(seiteDogi, "/workspace/api/serien", {
method: "POST",
koerper: {
titel: "Wochen-Call Agentur", art: "call", takt: "woechentlich",
start_tag: morgen, uhrzeit: "11:00", teilnehmer_extern: "Agentur Müller",
},
});
ok(serie.status === 201, "eine Wiederholung mit freiem Namen lässt sich anlegen");
ok(zahl("SELECT COUNT(*) AS n FROM termine WHERE serie_id = ? AND teilnehmer_extern = 'Agentur Müller'",
serie.daten.id) > 20,
"der Name landet auf JEDER erzeugten Ausprägung, nicht nur auf der Regel");
console.log("\n— Bereichseintrag mit frei eingetragenem Namen —");
const eintragB = await api(seiteCigdem, "/workspace/api/bereich/live", {
method: "POST",
koerper: { art: "vorbereitung", titel: "Technikpartner meldet Ausfall", datum: morgen,
creator_extern: "Technikpartner" },
});
ok(eintragB.status === 201, "ein Bereichseintrag mit freiem Namen lässt sich anlegen"
+ (eintragB.status === 201 ? "" : " · " + (eintragB.daten.fehler || eintragB.status)));
const bereich = await api(seiteCigdem, "/workspace/api/bereich/live");
const be = bereich.daten.eintraege?.find((x) => x.id === eintragB.daten.id);
ok(!!be, "Cigdem sieht ihn danach – auch ohne zugeteilten Creator");
ok(be?.creator_name === "Technikpartner (extern)",
`mit Namen und Kennzeichnung ("${be?.creator_name}")`);
console.log("\n— Grenzen —");
for (const [koerper, was, erwartet] of [
[{ titel: "Langer Name", creator_extern: "A".repeat(200) }, "zu langer Name", 400],
[{ titel: "Beides gesetzt", creator_id: idTili, creator_extern: "Gast" }, "Person UND Name → Name gewinnt", 201],
]) {
const a = await api(seiteDogi, "/workspace/api/aufgaben", { method: "POST", koerper });
ok(a.status === erwartet, `${was} → ${a.status}${a.status === erwartet ? "" : " · " + (a.daten.fehler || "")}`);
if (a.status === 201) {
ok(zahl("SELECT COUNT(*) AS n FROM aufgaben WHERE id = ? AND creator_id IS NULL", a.daten.id) === 1,
" und die Verknüpfung ist dabei wirklich leer – nie beides zugleich");
}
}
/* ===================================================================
TEIL 3 — Die Bedienung im Browser
=================================================================== */
console.log("\n— Suchen und eintragen im echten Browser —");
await seiteDogi.goto(BASIS + "/workspace/aufgaben.html", { waitUntil: "networkidle" });
await seiteDogi.waitForTimeout(800);
await seiteDogi.click("#neu-oeffnen").catch(() => {});
await seiteDogi.waitForTimeout(400);
/* Das Auswahlfeld ist durch wahl.js ein Knopf -- angeklickt wird der. */
const knopf = seiteDogi.locator('#f-creator').locator('xpath=following-sibling::button').first();
await knopf.click();
await seiteDogi.waitForTimeout(300);
const hatSuche = await seiteDogi.locator(".wahl2__suche").count();
ok(hatSuche === 1, "die geöffnete Liste hat ein Suchfeld");
await seiteDogi.fill(".wahl2__suche", "Til");
await seiteDogi.waitForTimeout(200);
const nachFilter = await seiteDogi.evaluate(() =>
[...document.querySelectorAll(".wahl2__eintrag")].map((e) => e.textContent.trim()));
ok(nachFilter.some((t) => t.includes("Tili")), "Tippen filtert die Liste auf Tili");
ok(!nachFilter.some((t) => t.includes("Cigdem")), "und blendet die anderen aus");
await seiteDogi.fill(".wahl2__suche", "Agentur Müller");
await seiteDogi.waitForTimeout(200);
const eigen = await seiteDogi.locator(".wahl2__eintrag--eigen").count();
ok(eigen === 1, "ein unbekannter Name wird als eigener Eintrag angeboten");
await seiteDogi.locator(".wahl2__eintrag--eigen").click();
await seiteDogi.waitForTimeout(200);
const zustand = await seiteDogi.evaluate(() => ({
extern: document.getElementById("f-creator").dataset.extern,
wert: document.getElementById("f-creator").value,
knopftext: document.querySelector('#f-creator + .wahl2__knopf .wahl2__text')?.textContent
|| document.querySelector('.wahl2 .wahl2__text')?.textContent,
hinweis: document.querySelector(".wahl2__hinweis")?.textContent || "",
}));
ok(zustand.extern === "Agentur Müller", `der Name steht am Feld ("${zustand.extern}")`);
ok(zustand.wert === "", "und die Personenauswahl ist dabei leer – nie beides");
ok(/Agentur Müller/.test(zustand.knopftext || ""), "der Knopf zeigt den Namen an");
ok(zustand.hinweis.length > 20,
"unter dem Feld steht, was ein freier Name bedeutet");
/* GEGENPROBE: Ohne Erlaubnis darf NICHTS erfunden werden. Die
Säulenauswahl im Content ist genau so ein Feld -- eine Säule gehört
immer einem echten Creator, der Server lehnte alles andere ab. */
await seiteDogi.goto(BASIS + "/workspace/content.html", { waitUntil: "networkidle" });
await seiteDogi.waitForTimeout(800);
const freiFlags = await seiteDogi.evaluate(() => ({
creator: document.getElementById("f-creator")?.dataset.frei,
saeule: document.getElementById("s-creator")?.dataset.frei,
}));
ok(freiFlags.creator === "ja", "im Content darf man beim Creator selbst eintragen");
ok(freiFlags.saeule === "nein",
"GEGENPROBE: bei der Säulen-Zuordnung ausdrücklich NICHT – dort gäbe es der Server nicht her");
await seiteDogi.goto(BASIS + "/workspace/aufgaben.html", { waitUntil: "networkidle" });
await seiteDogi.waitForTimeout(600);
await seiteDogi.click("#neu-oeffnen").catch(() => {});
await seiteDogi.waitForTimeout(300);
await seiteDogi.screenshot({ path: "pruef-freie-namen.png", fullPage: false });
/* ===================================================================
Abschluss — die Anzahl zählt mit
=================================================================== */
const ERWARTET = 31;
console.log("");
ok(geprueft >= ERWARTET,
`es sind ${geprueft} Prüfungen gelaufen (erwartet mindestens ${ERWARTET})`);
await browser.close();
d.close();
try { rmSync(ordner, { recursive: true, force: true }); } catch { /* egal */ }
console.log(`\n${fehler ? "FEHLER: " + fehler : "Alles in Ordnung"} — ${geprueft} Prüfungen`);
process.exit(fehler ? 1 : 0);