From c019d3c3b03faf0df6dd21a3c1b3e10b2d0ebdfc Mon Sep 17 00:00:00 2001 From: Kenso Grimm Date: Wed, 7 Oct 2026 14:38:35 +0000 Subject: [PATCH] =?UTF-8?q?Buchungskalender:=20Aufbewahrung=20und=20L?= =?UTF-8?q?=C3=B6schfristen?= 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 | 4 + server/services/customerAdmin.js | 15 + server/services/retention.js | 31 +- server/services/termine/jobs.js | 5 +- server/services/unterkunft/aufbewahrung.js | 357 +++++++++++++++++++++ server/services/unterkunft/jobs.js | 43 ++- test/retention.test.js | 31 ++ test/unterkunftAufbewahrung.test.js | 238 ++++++++++++++ test/unterkunftWiring.test.js | 8 +- 9 files changed, 719 insertions(+), 13 deletions(-) create mode 100644 server/services/unterkunft/aufbewahrung.js create mode 100644 test/unterkunftAufbewahrung.test.js diff --git a/server/config.js b/server/config.js index e5b7c62..9c77002 100644 --- a/server/config.js +++ b/server/config.js @@ -1098,6 +1098,10 @@ 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 anonymisierenJahre: 3, rechnungJahre: 8, diff --git a/server/services/customerAdmin.js b/server/services/customerAdmin.js index 7b04f43..d3b549c 100644 --- a/server/services/customerAdmin.js +++ b/server/services/customerAdmin.js @@ -307,6 +307,21 @@ export async function deleteCustomerWithPortalAccess(customerId) { console.error(`[customerAdmin] Webshop von Kunde ${customerId} nicht geschlossen:`, err.message) } + // Buchungskalender abschalten: Gaeste koennen keine Anfragen mehr stellen, die Daten loescht der Tagesjob 90 Tage nach disabledAt. Neue + // Anfragen schliessen wir sofort (Unterkuenfte inaktiv, Speicher der oeffentlichen Schnittstelle leeren). Jeder Teilschritt fuer sich abgesichert. + try { + const { disableCustomerFeatures } = await import('./siteFeatures.js') + await disableCustomerFeatures(customerId, 'unterkunft', { adminNote: 'Kunde gelöscht', reason: 'kunde_geloescht' }) + } catch (err) { + console.error(`[customerAdmin] Buchungskalender von Kunde ${customerId} nicht abgeschaltet:`, err.message) + } + try { + const { schliesseUkVonKunde } = await import('./unterkunft/aufbewahrung.js') + await schliesseUkVonKunde(customerId) + } catch (err) { + console.error(`[customerAdmin] Buchungskalender von Kunde ${customerId} nicht geschlossen:`, err.message) + } + // Oeffnungszeiten live: wie der Terminplaner abschalten (die Antwort der Website ist sofort aktiv false: Kunde und Funktion fehlen/sind // disabled), danach Zeiten und Erinnerungsmerker sofort loeschen (Spec 5) und den Speicher der Antworten leeren. Beides fuer sich in // try/catch: ein Fehler haelt weder das Loeschen des Kunden noch den jeweils anderen Schritt auf. Geloggt wird nur die Kunden-ID. diff --git a/server/services/retention.js b/server/services/retention.js index 7e2f30c..064c0f8 100644 --- a/server/services/retention.js +++ b/server/services/retention.js @@ -4,6 +4,7 @@ import { config } from '../config.js' import { deleteDocument, getDocument, listDocuments, Query, updateDocument } from './appwriteAdmin.js' import { deleteNewsImage } from './newsImages.js' import { loescheShopDaten } from './shop/loeschen.js' +import { hatUkDaten, loescheUkDaten } from './unterkunft/aufbewahrung.js' import { disable } from './siteFeatures.js' import { getStripe } from './stripeBilling.js' import { filesOf, STATUS } from './takeoverCredits.js' @@ -37,6 +38,9 @@ import { filesOf, STATUS } from './takeoverCredits.js' * Abschalten die Zeiten und Erinnerungsmerker des Kunden (loescheZeitenVonKunde, per dynamischem Import wie bei siteFeatures). * Scheitert das, werden diese Funktionen nicht abgeschaltet (solange Daten da sind, soll nichts "aus" aussehen); der Fehler steht unter * "Fehlgeschlagen" im Bericht. + * - Buchungskalender: Fuer faellige Kunden loescht derselbe Lauf auch alle uk*-Daten ihrer Funktionen 'unterkunft' (plan.news[].ukIds, nur wenn es welche gibt; + * Deckel UK_LOESCH_DECKEL je Kunde, Rest = Kunde gehalten). Die eigenen Fristen (ipHash, Anonymisierung, Rechnungen 8 Jahre, 90 Tage nach dem + * Abschalten) laufen im Tagesjob des Buchungskalenders (unterkunft/aufbewahrung.js). * Zeitrechnung in deutscher Zeit: Silvester 23:30 in Berlin ist noch das alte Jahr. * Nicht abgedeckt: Vertragsereignisse ohne customerId bzw. ohne zugehoeriges Abo-Dokument (Waisen) werden * weder gefunden noch geloescht; sie muessen von Hand geprueft werden. Listen werden nicht paginiert: wird eine @@ -51,6 +55,7 @@ const FEATURES = () => config.collections.portalFeatures const CONSENTS = () => config.collections.featureConsents const SHOP_CONFIG = () => config.collections.shopConfig const SHOP_LOESCH_DECKEL = 2000 +const UK_LOESCH_DECKEL = 2000 /** Felder am Funktionsdokument, die die Zustimmung zur Bestellung festhalten (leer => nichts zu loeschen). */ const FEATURE_CONSENT_FIELDS = ['consentIp', 'consentUserAgent', 'consentText', 'consentAt'] @@ -154,7 +159,10 @@ export async function findDueDeletions(now = new Date(), { limit = LIST_LIMIT } const withFields = allFeatures.filter(hasConsentFields) const consents = await list(CONSENTS(), [Query.equal('customerId', customerId)]) const shops = await shopsOf(customerId, list) - if (!posts.length && !features.length && !consents.length && !withFields.length && !shops.length) continue + // Buchungskalender: Funktionen 'unterkunft' mit uk*-Daten (auch abgeschaltete); ohne Collections (Setup nicht gelaufen) gibt es keine + const ukIds = [] + for (const f of allFeatures.filter((x) => x.featureKey === 'unterkunft')) if (await hatUkDaten(f.$id)) ukIds.push(f.$id) + if (!posts.length && !features.length && !consents.length && !withFields.length && !shops.length && !ukIds.length) continue news.push({ customerId, posts: posts.map((p) => ({ id: p.$id, projectId: p.projectId, imageId: p.imageId || null })), @@ -162,6 +170,7 @@ export async function findDueDeletions(now = new Date(), { limit = LIST_LIMIT } consentIds: consents.map((c) => c.$id), featureFieldIds: withFields.map((f) => f.$id), ...(shops.length ? { shops } : {}), + ...(ukIds.length ? { ukIds } : {}), zeitenIds: features.filter((f) => f.featureKey === 'zeiten').map((f) => f.$id), }) } @@ -216,12 +225,12 @@ async function deleteQuietly(collection, id) { * die Appwrite-Dokumente werden trotzdem geloescht, denn die Frist ist abgelaufen. * @returns {Promise<{ deleted: { subscriptions: number, contractEvents: number, takeover: number, stripeCleared: number, * newsPosts: number, newsImages: number, features: number, featureConsents: number, featureFields: number, shops: number, shopDocs: number, - * zeiten: number }, + * zeiten: number, unterkuenfte: number, ukDocs: number }, * manual: { customerId: string, subscriptionId: string, reason: string }[], * failed: { what: string, id: string, reason: string }[], held: string[] }>} */ export async function applyDeletions(plan) { - const deleted = { subscriptions: 0, contractEvents: 0, takeover: 0, stripeCleared: 0, newsPosts: 0, newsImages: 0, features: 0, featureConsents: 0, featureFields: 0, shops: 0, shopDocs: 0, zeiten: 0 } + const deleted = { subscriptions: 0, contractEvents: 0, takeover: 0, stripeCleared: 0, newsPosts: 0, newsImages: 0, features: 0, featureConsents: 0, featureFields: 0, shops: 0, shopDocs: 0, zeiten: 0, unterkuenfte: 0, ukDocs: 0 } const manual = [] const failed = [] const held = [] @@ -286,6 +295,18 @@ export async function applyDeletions(plan) { for (const id of entry.consentIds || []) { if (await attempt('featureConsents', id, () => deleteQuietly(CONSENTS(), id))) deleted.featureConsents += 1 } + // Buchungskalender: alle uk*-Daten der Funktion (Anfragen, Rechnungen, Sperren, Preise, Unterkuenfte, Einstellungen); gemeinsamer Deckel je Kunde + let ukRest = UK_LOESCH_DECKEL + for (const featureId of entry.ukIds || []) { + await attempt('unterkunft', featureId, async () => { + const weg = await loescheUkDaten({ featureId, deckel: ukRest }) + ukRest -= weg.geloescht + deleted.ukDocs += weg.geloescht + if (!weg.vollstaendig) throw new Error('Buchungsdaten nicht vollständig gelöscht (Deckel je Lauf), der nächste Lauf macht weiter') + deleted.unterkuenfte += 1 + return true + }) + } // Webshop-Daten: Bestellungen, Artikel, Widerrufe, Rechtstexte-Archiv, Bilder, zuletzt shopConfig (Deckel je Kunde; Rest = der Kunde wird gehalten) let shopRest = SHOP_LOESCH_DECKEL for (const shop of entry.shops || []) { @@ -366,9 +387,10 @@ export function formatReport(plan, result = null) { const consents = n.consentIds?.length || 0 const fields = n.featureFieldIds?.length || 0 const shops = n.shops?.length || 0 + const uk = n.ukIds?.length ? `; ${n.ukIds.length} Buchungskalender samt Anfragen, Rechnungen und Unterkünften zu löschen` : '' const zeiten = n.zeitenIds?.length ? `; Öffnungszeiten-Daten von ${n.zeitenIds.length} Funktion(en) zu löschen` : '' lines.push( - `Kunde ${n.customerId}: ${n.posts.length} News-Beitrag/-Beiträge mit ${images} Foto(s) zu löschen, ${n.featureIds.length} Funktion(en) abzuschalten, ${consents} Zustimmungsnachweis(e) zu löschen, ${fields} Funktionsdokument(e) mit Zustimmungsangaben zu leeren${shops ? `, ${shops} Webshop(s) samt Bestellungen, Artikeln, Widerrufen und Bildern zu löschen` : ''}${zeiten}` + `Kunde ${n.customerId}: ${n.posts.length} News-Beitrag/-Beiträge mit ${images} Foto(s) zu löschen, ${n.featureIds.length} Funktion(en) abzuschalten, ${consents} Zustimmungsnachweis(e) zu löschen, ${fields} Funktionsdokument(e) mit Zustimmungsangaben zu leeren${shops ? `, ${shops} Webshop(s) samt Bestellungen, Artikeln, Widerrufen und Bildern zu löschen` : ''}${uk}${zeiten}` ) } const del = plan.takeover.filter((t) => t.action === 'loeschen') @@ -391,6 +413,7 @@ export function formatReport(plan, result = null) { `Zustimmungsangaben an Funktionsdokumenten (IP, Browser, Text, Zeit): ${d.featureFields} geleert.`, ...(d.shops || d.shopDocs ? [`Webshop: ${d.shops} Shop(s) gelöscht (${d.shopDocs} Dokument(e) in Bestellungen, Artikeln und Widerrufen).`] : []) ) + if (d.unterkuenfte || d.ukDocs) lines.push(`Buchungskalender: ${d.unterkuenfte} Funktion(en) gelöscht (${d.ukDocs} Dokument(e)).`) if (d.zeiten) lines.push(`Öffnungszeiten: ${d.zeiten} Datensatz/Datensätze gelöscht.`) if (result.failed?.length) { lines.push('', 'Fehlgeschlagen (Lauf erneut starten):', ...result.failed.map((f) => ` ${f.what} ${f.id}: ${f.reason}`)) diff --git a/server/services/termine/jobs.js b/server/services/termine/jobs.js index 998ceb5..48db4a3 100644 --- a/server/services/termine/jobs.js +++ b/server/services/termine/jobs.js @@ -24,6 +24,7 @@ import { kontextPerFeature, ladeEinstellung } from './kontext.js' import { anGast } from './mails.js' import { beendeFestgefahrene, raeumeAuf, verarbeite } from './outbox.js' import { shopModus } from '../shop/schalter.js' +import { unterkunftModus } from '../unterkunft/schalter.js' import { termineAus } from './schalter.js' import { buchungsNummer } from './token.js' import { pruefeNow, zeitMs } from './util.js' @@ -812,11 +813,11 @@ async function gibtBezahlteTerminplaner() { /** * Freigabe des Tagesjobs im Modus off (fuehreAus: trotzAus): false (aus) oder { termine, mails }. termine = es gibt ein bezahltes - * Termine-Dokument (Abo-Abgleich und Loeschung abgeschalteter Funktionen), mails = der Webshop ist aktiv (Warteschlange aufraeumen). + * Termine-Dokument (Abo-Abgleich und Loeschung abgeschalteter Funktionen), mails = der Webshop oder der Buchungskalender ist aktiv (Warteschlange aufraeumen). * Eine einzige Abfrage; scheitert sie bei aktivem Shop, laeuft nur der Mail-Teil (Warnung), sonst wirft sie wie bisher. */ async function tagesjobFreigabe() { - const mails = shopModus() !== 'off' + const mails = shopModus() !== 'off' || unterkunftModus() !== 'off' let termine = false try { termine = await gibtBezahlteTerminplaner() diff --git a/server/services/unterkunft/aufbewahrung.js b/server/services/unterkunft/aufbewahrung.js new file mode 100644 index 0000000..5379948 --- /dev/null +++ b/server/services/unterkunft/aufbewahrung.js @@ -0,0 +1,357 @@ +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 = 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 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) +const fehlerText = (err) => `${err?.name}${err?.status ? ` ${err.status}` : ''}: ${String(err?.message ?? '').slice(0, 160)}` +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<{ 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 } + let rest = Number.isInteger(deckel) && deckel > 0 ? deckel : STANDARD_DECKEL + 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', '$createdAt'], (d) => zeitMs(d.$createdAt) < grenze, rest) + await bearbeite('neu verfallen', gefunden, async (d) => { + const neu = await withLocks(['uk-aufbewahrung:' + d.$id], async () => { + 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) => { + await aktualisiereAnfrage(d.$id, { ...ANFRAGE_ANONYM, 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)}`) + } + } + }) + + 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 +} + +/** + * 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 + if (await nochAus()) await updateDocument(FEATURES, f.$id, { adminNote: `${MARKER}${heute(new Date(nowMs))}`.slice(0, 300) }) + } 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 +} diff --git a/server/services/unterkunft/jobs.js b/server/services/unterkunft/jobs.js index 336a0ef..d798b63 100644 --- a/server/services/unterkunft/jobs.js +++ b/server/services/unterkunft/jobs.js @@ -1,11 +1,14 @@ import { laufeFristenAb } from './ablauf.js' +import { config } from '../../config.js' +import { listDocuments, Query } from '../appwriteAdmin.js' +import { anonymisiere, loescheAbgeschaltete } from './aufbewahrung.js' import { gleicheAlleAb } from './icalAbgleich.js' import { unterkunftModus } from './schalter.js' /** * Jobs des Buchungskalenders (Registrierung in server/index.js: Minuten 80 s Start / 60 s, Viertelstunden 140 s / 15 min, * taeglich 210 s / 24 h). Jeder Job laeuft hoechstens einmal gleichzeitig (ein Lauf, der noch haengt, ueberspringt den naechsten - * Takt), wirft nie und kehrt bei UNTERKUNFT_ENABLED=off sofort zurueck. Geruest: Task 10 fuellt den Tagesjob. + * Takt), wirft nie und kehrt bei UNTERKUNFT_ENABLED=off sofort zurueck (der Tagesjob nur, solange es keine Buchungsdaten mehr gibt). */ const laufend = new Set() @@ -38,9 +41,39 @@ export async function viertelstundenJob(now = new Date()) { return einzeln('viertelstundenJob', () => gleicheAlleAb(now)) } -/** Tagesjob (Task 10: Aufbewahrung, Loeschen). Bei UNTERKUNFT_ENABLED=off aus. */ +/** Gibt es irgendwelche Buchungsdaten? Die Fristen gelten auch bei UNTERKUNFT_ENABLED=off (fehlende Collections = nein). */ +async function gibtDaten() { + for (const name of ['ukEinstellungen', 'ukAnfragen']) { + try { + if ((await listDocuments(config.collections[name], [Query.select(['$id']), Query.limit(1)])).length > 0) return true + } catch (err) { + if (err?.type !== 'collection_not_found' && err?.code !== 'collection_not_found') throw err + } + } + return false +} + +/** + * 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 + * Buchungsdaten gibt (die Fristen gelten auch dann). Ein Teil, der scheitert, stoppt den anderen nicht. + */ export async function taeglicherJob(now = new Date()) { - void now - if (unterkunftModus() === 'off') return { aus: true } - return einzeln('taeglicherJob', async () => ({})) + return einzeln('taeglicherJob', async () => { + if (unterkunftModus() === 'off' && !(await gibtDaten())) return { aus: true } + const deckel = config.unterkunft.aufbewahrungDeckel ?? 500 + let anon = { anfragen: 0, gaeste: 0, fehler: 1 } + let weg = { funktionen: 0, dokumente: 0, fehler: 1, gedeckelt: false } + try { + anon = await anonymisiere(now, { deckel }) + } catch (err) { + console.error(`[unterkunft-jobs] taeglicherJob: Aufbewahrung fehlgeschlagen: ${err?.name}: ${String(err?.message).slice(0, 200)}`) + } + try { + weg = await loescheAbgeschaltete(now, { deckel }) + } catch (err) { + console.error(`[unterkunft-jobs] taeglicherJob: Löschung abgeschalteter Funktionen fehlgeschlagen: ${err?.name}: ${String(err?.message).slice(0, 200)}`) + } + return { aufbewahrung: anon, abgeschaltet: weg } + }) } diff --git a/test/retention.test.js b/test/retention.test.js index 8570c1c..dcac6a9 100644 --- a/test/retention.test.js +++ b/test/retention.test.js @@ -23,6 +23,12 @@ const aw = await startAppwriteMock({ shopOrders: schemaKeys('shopOrders'), shopWiderrufe: schemaKeys('shopWiderrufe'), shopRechtstexteArchiv: schemaKeys('shopRechtstexteArchiv'), + ukUnterkuenfte: schemaKeys('ukUnterkuenfte'), + ukPreise: schemaKeys('ukPreise'), + ukSperren: schemaKeys('ukSperren'), + ukAnfragen: schemaKeys('ukAnfragen'), + ukRechnungen: schemaKeys('ukRechnungen'), + ukEinstellungen: schemaKeys('ukEinstellungen'), }, }) const st = await startFakeStripe() @@ -673,3 +679,28 @@ test('Webshop: scheitert das Löschen der Shopdaten, bleiben die Abo-Dokumente s assert.deepEqual(nochmal.held, []) assert.ok(!aw.docs('shopConfig').has('f_sh4_shop') && !aw.docs('subscriptions').has('sh4_sub')) }) + +// ------------------------------------------------------------------------------------------------ Buchungskalender (Task 10) + +test('Buchungskalender: fällige Kunden verlieren alle uk*-Daten ihrer Funktion; andere Kunden bleiben; Bericht nennt es', async () => { + for (const [kunde, faellig] of [['uk1', true], ['uk2', false]]) { + sub(`${kunde}_sub`, kunde, faellig ? { status: 'canceled', endedAt: '2022-03-15T10:00:00.000Z' } : { status: 'active' }) + if (faellig) stripeSub(`${kunde}_sub`, kunde) + const f = `f_${kunde}` + aw.seed('portalFeatures', { $id: f, customerId: kunde, projectId: `${kunde}p`, featureKey: 'unterkunft', siteKey: 'defghijklmnopqrs', state: 'disabled', enabled: false }) + aw.seed('ukEinstellungen', { $id: f, featureId: f, firma: 'Haus' }) + aw.seed('ukUnterkuenfte', { $id: `${f}_u`, featureId: f, customerId: kunde, name: 'W', aktiv: false }) + aw.seed('ukAnfragen', { $id: `${f}_a`, featureId: f, customerId: kunde, unterkunftId: `${f}_u`, nummer: 'A-X', status: 'bezahlt', gast: '{}' }) + aw.seed('ukRechnungen', { $id: `${f}_r`, featureId: f, anfrageId: `${f}_a`, art: 'rechnung', nummer: 'R', datum: '2022-01-01', bruttoCent: 1, inhalt: '{}' }) + } + const plan = await findDueDeletions(JAN_2026) + assert.deepEqual(plan.news.find((n) => n.customerId === 'uk1').ukIds, ['f_uk1']) + assert.ok(!plan.news.some((n) => n.customerId === 'uk2')) + assert.match(formatReport(plan), /Kunde uk1: .*1 Buchungskalender samt Anfragen, Rechnungen und Unterkünften zu löschen/) + const result = await applyDeletions({ ...plan, customers: plan.customers.filter((c) => c.customerId === 'uk1'), news: plan.news.filter((n) => n.customerId === 'uk1'), takeover: [] }) + assert.equal(result.deleted.unterkuenfte, 1) + assert.equal(result.deleted.ukDocs, 4) + for (const c of ['ukEinstellungen', 'ukUnterkuenfte', 'ukAnfragen', 'ukRechnungen']) assert.equal([...aw.docs(c).values()].filter((d) => d.$id.startsWith('f_uk1')).length, 0, c) + assert.ok(aw.docs('ukAnfragen').has('f_uk2_a')) + assert.match(formatReport(plan, result), /Buchungskalender: 1 Funktion\(en\) gelöscht \(4 Dokument/) +}) diff --git a/test/unterkunftAufbewahrung.test.js b/test/unterkunftAufbewahrung.test.js new file mode 100644 index 0000000..8d01240 --- /dev/null +++ b/test/unterkunftAufbewahrung.test.js @@ -0,0 +1,238 @@ +import { test, after, beforeEach } from 'node:test' +import assert from 'node:assert/strict' +import { startAppwriteMock } from './support/appwriteMock.js' +import { useTestEnv } from './support/testEnv.js' +import { schemaKeys } from './support/schema.js' + +// Buchungskalender Task 10: Aufbewahrung, Fristen, Loeschen (aufbewahrung.js, Tagesjob, Kundenloeschung). Die Uhr ist immer ein fester Zeitpunkt. +const NAMEN = ['ukUnterkuenfte', 'ukPreise', 'ukSperren', 'ukAnfragen', 'ukRechnungen', 'ukEinstellungen'] +const aw = await startAppwriteMock({ + schema: { ...Object.fromEntries(NAMEN.map((n) => [n, schemaKeys(n)])), portalFeatures: schemaKeys('portalFeatures'), termineOutbox: schemaKeys('termineOutbox') }, + datetimes: { ukAnfragen: ['fristAt', 'freigegebenAt', 'gebuchtAt', 'abgelehntAt', 'abgelaufenAt', 'storniertAt', 'bezahltAt', 'anonymisiertAt'], termineOutbox: ['naechsterVersuch', 'versendetAt'] }, + defaultLimit: 25, +}) +after(() => aw.close()) +useTestEnv({ appwriteUrl: aw.url, stripeUrl: 'http://127.0.0.1:1/v1' }) + +const { config } = await import('../server/config.js') +const { deleteDocument } = await import('../server/services/appwriteAdmin.js') +const { anonymisiere, loescheAbgeschaltete, loescheUkDaten, schliesseUkVonKunde } = await import('../server/services/unterkunft/aufbewahrung.js') +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 { raeumeAuf } = await import('../server/services/termine/outbox.js') + +const TAG = 24 * 3600 * 1000 +const JETZT = new Date('2026-10-07T10:00:00.000Z') +const vor = (tage, von = JETZT) => new Date(von.getTime() - tage * TAG).toISOString() +const GAST = { name: 'Eva Gast', firma: '', email: 'eva@example.com', telefon: '0123', nachricht: 'Hallo' } +const ADRESSE = { name: 'Eva Gast', strasse: 'Weg 1', plz: '12345', ort: 'Berlin', land: 'DE' } +let n = 0 +const anfrage = (over = {}) => { + const $id = over.$id ?? `a${++n}` + aw.seed('ukAnfragen', { $id, featureId: 'f1', customerId: 'c1', projectId: 'p1', unterkunftId: 'u1', nummer: 'A-' + $id, von: '2026-09-01', bis: '2026-09-05', personen: 2, status: 'neu', gast: JSON.stringify(GAST), nachrichtVermieter: 'Willkommen', ipHash: 'abcd', erstattetCent: 0, ...over }) + return $id +} +const holen = (id) => aw.docs('ukAnfragen').get(id) +const gast = (id) => JSON.parse(holen(id).gast) + +beforeEach(async () => { + for (const c of [...NAMEN, 'termineOutbox', 'portalFeatures', 'customers', 'websiteProjects']) for (const id of aw.docs(c).keys()) await deleteDocument(c, id) + mails._aufrufe.length = 0 +}) + +test('ipHash: nach 29 Tagen noch da, nach 31 Tagen leer; idempotent', async () => { + const jung = anfrage({ $createdAt: vor(29) }) + const alt = anfrage({ $createdAt: vor(31) }) + const r = await anonymisiere(JETZT) + assert.equal(r.ipHashes, 1) + assert.equal(holen(jung).ipHash, 'abcd') + assert.equal(holen(alt).ipHash, '') + assert.equal((await anonymisiere(JETZT)).ipHashes, 0) +}) + +test('neu: nach 59 Tagen bleibt neu, nach 61 Tagen abgelaufen ohne Mail', async () => { + const jung = anfrage({ $createdAt: vor(59) }) + const alt = anfrage({ $createdAt: vor(61) }) + const entschieden = anfrage({ $createdAt: vor(61), status: 'freigegeben' }) + const r = await anonymisiere(JETZT) + assert.equal(r.abgelaufen, 1) + assert.equal(holen(jung).status, 'neu') + assert.equal(holen(alt).status, 'abgelaufen') + assert.ok(Math.abs(Date.parse(holen(alt).abgelaufenAt) - JETZT.getTime()) < 1000) + assert.equal(holen(entschieden).status, 'freigegeben') + assert.equal(mails._aufrufe.length, 0) +}) + +test('Unverbindliche Anfragen: nach 6 Monaten anonymisiert, knapp davor nicht; storniert ohne Rechnung auch, mit Rechnung nicht', async () => { + const sechs = new Date('2026-04-07T10:00:00.000Z') + const davor = anfrage({ status: 'abgelehnt', $createdAt: new Date(sechs.getTime() + 3600e3).toISOString(), rechnungsadresse: JSON.stringify(ADRESSE) }) + const danach = anfrage({ status: 'abgelehnt', $createdAt: new Date(sechs.getTime() - 3600e3).toISOString(), rechnungsadresse: JSON.stringify(ADRESSE) }) + const verfallen = anfrage({ status: 'abgelaufen', $createdAt: vor(200) }) + const stornoOhne = anfrage({ status: 'storniert', $createdAt: vor(200) }) + const stornoMit = anfrage({ status: 'storniert', $createdAt: vor(200), rechnungId: 'r1', rechnungsadresse: JSON.stringify(ADRESSE), bis: '2026-09-05' }) + aw.seed('ukRechnungen', { $id: 'r1', featureId: 'f1', anfrageId: stornoMit, art: 'rechnung', nummer: 'R-1', datum: '2026-09-01', bruttoCent: 100, inhalt: '{"x":1}' }) + const r = await anonymisiere(JETZT) + assert.equal(r.anfragen, 3) + assert.equal(r.gaeste, 0) + assert.deepEqual(gast(davor), GAST) + for (const id of [danach, verfallen, stornoOhne]) { + const d = holen(id) + assert.deepEqual(JSON.parse(d.gast), {}) + assert.equal(d.nachrichtVermieter, '') + assert.equal(d.rechnungsadresse, null) + assert.equal(d.ipHash, '') + assert.ok(d.anonymisiertAt) + } + assert.deepEqual(gast(stornoMit), GAST) + assert.deepEqual(JSON.parse(holen(stornoMit).rechnungsadresse), ADRESSE) + assert.equal((await anonymisiere(JETZT)).anfragen, 0) +}) + +test('Gebuchte Aufenthalte: Gast weg 3 Jahre nach Jahresende der Abreise (Berlin); Rechnungsadresse und Rechnung bleiben unveraendert', async () => { + const inhalt = JSON.stringify({ gast: ADRESSE, summe: 12345 }) + const a = anfrage({ status: 'bezahlt', bis: '2022-12-31', von: '2022-12-28', $createdAt: vor(1500), rechnungsadresse: JSON.stringify(ADRESSE), rechnungId: 'r1' }) + const b = anfrage({ status: 'gebucht', bis: '2023-01-02', von: '2022-12-30', $createdAt: vor(1500), rechnungsadresse: JSON.stringify(ADRESSE) }) + aw.seed('ukRechnungen', { $id: 'r1', featureId: 'f1', anfrageId: a, art: 'rechnung', nummer: 'R-1', datum: '2022-12-20', bruttoCent: 12345, inhalt }) + // 31.12.2025 23:30 Berlin: noch Jahr 2025 -> nichts + assert.equal((await anonymisiere(new Date('2025-12-31T22:30:00Z'))).gaeste, 0) + assert.deepEqual(gast(a), GAST) + // 01.01.2026 00:30 Berlin -> a (Abreise 2022), b (Abreise 2023) noch nicht + const r = await anonymisiere(new Date('2025-12-31T23:30:00Z')) + assert.equal(r.gaeste, 1) + assert.deepEqual(gast(a), {}) + assert.ok(holen(a).anonymisiertAt) + assert.deepEqual(JSON.parse(holen(a).rechnungsadresse), ADRESSE) + assert.deepEqual(gast(b), GAST) + const re = aw.docs('ukRechnungen').get('r1') + assert.equal(re.inhalt, inhalt, 'PDF-Schnappschuss unveraendert') + assert.equal(re.nummer, 'R-1') + assert.equal((await anonymisiere(new Date('2025-12-31T23:30:00Z'))).gaeste, 0) +}) + +test('Rechnungen: 8 Jahre nach Ende des Rechnungsjahres geloescht, zugleich Rechnungsadresse der Anfrage weg', async () => { + const a = anfrage({ status: 'bezahlt', bis: '2018-05-05', anonymisiertAt: vor(1000), gast: '{}', rechnungsadresse: JSON.stringify(ADRESSE), rechnungId: 'r1' }) + aw.seed('ukRechnungen', { $id: 'r1', featureId: 'f1', anfrageId: a, art: 'rechnung', nummer: 'R-1', datum: '2018-05-01', bruttoCent: 1, inhalt: '{}' }) + aw.seed('ukRechnungen', { $id: 'r2', featureId: 'f1', anfrageId: a, art: 'storno', nummer: 'S-1', datum: '2019-01-10', bruttoCent: -1, inhalt: '{}' }) + // Ende 2026 -> r1 faellig am 01.01.2027; r2 (2019) erst 01.01.2028 + assert.equal((await anonymisiere(new Date('2026-12-31T22:30:00Z'))).rechnungen, 0) + const r = await anonymisiere(new Date('2026-12-31T23:30:00Z')) + assert.equal(r.rechnungen, 1) + assert.equal(aw.docs('ukRechnungen').has('r1'), false) + assert.equal(aw.docs('ukRechnungen').has('r2'), true) + assert.deepEqual(JSON.parse(holen(a).rechnungsadresse), ADRESSE, 'noch eine Rechnung der Anfrage uebrig') + const r2 = await anonymisiere(new Date('2027-12-31T23:30:00Z')) + assert.equal(r2.rechnungen, 1) + assert.equal(r2.adressen, 1) + assert.equal(aw.docs('ukRechnungen').size, 0) + assert.equal(holen(a).rechnungsadresse, null) +}) + +test('Deckel begrenzt die Zahl je Lauf, der Rest folgt am naechsten', async () => { + for (let i = 0; i < 5; i++) anfrage({ $createdAt: vor(40) }) + const r1 = await anonymisiere(JETZT, { deckel: 2 }) + assert.equal(r1.ipHashes, 2) + assert.equal(r1.gedeckelt, true) + assert.equal((await anonymisiere(JETZT, { deckel: 2 })).ipHashes, 2) + const r3 = await anonymisiere(JETZT, { deckel: 2 }) + assert.equal(r3.ipHashes, 1) + assert.equal(r3.gedeckelt, false) +}) + +test('Logs ohne Personendaten: ein Fehler wird gezaehlt, der Lauf macht weiter', async (t) => { + const log = t.mock.method(console, 'warn', () => {}) + const a = anfrage({ $createdAt: vor(40) }) + const b = anfrage({ $createdAt: vor(40) }) + aw.failNext({ collection: 'ukAnfragen', method: 'PATCH', status: 500, message: 'kaputt eva@example.com' }) + const r = await anonymisiere(JETZT) + assert.equal(r.fehler, 1) + assert.equal(r.ipHashes, 1) + assert.deepEqual([holen(a).ipHash, holen(b).ipHash].sort(), ['', 'abcd']) + assert.ok(log.mock.calls.length >= 1) +}) + +const funktion = (over = {}) => aw.seed('portalFeatures', { $id: 'f1', customerId: 'c1', projectId: 'p1', featureKey: 'unterkunft', siteKey: 'abcdefghijklmnop', state: 'active', enabled: true, ...over }) +const datenFuer = (f) => { + aw.seed('ukEinstellungen', { $id: f, featureId: f, firma: 'Haus' }) + aw.seed('ukUnterkuenfte', { $id: 'u_' + f, featureId: f, customerId: 'c1', name: 'W', aktiv: true }) + aw.seed('ukPreise', { $id: 'p_' + f, unterkunftId: 'u_' + f, featureId: f }) + aw.seed('ukSperren', { $id: 's_' + f, unterkunftId: 'u_' + f, featureId: f, von: '2026-12-01', bis: '2026-12-02' }) + anfrage({ $id: 'an_' + f, featureId: f }) + aw.seed('ukRechnungen', { $id: 're_' + f, featureId: f, anfrageId: 'an_' + f, art: 'rechnung', nummer: 'R', datum: '2026-09-01', bruttoCent: 1, inhalt: '{}' }) +} +const anzahl = (f) => NAMEN.reduce((s, c) => s + [...aw.docs(c).values()].filter((d) => d.featureId === f).length, 0) + +test('Abgeschaltete Funktion: nach 89 Tagen alles da, nach 91 Tagen alle uk*-Daten weg, Marker gesetzt, Rest unberuehrt, idempotent', async () => { + funktion({ state: 'disabled', enabled: false, disabledAt: vor(89) }) + funktion({ $id: 'f2', state: 'disabled', enabled: false, disabledAt: vor(100), siteKey: 'bcdefghijklmnopq' }) + funktion({ $id: 'f3', state: 'active' }) + for (const f of ['f1', 'f2', 'f3']) datenFuer(f) + let r = await loescheAbgeschaltete(JETZT) + assert.equal(r.funktionen, 1) + assert.equal(anzahl('f1'), 6) + assert.equal(anzahl('f2'), 0) + assert.equal(anzahl('f3'), 6) + assert.match(aw.docs('portalFeatures').get('f2').adminNote, /^Buchungsdaten gelöscht am 2026-10-07/) + r = await loescheAbgeschaltete(JETZT) + assert.equal(r.funktionen, 0) + r = await loescheAbgeschaltete(new Date(JETZT.getTime() + 3 * TAG)) + assert.equal(r.funktionen, 1) + assert.equal(anzahl('f1'), 0) + assert.equal(anzahl('f3'), 6) +}) + +test('loescheUkDaten mit Deckel: unvollstaendig, Fortsetzung loescht den Rest', async () => { + datenFuer('f1') + const a = await loescheUkDaten({ featureId: 'f1', deckel: 2 }) + assert.equal(a.vollstaendig, false) + assert.equal(a.geloescht, 2) + const b = await loescheUkDaten({ featureId: 'f1', deckel: 100 }) + assert.equal(b.vollstaendig, true) + assert.equal(anzahl('f1'), 0) +}) + +test('Tagesjob: off ohne Daten = aus, mit Daten laeuft er; on fuehrt Aufbewahrung und Loeschung aus', async () => { + const modus = config.unterkunft.mode + try { + config.unterkunft.mode = 'off' + assert.deepEqual(await jobs.taeglicherJob(JETZT), { aus: true }) + anfrage({ $createdAt: vor(40) }) + const r = await jobs.taeglicherJob(JETZT) + assert.equal(r.aufbewahrung.ipHashes, 1, 'die Fristen gelten auch bei off') + config.unterkunft.mode = 'on' + funktion({ state: 'disabled', enabled: false, disabledAt: vor(120) }) + datenFuer('f1') + const r2 = await jobs.taeglicherJob(JETZT) + assert.equal(r2.abgeschaltet.funktionen, 1) + assert.equal(anzahl('f1'), 0) + } finally { + config.unterkunft.mode = modus + } +}) + +test('Warteschlange: raeumeAuf loescht Eintraege jeder art (auch uk-*) 7 Tage nach Versand bzw. 48 Stunden nach Fehler', async () => { + const eintrag = (id, status, wann) => aw.seed('termineOutbox', { $id: id, featureId: 'f1', art: 'G16', bookingId: 'x', status, naechsterVersuch: wann }) + eintrag('o1', 'gesendet', vor(8)) + eintrag('o2', 'gesendet', vor(-6)) + eintrag('o3', 'fehler', vor(3)) + eintrag('o4', 'wartet', vor(10)) + assert.equal(await raeumeAuf(JETZT), 2) + assert.deepEqual([...aw.docs('termineOutbox').keys()].sort(), ['o2', 'o4']) +}) + +test('Kundenloeschung schaltet die Funktion ab, schliesst die Unterkuenfte, Daten bleiben bis zur Frist', async () => { + aw.seed('customers', { $id: 'c1', name: 'Weg', email: 'weg@example.invalid' }) + aw.seed('websiteProjects', { $id: 'p1', customerId: 'c1', projectName: 'Haus' }) + funktion() + datenFuer('f1') + aw.seed('ukUnterkuenfte', { $id: 'u_fremd', featureId: 'f9', customerId: 'c9', name: 'X', aktiv: true }) + await deleteCustomerWithPortalAccess('c1') + const f = aw.docs('portalFeatures').get('f1') + assert.deepEqual([f.state, f.enabled, f.adminNote, f.disabledReason], ['disabled', false, 'Kunde gelöscht', 'kunde_geloescht']) + assert.equal(aw.docs('ukUnterkuenfte').get('u_f1').aktiv, false) + assert.equal(aw.docs('ukUnterkuenfte').get('u_fremd').aktiv, true) + assert.equal(anzahl('f1'), 6) + assert.equal((await loescheAbgeschaltete(new Date(Date.parse(f.disabledAt) + 91 * TAG))).funktionen, 1) + assert.equal(anzahl('f1'), 0) + assert.equal(await schliesseUkVonKunde('c1'), 0) +}) diff --git a/test/unterkunftWiring.test.js b/test/unterkunftWiring.test.js index f5c8d60..00eacba 100644 --- a/test/unterkunftWiring.test.js +++ b/test/unterkunftWiring.test.js @@ -227,14 +227,18 @@ 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 }) - assert.deepEqual(await jobs.taeglicherJob(T0), { aus: true }) + // Tagesjob: die Fristen gelten auch bei off, solange es Buchungsdaten gibt (dann laeuft er, sonst {aus:true}) + const tagAus = await jobs.taeglicherJob(T0) + assert.ok(tagAus.aus === true || tagAus.aufbewahrung?.fehler === 0, JSON.stringify(tagAus)) await leere() config.unterkunft.mode = 'on' const r = await jobs.minutenJob(T0) assert.notEqual(r?.aus, true) assert.notEqual(r?.fehler, true, JSON.stringify(r)) assert.deepEqual(await jobs.viertelstundenJob(T0), { importe: 0, erfolg: 0, fehler: 0 }) - assert.deepEqual(await jobs.taeglicherJob(T0), {}) + const tag = await jobs.taeglicherJob(T0) + assert.equal(tag.aufbewahrung.fehler, 0, JSON.stringify(tag)) + assert.equal(tag.abgeschaltet.fehler, 0) }) // -------------------------------------------------------------------------------------------------- Webhook