diff --git a/cloudflare-worker/migrations/0018_webdesign_altabbrueche_archivieren.sql b/cloudflare-worker/migrations/0018_webdesign_altabbrueche_archivieren.sql new file mode 100644 index 00000000..207eaef3 --- /dev/null +++ b/cloudflare-worker/migrations/0018_webdesign_altabbrueche_archivieren.sql @@ -0,0 +1,52 @@ +-- ===================================================================== +-- Altfälle aufräumen: abgebrochene Projekte, die noch in der laufenden +-- Liste stehen (24.08.2026) +-- +-- Warum es diese Migration überhaupt braucht: +-- +-- Der Wunsch "wenn ich abbreche dan sollen die auch da weg" wurde erst +-- am 24.08.2026 umgesetzt. Seitdem setzt der Abbruch neben dem Status +-- auch archiviert = 1, und genau dieses Feld blendet das Projekt aus +-- der laufenden Liste aus. +-- +-- Projekte, die VORHER abgebrochen wurden, haben den Status, aber nicht +-- das Archiv-Kennzeichen. Sie stehen deshalb bis heute zwischen den +-- offenen Aufträgen -- also genau das, was der Wunsch abstellen sollte. +-- Konkret betrifft das P-2608-0001. +-- +-- Warum nicht von Hand in der Datenbank: +-- +-- Ein einzelner UPDATE per Hand repariert einen Fall und hinterlässt +-- keine Spur. Diese Migration erwischt jeden Altfall, läuft überall +-- gleich (Server, Testdatenbank, ein späterer Neuaufbau) und steht +-- nachvollziehbar in der Versionsgeschichte. +-- +-- Warum archivieren und nicht löschen: +-- +-- Dieselbe Begründung wie beim Abbruch selbst: Zahlungen, Erstattung +-- und Verlauf hängen daran, und bei einem Streit braucht man genau das. +-- Über den Archiv-Umschalter in der Projektliste bleibt alles +-- auffindbar. +-- +-- Bewusst NICHT angefasst: +-- +-- abbruch_am, abbruch_grund, abbruch_wer, abbruch_erstattung_cent. +-- Diese Felder gab es beim damaligen Abbruch noch nicht, sie sind also +-- leer. Sie nachträglich mit erfundenen Werten zu füllen wäre eine +-- Behauptung über einen Vorgang, bei dem niemand mehr weiß, wie er +-- wirklich ablief -- und ausgerechnet bei einem Streitfall wäre das die +-- gefährlichste Stelle für eine Erfindung. Ein leeres Feld sagt +-- ehrlich "unbekannt". Die Verwaltung kommt damit zurecht: Sie zeigt +-- Datum, Grund und Erstattung nur an, wenn sie gefüllt sind. +-- ===================================================================== + +UPDATE wd_projekte + SET archiviert = 1, + -- Die Uhr steht ab jetzt auch formal still. Ohne das bliebe + -- wartet_auf auf einem Wert wie 'kunde' stehen, und jede + -- Auswertung über offene Wartezustände zählte einen toten + -- Vorgang mit. + wartet_auf = 'niemand', + aktualisiert_am = strftime('%Y-%m-%dT%H:%M:%fZ', 'now') + WHERE status = 'abgebrochen' + AND (archiviert IS NULL OR archiviert = 0); diff --git a/server-internal/db.js b/server-internal/db.js index 129981ff..36e87e16 100644 --- a/server-internal/db.js +++ b/server-internal/db.js @@ -62,6 +62,9 @@ const MIGRATION_FILES = [ "0016_webdesign_automatik.sql", // Angebote mit Zusage im Portal. "0017_webdesign_angebote.sql", + // Altfaelle: Projekte, die abgebrochen wurden, BEVOR es das + // Archiv-Kennzeichen gab, stehen sonst weiter in der laufenden Liste. + "0018_webdesign_altabbrueche_archivieren.sql", ]; function alreadyApplied(name) { diff --git a/server-internal/test-altabbruch.mjs b/server-internal/test-altabbruch.mjs new file mode 100644 index 00000000..6e764bd9 --- /dev/null +++ b/server-internal/test-altabbruch.mjs @@ -0,0 +1,120 @@ +/* ===================================================================== + Prueft Migration 0018: Ein Projekt, das abgebrochen wurde, BEVOR es + das Archiv-Kennzeichen gab, muss danach aus der laufenden Liste + verschwinden -- ohne dass dabei etwas erfunden oder zerstoert wird. + + Der Test stellt den Altzustand echt nach: Er laesst alle Migrationen + bis 0017 laufen, baut den Altfall, und laesst DANN 0018 laufen. + Ein Test, der 0018 einfach zweimal auf eine leere Datenbank wirft, + wuerde nichts beweisen. + ===================================================================== */ +import Database from "better-sqlite3"; +import { readFileSync, readdirSync } from "fs"; +import { join, dirname } from "path"; +import { fileURLToPath } from "url"; + +const HIER = dirname(fileURLToPath(import.meta.url)); +const MIG = join(HIER, "..", "cloudflare-worker", "migrations"); + +let ok = 0, fehl = 0; +const pruefe = (name, gut, info) => { + if (gut) { ok++; console.log(" ok " + name + (info ? " -> " + info : "")); } + else { fehl++; console.log(" FEHL " + name + (info ? " -> " + info : "")); } +}; + +const db = new Database(":memory:"); +db.pragma("foreign_keys = ON"); + +/* ---- Alle Migrationen BIS 0017 (der Stand vor der Reparatur) ---- */ +const alle = readdirSync(MIG).filter((f) => f.endsWith(".sql")).sort(); +const bis17 = alle.filter((f) => f < "0018"); +for (const f of bis17) db.exec(readFileSync(join(MIG, f), "utf8")); + +console.log("1. DER ALTZUSTAND"); + +/* Ein Kunde, weil wd_projekte einen Fremdschluessel darauf hat. */ +db.prepare( + `INSERT INTO wd_kunden (id, name, email, erstellt_am) + VALUES ('k-alt', 'Testkunde', 'alt@example.com', '2026-08-01T10:00:00.000Z')` +).run(); + +/* Der Altfall: Status abgebrochen, aber archiviert = 0 und alle + Abbruch-Felder leer -- genau wie P-2608-0001. */ +db.prepare( + `INSERT INTO wd_projekte (id, nummer, kunde_id, titel, paket, status, archiviert, + wartet_auf, erstellt_am, aktualisiert_am) + VALUES ('p-alt', 'P-2608-0001', 'k-alt', 'Altfall', 'start', 'abgebrochen', 0, + 'kunde', '2026-08-01T10:00:00.000Z', '2026-08-01T10:00:00.000Z')` +).run(); + +/* Ein laufendes Projekt als Gegenprobe -- es darf NICHT angefasst werden. */ +db.prepare( + `INSERT INTO wd_projekte (id, nummer, kunde_id, titel, paket, status, archiviert, + wartet_auf, erstellt_am, aktualisiert_am) + VALUES ('p-lauf', 'P-2608-0002', 'k-alt', 'Laeuft', 'start', 'design', 0, + 'kunde', '2026-08-02T10:00:00.000Z', '2026-08-02T10:00:00.000Z')` +).run(); + +/* Und ein bereits sauber abgebrochenes -- seine echten Abbruchdaten + duerfen durch die Migration nicht ueberschrieben werden. */ +db.prepare( + `INSERT INTO wd_projekte (id, nummer, kunde_id, titel, paket, status, archiviert, + wartet_auf, abbruch_am, abbruch_grund, abbruch_wer, + abbruch_erstattung_cent, erstellt_am, aktualisiert_am) + VALUES ('p-neu', 'P-2608-0003', 'k-alt', 'Sauber abgebrochen', 'start', 'abgebrochen', 1, + 'niemand', '2026-08-20T09:00:00.000Z', 'kunde_zahlt_nicht', 'dogfather', + 14900, '2026-08-03T10:00:00.000Z', '2026-08-20T09:00:00.000Z')` +).run(); + +const vorher = db.prepare(`SELECT * FROM wd_projekte WHERE id = 'p-alt'`).get(); +pruefe("der Altfall steht in der laufenden Liste", vorher.archiviert === 0, + "archiviert = " + vorher.archiviert); +pruefe("und hat keine Abbruchdaten", !vorher.abbruch_am, String(vorher.abbruch_am)); + +console.log("\n2. NACH DER MIGRATION"); +db.exec(readFileSync(join(MIG, "0018_webdesign_altabbrueche_archivieren.sql"), "utf8")); + +const alt = db.prepare(`SELECT * FROM wd_projekte WHERE id = 'p-alt'`).get(); +pruefe("der Altfall ist archiviert", alt.archiviert === 1, "archiviert = " + alt.archiviert); +pruefe("die Uhr steht formal still", alt.wartet_auf === "niemand", alt.wartet_auf); +pruefe("der Status bleibt abgebrochen", alt.status === "abgebrochen", alt.status); + +/* Der Kern der Ehrlichkeitsregel aus dem Migrationskopf. */ +pruefe("es wurde KEIN Abbruchdatum erfunden", alt.abbruch_am === null, String(alt.abbruch_am)); +pruefe("es wurde KEIN Grund erfunden", alt.abbruch_grund === null, String(alt.abbruch_grund)); +pruefe("es wurde KEINE Erstattung erfunden", + alt.abbruch_erstattung_cent === null, String(alt.abbruch_erstattung_cent)); + +console.log("\n3. WAS NICHT ANGEFASST WERDEN DARF"); +const lauf = db.prepare(`SELECT * FROM wd_projekte WHERE id = 'p-lauf'`).get(); +pruefe("das laufende Projekt bleibt in der Liste", lauf.archiviert === 0, + "archiviert = " + lauf.archiviert); +pruefe("und sein Wartezustand bleibt unveraendert", lauf.wartet_auf === "kunde", lauf.wartet_auf); +pruefe("und sein Aenderungsdatum wurde nicht angefasst", + lauf.aktualisiert_am === "2026-08-02T10:00:00.000Z", lauf.aktualisiert_am); + +const neu = db.prepare(`SELECT * FROM wd_projekte WHERE id = 'p-neu'`).get(); +pruefe("der echte Abbruch behaelt sein Datum", + neu.abbruch_am === "2026-08-20T09:00:00.000Z", neu.abbruch_am); +pruefe("und seinen Grund", neu.abbruch_grund === "kunde_zahlt_nicht", neu.abbruch_grund); +pruefe("und seine Erstattung", neu.abbruch_erstattung_cent === 14900, + String(neu.abbruch_erstattung_cent)); +pruefe("und sein Aenderungsdatum wurde nicht angefasst", + neu.aktualisiert_am === "2026-08-20T09:00:00.000Z", neu.aktualisiert_am); + +console.log("\n4. NOCHMAL LAUFEN LASSEN AENDERT NICHTS"); +/* Eine Migration darf nie davon abhaengen, dass sie genau einmal + laeuft -- beim Wiederherstellen einer Sicherung passiert genau das. */ +const standVorher = db.prepare( + `SELECT id, archiviert, wartet_auf, aktualisiert_am FROM wd_projekte ORDER BY id` +).all(); +db.exec(readFileSync(join(MIG, "0018_webdesign_altabbrueche_archivieren.sql"), "utf8")); +const standNachher = db.prepare( + `SELECT id, archiviert, wartet_auf, aktualisiert_am FROM wd_projekte ORDER BY id` +).all(); +pruefe("zweiter Durchlauf aendert nichts mehr", + JSON.stringify(standVorher) === JSON.stringify(standNachher)); + +console.log(`\n===== ${ok} bestanden, ${fehl} fehlgeschlagen =====`); +db.close(); +process.exit(fehl ? 1 : 0);