Files

373 lines
17 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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: '', tokenHash: '' } // tokenHash leer: der Buchungslink ist mit der Anonymisierung tot
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<number>} 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
}