Aufgaben abbrechen, Teilnehmerwahl fuer alle, Benachrichtigungen

Zwei Wuensche vom 05.09.2026, dazu drei Fehler, die dabei ans Licht kamen.

ABBRECHEN (Wunsch: "in jedem status die aufgaben auch abbrechen koennen,
nur ich die manager und scouts")

Neuer Status mit Pflicht-Grund, aus jedem der vier Status heraus.
Festgehalten wird auch, WO die Aufgabe stand -- "im Review abgebrochen"
ist eine andere Aussage als "nie angefangen", und das Wiederaufnehmen
geht dorthin zurueck statt nach "offen".

Eigener Weg statt "abgebrochen" in der Statusliste: Dort entscheidet
darfAendern(), und das laesst auch den zustaendigen Creator aendern.
Der gewoehnliche PATCH kann diesen Zustand deshalb gar nicht erreichen
-- auch nicht fuer DogFather, sonst waere die Grund-Pflicht umgehbar.

Abgebrochenes steht in einem zugeklappten Bereich unter dem Brett, nicht
als fuenfte Spalte: Am Handy waeren dann alle fuenf unlesbar schmal.
Verschwinden darf es nicht, sonst waere der Abbruch ein Loeschen mit
Zwischenschritt.

Die Tabelle musste dafuer getauscht werden (SQLite kann CHECK nicht
aendern). Vorher auf einer Kopie durchgespielt: 40 von 40 Aufgaben,
Inhalte, Verweise und Indizes geprueft, Gegenprobe zeigt, dass der CHECK
noch lebt.

TEILNEHMERWAHL (Wunsch: "das soll viel besser aussehen und fuer jeden
verfuegbar sein")

Auf dem Bildschirm klebten die Namen aneinander: "DogfatherDogFather".
Ursache war, dass kalender.html das Stylesheet mit diesen Klassen nie
eingebunden hat -- sie standen in dateien.css. Vierzig gruene Pruefungen
zur Teilnehmerwahl hatten das nicht gemerkt, weil keine je gefragt hat,
ob es AUSSIEHT wie gedacht.

Jetzt eigene Klassen im eigenen Stylesheet, nach Rollen gruppiert: Die
Rolle steht einmal als Ueberschrift statt neunmal am Namen. Damit ist
das Kleben an der Wurzel weg, nicht zugepflastert.

"Fuer jeden" war mehr als ein hidden zu entfernen: darfEintragen() haette
einem Creator nur sich selbst erlaubt. Er haette seinen Scout gesehen,
angeklickt, und der Server haette ihn still weggelassen -- ein Knopf, der
nichts tut. einladbareIds() schaut jetzt in beide Richtungen, bewusst
getrennt von /api/personen: Wer die erweitert, gibt einem Creator
nebenbei die Moeglichkeit, seinem Scout Aufgaben zuzuweisen.

BENACHRICHTIGUNGEN (Wunsch: "sowas, und dass es perfekt funktioniert
fuer jeden")

Web Push nach RFC 8291/8292, ohne fremde Abhaengigkeit. Der Knopf sagt
in jeder Lage die Wahrheit, auch die unbequemen: abgelehnt (mit dem
Hinweis, wo man es zuruecknimmt), iPhone im Reiter (mit Anleitung),
Browser ohne Push. Ein Knopf, der bei abgelehnter Berechtigung nur
nichts tut, ist der sichere Weg zu "das funktioniert nicht".

DREI FEHLER, DIE DABEI AUFFIELEN

1. Ein defekter Zugangsdatensatz sperrte ALLE einer Rolle aus. Wirft
   hashe() bei einer Person, flog die ganze Anmeldung in den catch: 503
   "nicht verfuegbar" fuer jeden mit dieser Rolle. Aufgefallen durch
   einen eigenen Testfehler. Jetzt wird die defekte Person uebersprungen
   und laut protokolliert; die Gegenprobe zeigt, dass ein falscher Code
   weiterhin abgelehnt wird.

2. Die Glocke sprengte die Kopfleiste -- zweimal. Bei 320 px lag die
   Lupe des Suchknopfes auf dem Sicht-Umschalter (ein Knopf, der auf 12
   Seiten ins Leere tippt), bei 768 px wurde der Abmelden-Knopf bis zu
   15 px aus dem Bild geschoben, weil die Textgrenze auf 760 stand und
   ein Tablet 768 hat. Nachgewiesen durch Messen mit und ohne Glocke,
   nicht durch Vermuten.

3. .block__frage war viermal gestaltet und stand auf einer Seite, die
   keine dieser Dateien laedt -- derselbe Fehler wie bei der
   Teilnehmerwahl. Gefunden von der neuen Klassenpruefung beim ersten
   Lauf.

NEUE PRUEFUNGEN

pruef-css-klassen   jede gestaltete Klasse muss auf ihrer Seite ankommen
                    (unterscheidet Struktur-Anker von echtem Verlust)
pruef-dabei-optik   die Wahl im Browser, an den echten Pixeln
pruef-abbrechen     Umstellung auf einer Kopie, Rechte, Rueckweg
pruef-abbrechen-optik   Knopf, Dialog, Bereich, Handy
pruef-glocke        Zustaende, An/Abmelden, jede Rolle
pruef-push(-weg)    Rechnung gegen die RFC-Vektoren, Zustellung

pruef-struktur prueft jetzt zusaetzlich, ob sich jedes Server-Modul als
ESM laden laesst. node --check auf einer .js-Datei prueft als CommonJS
und meldete "ok", waehrend der Import scheiterte.

Gesamtlauf: 55 von 55 Dateien, 1973 von 1973 Punkten.

Was NICHT geprueft werden konnte und deshalb dasteht: Der Schritt
"Browser holt eine Adresse beim Push-Dienst" braucht eine Verbindung zu
Googles FCM, die ein Pruef-Browser nicht hat. Die Pruefung misst das
zuerst und meldet es als dritten Ausgang, statt gruen zu sein.
Verschluesselung und Zustellung sind getrennt geprueft; diese eine
Strecke beweist sich erst auf dem Server.

Co-Authored-By: Claude Opus 5 <[email protected]>
This commit is contained in:
2026-09-05 18:05:28 +02:00
co-authored by Claude Opus 5
parent 529c814389
commit 627f710d71
39 changed files with 4743 additions and 177 deletions
+288 -2
View File
@@ -225,6 +225,27 @@ function umstellungen(d) {
["aufgaben", "verantwortlich_extern", "TEXT"],
["eintraege", "creator_extern", "TEXT"],
["dateien", "creator_extern", "TEXT"],
/* ABBRECHEN (05.09.2026). Wunsch: *"ich will dass man in jedem
status die aufgaben auch abbrechen kann."*
Drei Spalten, weil "abgebrochen" allein zu wenig sagt:
abbruch_grund WARUM. Pflichtfeld in der Oberflaeche -- ohne
Grund weiss in vier Wochen niemand mehr, warum
etwas wegfiel, und es sieht aus wie Loeschen.
abgebrochen_am WANN.
abbruch_von WER. Nur Leitung und Scouts duerfen es, und
wer es war, gehoert dazu.
status_vorher IN WELCHEM ZUSTAND. Diese Spalte ist der Grund,
warum "abgebrochen" ein eigener Status wurde
und nicht bloss ein Haken: Ohne sie ginge beim
Wechsel verloren, ob die Arbeit schon lief.
"Im Review abgebrochen" ist eine ganz andere
Aussage als "nie angefangen". */
["aufgaben", "abbruch_grund", "TEXT"],
["aufgaben", "abgebrochen_am", "TEXT"],
["aufgaben", "abbruch_von", "INTEGER REFERENCES personen(id) ON DELETE SET NULL"],
["aufgaben", "status_vorher", "TEXT"],
]) {
try {
const vorhanden = d.prepare(`PRAGMA table_info(${tabelle})`).all().map((s) => s.name);
@@ -321,6 +342,108 @@ function umstellungen(d) {
console.error("[workspace] Content-Arten:", fehler?.message);
}
/* ---- Status "abgebrochen" erlauben (05.09.2026) ----
Der CHECK-Constraint einer Tabelle laesst sich in SQLite nicht
aendern -- kein ALTER TABLE der Welt hilft, die Tabelle muss neu
gebaut werden. Deshalb derselbe Weg wie bei der Rolle "manager"
darunter: erst sichern, dann tauschen, danach die Verweise pruefen.
WARUM UEBERHAUPT EIN NEUER STATUS und nicht bloss ein Haken
"abgebrochen": Weil die vier Spalten des Bretts nach Status
gruppieren. Eine abgebrochene Aufgabe faellt damit von selbst aus
allen vieren heraus -- ohne dass irgendeine Abfrage angefasst
werden muss. Ein zusaetzlicher Haken haette bedeutet, JEDE Abfrage
um "AND nicht abgebrochen" zu ergaenzen, und die eine vergessene
waere ein stiller Fehler gewesen: Die Aufgabe stuende weiter im
Brett, und niemand wuesste warum. */
const aufgabenPlan = d.prepare(
"SELECT sql FROM sqlite_master WHERE type = 'table' AND name = 'aufgaben'").get()?.sql || "";
if (aufgabenPlan && !aufgabenPlan.includes("'abgebrochen'")) {
const sicherung = `${DB_PFAD}.vor-abbruch-${jetztStempel}`;
try {
d.exec(`VACUUM INTO '${sicherung.replace(/'/g, "''")}'`);
console.log("[workspace] Sicherung vor der Umstellung:", sicherung);
} catch (fehler) {
console.error("[workspace] Sicherung fehlgeschlagen, Umstellung abgebrochen:", fehler?.message);
return;
}
d.exec("PRAGMA foreign_keys = OFF");
try {
/* Die Spalten stehen hier vollstaendig, weil die Schleife oben
sie zu diesem Zeitpunkt schon ergaenzt hat. Wer hier eine
vergisst, verliert ihren Inhalt still -- deshalb wird nach dem
Tausch die Zeilenzahl verglichen. */
const vorher = d.prepare("SELECT COUNT(*) AS n FROM aufgaben").get().n;
d.exec("BEGIN");
d.exec(`
CREATE TABLE aufgaben_neu (
id INTEGER PRIMARY KEY AUTOINCREMENT,
titel TEXT NOT NULL,
beschreibung TEXT,
status TEXT NOT NULL DEFAULT 'offen'
CHECK (status IN ('offen','arbeit','review','erledigt','abgebrochen')),
prioritaet TEXT NOT NULL DEFAULT 'mittel'
CHECK (prioritaet IN ('hoch','mittel','niedrig')),
creator_id INTEGER REFERENCES personen(id) ON DELETE SET NULL,
verantwortlich_id INTEGER REFERENCES personen(id) ON DELETE SET NULL,
frist TEXT,
erstellt TEXT NOT NULL,
erstellt_von INTEGER REFERENCES personen(id) ON DELETE SET NULL,
geaendert TEXT,
erledigt_am TEXT,
creator_extern TEXT,
verantwortlich_extern TEXT,
abbruch_grund TEXT,
abgebrochen_am TEXT,
abbruch_von INTEGER REFERENCES personen(id) ON DELETE SET NULL,
status_vorher TEXT
);
INSERT INTO aufgaben_neu
(id, titel, beschreibung, status, prioritaet, creator_id, verantwortlich_id,
frist, erstellt, erstellt_von, geaendert, erledigt_am,
creator_extern, verantwortlich_extern,
abbruch_grund, abgebrochen_am, abbruch_von, status_vorher)
SELECT id, titel, beschreibung, status, prioritaet, creator_id, verantwortlich_id,
frist, erstellt, erstellt_von, geaendert, erledigt_am,
creator_extern, verantwortlich_extern,
abbruch_grund, abgebrochen_am, abbruch_von, status_vorher
FROM aufgaben;
DROP TABLE aufgaben;
ALTER TABLE aufgaben_neu RENAME TO aufgaben;
CREATE INDEX IF NOT EXISTS idx_aufgaben_status ON aufgaben (status);
CREATE INDEX IF NOT EXISTS idx_aufgaben_creator ON aufgaben (creator_id);
`);
/* Nach dem RENAME heisst die neue Tabelle wieder "aufgaben" --
gezaehlt wird also unter dem alten Namen, und der Vergleich
laeuft noch INNERHALB der Transaktion. Stimmt er nicht, ist
ein ROLLBACK noch moeglich. */
const nachher = d.prepare("SELECT COUNT(*) AS n FROM aufgaben").get().n;
/* Stimmt die Zahl nicht, wird NICHT bestaetigt. Lieber laeuft das
Abbrechen noch nicht, als dass eine Aufgabe verschwindet. */
if (nachher !== vorher) {
d.exec("ROLLBACK");
console.error(`[workspace] Umstellung abgebrochen: ${vorher} Aufgaben vorher, `
+ `${nachher} nachher. Sicherung: ${sicherung}`);
} else {
d.exec("COMMIT");
const kaputt = d.prepare("PRAGMA foreign_key_check").all();
if (kaputt.length) {
console.error("[workspace] ACHTUNG: nach der Umstellung", kaputt.length,
"verwaiste Verweise. Sicherung liegt unter", sicherung);
} else {
console.log(`[workspace] Status 'abgebrochen' freigeschaltet, ${vorher} Aufgaben, Verweise geprueft.`);
}
}
} catch (fehler) {
try { d.exec("ROLLBACK"); } catch { /* schon zurueckgerollt */ }
console.error("[workspace] Umstellung 'abgebrochen' fehlgeschlagen:", fehler?.message);
} finally {
d.exec("PRAGMA foreign_keys = ON");
}
}
/* ---- Rolle "manager" erlauben ---- */
const bauplan = d.prepare(
"SELECT sql FROM sqlite_master WHERE type = 'table' AND name = 'personen'").get()?.sql || "";
@@ -435,7 +558,7 @@ export function db() {
titel TEXT NOT NULL,
beschreibung TEXT,
status TEXT NOT NULL DEFAULT 'offen'
CHECK (status IN ('offen','arbeit','review','erledigt')),
CHECK (status IN ('offen','arbeit','review','erledigt','abgebrochen')),
prioritaet TEXT NOT NULL DEFAULT 'mittel'
CHECK (prioritaet IN ('hoch','mittel','niedrig')),
creator_id INTEGER REFERENCES personen(id) ON DELETE SET NULL,
@@ -461,6 +584,71 @@ export function db() {
von INTEGER REFERENCES personen(id) ON DELETE SET NULL
);
/* BENACHRICHTIGUNGEN (05.09.2026) ---------------------------------
Wunsch Filipe, mit dem Bild eines "Benachrichtigungen aus"-
Knopfes: *"ich will auch sowas und dass es perfekt funktioniert
auf der seite fuer jeden."*
Eine Anmeldung ist die Adresse, unter der ein bestimmter
BROWSER auf einem bestimmten GERAET erreichbar ist -- nicht die
Person. Wer den Workspace auf dem Rechner und auf dem Handy
benutzt, hat zwei; beide sollen klingeln, und beide muessen
einzeln abschaltbar sein.
Der Endpunkt ist der Schluessel, nicht eine eigene Nummer: Meldet
sich derselbe Browser erneut an (nach dem Leeren der
Website-Daten etwa), soll daraus KEIN zweiter Eintrag werden,
sonst kaeme jede Nachricht doppelt.
p256dh und auth sind die Schluessel des Browsers. Ohne sie
laesst sich nichts verschluesseln -- und ohne Verschluesselung
nimmt kein Push-Dienst etwas an. */
CREATE TABLE IF NOT EXISTS push_anmeldungen (
endpunkt TEXT PRIMARY KEY,
person_id INTEGER NOT NULL REFERENCES personen(id) ON DELETE CASCADE,
p256dh TEXT NOT NULL,
auth TEXT NOT NULL,
geraet TEXT,
erstellt TEXT NOT NULL,
zuletzt_ok TEXT,
fehler INTEGER NOT NULL DEFAULT 0
);
CREATE INDEX IF NOT EXISTS idx_push_person ON push_anmeldungen (person_id);
/* Was jemand bekommen WILL -- je Art einzeln.
Der Plan ist an dieser Stelle deutlich: *"je Person abschaltbar
-- pro Art, nicht alles oder nichts. Eine Benachrichtigung, die
nervt, wird abgeschaltet und dann fehlt auch die wichtige."*
Fehlt eine Zeile, gilt die Voreinstellung aus workspace-push.js
(an). Damit muss niemand erst etwas einstellen, um etwas zu
bekommen -- und wer etwas abstellt, bekommt genau das nicht
mehr. */
CREATE TABLE IF NOT EXISTS push_einstellungen (
person_id INTEGER NOT NULL REFERENCES personen(id) ON DELETE CASCADE,
art TEXT NOT NULL,
an INTEGER NOT NULL DEFAULT 1,
PRIMARY KEY (person_id, art)
);
/* Was schon verschickt wurde. Ohne dieses Gedaechtnis bekaeme
jemand dieselbe Erinnerung bei jedem Lauf erneut -- viermal am
Tag "Aufgabe ist ueberfaellig" ist der schnellste Weg, dass
jemand Benachrichtigungen komplett abschaltet.
Das Merkmal ist die Sache selbst (z. B. "aufgabe-faellig:42"),
nicht der Zeitpunkt: Dieselbe Aufgabe erinnert einmal, nicht
einmal je Stunde. */
CREATE TABLE IF NOT EXISTS push_verschickt (
person_id INTEGER NOT NULL REFERENCES personen(id) ON DELETE CASCADE,
merkmal TEXT NOT NULL,
zeit TEXT NOT NULL,
PRIMARY KEY (person_id, merkmal)
);
CREATE INDEX IF NOT EXISTS idx_push_verschickt_zeit ON push_verschickt (zeit);
/* Rueckmeldungen zu einer Aufgabe (Konzept: "Aufgaben & Feedback").
Ohne sie endet jede Rueckfrage ausserhalb des Systems -- in
WhatsApp, und damit ausserhalb dessen, was spaeter noch
@@ -1073,9 +1261,37 @@ workspaceRouter.post("/workspace/api/anmelden", (req, res) => {
.prepare("SELECT id, name, rolle, code_hash, code_salt, code_n FROM personen WHERE rolle = ? AND aktiv = 1")
.all(rolle);
/* EIN KAPUTTER DATENSATZ DARF NICHT ALLE AUSSPERREN (05.09.2026).
Hier stand die Schleife ohne Absicherung. Wirft `hashe()` bei
EINER Person -- etwa weil ihr code_n keine Zweierpotenz ist und
scrypt "Invalid scrypt params" meldet --, dann flog die ganze
Anmeldung in den catch am Ende: 503 "nicht_verfuegbar", für
JEDEN mit dieser Rolle, auch für die, deren Daten in Ordnung
sind.
Aufgefallen ist es beim Bau der Abbruchpruefung, wo ich zum
Testen versehentlich eine Person mit code_n = 1 angelegt hatte.
Ab da kam kein einziger DogFather mehr herein -- und die Meldung
sagte "nicht verfügbar", nicht "ein Datensatz ist defekt".
Danach hätte man lange gesucht.
Ein Datenfehler bei einer Person ist jetzt ein Problem DIESER
Person: Sie wird übersprungen, der Rest der Anmeldung läuft
normal weiter. Und sie wird laut protokolliert, denn sie kann
sich selbst nicht mehr anmelden -- das muss auffallen. */
let gefunden = null;
for (const k of kandidaten) {
if (gleich(hashe(code, k.code_salt, k.code_n), k.code_hash)) { gefunden = k; break; }
try {
if (gleich(hashe(code, k.code_salt, k.code_n), k.code_hash)) { gefunden = k; break; }
} catch (f) {
console.error(`[workspace] Zugangsdaten von Person #${k.id} (${k.rolle}) sind defekt `
+ `-- sie kann sich nicht anmelden. Grund: ${f?.message}`);
protokolliere("zugangsdaten_defekt", {
personId: k.id, rolle: k.rolle, ip,
detail: String(f?.message || "").slice(0, 80),
});
}
}
if (!gefunden) {
@@ -1362,6 +1578,76 @@ export function sichtbarePersonenIds(person) {
return [...new Set([person.id, ...creator, ...scouts])];
}
/** WER BETREUT MICH -- die Blickrichtung nach oben.
*
* betreuteIds() fragt "wen führe ich?". Diese Funktion fragt das
* Gegenteil: "wer führt mich?" -- also der eigene Scout, dessen
* Manager, und bei einem Scout sein Manager.
*
* Es gab die Richtung bisher nicht, weil sie für Sichtbarkeit nie
* gebraucht wurde: Ein Creator soll die Daten seines Scouts nicht
* sehen. Für eine EINLADUNG ist die Frage aber eine andere -- siehe
* einladbareIds(). */
export function betreuerIds(person) {
if (!person) return [];
try {
const d = db();
if (person.rolle === "creator") {
const betreuer = d.prepare("SELECT betreuer_id FROM betreuung WHERE creator_id = ?")
.all(person.id).map((z) => z.betreuer_id);
if (!betreuer.length) return [];
/* Über den Scout auch dessen Manager: Wer mit seinem Scout einen
Call hat, hat ihn oft mit dessen Manager zusammen. */
const manager = d.prepare(
`SELECT manager_id FROM scout_zuteilung
WHERE scout_id IN (${betreuer.map(() => "?").join(",")})`)
.all(...betreuer).map((z) => z.manager_id);
return [...new Set([...betreuer, ...manager])];
}
if (person.rolle === "scout") {
return d.prepare("SELECT manager_id FROM scout_zuteilung WHERE scout_id = ?")
.all(person.id).map((z) => z.manager_id);
}
return [];
} catch {
return []; // im Zweifel niemand -- nie mehr, als sicher ist
}
}
/** WEN DARF ICH ZU EINEM TERMIN DAZUSTELLEN?
*
* Eingeführt am 05.09.2026 auf den Wunsch, die Teilnehmerwahl solle
* *"für jeden verfügbar sein"*.
*
* WARUM NICHT EINFACH sichtbarePersonenIds: Die Frage ist eine
* andere. Sichtbarkeit heißt "wessen Daten darf ich sehen" und zeigt
* bewusst nach unten -- ein Creator sieht dort nur sich selbst.
* Einladen heißt "mit wem arbeite ich zusammen" und geht in BEIDE
* Richtungen: Ein Creator lädt seinen Scout ein, ein Scout seinen
* Manager.
*
* Und warum nicht die eine Funktion erweitern: sichtbarePersonenIds
* hängt auch an der AUFGABENZUWEISUNG. Wer dort einen Betreuer
* hinzufügt, gibt einem Creator nebenbei die Möglichkeit, seinem
* Scout Aufgaben zu erteilen. Zwei Fragen, zwei Funktionen.
*
* Was ein Einladen bewirkt, ist eng: Die Person sieht diesen einen
* Termin in ihrem Kalender. Keine Daten, keine Rechte, kein Zugriff.
* Deshalb ist die weitere Menge hier vertretbar.
*
* null = alle (nur DogFather), wie überall in dieser Datei. */
export function einladbareIds(person) {
if (!person) return [];
if (istDogFather(person)) return null;
const sichtbar = sichtbarePersonenIds(person) || [];
/* DogFather gehört immer dazu: Er ist der Einzige, mit dem jede
Rolle zu tun hat, und ihn nicht einladen zu können wäre der erste
Fall, der auffällt. */
const chefs = db().prepare("SELECT id FROM personen WHERE rolle = 'admin' AND aktiv = 1")
.all().map((z) => z.id);
return [...new Set([...sichtbar, ...betreuerIds(person), ...chefs])];
}
/** SQL-Baustein daraus: "diese Spalte ist eine Person, die ich sehen
* darf". Gibt null zurück, wenn nicht eingeschränkt werden muss. */
export function personenWo(person, spalte) {