import { config } from '../../config.js' import { deleteDocument, listAllDocuments, listDocuments, Query, updateDocument, getDocument } from '../appwriteAdmin.js' import { withLocks } from '../keyedLock.js' import { heute } from '../termine/zeit.js' import { aktualisiereAnfrage, ladeAnfrage } from './speicher.js' import { leereCache } from './kontext.js' /** * Buchungskalender: Aufbewahrung und Loeschfristen (AVV Nr. 9, bindend): * - ipHash: spaetestens config.unterkunft.ipHashTage (30) Tage nach Anlage der Anfrage weg (bei Freigabe/Ablehnung schon in ablauf.js). * - Status `neu` ohne Entscheidung nach neuVerfallTage (60) Tagen: `abgelaufen`, ohne Mail. * - Anfragen, die nie eine Buchung wurden (abgelehnt, abgelaufen, storniert ohne Rechnung): unverbindlichMonate (6) nach Anlage * anonymisiert (gast {}, nachrichtVermieter '', rechnungsadresse null, ipHash '', anonymisiertAt). * - Gebuchte Aufenthalte (gebucht, bezahlt, storniert mit Rechnung): Gastdaten 3 Jahre nach Ende des Kalenderjahres der Abreise (Berlin) * anonymisiert; rechnungsadresse und ukRechnungen bleiben (der Rechnungs-Schnappschuss ist der Beleg). * - ukRechnungen: 8 Jahre nach Ende des Kalenderjahres des Rechnungsdatums geloescht; zugleich wird die rechnungsadresse der Anfrage * anonymisiert (sobald keine Rechnung der Anfrage mehr uebrig ist). * - Abgeschaltete Funktion: 90 Tage nach disabledAt werden alle uk*-Daten der Funktion geloescht (Marker in portalFeatures.adminNote). * Je Lauf hoechstens `deckel` (500) Schreib-/Loeschvorgaenge; idempotent; Logs ohne Personendaten (nur Zaehler, IDs, Fehlername). * Leichtgewichtig gehalten (retention.js importiert dieses Modul): keine Mail-, Stripe- oder Ablauf-Importe. */ const TAG_MS = 24 * 60 * 60 * 1000 const STANDARD_DECKEL = 500 const SEITE = 100 const ANONYM_JAHRE = () => config.unterkunft.anonymisierenJahre ?? 3 const RECHNUNG_JAHRE = () => config.unterkunft.rechnungJahre ?? 8 const MARKER = 'Buchungsdaten gelöscht am ' const MARKER_RE = /Buchungsdaten gelöscht am (\d{4}-\d{2}-\d{2})/ const ANFRAGE_ANONYM = { gast: {}, nachrichtVermieter: '', ipHash: '' } const zeitMs = (v) => { const t = Date.parse(v) return Number.isNaN(t) ? NaN : t } const jahrVon = (tag) => Number(String(tag).slice(0, 4)) const gueltigerTag = (tag) => typeof tag === 'string' && /^\d{4}-\d{2}-\d{2}/.test(tag) /** 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). */ function minusMonate(ms, n) { const d = new Date(ms) const ziel = new Date(Date.UTC(d.getUTCFullYear(), d.getUTCMonth() - n, 1, d.getUTCHours(), d.getUTCMinutes(), d.getUTCSeconds(), d.getUTCMilliseconds())) const letzter = new Date(Date.UTC(ziel.getUTCFullYear(), ziel.getUTCMonth() + 1, 0)).getUTCDate() ziel.setUTCDate(Math.min(d.getUTCDate(), letzter)) return ziel.getTime() } const nowMsVon = (now) => { const ms = now instanceof Date ? now.getTime() : Number(now) if (!Number.isFinite(ms)) throw new TypeError('aufbewahrung: now fehlt') return ms } /** Liest seitenweise (nach $id, 100 je Seite, nur die genannten Felder) und sammelt Treffer von `passt`, hoechstens max. */ async function sammle(sammlung, filter, felder, passt, max) { const treffer = [] if (max <= 0) return { treffer, gedeckelt: true } let cursor = null for (;;) { const seite = await listDocuments(sammlung, [ ...filter, Query.select(['$id', ...felder]), Query.orderAsc('$id'), Query.limit(SEITE), ...(cursor ? [Query.cursorAfter(cursor)] : []), ]) for (const doc of seite) { if (!passt(doc)) continue if (treffer.length >= max) return { treffer, gedeckelt: true } treffer.push(doc) } if (seite.length < SEITE) return { treffer, gedeckelt: false } cursor = seite[seite.length - 1].$id } } /** Anfragen mit Rechnung: Menge der anfrageIds aller ukRechnungen (nur ein Feld gelesen). */ async function anfragenMitRechnung() { const alle = await listAllDocuments(config.collections.ukRechnungen, [Query.select(['$id', 'anfrageId'])]) return new Set(alle.map((r) => r.anfrageId).filter(Boolean)) } /** * Tagesarbeit der Aufbewahrung. * @param {Date} now * @param {{ deckel?: number }} [opt] hoechstens so viele Schreib-/Loeschvorgaenge (Standard 500) * @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, 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 const bearbeite = async (name, { treffer, gedeckelt }, arbeit) => { if (gedeckelt) erg.gedeckelt = true for (const doc of treffer) { if (rest <= 0) { erg.gedeckelt = true return } try { if (await arbeit(doc)) rest -= 1 } catch (err) { erg.fehler += 1 console.warn(`[unterkunft-aufbewahrung] ${name} ${doc.$id} fehlgeschlagen: ${fehlerText(err)}`) } } } const schritt = async (name, arbeit) => { try { await arbeit() } catch (err) { erg.fehler += 1 console.warn(`[unterkunft-aufbewahrung] ${name} fehlgeschlagen: ${fehlerText(err)}`) } } // 1. ipHash leeren (30 Tage nach Anlage) await schritt('ipHash', async () => { const grenze = nowMs - c.ipHashTage * TAG_MS const gefunden = await sammle(AN, [Query.lessThan('$createdAt', new Date(grenze).toISOString())], ['ipHash', '$createdAt'], (d) => d.ipHash && zeitMs(d.$createdAt) < grenze, rest) await bearbeite('ipHash', gefunden, async (d) => { await aktualisiereAnfrage(d.$id, { ipHash: '' }) erg.ipHashes += 1 return true }) }) // 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', 'unterkunftId', '$createdAt'], (d) => zeitMs(d.$createdAt) < grenze, rest) await bearbeite('neu verfallen', gefunden, async (d) => { 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: '' }) }) if (!neu) return false erg.abgelaufen += 1 return true }) }) // 3. + 4. Anonymisieren await schritt('anonymisieren', async () => { const mitRechnung = await anfragenMitRechnung() const hatRechnung = (d) => Boolean(d.rechnungId) || mitRechnung.has(d.$id) const unverbindlichGrenze = minusMonate(nowMs, c.unverbindlichMonate) const gebuchtJahr = jahrVon(heute(new Date(nowMs))) const unv = await sammle( AN, [Query.equal('status', ['abgelehnt', 'abgelaufen', 'storniert']), Query.lessThan('$createdAt', new Date(unverbindlichGrenze).toISOString())], ['status', 'rechnungId', 'anonymisiertAt', '$createdAt'], (d) => !d.anonymisiertAt && zeitMs(d.$createdAt) < unverbindlichGrenze && !(d.status === 'storniert' && hatRechnung(d)), rest, ) await bearbeite('anonymisieren', unv, async (d) => { await aktualisiereAnfrage(d.$id, { ...ANFRAGE_ANONYM, rechnungsadresse: null, anonymisiertAt: jetztIso }) erg.anfragen += 1 return true }) const gebucht = await sammle( AN, [Query.equal('status', ['gebucht', 'bezahlt', 'storniert'])], ['status', 'rechnungId', 'anonymisiertAt', 'bis'], // Abreisejahr Y: faellig, sobald das Berliner Jahr >= Y + 3 + 1 ist (3 Jahre nach Ende des Jahres Y) (d) => !d.anonymisiertAt && gueltigerTag(d.bis) && gebuchtJahr >= jahrVon(d.bis) + ANONYM_JAHRE() + 1 && (d.status !== 'storniert' || hatRechnung(d)), rest, ) await bearbeite('anonymisieren', gebucht, async (d) => { // 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 }) }) // 5. Rechnungen nach 8 Jahren loeschen, vorher die Rechnungsadresse der Anfrage anonymisieren await schritt('rechnungen', async () => { const jahr = jahrVon(heute(new Date(nowMs))) const RE = config.collections.ukRechnungen const faellig = (r) => gueltigerTag(r.datum) && jahr >= jahrVon(r.datum) + RECHNUNG_JAHRE() + 1 const gefunden = await sammle(RE, [], ['anfrageId', 'datum'], faellig, rest) if (gefunden.gedeckelt) erg.gedeckelt = true const erledigt = new Set() for (const r of gefunden.treffer) { if (rest <= 0) { erg.gedeckelt = true return } try { // Adresse erst, wenn keine nicht faellige Rechnung der Anfrage uebrig ist (sonst braucht die Anfrage sie noch) if (r.anfrageId && !erledigt.has(r.anfrageId)) { const alle = await listAllDocuments(RE, [Query.equal('anfrageId', r.anfrageId), Query.select(['$id', 'datum'])]) if (alle.every(faellig)) { const a = await ladeAnfrage(r.anfrageId) if (a && (a.rechnungsadresse !== null || !a.anonymisiertAt)) { await aktualisiereAnfrage(a.$id, { rechnungsadresse: null, ...(a.anonymisiertAt ? {} : { ...ANFRAGE_ANONYM, anonymisiertAt: jetztIso }) }) erg.adressen += 1 rest -= 1 } erledigt.add(r.anfrageId) } } try { await deleteDocument(RE, r.$id) } catch (err) { if (err?.status !== 404) throw err } erg.rechnungen += 1 rest -= 1 } catch (err) { erg.fehler += 1 console.warn(`[unterkunft-aufbewahrung] rechnungen ${r.$id} fehlgeschlagen: ${fehlerText(err)}`) } } }) erg.verbraucht = start - rest return erg } const GRUPPEN = ['ukAnfragen', 'ukRechnungen', 'ukSperren', 'ukPreise', 'ukUnterkuenfte'] /** * Alle uk*-Daten EINER Funktion (featureId) loeschen: Anfragen, Rechnungen, Sperren, Preise, Unterkuenfte und zuletzt die Einstellungen * (ukEinstellungen, Dokument-ID = featureId). Seitenweise (immer die erste Seite neu), am Deckel endet der Lauf (vollstaendig: false). * @returns {Promise<{ geloescht: number, vollstaendig: boolean, abgebrochen: boolean }>} */ export async function loescheUkDaten({ featureId, deckel = Number.POSITIVE_INFINITY, nochGueltig = async () => true } = {}) { if (typeof featureId !== 'string' || featureId === '') throw new TypeError('loescheUkDaten: featureId fehlt') const erg = { geloescht: 0, vollstaendig: false, abgebrochen: false } for (const name of GRUPPEN) { const sammlung = config.collections[name] for (;;) { if (!(await nochGueltig())) return { ...erg, abgebrochen: true } const rest = deckel - erg.geloescht if (rest <= 0) return erg let seite try { seite = await listDocuments(sammlung, [Query.equal('featureId', featureId), Query.select(['$id']), Query.limit(Math.min(SEITE, rest))]) } catch (err) { if (sammlungFehlt(err)) break throw err } if (seite.length === 0) break for (const doc of seite) { try { await deleteDocument(sammlung, doc.$id) } catch (err) { if (err?.status !== 404) throw err } erg.geloescht += 1 } } } if (!(await nochGueltig())) return { ...erg, abgebrochen: true } try { await deleteDocument(config.collections.ukEinstellungen, featureId) erg.geloescht += 1 } catch (err) { if (err?.status !== 404 && !sammlungFehlt(err)) throw err } erg.vollstaendig = true return erg } /** Hat die Funktion (featureId) noch uk*-Daten? (fuer die Kundenloeschung in retention.js; fehlende Collections zaehlen als leer) */ export async function hatUkDaten(featureId) { for (const name of ['ukUnterkuenfte', 'ukAnfragen', 'ukRechnungen', 'ukSperren', 'ukPreise', 'ukEinstellungen']) { try { const treffer = await listDocuments(config.collections[name], [Query.equal('featureId', featureId), Query.select(['$id']), Query.limit(1)]) if (treffer.length) return true } catch (err) { if (!sammlungFehlt(err)) throw err } } 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). * @returns {Promise<{ funktionen: number, dokumente: number, fehler: number, gedeckelt: boolean }>} */ export async function loescheAbgeschaltete(now, { deckel = STANDARD_DECKEL } = {}) { const nowMs = nowMsVon(now) const tage = config.unterkunft.loeschTageNachAbschalten ?? 90 const grenze = nowMs - tage * TAG_MS const FEATURES = config.collections.portalFeatures const erg = { funktionen: 0, dokumente: 0, fehler: 0, gedeckelt: false } let rest = deckel const funktionen = await listAllDocuments(FEATURES, [Query.equal('featureKey', 'unterkunft'), Query.equal('state', 'disabled')]) for (const f of funktionen) { if (!(zeitMs(f.disabledAt) < grenze)) continue const marker = MARKER_RE.exec(String(f.adminNote ?? '')) if (marker && marker[1] >= String(f.disabledAt).slice(0, 10)) continue if (rest <= 0) { erg.gedeckelt = true return erg } try { // vor jeder Seite frisch lesen: das Team kann die Funktion zwischenzeitlich wieder einschalten const nochAus = async () => { const frisch = await getDocument(FEATURES, f.$id) return frisch.state === 'disabled' && zeitMs(frisch.disabledAt) < grenze } const weg = await loescheUkDaten({ featureId: f.$id, deckel: rest, nochGueltig: nochAus }) rest -= weg.geloescht erg.dokumente += weg.geloescht if (f.siteKey) leereCache(f.siteKey) if (weg.vollstaendig) { erg.funktionen += 1 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 } } catch (err) { erg.fehler += 1 console.warn(`[unterkunft-aufbewahrung] Funktion ${f.$id} nicht vollständig gelöscht: ${fehlerText(err)}`) } } return erg } /** * Kundenloeschung (customerAdmin.js): schliesst die Buchungskalender des Kunden fuer neue Anfragen (alle Unterkuenfte aktiv=false) und * leert den Speicher der oeffentlichen Schnittstelle. Die Funktion selbst schaltet disableCustomerFeatures ab; die Daten loescht * loescheAbgeschaltete 90 Tage nach dem Abschalten. Scheitert eines, werden die uebrigen trotzdem geschlossen, der erste Fehler wird weitergereicht. * @returns {Promise} Zahl der geschlossenen Unterkuenfte */ export async function schliesseUkVonKunde(customerId) { const funktionen = await listAllDocuments(config.collections.portalFeatures, [Query.equal('customerId', customerId), Query.equal('featureKey', 'unterkunft')]) let geschlossen = 0 let erster = null for (const f of funktionen) { try { const unterkuenfte = await listAllDocuments(config.collections.ukUnterkuenfte, [Query.equal('featureId', f.$id)]) for (const u of unterkuenfte) { if (u.aktiv !== false) await updateDocument(config.collections.ukUnterkuenfte, u.$id, { aktiv: false }) geschlossen += 1 } } catch (err) { if (!sammlungFehlt(err)) erster ||= err } if (f.siteKey) leereCache(f.siteKey) } if (erster) throw erster return geschlossen }