Buchungskalender: Aufbewahrung – Sperre wie Ablauf, Restpunkte

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
knsoandClaude Opus 5.5 committed 2026-10-07 14:43:44 +00:00
1 parent c019d3c3b0
commit 827ee54875
5 files changed
+102 -17

No files matched your search

-2
View File
@@ -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
+23 -8
View File
@@ -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
}
+7 -5
View File
@@ -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 }
})
+68
View File
@@ -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
}
})
+4 -2
View File
@@ -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)