From 827ee54875d1d8c5cfb402a5cd45e748c44a79d1 Mon Sep 17 00:00:00 2001 From: Kenso Grimm Date: Wed, 7 Oct 2026 14:43:44 +0000 Subject: [PATCH] =?UTF-8?q?Buchungskalender:=20Aufbewahrung=20=E2=80=93=20?= =?UTF-8?q?Sperre=20wie=20Ablauf,=20Restpunkte?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 --- server/config.js | 2 - server/services/unterkunft/aufbewahrung.js | 31 +++++++--- server/services/unterkunft/jobs.js | 12 ++-- test/unterkunftAufbewahrung.test.js | 68 ++++++++++++++++++++++ test/unterkunftWiring.test.js | 6 +- 5 files changed, 102 insertions(+), 17 deletions(-) diff --git a/server/config.js b/server/config.js index 9c77002..930bec3 100644 --- a/server/config.js +++ b/server/config.js @@ -1098,8 +1098,6 @@ export const config = { unverbindlichMonate: 6, // Offene (neue) Anfragen verfallen nach so vielen Tagen neuVerfallTage: 60, - // Tage nach Abschalten der Funktion bis zur Loeschung aller Buchungsdaten (Tagesjob) - loeschTageNachAbschalten: 90, // Hoechstzahl Schreib-/Loeschvorgaenge der Aufbewahrung je Tageslauf aufbewahrungDeckel: 500, // Anfragen werden so viele Jahre nach Abschluss anonymisiert; Rechnungen bleiben rechnungJahre Jahre diff --git a/server/services/unterkunft/aufbewahrung.js b/server/services/unterkunft/aufbewahrung.js index 5379948..8968399 100644 --- a/server/services/unterkunft/aufbewahrung.js +++ b/server/services/unterkunft/aufbewahrung.js @@ -26,7 +26,7 @@ const SEITE = 100 const ANONYM_JAHRE = 3 const RECHNUNG_JAHRE = 8 const MARKER = 'Buchungsdaten gelöscht am ' -const MARKER_RE = /^Buchungsdaten gelöscht am (\d{4}-\d{2}-\d{2})/ +const MARKER_RE = /Buchungsdaten gelöscht am (\d{4}-\d{2}-\d{2})/ const ANFRAGE_ANONYM = { gast: {}, nachrichtVermieter: '', ipHash: '' } const zeitMs = (v) => { @@ -35,7 +35,8 @@ const zeitMs = (v) => { } const jahrVon = (tag) => Number(String(tag).slice(0, 4)) const gueltigerTag = (tag) => typeof tag === 'string' && /^\d{4}-\d{2}-\d{2}/.test(tag) -const fehlerText = (err) => `${err?.name}${err?.status ? ` ${err.status}` : ''}: ${String(err?.message ?? '').slice(0, 160)}` +/** Nur Name, Status und Code: die Meldung kann Personendaten enthalten und kommt nie ins Log. */ +const fehlerText = (err) => [err?.name, err?.status, err?.code ?? err?.type].filter((x) => x !== undefined && x !== null && x !== '').join(' ') const sammlungFehlt = (err) => err?.type === 'collection_not_found' || err?.code === 'collection_not_found' /** now minus n Kalendermonate (UTC, Tag wird auf den Monatsletzten gekuerzt). */ @@ -86,13 +87,14 @@ async function anfragenMitRechnung() { * Tagesarbeit der Aufbewahrung. * @param {Date} now * @param {{ deckel?: number }} [opt] hoechstens so viele Schreib-/Loeschvorgaenge (Standard 500) - * @returns {Promise<{ anfragen: number, gaeste: number, ipHashes: number, abgelaufen: number, rechnungen: number, adressen: number, fehler: number, gedeckelt: boolean }>} + * @returns {Promise<{ verbraucht: number (genutzter Deckel), anfragen: number, gaeste: number, ipHashes: number, abgelaufen: number, rechnungen: number, adressen: number, fehler: number, gedeckelt: boolean }>} * anfragen = unverbindliche Anfragen anonymisiert, gaeste = Gastdaten gebuchter Aufenthalte anonymisiert */ export async function anonymisiere(now, { deckel = config.unterkunft.aufbewahrungDeckel ?? STANDARD_DECKEL } = {}) { const nowMs = nowMsVon(now) - const erg = { anfragen: 0, gaeste: 0, ipHashes: 0, abgelaufen: 0, rechnungen: 0, adressen: 0, fehler: 0, gedeckelt: false } + const erg = { anfragen: 0, gaeste: 0, ipHashes: 0, abgelaufen: 0, rechnungen: 0, adressen: 0, fehler: 0, gedeckelt: false, verbraucht: 0 } let rest = Number.isInteger(deckel) && deckel > 0 ? deckel : STANDARD_DECKEL + const start = rest const jetztIso = new Date(nowMs).toISOString() const AN = config.collections.ukAnfragen const c = config.unterkunft @@ -135,9 +137,9 @@ export async function anonymisiere(now, { deckel = config.unterkunft.aufbewahrun // 2. neu, nach neuVerfallTage nicht entschieden: abgelaufen (ohne Mail) await schritt('neu verfallen', async () => { const grenze = nowMs - c.neuVerfallTage * TAG_MS - const gefunden = await sammle(AN, [Query.equal('status', 'neu'), Query.lessThan('$createdAt', new Date(grenze).toISOString())], ['status', '$createdAt'], (d) => zeitMs(d.$createdAt) < grenze, rest) + const gefunden = await sammle(AN, [Query.equal('status', 'neu'), Query.lessThan('$createdAt', new Date(grenze).toISOString())], ['status', 'unterkunftId', '$createdAt'], (d) => zeitMs(d.$createdAt) < grenze, rest) await bearbeite('neu verfallen', gefunden, async (d) => { - const neu = await withLocks(['uk-aufbewahrung:' + d.$id], async () => { + const neu = await withLocks(['unterkunft:' + d.unterkunftId], async () => { // derselbe Schluessel wie ablauf.js (gibFrei/lehneAb/Fristen) const a = await ladeAnfrage(d.$id) if (!a || a.status !== 'neu') return null return aktualisiereAnfrage(a.$id, { status: 'abgelaufen', abgelaufenAt: jetztIso, ipHash: '' }) @@ -177,7 +179,8 @@ export async function anonymisiere(now, { deckel = config.unterkunft.aufbewahrun rest, ) await bearbeite('anonymisieren', gebucht, async (d) => { - await aktualisiereAnfrage(d.$id, { ...ANFRAGE_ANONYM, anonymisiertAt: jetztIso }) + // ohne Rechnung gibt es keinen Beleg, der die Rechnungsadresse schuetzt: sie geht mit den Gastdaten + await aktualisiereAnfrage(d.$id, { ...ANFRAGE_ANONYM, ...(hatRechnung(d) ? {} : { rechnungsadresse: null }), anonymisiertAt: jetztIso }) erg.gaeste += 1 return true }) @@ -224,6 +227,7 @@ export async function anonymisiere(now, { deckel = config.unterkunft.aufbewahrun } }) + erg.verbraucht = start - rest return erg } @@ -285,6 +289,14 @@ export async function hatUkDaten(featureId) { return false } +/** Marker an die bisherige Notiz haengen (eine alte Marke wird ersetzt, der Rest bleibt; hoechstens 300 Zeichen, der Marker bleibt ganz). */ +function markiere(notiz, tag) { + const marker = `${MARKER}${tag}` + const alt = String(notiz ?? '').replace(/\s*(?:[-–|]\s*)?Buchungsdaten gelöscht am \d{4}-\d{2}-\d{2}/g, '').trim() + if (!alt) return marker + return `${alt.slice(0, Math.max(0, 300 - marker.length - 3))} – ${marker}` +} + /** * Abgeschaltete Buchungskalender (portalFeatures.state disabled, disabledAt aelter als loeschTageNachAbschalten = 90 Tage): alle uk*-Daten * loeschen, danach Marker in adminNote (wie beim Shop; ein spaeteres Abschalten setzt disabledAt neu und gilt wieder). @@ -318,7 +330,10 @@ export async function loescheAbgeschaltete(now, { deckel = STANDARD_DECKEL } = { if (f.siteKey) leereCache(f.siteKey) if (weg.vollstaendig) { erg.funktionen += 1 - if (await nochAus()) await updateDocument(FEATURES, f.$id, { adminNote: `${MARKER}${heute(new Date(nowMs))}`.slice(0, 300) }) + const frisch = await getDocument(FEATURES, f.$id) + if (frisch.state === 'disabled' && zeitMs(frisch.disabledAt) < grenze) { + await updateDocument(FEATURES, f.$id, { adminNote: markiere(frisch.adminNote, heute(new Date(nowMs))) }) + } } else if (!weg.abgebrochen) { erg.gedeckelt = true } diff --git a/server/services/unterkunft/jobs.js b/server/services/unterkunft/jobs.js index d798b63..bc4a7b0 100644 --- a/server/services/unterkunft/jobs.js +++ b/server/services/unterkunft/jobs.js @@ -22,7 +22,7 @@ async function einzeln(name, arbeit) { try { return await arbeit() } catch (err) { - console.error(`[unterkunft-jobs] ${name} fehlgeschlagen: ${err?.name}: ${String(err?.message).slice(0, 200)}`) + console.error(`[unterkunft-jobs] ${name} fehlgeschlagen: ${err?.name} ${err?.status ?? ''}`) return { fehler: true } } finally { laufend.delete(name) @@ -55,7 +55,7 @@ async function gibtDaten() { /** * Tagesjob: Aufbewahrung (ipHash, Verfall von `neu`, Anonymisierung, Rechnungen nach 8 Jahren) und Loeschung abgeschalteter Funktionen - * (90 Tage). Gemeinsamer Deckel config.unterkunft.aufbewahrungDeckel (500) je Lauf. Bei UNTERKUNFT_ENABLED=off nur, wenn es noch + * (90 Tage). Gemeinsamer Deckel config.unterkunft.aufbewahrungDeckel (500) je Lauf (die Loeschung bekommt, was die Aufbewahrung uebrig laesst). Bei UNTERKUNFT_ENABLED=off nur, wenn es noch * Buchungsdaten gibt (die Fristen gelten auch dann). Ein Teil, der scheitert, stoppt den anderen nicht. */ export async function taeglicherJob(now = new Date()) { @@ -64,15 +64,17 @@ export async function taeglicherJob(now = new Date()) { const deckel = config.unterkunft.aufbewahrungDeckel ?? 500 let anon = { anfragen: 0, gaeste: 0, fehler: 1 } let weg = { funktionen: 0, dokumente: 0, fehler: 1, gedeckelt: false } + let rest = deckel try { anon = await anonymisiere(now, { deckel }) + rest = Math.max(0, deckel - (anon.verbraucht ?? 0)) } catch (err) { - console.error(`[unterkunft-jobs] taeglicherJob: Aufbewahrung fehlgeschlagen: ${err?.name}: ${String(err?.message).slice(0, 200)}`) + console.error(`[unterkunft-jobs] taeglicherJob: Aufbewahrung fehlgeschlagen: ${err?.name} ${err?.status ?? ''}`) } try { - weg = await loescheAbgeschaltete(now, { deckel }) + weg = await loescheAbgeschaltete(now, { deckel: rest }) } catch (err) { - console.error(`[unterkunft-jobs] taeglicherJob: Löschung abgeschalteter Funktionen fehlgeschlagen: ${err?.name}: ${String(err?.message).slice(0, 200)}`) + console.error(`[unterkunft-jobs] taeglicherJob: Löschung abgeschalteter Funktionen fehlgeschlagen: ${err?.name} ${err?.status ?? ''}`) } return { aufbewahrung: anon, abgeschaltet: weg } }) diff --git a/test/unterkunftAufbewahrung.test.js b/test/unterkunftAufbewahrung.test.js index 8d01240..dace5fd 100644 --- a/test/unterkunftAufbewahrung.test.js +++ b/test/unterkunftAufbewahrung.test.js @@ -20,6 +20,7 @@ const { anonymisiere, loescheAbgeschaltete, loescheUkDaten, schliesseUkVonKunde const jobs = await import('../server/services/unterkunft/jobs.js') const mails = await import('../server/services/unterkunft/mails.js') const { deleteCustomerWithPortalAccess } = await import('../server/services/customerAdmin.js') +const { withLocks } = await import('../server/services/keyedLock.js') const { raeumeAuf } = await import('../server/services/termine/outbox.js') const TAG = 24 * 3600 * 1000 @@ -149,6 +150,7 @@ test('Logs ohne Personendaten: ein Fehler wird gezaehlt, der Lauf macht weiter', assert.equal(r.ipHashes, 1) assert.deepEqual([holen(a).ipHash, holen(b).ipHash].sort(), ['', 'abcd']) assert.ok(log.mock.calls.length >= 1) + assert.ok(!JSON.stringify(log.mock.calls.map((c) => c.arguments)).includes('eva@example.com'), 'keine Personendaten im Log') }) const funktion = (over = {}) => aw.seed('portalFeatures', { $id: 'f1', customerId: 'c1', projectId: 'p1', featureKey: 'unterkunft', siteKey: 'abcdefghijklmnop', state: 'active', enabled: true, ...over }) @@ -236,3 +238,69 @@ test('Kundenloeschung schaltet die Funktion ab, schliesst die Unterkuenfte, Date assert.equal(anzahl('f1'), 0) assert.equal(await schliesseUkVonKunde('c1'), 0) }) + +test('Sperre wie ablauf.js: ein Wechsel auf freigegeben unter der Unterkunfts-Sperre wird nicht ueberschrieben', async () => { + const id = anfrage({ $createdAt: vor(61), unterkunftId: 'u1' }) + let lauf + await withLocks(['unterkunft:u1'], async () => { + // der Job laeuft an und wartet auf die Sperre; waehrenddessen gibt der Vermieter frei + lauf = anonymisiere(JETZT) + await new Promise((r) => setTimeout(r, 150)) + await (await import('../server/services/appwriteAdmin.js')).updateDocument(config.collections.ukAnfragen, id, { status: 'freigegeben' }) + }) + const r = await lauf + assert.equal(r.abgelaufen, 0) + assert.equal(holen(id).status, 'freigegeben') +}) + +test('Gebucht ohne jede Rechnung: Rechnungsadresse geht mit den Gastdaten', async () => { + const a = anfrage({ status: 'gebucht', bis: '2022-06-01', rechnungsadresse: JSON.stringify(ADRESSE) }) + const r = await anonymisiere(new Date('2026-01-02T10:00:00Z')) + assert.equal(r.gaeste, 1) + assert.deepEqual(gast(a), {}) + assert.equal(holen(a).rechnungsadresse, null) +}) + +test('adminNote: bisheriger Text bleibt vor dem Marker; Wiedereinschalten bricht ab; erneutes Abschalten loescht nach neuen 90 Tagen; aktive Funktion mit altem Marker unberuehrt', async () => { + const sp = await import('../server/services/appwriteAdmin.js') + funktion({ state: 'disabled', enabled: false, disabledAt: vor(100), adminNote: 'Kunde gelöscht' }) + datenFuer('f1') + assert.equal((await loescheAbgeschaltete(JETZT)).funktionen, 1) + assert.equal(aw.docs('portalFeatures').get('f1').adminNote, 'Kunde gelöscht – Buchungsdaten gelöscht am 2026-10-07') + // wieder eingeschaltet: nichts wird geloescht + funktion({ $id: 'f4', state: 'disabled', enabled: false, disabledAt: vor(91) }) + datenFuer('f4') + await sp.updateDocument(config.collections.portalFeatures, 'f4', { state: 'active', enabled: true }) + assert.equal((await loescheAbgeschaltete(JETZT)).funktionen, 0) + assert.equal(anzahl('f4'), 6) + // erneut abgeschaltet (alter Marker steht noch): erst nach neuen 90 Tagen + await sp.updateDocument(config.collections.portalFeatures, 'f4', { adminNote: 'Buchungsdaten gelöscht am 2026-01-01', state: 'disabled', enabled: false, disabledAt: vor(10) }) + assert.equal((await loescheAbgeschaltete(JETZT)).funktionen, 0) + assert.equal(anzahl('f4'), 6) + assert.equal((await loescheAbgeschaltete(new Date(JETZT.getTime() + 81 * TAG))).funktionen, 1) + assert.equal(anzahl('f4'), 0) + // aktive Funktion mit altem Marker: unberuehrt + funktion({ $id: 'f5', state: 'active', adminNote: 'Buchungsdaten gelöscht am 2026-01-01' }) + datenFuer('f5') + await loescheAbgeschaltete(new Date(JETZT.getTime() + 500 * TAG)) + assert.equal(anzahl('f5'), 6) +}) + +test('Gemeinsamer Deckel: der Tagesjob gibt der Loeschung nur den Rest', async () => { + const modus = config.unterkunft.mode + const alt = config.unterkunft.aufbewahrungDeckel + try { + config.unterkunft.mode = 'on' + config.unterkunft.aufbewahrungDeckel = 3 + for (let i = 0; i < 3; i++) anfrage({ $createdAt: vor(40) }) + funktion({ state: 'disabled', enabled: false, disabledAt: vor(120) }) + datenFuer('f1') + const r = await jobs.taeglicherJob(JETZT) + assert.equal(r.aufbewahrung.verbraucht, 3) + assert.equal(r.abgeschaltet.dokumente, 0) + assert.equal(r.abgeschaltet.gedeckelt, true) + } finally { + config.unterkunft.mode = modus + config.unterkunft.aufbewahrungDeckel = alt + } +}) diff --git a/test/unterkunftWiring.test.js b/test/unterkunftWiring.test.js index 00eacba..87cc241 100644 --- a/test/unterkunftWiring.test.js +++ b/test/unterkunftWiring.test.js @@ -227,9 +227,11 @@ test('Jobs: im Modus off {aus:true}; Minutenjob ruft die Fristen ab, die anderen config.unterkunft.mode = 'off' assert.deepEqual(await jobs.minutenJob(T0), { aus: true }) assert.deepEqual(await jobs.viertelstundenJob(T0), { aus: true }) - // Tagesjob: die Fristen gelten auch bei off, solange es Buchungsdaten gibt (dann laeuft er, sonst {aus:true}) + // Tagesjob: die Fristen gelten auch bei off; ohne Buchungsdaten {aus:true}, mit Daten laeuft er const tagAus = await jobs.taeglicherJob(T0) - assert.ok(tagAus.aus === true || tagAus.aufbewahrung?.fehler === 0, JSON.stringify(tagAus)) + const hatDaten = aw.docs('ukEinstellungen').size > 0 || aw.docs('ukAnfragen').size > 0 + if (hatDaten) assert.equal(tagAus.aufbewahrung?.fehler, 0, JSON.stringify(tagAus)) + else assert.deepEqual(tagAus, { aus: true }) await leere() config.unterkunft.mode = 'on' const r = await jobs.minutenJob(T0)