Buchungskalender: Aufbewahrung und Löschfristen
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
f57a3d477e
commit
c019d3c3b0
9 files changed
+719
-13
No files matched your search
@@ -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,
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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}`))
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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<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
|
||||
}
|
||||
@@ -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 }
|
||||
})
|
||||
}
|
||||
Reference in new issue
Block a user